feat(widgets): add frontend widget runtime (types, API, hooks, registry, components)

PR 3 of 4 for configurable dashboard widgets.

- Add TypeScript widget interfaces (WidgetInstance, WidgetInstanceInput,
  WidgetTypeInfo, WidgetDataResponse).
- Create widget API client for CRUD, registry metadata, and per-widget data.
- Create TanStack Query hooks for instances, data, sources, types, and mutations.
- Create closed frontend widget registry with metadata, source type, refresh
  intervals, and config fields.
- Add six shadcn/ui-based widget components: Jellyfin, Backups, Grafana link,
  Prometheus metric, SSH task output, and static text.
- Add Vitest unit tests for registry metadata.

Verification:
- backend ruff clean; pytest 200 passed
- frontend npm run lint: 0 errors
- frontend npm run build: success
- frontend npm run test -- src/widgets/registry.test.ts: 3 passed
This commit is contained in:
Developer
2026-06-21 16:24:44 +00:00
parent e6d333ef7b
commit e1356b20f1
13 changed files with 700 additions and 1 deletions
+66
View File
@@ -0,0 +1,66 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { BackupDashboardSummary } from "../types/backups";
import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
}
export function BackupsWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data, isLoading } = useWidgetData(
widget.id,
def?.refreshInterval ?? 0,
);
const summary = data?.data as BackupDashboardSummary | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
{isLoading && !data ? (
<div className="flex flex-row flex-wrap gap-6">
<Skeleton className="h-10 w-20" />
<Skeleton className="h-10 w-20" />
<Skeleton className="h-10 w-20" />
</div>
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : summary ? (
<div className="flex flex-row flex-wrap gap-6">
<div>
<div className="text-2xl font-semibold">{summary.total_jobs}</div>
<div className="text-xs text-muted-foreground">Jobs</div>
</div>
<div>
<div className="text-2xl font-semibold">
{summary.success_rate_24h}%
</div>
<div className="text-xs text-muted-foreground">24h Success</div>
</div>
<div>
<div className="text-2xl font-semibold">
{summary.active_alerts > 0 ? (
<Badge variant="destructive">{summary.active_alerts}</Badge>
) : (
0
)}
</div>
<div className="text-xs text-muted-foreground">Alerts</div>
</div>
{summary.last_failed_at ? (
<div className="self-center text-xs text-destructive">
Last failed:{" "}
{new Date(summary.last_failed_at * 1000).toLocaleString()}
</div>
) : null}
</div>
) : null}
</SectionCard>
);
}
@@ -0,0 +1,48 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ExternalLink } from "lucide-react";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
}
export function GrafanaLinkWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data, isLoading } = useWidgetData(
widget.id,
def?.refreshInterval ?? 0,
);
const url = data?.data?.url as string | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
{isLoading && !data ? (
<Skeleton className="h-10 w-48" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : url ? (
<Button asChild>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
>
Open Grafana
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
) : (
<Alert>
<AlertDescription>No Grafana URL configured.</AlertDescription>
</Alert>
)}
</SectionCard>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { SessionActivityPanel } from "../components/SessionActivityPanel";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { NowPlayingSession, WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
}
export function JellyfinWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data, isLoading } = useWidgetData(
widget.id,
def?.refreshInterval ?? 0,
);
const sessions = data?.data?.sessions as NowPlayingSession[] | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
{isLoading && !data ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : Array.isArray(sessions) ? (
<SessionActivityPanel
sessions={sessions}
emptyMessage="No recent user activity sessions right now."
/>
) : null}
</SectionCard>
);
}
@@ -0,0 +1,61 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
}
type PromQLResult = {
resultType?: string;
result?: unknown;
};
type PromQLVectorSample = {
metric?: Record<string, string>;
value?: [number, string];
};
function formatPrometheusValue(result: PromQLResult | undefined): string {
if (!result) return "No data";
if (result.resultType === "scalar" && Array.isArray(result.result)) {
return String(result.result[1] ?? "No data");
}
if (
result.resultType === "vector" &&
Array.isArray(result.result) &&
result.result.length > 0
) {
const first = result.result[0] as PromQLVectorSample;
if (first.value) return String(first.value[1]);
}
return JSON.stringify(result, null, 2);
}
export function PrometheusMetricWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data, isLoading } = useWidgetData(
widget.id,
def?.refreshInterval ?? 0,
);
const result = data?.data?.result as PromQLResult | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
{isLoading && !data ? (
<Skeleton className="h-10 w-32" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : (
<pre className="whitespace-pre-wrap text-sm">
{formatPrometheusValue(result)}
</pre>
)}
</SectionCard>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
}
type SshTaskResult = {
exit_status: number;
stdout: string;
stderr: string;
};
export function SshTaskWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data, isLoading } = useWidgetData(
widget.id,
def?.refreshInterval ?? 0,
);
const result = data?.data as SshTaskResult | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
{isLoading && !data ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : result ? (
<div className="flex flex-col gap-3">
<div className="text-xs text-muted-foreground">
Exit status:{" "}
<span
className={
result.exit_status === 0
? "text-green-600"
: "text-destructive"
}
>
{result.exit_status}
</span>
</div>
{result.stdout ? (
<pre className="max-h-64 overflow-auto rounded bg-muted p-2 text-xs">
{result.stdout}
</pre>
) : null}
{result.stderr ? (
<pre className="max-h-64 overflow-auto rounded bg-destructive/10 p-2 text-xs text-destructive">
{result.stderr}
</pre>
) : null}
</div>
) : null}
</SectionCard>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import { getWidgetDefinition } from "./registry";
interface Props {
widget: WidgetInstance;
}
export function StaticWidget({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
const { data } = useWidgetData(widget.id, def?.refreshInterval ?? 0);
const text = data?.data?.text as string | undefined;
return (
<SectionCard title={widget.title} description={def?.description}>
{text ? (
<p className="whitespace-pre-wrap text-sm">{text}</p>
) : (
<p className="text-sm text-muted-foreground">No content configured.</p>
)}
</SectionCard>
);
}
+8
View File
@@ -0,0 +1,8 @@
export { BackupsWidget } from "./BackupsWidget";
export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
export { JellyfinWidget } from "./JellyfinWidget";
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
export { SshTaskWidget } from "./SshTaskWidget";
export { StaticWidget } from "./StaticWidget";
export { getWidgetDefinition, listWidgetTypes, WIDGET_REGISTRY } from "./registry";
export type { WidgetConfigField, WidgetDefinition } from "./registry";
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import {
getWidgetDefinition,
listWidgetTypes,
WIDGET_REGISTRY,
} from "./registry";
describe("widget registry", () => {
it("contains exactly six Phase 1 types", () => {
const types = listWidgetTypes();
expect(types).toHaveLength(6);
expect(types.map((t) => t.widgetType).sort()).toEqual([
"backups",
"grafana-link",
"jellyfin",
"prometheus-metric",
"ssh-task",
"static",
]);
});
it("has refresh intervals matching the spec", () => {
expect(getWidgetDefinition("jellyfin")?.refreshInterval).toBe(30_000);
expect(getWidgetDefinition("backups")?.refreshInterval).toBe(60_000);
expect(getWidgetDefinition("grafana-link")?.refreshInterval).toBe(0);
expect(getWidgetDefinition("prometheus-metric")?.refreshInterval).toBe(
30_000,
);
expect(getWidgetDefinition("ssh-task")?.refreshInterval).toBe(0);
expect(getWidgetDefinition("static")?.refreshInterval).toBe(0);
});
it("defines required metadata for every widget", () => {
for (const def of Object.values(WIDGET_REGISTRY)) {
expect(def.widgetType).toBeTruthy();
expect(def.addonId).toBeTruthy();
expect(def.name).toBeTruthy();
expect(def.sourceType).toBeTruthy();
expect(def.component).toBeDefined();
}
});
});
+126
View File
@@ -0,0 +1,126 @@
import type { ComponentType } from "react";
import type { WidgetInstance } from "../types";
import { BackupsWidget } from "./BackupsWidget";
import { GrafanaLinkWidget } from "./GrafanaLinkWidget";
import { JellyfinWidget } from "./JellyfinWidget";
import { PrometheusMetricWidget } from "./PrometheusMetricWidget";
import { SshTaskWidget } from "./SshTaskWidget";
import { StaticWidget } from "./StaticWidget";
export interface WidgetConfigField {
key: string;
label: string;
type: "string" | "select" | "boolean" | "number";
options?: { label: string; value: string }[];
helper?: string;
}
export interface WidgetDefinition {
widgetType: string;
addonId: string;
name: string;
description: string;
sourceType: string;
refreshInterval: number;
defaultConfig: Record<string, unknown>;
configFields: WidgetConfigField[];
component: ComponentType<{ widget: WidgetInstance }>;
}
export const WIDGET_REGISTRY: Record<string, WidgetDefinition> = {
jellyfin: {
widgetType: "jellyfin",
addonId: "core",
name: "Jellyfin activity",
description: "Live sessions and idle users from a Jellyfin server.",
sourceType: "jellyfin",
refreshInterval: 30_000,
defaultConfig: { machine_id: "" },
configFields: [
{
key: "machine_id",
label: "Machine ID",
type: "string",
helper: "Jellyfin machine id (empty = default)",
},
],
component: JellyfinWidget,
},
backups: {
widgetType: "backups",
addonId: "backups",
name: "Backups",
description: "Backup job summary and active alerts.",
sourceType: "backups",
refreshInterval: 60_000,
defaultConfig: {},
configFields: [],
component: BackupsWidget,
},
"grafana-link": {
widgetType: "grafana-link",
addonId: "grafana",
name: "Grafana link",
description: "Deep-link to a Grafana dashboard or panel.",
sourceType: "grafana",
refreshInterval: 0,
defaultConfig: { dashboard_uid: "" },
configFields: [
{ key: "dashboard_uid", label: "Dashboard UID", type: "string" },
{
key: "panel_id",
label: "Panel ID",
type: "number",
helper: "Optional",
},
],
component: GrafanaLinkWidget,
},
"prometheus-metric": {
widgetType: "prometheus-metric",
addonId: "prometheus",
name: "Prometheus metric",
description: "Instant query result rendered as a metric.",
sourceType: "prometheus",
refreshInterval: 30_000,
defaultConfig: { promql: "" },
configFields: [
{ key: "promql", label: "PromQL query", type: "string" },
],
component: PrometheusMetricWidget,
},
"ssh-task": {
widgetType: "ssh-task",
addonId: "ssh-tasks",
name: "SSH task output",
description: "Output of a saved task run on a machine.",
sourceType: "ssh_task",
refreshInterval: 0,
defaultConfig: { task_id: "" },
configFields: [
{ key: "task_id", label: "Saved task ID", type: "string" },
],
component: SshTaskWidget,
},
static: {
widgetType: "static",
addonId: "core",
name: "Static text",
description: "Plain text or markdown note.",
sourceType: "static",
refreshInterval: 0,
defaultConfig: { text: "" },
configFields: [{ key: "text", label: "Text", type: "string" }],
component: StaticWidget,
},
};
export function getWidgetDefinition(
widgetType: string,
): WidgetDefinition | undefined {
return WIDGET_REGISTRY[widgetType];
}
export function listWidgetTypes(): WidgetDefinition[] {
return Object.values(WIDGET_REGISTRY);
}