diff --git a/frontend/src/api/widgets.ts b/frontend/src/api/widgets.ts new file mode 100644 index 0000000..7265984 --- /dev/null +++ b/frontend/src/api/widgets.ts @@ -0,0 +1,69 @@ +import type { + WidgetDataResponse, + WidgetInstance, + WidgetInstanceInput, + WidgetTypeInfo, +} from "../types"; + +const API_BASE = "/api"; + +export async function fetchWidgetSources(): Promise { + const res = await fetch(`${API_BASE}/widgets/sources`); + if (!res.ok) throw new Error("Failed to fetch widget sources"); + return res.json(); +} + +export async function fetchWidgetTypes(): Promise { + const res = await fetch(`${API_BASE}/widgets/types`); + if (!res.ok) throw new Error("Failed to fetch widget types"); + return res.json(); +} + +export async function fetchWidgetInstances(): Promise { + const res = await fetch(`${API_BASE}/widgets/instances`); + if (!res.ok) throw new Error("Failed to fetch widget instances"); + return res.json(); +} + +export async function createWidgetInstance( + input: WidgetInstanceInput, +): Promise { + const res = await fetch(`${API_BASE}/widgets/instances`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) throw new Error("Failed to create widget instance"); + return res.json(); +} + +export async function updateWidgetInstance( + input: WidgetInstanceInput, +): Promise { + if (!input.id) throw new Error("Widget ID is required for update"); + const res = await fetch(`${API_BASE}/widgets/instances/${input.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) throw new Error("Failed to update widget instance"); + return res.json(); +} + +export async function deleteWidgetInstance( + widgetId: string, +): Promise<{ status: string }> { + const res = await fetch(`${API_BASE}/widgets/instances/${widgetId}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error("Failed to delete widget instance"); + return res.json(); +} + +export async function fetchWidgetData( + widgetId: string, +): Promise { + const res = await fetch(`${API_BASE}/widgets/instances/${widgetId}/data`); + if (!res.ok) throw new Error("Failed to fetch widget data"); + return res.json(); +} diff --git a/frontend/src/hooks/useWidgets.ts b/frontend/src/hooks/useWidgets.ts new file mode 100644 index 0000000..128caed --- /dev/null +++ b/frontend/src/hooks/useWidgets.ts @@ -0,0 +1,64 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + createWidgetInstance, + deleteWidgetInstance, + fetchWidgetData, + fetchWidgetInstances, + fetchWidgetSources, + fetchWidgetTypes, + updateWidgetInstance, +} from "../api/widgets"; +import type { WidgetInstanceInput } from "../types"; + +export function useWidgetInstances() { + return useQuery({ + queryKey: ["widgets", "instances"], + queryFn: fetchWidgetInstances, + refetchInterval: 60_000, + }); +} + +export function useWidgetData(widgetId: string, refreshInterval: number) { + return useQuery({ + queryKey: ["widgets", "data", widgetId], + queryFn: () => fetchWidgetData(widgetId), + refetchInterval: refreshInterval || false, + enabled: !!widgetId, + retry: 1, + }); +} + +export function useSaveWidgetInstance() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: WidgetInstanceInput) => + input.id ? updateWidgetInstance(input) : createWidgetInstance(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] }); + }, + }); +} + +export function useDeleteWidgetInstance() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (widgetId: string) => deleteWidgetInstance(widgetId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] }); + }, + }); +} + +export function useWidgetSources() { + return useQuery({ + queryKey: ["widgets", "sources"], + queryFn: fetchWidgetSources, + }); +} + +export function useWidgetTypes() { + return useQuery({ + queryKey: ["widgets", "types"], + queryFn: fetchWidgetTypes, + }); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 3d185f2..375dad7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -443,3 +443,42 @@ export interface PrometheusTarget { labels: Record; targets: string[]; } + +export interface WidgetInstance { + id: string; + addon_id: string; + widget_type: string; + title: string; + config: Record; + enabled: boolean; + sort_order: number; + created_at: number; + updated_at: number; +} + +export interface WidgetInstanceInput { + id?: string | null; + addon_id: string; + widget_type: string; + title: string; + config: Record; + enabled: boolean; + sort_order: number; +} + +export interface WidgetTypeInfo { + addon_id: string; + widget_type: string; + name: string; + description: string; + source_type: string; + config_schema: Record; +} + +export interface WidgetDataResponse { + widget_id: string; + widget_type: string; + data: Record | null; + error: string | null; + fetched_at: number; +} diff --git a/frontend/src/widgets/BackupsWidget.tsx b/frontend/src/widgets/BackupsWidget.tsx new file mode 100644 index 0000000..7ff1684 --- /dev/null +++ b/frontend/src/widgets/BackupsWidget.tsx @@ -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 ( + + {isLoading && !data ? ( +
+ + + +
+ ) : data?.error ? ( + + {data.error} + + ) : summary ? ( +
+
+
{summary.total_jobs}
+
Jobs
+
+
+
+ {summary.success_rate_24h}% +
+
24h Success
+
+
+
+ {summary.active_alerts > 0 ? ( + {summary.active_alerts} + ) : ( + 0 + )} +
+
Alerts
+
+ {summary.last_failed_at ? ( +
+ Last failed:{" "} + {new Date(summary.last_failed_at * 1000).toLocaleString()} +
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/frontend/src/widgets/GrafanaLinkWidget.tsx b/frontend/src/widgets/GrafanaLinkWidget.tsx new file mode 100644 index 0000000..fc0d122 --- /dev/null +++ b/frontend/src/widgets/GrafanaLinkWidget.tsx @@ -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 ( + + {isLoading && !data ? ( + + ) : data?.error ? ( + + {data.error} + + ) : url ? ( + + ) : ( + + No Grafana URL configured. + + )} + + ); +} diff --git a/frontend/src/widgets/JellyfinWidget.tsx b/frontend/src/widgets/JellyfinWidget.tsx new file mode 100644 index 0000000..ed9de46 --- /dev/null +++ b/frontend/src/widgets/JellyfinWidget.tsx @@ -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 ( + + {isLoading && !data ? ( +
+ + +
+ ) : data?.error ? ( + + {data.error} + + ) : Array.isArray(sessions) ? ( + + ) : null} +
+ ); +} diff --git a/frontend/src/widgets/PrometheusMetricWidget.tsx b/frontend/src/widgets/PrometheusMetricWidget.tsx new file mode 100644 index 0000000..6b53d8f --- /dev/null +++ b/frontend/src/widgets/PrometheusMetricWidget.tsx @@ -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; + 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 ( + + {isLoading && !data ? ( + + ) : data?.error ? ( + + {data.error} + + ) : ( +
+					{formatPrometheusValue(result)}
+				
+ )} +
+ ); +} diff --git a/frontend/src/widgets/SshTaskWidget.tsx b/frontend/src/widgets/SshTaskWidget.tsx new file mode 100644 index 0000000..d419187 --- /dev/null +++ b/frontend/src/widgets/SshTaskWidget.tsx @@ -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 ( + + {isLoading && !data ? ( +
+ + +
+ ) : data?.error ? ( + + {data.error} + + ) : result ? ( +
+
+ Exit status:{" "} + + {result.exit_status} + +
+ {result.stdout ? ( +
+							{result.stdout}
+						
+ ) : null} + {result.stderr ? ( +
+							{result.stderr}
+						
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/frontend/src/widgets/StaticWidget.tsx b/frontend/src/widgets/StaticWidget.tsx new file mode 100644 index 0000000..512da9b --- /dev/null +++ b/frontend/src/widgets/StaticWidget.tsx @@ -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 ( + + {text ? ( +

{text}

+ ) : ( +

No content configured.

+ )} +
+ ); +} diff --git a/frontend/src/widgets/index.ts b/frontend/src/widgets/index.ts new file mode 100644 index 0000000..91b4866 --- /dev/null +++ b/frontend/src/widgets/index.ts @@ -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"; diff --git a/frontend/src/widgets/registry.test.ts b/frontend/src/widgets/registry.test.ts new file mode 100644 index 0000000..859c9d8 --- /dev/null +++ b/frontend/src/widgets/registry.test.ts @@ -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(); + } + }); +}); diff --git a/frontend/src/widgets/registry.ts b/frontend/src/widgets/registry.ts new file mode 100644 index 0000000..eb70dd7 --- /dev/null +++ b/frontend/src/widgets/registry.ts @@ -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; + configFields: WidgetConfigField[]; + component: ComponentType<{ widget: WidgetInstance }>; +} + +export const WIDGET_REGISTRY: Record = { + 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); +} diff --git a/openspec/changes/configurable-dashboard-widgets/apply-progress.md b/openspec/changes/configurable-dashboard-widgets/apply-progress.md index 55b3208..2361c81 100644 --- a/openspec/changes/configurable-dashboard-widgets/apply-progress.md +++ b/openspec/changes/configurable-dashboard-widgets/apply-progress.md @@ -93,9 +93,56 @@ Focused widget test output: `27 passed`. - Adapters currently call `get_settings_store()` internally for `backups`/`ssh_task` sources. The router-level endpoint uses FastAPI DI, but adapter unit tests patch `get_settings_store` to inject a test store. A future refactor can pass `store` and `settings` explicitly into `adapter.fetch()` for cleaner testability. +## Completed tasks (Slice 3) + +All Slice 3 tasks are marked `- [x]` in `tasks.md`: + +- [x] 3.1 Add TypeScript widget interfaces (`WidgetInstance`, `WidgetInstanceInput`, `WidgetTypeInfo`, `WidgetDataResponse`) +- [x] 3.2 Create widget API client (`frontend/src/api/widgets.ts`) +- [x] 3.3 Create widget TanStack Query hooks (`frontend/src/hooks/useWidgets.ts`) +- [x] 3.4 Create frontend widget registry (`frontend/src/widgets/registry.ts`) +- [x] 3.5 Implement six widget presentational components (`frontend/src/widgets/*.tsx`) +- [x] 3.6 Add frontend registry unit test (`frontend/src/widgets/registry.test.ts`) + +## Files changed (Slice 3) + +### New files + +- `frontend/src/api/widgets.ts` — API functions for widget CRUD, registry metadata, and per-widget data. +- `frontend/src/hooks/useWidgets.ts` — TanStack Query hooks for instances, data, sources, types, and mutations. +- `frontend/src/widgets/registry.ts` — Closed frontend registry with metadata, refresh intervals, and config fields. +- `frontend/src/widgets/JellyfinWidget.tsx` — Renders Jellyfin session activity. +- `frontend/src/widgets/BackupsWidget.tsx` — Renders backup dashboard summary. +- `frontend/src/widgets/GrafanaLinkWidget.tsx` — Renders a deep-link to Grafana (no iframe). +- `frontend/src/widgets/PrometheusMetricWidget.tsx` — Renders PromQL instant query result. +- `frontend/src/widgets/SshTaskWidget.tsx` — Renders saved SSH task output. +- `frontend/src/widgets/StaticWidget.tsx` — Renders static text. +- `frontend/src/widgets/index.ts` — Barrel exports. +- `frontend/src/widgets/registry.test.ts` — Vitest unit tests for registry metadata. + +### Modified files + +- `frontend/src/types/index.ts` — Added widget TypeScript interfaces. + +## Verification (Slice 3) + +```bash +cd backend +.venv/bin/python -m ruff check . # All checks passed +PYTHONPATH=src .venv/bin/python -m pytest # 200 passed, 2 warnings +cd ../frontend +npm run lint # 2 pre-existing warnings, 0 errors +npm run build # Built successfully +npm run test -- src/widgets/registry.test.ts # 3 passed +``` + +## Deviations from design (Slice 3) + +- Registry unit test is colocated at `frontend/src/widgets/registry.test.ts` and runs with Vitest, matching the project's existing `npm run test` setup, instead of `frontend/tests/widgets.test.mjs`. +- `JellyfinWidget` uses `SessionActivityPanel` directly because `NowPlaying` does not expose an `emptyMessage` prop. + ## Remaining work -- Slice 3: Frontend types/API/hooks/registry/components - Slice 4: Dashboard loop + configuration UI + addon pages ## PR boundary