feat: phase 2 widgets — registry, fetchers, clock, config dialog

Backend: internal/widget registry (type metadata + Fetcher), Pi-hole
v5 / AdGuard / Immich fetchers, /api/widgets/types + /api/widgets/:id/data
with 30s TTL cache (failures cached too), kind+config on item create/patch.
Config secrets are never logged or echoed in errors.

Frontend: WidgetContent host maps config.type to a component, polls
/data every 30s for server-backed types; clock renders client-side.
Generic WidgetDialog builds its form from the registry's field list.
Widget tiles are sortable items in both renderers.

Generated with [Devin](https://devin.ai)

Authored By: TDvorak <[email protected]>
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas Dvorak committed 2026-09-17 20:29:22 +02:00
1 parent 6004105038
commit 8afc7df53b
21 files changed
+1072 -40

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
import type { Item } from "@/types";
import type { WidgetData } from "@/widgets/index";
// Shared DNS-blocker layout: big block-rate, queries + blocked counts.
// Pi-hole and AdGuard return the same fields from their fetchers.
function num(d: WidgetData | undefined, k: string): number {
const v = d?.[k];
return typeof v === "number" ? v : 0;
}
const fmt = (n: number) =>
n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
function BlockerWidget({ item, data, error }: { item: Item; data: WidgetData | undefined; error: string | undefined }) {
if (error) {
return <span className="font-mono text-[11px] text-text-faint">{item.name} — {error}</span>;
}
const pct = num(data, "blockedPercent");
return (
<div className="flex min-w-0 items-center gap-4">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="font-mono text-[22px] font-medium leading-none tracking-tight tabular-nums">
{data ? `${pct.toFixed(1)}%` : "…"}
</span>
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">blocked</span>
</div>
<div className="ml-auto flex flex-col items-end gap-1 font-mono text-[10.5px] text-text-faint">
<span>{fmt(num(data, "queriesToday"))} queries</span>
<span>{fmt(num(data, "blockedToday"))} blocked</span>
{typeof data?.avgProcessMs === "number" && <span>{num(data, "avgProcessMs").toFixed(0)} ms avg</span>}
</div>
</div>
);
}
export function PiholeWidget(p: { item: Item; data: WidgetData | undefined; error: string | undefined }) {
return <BlockerWidget {...p} />;
}
export function AdguardWidget(p: { item: Item; data: WidgetData | undefined; error: string | undefined }) {
return <BlockerWidget {...p} />;
}
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useState } from "react";
import type { Item } from "@/types";
import { cfgStr } from "@/widgets/index";
// Clock — fully client-side; ticks once a second.
export function ClockWidget({ item }: { item: Item }) {
const tz = cfgStr(item, "timezone") || undefined;
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(t);
}, []);
const time = new Intl.DateTimeFormat(undefined, {
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, timeZone: tz,
}).format(now);
const date = new Intl.DateTimeFormat(undefined, {
weekday: "short", month: "short", day: "numeric", timeZone: tz,
}).format(now);
return (
<div className="flex min-w-0 flex-col justify-center gap-0.5">
<span className="font-mono text-[22px] font-medium leading-none tracking-tight tabular-nums">
{time}
</span>
<span className="truncate text-[10.5px] uppercase tracking-[0.1em] text-text-faint">
{date}{tz ? ` · ${tz.split("/").pop()?.replace("_", " ")}` : ""}
</span>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { Item } from "@/types";
import type { WidgetData } from "@/widgets/index";
// Immich — photo/video counts and library footprint.
function num(d: WidgetData | undefined, k: string): number {
const v = d?.[k];
return typeof v === "number" ? v : 0;
}
const fmtNum = (n: number) =>
n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
const fmtBytes = (b: number) =>
b >= 1 << 30 ? `${(b / (1 << 30)).toFixed(1)} GB` : `${(b / (1 << 20)).toFixed(0)} MB`;
export function ImmichWidget({ item, data, error }: { item: Item; data: WidgetData | undefined; error: string | undefined }) {
if (error) {
return <span className="font-mono text-[11px] text-text-faint">{item.name} — {error}</span>;
}
return (
<div className="flex min-w-0 items-center gap-4">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="font-mono text-[22px] font-medium leading-none tracking-tight tabular-nums">
{data ? fmtNum(num(data, "photos")) : "…"}
</span>
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">photos</span>
</div>
<div className="ml-auto flex flex-col items-end gap-1 font-mono text-[10.5px] text-text-faint">
<span>{fmtNum(num(data, "videos"))} videos</span>
<span>{fmtBytes(num(data, "usageBytes"))}</span>
</div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { useEffect, useState } from "react";
import type { Item } from "@/types";
import { ClockWidget } from "@/widgets/clock";
import { PiholeWidget, AdguardWidget } from "@/widgets/blocker";
import { ImmichWidget } from "@/widgets/immich";
// WidgetHost seam: renderers own the tile chrome; this maps config.type to
// the component that fills it. Adding a widget = one fetcher in Go + one
// entry here.
export type WidgetComponent = (p: { item: Item; data: WidgetData | undefined; error: string | undefined }) => React.ReactNode;
export interface WidgetData {
[key: string]: string | number | boolean | undefined;
}
// Widget payloads are free-form per type; components read fields defensively.
function isData(v: unknown): v is WidgetData {
return typeof v === "object" && v !== null;
}
interface WidgetRegistry {
[type: string]: { local?: boolean; view: WidgetComponent };
}
const registry: WidgetRegistry = {
clock: { local: true, view: ClockWidget },
pihole: { view: PiholeWidget },
adguard: { view: AdguardWidget },
immich: { view: ImmichWidget },
};
export function cfgStr(item: Item, key: string): string {
const cfg = item.config;
if (!cfg) return "";
const v = cfg[key];
return typeof v === "string" ? v : "";
}
export function cfgType(item: Item): string {
return cfgStr(item, "type");
}
/** Polls /api/widgets/:id/data every 30s for server-backed widgets. */
export function useWidgetData(item: Item): { data: WidgetData | undefined; error: string | undefined } {
const type = cfgType(item);
const local = registry[type]?.local ?? false;
const [state, setState] = useState<{ data: WidgetData | undefined; error: string | undefined }>({
data: undefined, error: undefined,
});
useEffect(() => {
if (local || !type) return;
let dead = false;
let timer: ReturnType<typeof setTimeout>;
const tick = async () => {
try {
const res = await fetch(`/api/widgets/${item.id}/data`);
const body: unknown = await res.json();
if (dead) return;
if (!res.ok) {
let msg = "fetch failed";
if (typeof body === "object" && body !== null && "error" in body) {
msg = String(body.error);
}
setState((s) => ({ data: s.data, error: msg }));
} else {
let data: WidgetData = {};
if (typeof body === "object" && body !== null && "data" in body && isData(body.data)) {
data = body.data;
}
setState({ data, error: undefined });
}
} catch {
if (!dead) setState((s) => ({ data: s.data, error: "offline" }));
}
if (!dead) timer = setTimeout(tick, 30_000);
};
void tick();
return () => {
dead = true;
clearTimeout(timer);
};
}, [item.id, type, local]);
return state;
}
/** WidgetContent renders inside a renderer's tile chrome. */
export function WidgetContent({ item }: { item: Item }) {
const { data, error } = useWidgetData(item);
const type = cfgType(item);
const entry = registry[type];
if (!entry) {
return <span className="text-[11px] uppercase tracking-[0.1em] text-text-faint">{item.name || "widget"}</span>;
}
return <>{entry.view({ item, data, error })}</>;
}