From 08eb0bd87e1f291565ff0d5aa7306f2a96d6d3f3 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Sat, 19 Sep 2026 00:07:32 +0200 Subject: [PATCH] Make DNS and Secrets dashboard widgets match the integration widgets DNS and Secrets each rendered as three separate stat-tile cards plus a fourth full-width breakdown card -- visually a different family from the single self-contained card each integration widget uses (label + status badge, a compact stat row, then its own breakdown bar inline). Extracted the integration widgets' card shell into a shared WidgetCard component (label + badge header, children below) and rebuilt DNS and Secrets on top of it instead of StatTile/TypeBreakdown, which are now unused and removed. Also refactored the Integrations map itself onto WidgetCard, so all eight dashboard widgets (DNS, Secrets, and the six integrations) are now literally the same component, not just visually similar. DNS and Secrets get a badge like the integrations' Connected/Not connected -- "Not configured" (gray) when nothing's set up yet, or a status summary otherwise ("X enabled" for DNS; "All OK" / "X expiring" / "X expired" for Secrets, mirroring how a failing integration shows a warning-colored count instead of a plain badge). They now sit under one "Overview" heading in a shared row instead of two separate sections, matching how the integration widgets already share one row-cards grid. Co-Authored-By: Claude Sonnet 5 --- README.md | 17 +- web/src/pages/Dashboard.tsx | 546 +++++++++++++++++------------------- 2 files changed, 272 insertions(+), 291 deletions(-) diff --git a/README.md b/README.md index 471fdc7..6506680 100644 --- a/README.md +++ b/README.md @@ -17,14 +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: 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 +- **Dashboard** — an overview of every system this app tracks, all sharing + one widget-card design (label + status badge, a small stat row, then its + own breakdown): DNS (domain/record counts per provider, cached records by + type), Secrets (monitored/expiring/expired, by type), and one widget per + integration — 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, diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 5c99de6..5ae9052 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -95,20 +95,6 @@ function SectionHeader({ title, hint }: { title: string; hint?: ReactNode }) { ); } -function StatTile({ label, value, sub, accent }: { label: string; value: ReactNode; sub?: ReactNode; accent?: string }) { - return ( -
-
-
{label}
-
- {value} -
- {sub &&
{sub}
} -
-
- ); -} - function breakdownFrom(items: T[], keyOf: (item: T) => string): { label: string; count: number }[] { const counts = new Map(); for (const item of items) { @@ -156,20 +142,6 @@ function BreakdownBar({ data, colors, compact }: { data: { label: string; 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}

-
-
- -
-
- ); -} - function MiniStat({ label, value, accent }: { label: string; value: ReactNode; accent?: string }) { return (
@@ -181,6 +153,23 @@ function MiniStat({ label, value, accent }: { label: string; value: ReactNode; a ); } +/** The single card shape shared by every dashboard widget — DNS, Secrets, and each integration — so they all read as one family: a label + status badge, then whatever stats/breakdown that widget has. */ +function WidgetCard({ label, badge, children }: { label: string; badge?: ReactNode; children: ReactNode }) { + return ( +
+
+
+
+
{label}
+ {badge &&
{badge}
} +
+ {children} +
+
+
+ ); +} + const WIDGETS: { type: IntegrationType; label: string }[] = [ { type: "proxmox", label: "Proxmox" }, { type: "synology", label: "Synology NAS" }, @@ -425,97 +414,93 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
- - {dnsError &&
{dnsError}
} - {dnsStats && dnsStats.totalProviders === 0 ? ( -
-
- No DNS providers configured yet. Add one under DNS. -
-
- ) : ( - <> -
-
- `${p.zoneCount} on ${p.name}`).join(" · ") || undefined} - /> -
-
- -
-
- -
-
- {dnsStats?.perProvider.some((p) => p.error) && ( -
- {dnsStats.perProvider - .filter((p) => p.error) - .map((p) => `${p.name}: ${p.error}`) - .join("; ")} + +
+ Not configured + ) : ( + + {dnsStats.enabledProviders} enabled + + ) + } + > + {dnsError &&
{dnsError}
} + {!dnsStats ? ( +
Fetching status…
+ ) : dnsStats.totalProviders === 0 ? ( +
+ Configure a DNS provider from the DNS page.
+ ) : ( + <> +
+
+ +
+
+ +
+
+ +
+
+ {dnsStats.perProvider.some((p) => p.error) && ( +
+ {dnsStats.perProvider + .filter((p) => p.error) + .map((p) => `${p.name}: ${p.error}`) + .join("; ")} +
+ )} + {dnsStats.recordsByType.length > 0 && ( + ({ label: r.type, count: r.count }))} colors={DNS_TYPE_COLORS} compact /> + )} + )} - {dnsStats && ( - ({ label: r.type, count: r.count }))} - colors={DNS_TYPE_COLORS} - /> - )} - - )} +
- - {secrets && totalSecrets === 0 ? ( -
-
- No secrets tracked yet. Add one under Secrets. -
-
- ) : ( - <> -
-
- + Not configured + ) : expiredSecrets > 0 ? ( + {expiredSecrets} expired + ) : expiringSecrets > 0 ? ( + {expiringSecrets} expiring + ) : ( + All OK + ) + } + > + {!secrets ? ( +
Fetching status…
+ ) : totalSecrets === 0 ? ( +
+ Configure a secret from the Secrets page.
-
- 0 ? "#f59e0b" : undefined} - /> -
-
- 0 ? "#ef4444" : undefined} - /> -
-
- - - )} + ) : ( + <> +
+
+ +
+
+ 0 ? "#f59e0b" : undefined} /> +
+
+ 0 ? "#ef4444" : undefined} /> +
+
+ + + )} + +
-
-
-
-
{label}
-
- {isLive ? ( - Connected - ) : integration ? ( - Connecting… - ) : ( - Not connected - )} + Connected + ) : integration ? ( + Connecting… + ) : ( + Not connected + ) + } + > + {isLiveTailscale ? ( + <> +
+
+ +
+
+ 0 ? "#f59e0b" : undefined} + /> +
+
+ 0 ? "#f59e0b" : undefined} + />
- {isLiveTailscale ? ( - <> -
-
- -
-
- 0 ? "#f59e0b" : undefined} - /> -
-
- 0 ? "#f59e0b" : undefined} - /> -
-
- {tailscaleExtra && tailscaleExtra.osBreakdown.length > 0 && ( - d.label))} - compact - /> - )} - - ) : isLiveGitea ? ( - <> -
-
- -
-
- -
-
- 0 ? "#ef4444" : undefined} - /> -
-
- {giteaSummary!.statusBreakdown.length > 0 && ( - - )} - - ) : isLiveDockhand ? ( - <> -
-
- -
-
- -
-
- 0 ? "#f59e0b" : undefined} - /> -
-
- {dockhandSummary!.stateBreakdown.length > 0 && ( - - )} - - ) : isLiveSemaphore ? ( - <> -
-
- -
-
- 0 ? "#ef4444" : undefined} - /> -
-
- {semaphoreSummary!.statusBreakdown.length > 0 && ( - - )} - - ) : isLiveProxmox ? ( - <> -
-
- -
-
- -
-
- -
-
- {proxmoxSummary!.nodeBreakdown.length > 1 && ( - d.label))} - compact - /> - )} - - ) : isLiveSynology ? ( - <> -
-
- -
-
- 0 ? "#ef4444" : undefined} - /> -
-
- -
-
- {synologySummary!.memTotalBytes !== null && ( -
- RAM: {formatBytes(synologySummary!.memUsedBytes)} / {formatBytes(synologySummary!.memTotalBytes)} -
- )} - {synologySummary!.diskStatusBreakdown.length > 0 && ( - d.label))} - compact - /> - )} - - ) : ( -
- {integration ? ( - "Fetching status…" - ) : ( - <> - Configure this integration from the{" "} - Integrations page. - - )} + {tailscaleExtra && tailscaleExtra.osBreakdown.length > 0 && ( + d.label))} + compact + /> + )} + + ) : isLiveGitea ? ( + <> +
+
+ +
+
+ +
+
+ 0 ? "#ef4444" : undefined} + /> +
+
+ {giteaSummary!.statusBreakdown.length > 0 && ( + + )} + + ) : isLiveDockhand ? ( + <> +
+
+ +
+
+ +
+
+ 0 ? "#f59e0b" : undefined} + /> +
+
+ {dockhandSummary!.stateBreakdown.length > 0 && ( + + )} + + ) : isLiveSemaphore ? ( + <> +
+
+ +
+
+ 0 ? "#ef4444" : undefined} + /> +
+
+ {semaphoreSummary!.statusBreakdown.length > 0 && ( + + )} + + ) : isLiveProxmox ? ( + <> +
+
+ +
+
+ +
+
+ +
+
+ {proxmoxSummary!.nodeBreakdown.length > 1 && ( + d.label))} + compact + /> + )} + + ) : isLiveSynology ? ( + <> +
+
+ +
+
+ 0 ? "#ef4444" : undefined} + /> +
+
+ +
+
+ {synologySummary!.memTotalBytes !== null && ( +
+ RAM: {formatBytes(synologySummary!.memUsedBytes)} / {formatBytes(synologySummary!.memTotalBytes)}
)} + {synologySummary!.diskStatusBreakdown.length > 0 && ( + d.label))} + compact + /> + )} + + ) : ( +
+ {integration ? ( + "Fetching status…" + ) : ( + <> + Configure this integration from the Integrations page. + + )}
-
-
+ )} + ); })}