From 16d64c42d8073f6ce3002b13848906def17934ee Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Thu, 17 Sep 2026 23:14:18 +0200 Subject: [PATCH] Enrich the Dashboard integration widgets to match the new DNS/Secrets sections The six integration cards only ever showed one headline number each (e.g. "3/5 devices online") -- thin compared to the new DNS/Secrets sections' stat-row-plus-breakdown layout. Each widget now shows 2-3 mini stats plus a compact breakdown bar, using data these endpoints already return (so no new API calls except one extra Synology call for CPU/RAM, matching what the Synology page itself already fetches): - Tailscale: online/unauthorized/expiring-soon stats + devices by OS - Proxmox: running/total + VM vs LXC counts + guests by node - Dockhand: running/total + host count + containers by state - Semaphore: template/failing counts + templates by last-run status - Gitea: repo/private/failing counts + repos by last-run status - Synology: volumes/unhealthy/CPU load + RAM used-of-total + disks by health status Extracted the breakdown-bar rendering out of the DNS/Secrets-only TypeBreakdown into a bare BreakdownBar (no card wrapper, optional compact sizing) so it drops into each integration card directly, and factored the per-item counting into a small breakdownFrom() helper used by all six. Status/state colors reuse the same palette each integration's own dedicated page already uses for its badges (Docker's container-state colors, Semaphore/Gitea's run-status colors); open- ended categories (OS names, Proxmox node names) get a generated palette instead since there's no fixed enum to hardcode against. Widget cards moved from a 3-column to a 2-column grid to fit the extra content without feeling cramped. Co-Authored-By: Claude Sonnet 5 --- README.md | 16 +- web/src/pages/Dashboard.tsx | 409 ++++++++++++++++++++++++++++-------- 2 files changed, 335 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 661c6f7..7d7ac65 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,15 @@ All modules from the original plan are built: - Monorepo scaffold, Tabler-themed app shell/navigation - Authentik OIDC login, roles (first user to sign in becomes admin), audit log -- **Dashboard** — an overview of every system this app tracks, not just the - six integrations: DNS (domain/record counts per provider, cached records - broken down by type), Secrets (monitored/expiring/expired counts, broken - down by type), and the existing live per-integration widgets - (Proxmox/Synology/Semaphore/Tailscale/Gitea/Dockhand). Breakdowns render - as a stacked proportion bar with a legend — no charting library, matching - the rest of the app's plain-Tabler-CSS approach. +- **Dashboard** — an overview of every system this app tracks: DNS (domain/ + record counts per provider, cached records broken down by type), Secrets + (monitored/expiring/expired counts, broken down by type), and a widget per + integration with a small stat row plus its own breakdown — Tailscale by + OS, Proxmox by node (VM/LXC counts too), Dockhand by container state (plus + host count), Semaphore and Gitea by last-run status (plus private-repo + count), and Synology's CPU/RAM alongside its disk-health breakdown. + Breakdowns render as a stacked proportion bar with a legend — no charting + library, matching the rest of the app's plain-Tabler-CSS approach. - **Diagnostic Log** (admin-only) — every call this app makes to a DNS provider or integration (Tailscale, Proxmox, Synology, Semaphore, Gitea, Dockhand), success or failure, with latency and the error message if it diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 847b19f..a585c9b 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -35,6 +35,57 @@ const SECRET_TYPE_COLORS: Record = { generic: "#94a3b8", }; +const CONTAINER_STATE_COLORS: Record = { + running: "#4ade80", + exited: "#f87171", + dead: "#f87171", + paused: "#fbbf24", + restarting: "#60a5fa", +}; + +const TASK_STATUS_COLORS: Record = { + success: "#4ade80", + error: "#f87171", + stopped: "#94a3b8", + rejected: "#94a3b8", + waiting: "#fbbf24", + starting: "#fbbf24", + waiting_confirmation: "#fbbf24", + confirmed: "#fbbf24", + running: "#60a5fa", + stopping: "#60a5fa", + "never run": "#94a3b8", +}; + +const REPO_STATUS_COLORS: Record = { + success: "#4ade80", + failure: "#f87171", + cancelled: "#94a3b8", + skipped: "#94a3b8", + waiting: "#fbbf24", + queued: "#fbbf24", + pending: "#fbbf24", + running: "#60a5fa", + in_progress: "#60a5fa", + "no runs": "#94a3b8", +}; + +const GENERIC_PALETTE = ["#6366f1", "#22d3ee", "#f472b6", "#4ade80", "#fb923c", "#a3e635", "#c084fc", "#facc15", "#60a5fa"]; + +function paletteColors(labels: string[]): Record { + const colors: Record = {}; + labels.forEach((label, i) => { + colors[label] = GENERIC_PALETTE[i % GENERIC_PALETTE.length]; + }); + return colors; +} + +function healthColors(labels: string[]): Record { + const colors: Record = {}; + for (const label of labels) colors[label] = label === "normal" ? "#4ade80" : "#f87171"; + return colors; +} + function SectionHeader({ title, hint }: { title: string; hint?: ReactNode }) { return (
@@ -58,49 +109,78 @@ function StatTile({ label, value, sub, accent }: { label: string; value: ReactNo ); } -function TypeBreakdown({ title, data, colors }: { title: string; data: { label: string; count: number }[]; colors: Record }) { +function breakdownFrom(items: T[], keyOf: (item: T) => string): { label: string; count: number }[] { + const counts = new Map(); + for (const item of items) { + const key = keyOf(item); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return [...counts.entries()].map(([label, count]) => ({ label, count })).sort((a, b) => b.count - a.count); +} + +function BreakdownBar({ data, colors, compact }: { data: { label: string; count: number }[]; colors: Record; compact?: boolean }) { const total = data.reduce((s, d) => s + d.count, 0); if (total === 0) return null; + return ( +
+
+ {data.map((d) => ( +
+ ))} +
+
+ {data.map((d) => ( +
+ + + {d.label} {compact ? `(${d.count})` : ({d.count})} + +
+ ))} +
+
+ ); +} + +function TypeBreakdown({ title, data, colors }: { title: string; data: { label: string; count: number }[]; colors: Record }) { + if (data.reduce((s, d) => s + d.count, 0) === 0) return null; return (

{title}

-
- {data.map((d) => ( -
- ))} -
-
- {data.map((d) => ( -
- - - {d.label} ({d.count}) - -
- ))} -
+
); } +function MiniStat({ label, value, accent }: { label: string; value: ReactNode; accent?: string }) { + return ( +
+
+ {value} +
+
{label}
+
+ ); +} + const WIDGETS: { type: IntegrationType; label: string }[] = [ { type: "proxmox", label: "Proxmox" }, { type: "synology", label: "Synology NAS" }, @@ -112,33 +192,62 @@ const WIDGETS: { type: IntegrationType; label: string }[] = [ interface GiteaSummary { repoCount: number; + privateCount: number; failing: number; + statusBreakdown: { label: string; count: number }[]; } interface DockhandSummary { running: number; total: number; + hostCount: number; + stateBreakdown: { label: string; count: number }[]; } interface SemaphoreSummary { total: number; failing: number; + statusBreakdown: { label: string; count: number }[]; } interface ProxmoxSummary { running: number; total: number; + vmCount: number; + lxcCount: number; + nodeBreakdown: { label: string; count: number }[]; } interface SynologySummary { volumeCount: number; volumesNotNormal: number; disksNotNormal: number; + cpuLoadPercent: number | null; + memUsedBytes: number | null; + memTotalBytes: number | null; + diskStatusBreakdown: { label: string; count: number }[]; +} + +interface TailscaleExtra { + osBreakdown: { label: string; count: number }[]; +} + +function formatBytes(bytes: number | null): string { + if (bytes === null) return "—"; + const units = ["B", "KB", "MB", "GB", "TB", "PB"]; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(1)} ${units[unit]}`; } export default function Dashboard({ user }: { user: CurrentUser }) { const [integrations, setIntegrations] = useState(null); const [tailscaleSummary, setTailscaleSummary] = useState(null); + const [tailscaleExtra, setTailscaleExtra] = useState(null); const [giteaSummary, setGiteaSummary] = useState(null); const [dockhandSummary, setDockhandSummary] = useState(null); const [semaphoreSummary, setSemaphoreSummary] = useState(null); @@ -172,8 +281,14 @@ export default function Dashboard({ user }: { user: CurrentUser }) { } api.integrations.tailscale .devices(tailscale.id) - .then((res) => setTailscaleSummary(res.summary)) - .catch(() => setTailscaleSummary(null)); + .then((res) => { + setTailscaleSummary(res.summary); + setTailscaleExtra({ osBreakdown: breakdownFrom(res.devices, (d) => d.os || "unknown") }); + }) + .catch(() => { + setTailscaleSummary(null); + setTailscaleExtra(null); + }); }, [integrations]); useEffect(() => { @@ -187,7 +302,9 @@ export default function Dashboard({ user }: { user: CurrentUser }) { .then((res) => setGiteaSummary({ repoCount: res.repos.length, + privateCount: res.repos.filter((r) => r.private).length, failing: res.repos.filter((r) => r.latestRun?.conclusion === "failure" || r.latestRun?.status === "failure").length, + statusBreakdown: breakdownFrom(res.repos, (r) => (r.latestRun ? r.latestRun.conclusion || r.latestRun.status : "no runs")), }), ) .catch(() => setGiteaSummary(null)); @@ -201,7 +318,14 @@ export default function Dashboard({ user }: { user: CurrentUser }) { } api.integrations.dockhand .containers(dockhand.id) - .then((res) => setDockhandSummary({ running: res.summary.running, total: res.summary.total })) + .then((res) => + setDockhandSummary({ + running: res.summary.running, + total: res.summary.total, + hostCount: new Set(res.containers.map((c) => c.environmentName)).size, + stateBreakdown: breakdownFrom(res.containers, (c) => c.state), + }), + ) .catch(() => setDockhandSummary(null)); }, [integrations]); @@ -213,7 +337,12 @@ export default function Dashboard({ user }: { user: CurrentUser }) { } api.integrations.semaphore .templates(semaphore.id) - .then((res) => setSemaphoreSummary(res.summary)) + .then((res) => + setSemaphoreSummary({ + ...res.summary, + statusBreakdown: breakdownFrom(res.templates, (t) => t.lastTask?.status ?? "never run"), + }), + ) .catch(() => setSemaphoreSummary(null)); }, [integrations]); @@ -225,7 +354,15 @@ export default function Dashboard({ user }: { user: CurrentUser }) { } api.integrations.proxmox .guests(proxmox.id) - .then((res) => setProxmoxSummary({ running: res.summary.running, total: res.summary.total })) + .then((res) => + setProxmoxSummary({ + running: res.summary.running, + total: res.summary.total, + vmCount: res.guests.filter((g) => g.type === "qemu").length, + lxcCount: res.guests.filter((g) => g.type === "lxc").length, + nodeBreakdown: breakdownFrom(res.guests, (g) => g.node), + }), + ) .catch(() => setProxmoxSummary(null)); }, [integrations]); @@ -235,14 +372,32 @@ export default function Dashboard({ user }: { user: CurrentUser }) { setSynologySummary(null); return; } + api.integrations.synology + .system(synology.id) + .then((sys) => + setSynologySummary((prev) => ({ + volumeCount: prev?.volumeCount ?? 0, + volumesNotNormal: prev?.volumesNotNormal ?? 0, + disksNotNormal: prev?.disksNotNormal ?? 0, + diskStatusBreakdown: prev?.diskStatusBreakdown ?? [], + cpuLoadPercent: sys.cpu.loadPercent, + memUsedBytes: sys.memory.usedBytes, + memTotalBytes: sys.memory.totalBytes, + })), + ) + .catch(() => {}); api.integrations.synology .storage(synology.id) .then((res) => - setSynologySummary({ + setSynologySummary((prev) => ({ + cpuLoadPercent: prev?.cpuLoadPercent ?? null, + memUsedBytes: prev?.memUsedBytes ?? null, + memTotalBytes: prev?.memTotalBytes ?? null, volumeCount: res.summary.volumeCount, volumesNotNormal: res.summary.volumesNotNormal, disksNotNormal: res.summary.disksNotNormal, - }), + diskStatusBreakdown: breakdownFrom(res.disks, (d) => d.status), + })), ) .catch(() => setSynologySummary(null)); }, [integrations]); @@ -376,10 +531,10 @@ export default function Dashboard({ user }: { user: CurrentUser }) { const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore || isLiveProxmox || isLiveSynology; return ( -
-
+
+
-
+
{label}
{isLive ? ( @@ -392,60 +547,148 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
{isLiveTailscale ? ( -
-
- {tailscaleSummary!.online}/{tailscaleSummary!.total} + <> +
+
+ +
+
+ 0 ? "#f59e0b" : undefined} + /> +
+
+ 0 ? "#f59e0b" : undefined} + /> +
-
devices online
- {tailscaleSummary!.unauthorized > 0 && ( -
{tailscaleSummary!.unauthorized} awaiting authorization
+ {tailscaleExtra && tailscaleExtra.osBreakdown.length > 0 && ( + d.label))} + compact + /> )} - {tailscaleSummary!.expiringSoon > 0 && ( -
{tailscaleSummary!.expiringSoon} key(s) expiring soon
- )} -
+ ) : isLiveGitea ? ( -
-
{giteaSummary!.repoCount}
-
repositories
- {giteaSummary!.failing > 0 && ( -
{giteaSummary!.failing} with a failing build
+ <> +
+
+ +
+
+ +
+
+ 0 ? "#ef4444" : undefined} + /> +
+
+ {giteaSummary!.statusBreakdown.length > 0 && ( + )} -
+ ) : isLiveDockhand ? ( -
-
- {dockhandSummary!.running}/{dockhandSummary!.total} + <> +
+
+ +
+
+ +
+
+ 0 ? "#f59e0b" : undefined} + /> +
-
containers running
-
- ) : isLiveSemaphore ? ( -
-
{semaphoreSummary!.total}
-
templates
- {semaphoreSummary!.failing > 0 && ( -
{semaphoreSummary!.failing} last failed
+ {dockhandSummary!.stateBreakdown.length > 0 && ( + )} -
- ) : isLiveProxmox ? ( -
-
- {proxmoxSummary!.running}/{proxmoxSummary!.total} + + ) : isLiveSemaphore ? ( + <> +
+
+ +
+
+ 0 ? "#ef4444" : undefined} + /> +
-
VMs/containers running
-
+ {semaphoreSummary!.statusBreakdown.length > 0 && ( + + )} + + ) : isLiveProxmox ? ( + <> +
+
+ +
+
+ +
+
+ +
+
+ {proxmoxSummary!.nodeBreakdown.length > 1 && ( + d.label))} + compact + /> + )} + ) : isLiveSynology ? ( -
-
{synologySummary!.volumeCount}
-
volumes
- {(synologySummary!.volumesNotNormal > 0 || synologySummary!.disksNotNormal > 0) && ( -
- {synologySummary!.volumesNotNormal > 0 && `${synologySummary!.volumesNotNormal} volume(s) unhealthy`} - {synologySummary!.volumesNotNormal > 0 && synologySummary!.disksNotNormal > 0 && ", "} - {synologySummary!.disksNotNormal > 0 && `${synologySummary!.disksNotNormal} disk(s) unhealthy`} + <> +
+
+ +
+
+ 0 ? "#ef4444" : undefined} + /> +
+
+ +
+
+ {synologySummary!.memTotalBytes !== null && ( +
+ RAM: {formatBytes(synologySummary!.memUsedBytes)} / {formatBytes(synologySummary!.memTotalBytes)}
)} -
+ {synologySummary!.diskStatusBreakdown.length > 0 && ( + d.label))} + compact + /> + )} + ) : (
{integration ? (