Complete Homarr provider integration
This commit is contained in:
1 parent
bc59355eab
commit
165201632d
23 files changed
+986
-61
No files matched your search
@@ -0,0 +1,161 @@
|
||||
.root {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 12px;
|
||||
background: var(--ifm-background-surface-color);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.8rem 1rem;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-300);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.live {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.beep {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: #2f9e44;
|
||||
box-shadow: 0 0 0 0 rgb(47 158 68 / 35%);
|
||||
animation: live-beep 2.2s ease-out infinite;
|
||||
}
|
||||
|
||||
.tableWrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
min-width: 36rem;
|
||||
margin: 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 0.58rem 0.75rem;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
text-align: start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table th {
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.status[data-status="processing"] {
|
||||
color: #e67700;
|
||||
}
|
||||
|
||||
.status[data-status="completed"] {
|
||||
color: #2f9e44;
|
||||
}
|
||||
|
||||
.status[data-status="failed"] {
|
||||
color: #e03131;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 1.2rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.compact {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.compact .header {
|
||||
padding: 0.38rem 0.55rem;
|
||||
}
|
||||
|
||||
.compact .title {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.compact .live {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.compact .table {
|
||||
min-width: 0;
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.compact .table th,
|
||||
.compact .table td {
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
|
||||
.compact .table th:nth-child(n + 4),
|
||||
.compact .table td:nth-child(n + 4) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.compact .empty {
|
||||
padding: 0.7rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
@keyframes live-beep {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgb(47 158 68 / 35%);
|
||||
}
|
||||
|
||||
55%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0.35rem rgb(47 158 68 / 0%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.beep {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.root:not(.compact) .table th:nth-child(5),
|
||||
.root:not(.compact) .table td:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import { IconCheck, IconLoader2, IconX } from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import type { WorkshopAssistantActivity } from "@homarr/workshop/schema";
|
||||
import { workshopAssistantActivitySchema } from "@homarr/workshop/schema";
|
||||
|
||||
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
|
||||
import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
|
||||
|
||||
import styles from "./assistant-provider-activity.module.css";
|
||||
|
||||
const statusIcon = {
|
||||
processing: IconLoader2,
|
||||
completed: IconCheck,
|
||||
failed: IconX,
|
||||
};
|
||||
|
||||
const statusLabel = {
|
||||
processing: "Processing",
|
||||
completed: "Completed",
|
||||
failed: "Failed",
|
||||
};
|
||||
|
||||
const mergeActivity = (current: WorkshopAssistantActivity[], next: WorkshopAssistantActivity) =>
|
||||
[next, ...current.filter((item) => item.id !== next.id)]
|
||||
.toSorted((left, right) => Date.parse(right.created) - Date.parse(left.created))
|
||||
.slice(0, 10);
|
||||
|
||||
export const AssistantProviderActivity = ({ compact = false }: { compact?: boolean }) => {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
|
||||
const [activities, setActivities] = useState<WorkshopAssistantActivity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const workshopUrl = useMemo(() => getRuntimeWorkshopApiUrl(configuredWorkshopUrl), [configuredWorkshopUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
const backend = getWorkshopBackend(workshopUrl);
|
||||
const load = async () => {
|
||||
try {
|
||||
const initial = await backend.listAssistantActivity(10);
|
||||
if (active) setActivities(initial);
|
||||
unsubscribe = await backend.pocketBase.collection("assistant_activity").subscribe("*", (event) => {
|
||||
const parsed = workshopAssistantActivitySchema.safeParse(event.record);
|
||||
if (!active || !parsed.success) return;
|
||||
setConnected(true);
|
||||
setActivities((current) => mergeActivity(current, parsed.data));
|
||||
});
|
||||
if (active) setConnected(true);
|
||||
} catch {
|
||||
if (active) setConnected(false);
|
||||
} finally {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
active = false;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [workshopUrl]);
|
||||
|
||||
const visibleActivities = compact ? activities.slice(0, 3) : activities;
|
||||
|
||||
return (
|
||||
<section className={clsx(styles.root, compact && styles.compact)} aria-label="Live Homarr provider activity">
|
||||
<header className={styles.header}>
|
||||
<h3 className={styles.title}>Homarr provider activity</h3>
|
||||
<span className={styles.live} aria-live="polite">
|
||||
<span className={styles.beep} aria-hidden />
|
||||
{connected ? "Live" : loading ? "Connecting" : "Offline"}
|
||||
</span>
|
||||
</header>
|
||||
{visibleActivities.length > 0 ? (
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Status</th>
|
||||
<th>Requests</th>
|
||||
<th>Tokens</th>
|
||||
<th>Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleActivities.map((activity) => {
|
||||
const StatusIcon = statusIcon[activity.status];
|
||||
return (
|
||||
<tr key={activity.id}>
|
||||
<td>
|
||||
<time dateTime={activity.created} title={new Date(activity.created).toLocaleString()}>
|
||||
{new Date(activity.created).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</time>
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.status} data-status={activity.status}>
|
||||
<StatusIcon size={13} aria-hidden />
|
||||
{statusLabel[activity.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td>{activity.requestUnits.toLocaleString()}</td>
|
||||
<td>{activity.totalTokens > 0 ? activity.totalTokens.toLocaleString() : "–"}</td>
|
||||
<td>{activity.durationMs > 0 ? `${(activity.durationMs / 1000).toFixed(1)} s` : "–"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.empty}>{loading ? "Loading recent activity…" : "No provider requests yet today."}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AppWidget } from "./widgets/app-widget";
|
||||
import { ClockWidget } from "./widgets/clock-widget";
|
||||
import { DownloadsWidget } from "./widgets/downloads-widget";
|
||||
import { AssistantProviderWidget } from "./widgets/assistant-provider-widget";
|
||||
import { WeatherWidget } from "./widgets/weather-widget";
|
||||
import { StockWidget } from "./widgets/stock-widget";
|
||||
import { EntityStateWidget } from "./widgets/entity-state-widget";
|
||||
@@ -12,7 +12,7 @@ export const HeroCards = () => {
|
||||
<AppWidget />
|
||||
<AppWidget className="hidden 3xl:block" />
|
||||
<WeatherWidget />
|
||||
<DownloadsWidget />
|
||||
<AssistantProviderWidget />
|
||||
<EntityStateWidget />
|
||||
<AppWidget />
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AssistantProviderActivity } from "@site/src/components/assistant-provider/assistant-provider-activity";
|
||||
|
||||
import type { CommonWidgetProps } from "./card";
|
||||
import { WidgetCard } from "./card";
|
||||
|
||||
export const AssistantProviderWidget = ({ className }: CommonWidgetProps) => (
|
||||
<WidgetCard width={2} className={className}>
|
||||
<AssistantProviderActivity compact />
|
||||
</WidgetCard>
|
||||
);
|
||||
Reference in new issue
Block a user