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 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-19 00:07:32 +02:00
1 parent b40234a557
commit 08eb0bd87e
2 files changed
+272 -291

No files matched your search

+9 -8
View File
@@ -17,14 +17,15 @@ All modules from the original plan are built:
- Monorepo scaffold, Tabler-themed app shell/navigation - Monorepo scaffold, Tabler-themed app shell/navigation
- Authentik OIDC login, roles (first user to sign in becomes admin), audit log - Authentik OIDC login, roles (first user to sign in becomes admin), audit log
- **Dashboard** — an overview of every system this app tracks: DNS (domain/ - **Dashboard** — an overview of every system this app tracks, all sharing
record counts per provider, cached records broken down by type), Secrets one widget-card design (label + status badge, a small stat row, then its
(monitored/expiring/expired counts, broken down by type), and a widget per own breakdown): DNS (domain/record counts per provider, cached records by
integration with a small stat row plus its own breakdown — Tailscale by type), Secrets (monitored/expiring/expired, by type), and one widget per
OS, Proxmox by node (VM/LXC counts too), Dockhand by container state (plus integration — Tailscale by OS, Proxmox by node (VM/LXC counts too),
host count), Semaphore and Gitea by last-run status (plus private-repo Dockhand by container state (plus host count), Semaphore and Gitea by
count), and Synology's CPU/RAM alongside its disk-health breakdown. last-run status (plus private-repo count), and Synology's CPU/RAM
Breakdowns render as a stacked proportion bar with a legend — no charting 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. library, matching the rest of the app's plain-Tabler-CSS approach.
- **Diagnostic Log** (admin-only) — every call this app makes to a DNS - **Diagnostic Log** (admin-only) — every call this app makes to a DNS
provider or integration (Tailscale, Proxmox, Synology, Semaphore, Gitea, provider or integration (Tailscale, Proxmox, Synology, Semaphore, Gitea,
+263 -283
View File
@@ -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 (
<div className="card h-100">
<div className="card-body">
<div className="subheader">{label}</div>
<div className="h3 mb-0 mt-2" style={accent ? { color: accent } : undefined}>
{value}
</div>
{sub && <div className="text-secondary small mt-1">{sub}</div>}
</div>
</div>
);
}
function breakdownFrom<T>(items: T[], keyOf: (item: T) => string): { label: string; count: number }[] { function breakdownFrom<T>(items: T[], keyOf: (item: T) => string): { label: string; count: number }[] {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
for (const item of items) { 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<string, string> }) {
if (data.reduce((s, d) => s + d.count, 0) === 0) return null;
return (
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title">{title}</h3>
</div>
<div className="card-body">
<BreakdownBar data={data} colors={colors} />
</div>
</div>
);
}
function MiniStat({ label, value, accent }: { label: string; value: ReactNode; accent?: string }) { function MiniStat({ label, value, accent }: { label: string; value: ReactNode; accent?: string }) {
return ( return (
<div> <div>
@@ -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 (
<div className="col-md-6">
<div className="card h-100">
<div className="card-body">
<div className="d-flex align-items-center mb-2">
<div className="subheader">{label}</div>
{badge && <div className="ms-auto">{badge}</div>}
</div>
{children}
</div>
</div>
</div>
);
}
const WIDGETS: { type: IntegrationType; label: string }[] = [ const WIDGETS: { type: IntegrationType; label: string }[] = [
{ type: "proxmox", label: "Proxmox" }, { type: "proxmox", label: "Proxmox" },
{ type: "synology", label: "Synology NAS" }, { type: "synology", label: "Synology NAS" },
@@ -425,97 +414,93 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
</div> </div>
</div> </div>
<SectionHeader <SectionHeader title="Overview" hint="DNS and Secrets are built into this app rather than connected integrations, but get the same at-a-glance treatment." />
title="DNS" <div className="row row-cards mb-3">
hint={ <WidgetCard
dnsStats label="DNS"
? `${dnsStats.totalZones} zone${dnsStats.totalZones !== 1 ? "s" : ""} across ${dnsStats.enabledProviders} enabled provider${dnsStats.enabledProviders !== 1 ? "s" : ""}` badge={
: undefined !dnsStats ? undefined : dnsStats.totalProviders === 0 ? (
} <span className="badge bg-secondary-lt text-secondary">Not configured</span>
/> ) : (
{dnsError && <div className="alert alert-danger">{dnsError}</div>} <span className="badge bg-green-lt text-green">
{dnsStats && dnsStats.totalProviders === 0 ? ( {dnsStats.enabledProviders} enabled
<div className="card"> </span>
<div className="card-body text-secondary"> )
No DNS providers configured yet. <Link to="/dns">Add one under DNS</Link>. }
</div> >
</div> {dnsError && <div className="alert alert-danger mb-2">{dnsError}</div>}
) : ( {!dnsStats ? (
<> <div className="text-secondary">Fetching status…</div>
<div className="row row-cards mb-3"> ) : dnsStats.totalProviders === 0 ? (
<div className="col-sm-6 col-lg-4"> <div className="text-secondary">
<StatTile Configure a DNS provider from the <Link to="/dns">DNS</Link> page.
label="Domains"
value={dnsStats?.totalZones ?? "—"}
sub={dnsStats?.perProvider.map((p) => `${p.zoneCount} on ${p.name}`).join(" · ") || undefined}
/>
</div>
<div className="col-sm-6 col-lg-4">
<StatTile
label="Cached Records"
value={dnsStats?.totalRecords ?? "—"}
sub={dnsStats ? `${dnsStats.recordsByType.length} type${dnsStats.recordsByType.length !== 1 ? "s" : ""}, as of last sync per zone` : undefined}
/>
</div>
<div className="col-sm-6 col-lg-4">
<StatTile
label="Providers"
value={`${dnsStats?.enabledProviders ?? "—"}/${dnsStats?.totalProviders ?? "—"}`}
sub="enabled / configured"
/>
</div>
</div>
{dnsStats?.perProvider.some((p) => p.error) && (
<div className="alert alert-warning">
{dnsStats.perProvider
.filter((p) => p.error)
.map((p) => `${p.name}: ${p.error}`)
.join("; ")}
</div> </div>
) : (
<>
<div className="row g-2 mb-3">
<div className="col-4">
<MiniStat label="Domains" value={dnsStats.totalZones} />
</div>
<div className="col-4">
<MiniStat label="Records" value={dnsStats.totalRecords} />
</div>
<div className="col-4">
<MiniStat label="Providers" value={`${dnsStats.enabledProviders}/${dnsStats.totalProviders}`} />
</div>
</div>
{dnsStats.perProvider.some((p) => p.error) && (
<div className="text-red small mb-2">
{dnsStats.perProvider
.filter((p) => p.error)
.map((p) => `${p.name}: ${p.error}`)
.join("; ")}
</div>
)}
{dnsStats.recordsByType.length > 0 && (
<BreakdownBar data={dnsStats.recordsByType.map((r) => ({ label: r.type, count: r.count }))} colors={DNS_TYPE_COLORS} compact />
)}
</>
)} )}
{dnsStats && ( </WidgetCard>
<TypeBreakdown
title="Cached Records by Type"
data={dnsStats.recordsByType.map((r) => ({ label: r.type, count: r.count }))}
colors={DNS_TYPE_COLORS}
/>
)}
</>
)}
<SectionHeader title="Secrets" hint="Expiry tracking for API tokens, certificates, and passwords." /> <WidgetCard
{secrets && totalSecrets === 0 ? ( label="Secrets"
<div className="card"> badge={
<div className="card-body text-secondary"> !secrets ? undefined : totalSecrets === 0 ? (
No secrets tracked yet. <Link to="/secrets">Add one under Secrets</Link>. <span className="badge bg-secondary-lt text-secondary">Not configured</span>
</div> ) : expiredSecrets > 0 ? (
</div> <span className="badge bg-red-lt text-red">{expiredSecrets} expired</span>
) : ( ) : expiringSecrets > 0 ? (
<> <span className="badge bg-yellow-lt text-yellow">{expiringSecrets} expiring</span>
<div className="row row-cards mb-3"> ) : (
<div className="col-sm-6 col-lg-4"> <span className="badge bg-green-lt text-green">All OK</span>
<StatTile label="Monitored" value={secrets ? totalSecrets : "—"} sub="total tracked" /> )
}
>
{!secrets ? (
<div className="text-secondary">Fetching status…</div>
) : totalSecrets === 0 ? (
<div className="text-secondary">
Configure a secret from the <Link to="/secrets">Secrets</Link> page.
</div> </div>
<div className="col-sm-6 col-lg-4"> ) : (
<StatTile <>
label="Expiring Soon" <div className="row g-2 mb-3">
value={secrets ? expiringSecrets : "—"} <div className="col-4">
sub="in warning window" <MiniStat label="Monitored" value={totalSecrets} />
accent={expiringSecrets > 0 ? "#f59e0b" : undefined} </div>
/> <div className="col-4">
</div> <MiniStat label="Expiring" value={expiringSecrets} accent={expiringSecrets > 0 ? "#f59e0b" : undefined} />
<div className="col-sm-6 col-lg-4"> </div>
<StatTile <div className="col-4">
label="Expired" <MiniStat label="Expired" value={expiredSecrets} accent={expiredSecrets > 0 ? "#ef4444" : undefined} />
value={secrets ? expiredSecrets : "—"} </div>
sub="need attention" </div>
accent={expiredSecrets > 0 ? "#ef4444" : undefined} <BreakdownBar data={secretsByType} colors={SECRET_TYPE_COLORS} compact />
/> </>
</div> )}
</div> </WidgetCard>
<TypeBreakdown title="Secrets by Type" data={secretsByType} colors={SECRET_TYPE_COLORS} /> </div>
</>
)}
<SectionHeader <SectionHeader
title="Integrations" title="Integrations"
@@ -533,179 +518,174 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore || isLiveProxmox || isLiveSynology; const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore || isLiveProxmox || isLiveSynology;
return ( return (
<div className="col-md-6" key={type}> <WidgetCard
<div className="card h-100"> key={type}
<div className="card-body"> label={label}
<div className="d-flex align-items-center mb-2"> badge={
<div className="subheader">{label}</div> isLive ? (
<div className="ms-auto"> <span className="badge bg-green-lt text-green">Connected</span>
{isLive ? ( ) : integration ? (
<span className="badge bg-green-lt text-green">Connected</span> <span className="badge bg-yellow-lt text-yellow">Connecting…</span>
) : integration ? ( ) : (
<span className="badge bg-yellow-lt text-yellow">Connecting…</span> <span className="badge bg-secondary-lt text-secondary">Not connected</span>
) : ( )
<span className="badge bg-secondary-lt text-secondary">Not connected</span> }
)} >
{isLiveTailscale ? (
<>
<div className="row g-2 mb-3">
<div className="col-4">
<MiniStat label="Online / Total" value={`${tailscaleSummary!.online}/${tailscaleSummary!.total}`} />
</div>
<div className="col-4">
<MiniStat
label="Unauthorized"
value={tailscaleSummary!.unauthorized}
accent={tailscaleSummary!.unauthorized > 0 ? "#f59e0b" : undefined}
/>
</div>
<div className="col-4">
<MiniStat
label="Keys expiring"
value={tailscaleSummary!.expiringSoon}
accent={tailscaleSummary!.expiringSoon > 0 ? "#f59e0b" : undefined}
/>
</div> </div>
</div> </div>
{isLiveTailscale ? ( {tailscaleExtra && tailscaleExtra.osBreakdown.length > 0 && (
<> <BreakdownBar
<div className="row g-2 mb-3"> data={tailscaleExtra.osBreakdown}
<div className="col-4"> colors={paletteColors(tailscaleExtra.osBreakdown.map((d) => d.label))}
<MiniStat label="Online / Total" value={`${tailscaleSummary!.online}/${tailscaleSummary!.total}`} /> compact
</div> />
<div className="col-4"> )}
<MiniStat </>
label="Unauthorized" ) : isLiveGitea ? (
value={tailscaleSummary!.unauthorized} <>
accent={tailscaleSummary!.unauthorized > 0 ? "#f59e0b" : undefined} <div className="row g-2 mb-3">
/> <div className="col-4">
</div> <MiniStat label="Repositories" value={giteaSummary!.repoCount} />
<div className="col-4"> </div>
<MiniStat <div className="col-4">
label="Keys expiring" <MiniStat label="Private" value={giteaSummary!.privateCount} />
value={tailscaleSummary!.expiringSoon} </div>
accent={tailscaleSummary!.expiringSoon > 0 ? "#f59e0b" : undefined} <div className="col-4">
/> <MiniStat
</div> label="Failing builds"
</div> value={giteaSummary!.failing}
{tailscaleExtra && tailscaleExtra.osBreakdown.length > 0 && ( accent={giteaSummary!.failing > 0 ? "#ef4444" : undefined}
<BreakdownBar />
data={tailscaleExtra.osBreakdown} </div>
colors={paletteColors(tailscaleExtra.osBreakdown.map((d) => d.label))} </div>
compact {giteaSummary!.statusBreakdown.length > 0 && (
/> <BreakdownBar data={giteaSummary!.statusBreakdown} colors={REPO_STATUS_COLORS} compact />
)} )}
</> </>
) : isLiveGitea ? ( ) : isLiveDockhand ? (
<> <>
<div className="row g-2 mb-3"> <div className="row g-2 mb-3">
<div className="col-4"> <div className="col-4">
<MiniStat label="Repositories" value={giteaSummary!.repoCount} /> <MiniStat label="Running / Total" value={`${dockhandSummary!.running}/${dockhandSummary!.total}`} />
</div> </div>
<div className="col-4"> <div className="col-4">
<MiniStat label="Private" value={giteaSummary!.privateCount} /> <MiniStat label="Hosts" value={dockhandSummary!.hostCount} />
</div> </div>
<div className="col-4"> <div className="col-4">
<MiniStat <MiniStat
label="Failing builds" label="Updates"
value={giteaSummary!.failing} value={dockhandSummary!.updatesAvailable}
accent={giteaSummary!.failing > 0 ? "#ef4444" : undefined} accent={dockhandSummary!.updatesAvailable > 0 ? "#f59e0b" : undefined}
/> />
</div> </div>
</div> </div>
{giteaSummary!.statusBreakdown.length > 0 && ( {dockhandSummary!.stateBreakdown.length > 0 && (
<BreakdownBar data={giteaSummary!.statusBreakdown} colors={REPO_STATUS_COLORS} compact /> <BreakdownBar data={dockhandSummary!.stateBreakdown} colors={CONTAINER_STATE_COLORS} compact />
)} )}
</> </>
) : isLiveDockhand ? ( ) : isLiveSemaphore ? (
<> <>
<div className="row g-2 mb-3"> <div className="row g-2 mb-3">
<div className="col-4"> <div className="col-6">
<MiniStat label="Running / Total" value={`${dockhandSummary!.running}/${dockhandSummary!.total}`} /> <MiniStat label="Templates" value={semaphoreSummary!.total} />
</div> </div>
<div className="col-4"> <div className="col-6">
<MiniStat label="Hosts" value={dockhandSummary!.hostCount} /> <MiniStat
</div> label="Last failed"
<div className="col-4"> value={semaphoreSummary!.failing}
<MiniStat accent={semaphoreSummary!.failing > 0 ? "#ef4444" : undefined}
label="Updates" />
value={dockhandSummary!.updatesAvailable} </div>
accent={dockhandSummary!.updatesAvailable > 0 ? "#f59e0b" : undefined} </div>
/> {semaphoreSummary!.statusBreakdown.length > 0 && (
</div> <BreakdownBar data={semaphoreSummary!.statusBreakdown} colors={TASK_STATUS_COLORS} compact />
</div> )}
{dockhandSummary!.stateBreakdown.length > 0 && ( </>
<BreakdownBar data={dockhandSummary!.stateBreakdown} colors={CONTAINER_STATE_COLORS} compact /> ) : isLiveProxmox ? (
)} <>
</> <div className="row g-2 mb-3">
) : isLiveSemaphore ? ( <div className="col-4">
<> <MiniStat label="Running / Total" value={`${proxmoxSummary!.running}/${proxmoxSummary!.total}`} />
<div className="row g-2 mb-3"> </div>
<div className="col-6"> <div className="col-4">
<MiniStat label="Templates" value={semaphoreSummary!.total} /> <MiniStat label="VMs" value={proxmoxSummary!.vmCount} />
</div> </div>
<div className="col-6"> <div className="col-4">
<MiniStat <MiniStat label="LXCs" value={proxmoxSummary!.lxcCount} />
label="Last failed" </div>
value={semaphoreSummary!.failing} </div>
accent={semaphoreSummary!.failing > 0 ? "#ef4444" : undefined} {proxmoxSummary!.nodeBreakdown.length > 1 && (
/> <BreakdownBar
</div> data={proxmoxSummary!.nodeBreakdown}
</div> colors={paletteColors(proxmoxSummary!.nodeBreakdown.map((d) => d.label))}
{semaphoreSummary!.statusBreakdown.length > 0 && ( compact
<BreakdownBar data={semaphoreSummary!.statusBreakdown} colors={TASK_STATUS_COLORS} compact /> />
)} )}
</> </>
) : isLiveProxmox ? ( ) : isLiveSynology ? (
<> <>
<div className="row g-2 mb-3"> <div className="row g-2 mb-3">
<div className="col-4"> <div className="col-4">
<MiniStat label="Running / Total" value={`${proxmoxSummary!.running}/${proxmoxSummary!.total}`} /> <MiniStat label="Volumes" value={synologySummary!.volumeCount} />
</div> </div>
<div className="col-4"> <div className="col-4">
<MiniStat label="VMs" value={proxmoxSummary!.vmCount} /> <MiniStat
</div> label="Unhealthy"
<div className="col-4"> value={synologySummary!.volumesNotNormal + synologySummary!.disksNotNormal}
<MiniStat label="LXCs" value={proxmoxSummary!.lxcCount} /> accent={synologySummary!.volumesNotNormal + synologySummary!.disksNotNormal > 0 ? "#ef4444" : undefined}
</div> />
</div> </div>
{proxmoxSummary!.nodeBreakdown.length > 1 && ( <div className="col-4">
<BreakdownBar <MiniStat
data={proxmoxSummary!.nodeBreakdown} label="CPU load"
colors={paletteColors(proxmoxSummary!.nodeBreakdown.map((d) => d.label))} value={synologySummary!.cpuLoadPercent !== null ? `${synologySummary!.cpuLoadPercent.toFixed(0)}%` : "—"}
compact />
/> </div>
)} </div>
</> {synologySummary!.memTotalBytes !== null && (
) : isLiveSynology ? ( <div className="text-secondary small mb-2">
<> RAM: {formatBytes(synologySummary!.memUsedBytes)} / {formatBytes(synologySummary!.memTotalBytes)}
<div className="row g-2 mb-3">
<div className="col-4">
<MiniStat label="Volumes" value={synologySummary!.volumeCount} />
</div>
<div className="col-4">
<MiniStat
label="Unhealthy"
value={synologySummary!.volumesNotNormal + synologySummary!.disksNotNormal}
accent={synologySummary!.volumesNotNormal + synologySummary!.disksNotNormal > 0 ? "#ef4444" : undefined}
/>
</div>
<div className="col-4">
<MiniStat
label="CPU load"
value={synologySummary!.cpuLoadPercent !== null ? `${synologySummary!.cpuLoadPercent.toFixed(0)}%` : "—"}
/>
</div>
</div>
{synologySummary!.memTotalBytes !== null && (
<div className="text-secondary small mb-2">
RAM: {formatBytes(synologySummary!.memUsedBytes)} / {formatBytes(synologySummary!.memTotalBytes)}
</div>
)}
{synologySummary!.diskStatusBreakdown.length > 0 && (
<BreakdownBar
data={synologySummary!.diskStatusBreakdown}
colors={healthColors(synologySummary!.diskStatusBreakdown.map((d) => d.label))}
compact
/>
)}
</>
) : (
<div className="text-secondary mt-2">
{integration ? (
"Fetching status…"
) : (
<>
Configure this integration from the{" "}
<Link to="/integrations">Integrations</Link> page.
</>
)}
</div> </div>
)} )}
{synologySummary!.diskStatusBreakdown.length > 0 && (
<BreakdownBar
data={synologySummary!.diskStatusBreakdown}
colors={healthColors(synologySummary!.diskStatusBreakdown.map((d) => d.label))}
compact
/>
)}
</>
) : (
<div className="text-secondary mt-2">
{integration ? (
"Fetching status…"
) : (
<>
Configure this integration from the <Link to="/integrations">Integrations</Link> page.
</>
)}
</div> </div>
</div> )}
</div> </WidgetCard>
); );
})} })}
</div> </div>