Merge pull request 'feat(widgets): add frontend widget runtime (types, API, hooks, registry, components)' (#4) from feat/dashboard-widgets-frontend-runtime into main
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
WidgetDataResponse,
|
||||
WidgetInstance,
|
||||
WidgetInstanceInput,
|
||||
WidgetTypeInfo,
|
||||
} from "../types";
|
||||
|
||||
const API_BASE = "/api";
|
||||
|
||||
export async function fetchWidgetSources(): Promise<string[]> {
|
||||
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<WidgetTypeInfo[]> {
|
||||
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<WidgetInstance[]> {
|
||||
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<WidgetInstance> {
|
||||
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<WidgetInstance> {
|
||||
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<WidgetDataResponse> {
|
||||
const res = await fetch(`${API_BASE}/widgets/instances/${widgetId}/data`);
|
||||
if (!res.ok) throw new Error("Failed to fetch widget data");
|
||||
return res.json();
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -443,3 +443,42 @@ export interface PrometheusTarget {
|
||||
labels: Record<string, string>;
|
||||
targets: string[];
|
||||
}
|
||||
|
||||
export interface WidgetInstance {
|
||||
id: string;
|
||||
addon_id: string;
|
||||
widget_type: string;
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface WidgetTypeInfo {
|
||||
addon_id: string;
|
||||
widget_type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
source_type: string;
|
||||
config_schema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WidgetDataResponse {
|
||||
widget_id: string;
|
||||
widget_type: string;
|
||||
data: Record<string, unknown> | null;
|
||||
error: string | null;
|
||||
fetched_at: number;
|
||||
}
|
||||
|
||||
@@ -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,44 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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";
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user