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
- 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,
+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 }[] {
const counts = new Map<string, number>();
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 }) {
return (
<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 }[] = [
{ type: "proxmox", label: "Proxmox" },
{ type: "synology", label: "Synology NAS" },
@@ -425,97 +414,93 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
</div>
</div>
<SectionHeader
title="DNS"
hint={
dnsStats
? `${dnsStats.totalZones} zone${dnsStats.totalZones !== 1 ? "s" : ""} across ${dnsStats.enabledProviders} enabled provider${dnsStats.enabledProviders !== 1 ? "s" : ""}`
: undefined
}
/>
{dnsError && <div className="alert alert-danger">{dnsError}</div>}
{dnsStats && dnsStats.totalProviders === 0 ? (
<div className="card">
<div className="card-body text-secondary">
No DNS providers configured yet. <Link to="/dns">Add one under DNS</Link>.
</div>
</div>
) : (
<>
<div className="row row-cards mb-3">
<div className="col-sm-6 col-lg-4">
<StatTile
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("; ")}
<SectionHeader title="Overview" hint="DNS and Secrets are built into this app rather than connected integrations, but get the same at-a-glance treatment." />
<div className="row row-cards mb-3">
<WidgetCard
label="DNS"
badge={
!dnsStats ? undefined : dnsStats.totalProviders === 0 ? (
<span className="badge bg-secondary-lt text-secondary">Not configured</span>
) : (
<span className="badge bg-green-lt text-green">
{dnsStats.enabledProviders} enabled
</span>
)
}
>
{dnsError && <div className="alert alert-danger mb-2">{dnsError}</div>}
{!dnsStats ? (
<div className="text-secondary">Fetching status…</div>
) : dnsStats.totalProviders === 0 ? (
<div className="text-secondary">
Configure a DNS provider from the <Link to="/dns">DNS</Link> page.
</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 && (
<TypeBreakdown
title="Cached Records by Type"
data={dnsStats.recordsByType.map((r) => ({ label: r.type, count: r.count }))}
colors={DNS_TYPE_COLORS}
/>
)}
</>
)}
</WidgetCard>
<SectionHeader title="Secrets" hint="Expiry tracking for API tokens, certificates, and passwords." />
{secrets && totalSecrets === 0 ? (
<div className="card">
<div className="card-body text-secondary">
No secrets tracked yet. <Link to="/secrets">Add one under Secrets</Link>.
</div>
</div>
) : (
<>
<div className="row row-cards mb-3">
<div className="col-sm-6 col-lg-4">
<StatTile label="Monitored" value={secrets ? totalSecrets : "—"} sub="total tracked" />
<WidgetCard
label="Secrets"
badge={
!secrets ? undefined : totalSecrets === 0 ? (
<span className="badge bg-secondary-lt text-secondary">Not configured</span>
) : expiredSecrets > 0 ? (
<span className="badge bg-red-lt text-red">{expiredSecrets} expired</span>
) : expiringSecrets > 0 ? (
<span className="badge bg-yellow-lt text-yellow">{expiringSecrets} expiring</span>
) : (
<span className="badge bg-green-lt text-green">All OK</span>
)
}
>
{!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 className="col-sm-6 col-lg-4">
<StatTile
label="Expiring Soon"
value={secrets ? expiringSecrets : "—"}
sub="in warning window"
accent={expiringSecrets > 0 ? "#f59e0b" : undefined}
/>
</div>
<div className="col-sm-6 col-lg-4">
<StatTile
label="Expired"
value={secrets ? expiredSecrets : "—"}
sub="need attention"
accent={expiredSecrets > 0 ? "#ef4444" : undefined}
/>
</div>
</div>
<TypeBreakdown title="Secrets by Type" data={secretsByType} colors={SECRET_TYPE_COLORS} />
</>
)}
) : (
<>
<div className="row g-2 mb-3">
<div className="col-4">
<MiniStat label="Monitored" value={totalSecrets} />
</div>
<div className="col-4">
<MiniStat label="Expiring" value={expiringSecrets} accent={expiringSecrets > 0 ? "#f59e0b" : undefined} />
</div>
<div className="col-4">
<MiniStat label="Expired" value={expiredSecrets} accent={expiredSecrets > 0 ? "#ef4444" : undefined} />
</div>
</div>
<BreakdownBar data={secretsByType} colors={SECRET_TYPE_COLORS} compact />
</>
)}
</WidgetCard>
</div>
<SectionHeader
title="Integrations"
@@ -533,179 +518,174 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore || isLiveProxmox || isLiveSynology;
return (
<div className="col-md-6" key={type}>
<div className="card h-100">
<div className="card-body">
<div className="d-flex align-items-center mb-2">
<div className="subheader">{label}</div>
<div className="ms-auto">
{isLive ? (
<span className="badge bg-green-lt text-green">Connected</span>
) : integration ? (
<span className="badge bg-yellow-lt text-yellow">Connecting…</span>
) : (
<span className="badge bg-secondary-lt text-secondary">Not connected</span>
)}
<WidgetCard
key={type}
label={label}
badge={
isLive ? (
<span className="badge bg-green-lt text-green">Connected</span>
) : integration ? (
<span className="badge bg-yellow-lt text-yellow">Connecting…</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>
{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>
{tailscaleExtra && tailscaleExtra.osBreakdown.length > 0 && (
<BreakdownBar
data={tailscaleExtra.osBreakdown}
colors={paletteColors(tailscaleExtra.osBreakdown.map((d) => d.label))}
compact
/>
)}
</>
) : isLiveGitea ? (
<>
<div className="row g-2 mb-3">
<div className="col-4">
<MiniStat label="Repositories" value={giteaSummary!.repoCount} />
</div>
<div className="col-4">
<MiniStat label="Private" value={giteaSummary!.privateCount} />
</div>
<div className="col-4">
<MiniStat
label="Failing builds"
value={giteaSummary!.failing}
accent={giteaSummary!.failing > 0 ? "#ef4444" : undefined}
/>
</div>
</div>
{giteaSummary!.statusBreakdown.length > 0 && (
<BreakdownBar data={giteaSummary!.statusBreakdown} colors={REPO_STATUS_COLORS} compact />
)}
</>
) : isLiveDockhand ? (
<>
<div className="row g-2 mb-3">
<div className="col-4">
<MiniStat label="Running / Total" value={`${dockhandSummary!.running}/${dockhandSummary!.total}`} />
</div>
<div className="col-4">
<MiniStat label="Hosts" value={dockhandSummary!.hostCount} />
</div>
<div className="col-4">
<MiniStat
label="Updates"
value={dockhandSummary!.updatesAvailable}
accent={dockhandSummary!.updatesAvailable > 0 ? "#f59e0b" : undefined}
/>
</div>
</div>
{dockhandSummary!.stateBreakdown.length > 0 && (
<BreakdownBar data={dockhandSummary!.stateBreakdown} colors={CONTAINER_STATE_COLORS} compact />
)}
</>
) : isLiveSemaphore ? (
<>
<div className="row g-2 mb-3">
<div className="col-6">
<MiniStat label="Templates" value={semaphoreSummary!.total} />
</div>
<div className="col-6">
<MiniStat
label="Last failed"
value={semaphoreSummary!.failing}
accent={semaphoreSummary!.failing > 0 ? "#ef4444" : undefined}
/>
</div>
</div>
{semaphoreSummary!.statusBreakdown.length > 0 && (
<BreakdownBar data={semaphoreSummary!.statusBreakdown} colors={TASK_STATUS_COLORS} compact />
)}
</>
) : isLiveProxmox ? (
<>
<div className="row g-2 mb-3">
<div className="col-4">
<MiniStat label="Running / Total" value={`${proxmoxSummary!.running}/${proxmoxSummary!.total}`} />
</div>
<div className="col-4">
<MiniStat label="VMs" value={proxmoxSummary!.vmCount} />
</div>
<div className="col-4">
<MiniStat label="LXCs" value={proxmoxSummary!.lxcCount} />
</div>
</div>
{proxmoxSummary!.nodeBreakdown.length > 1 && (
<BreakdownBar
data={proxmoxSummary!.nodeBreakdown}
colors={paletteColors(proxmoxSummary!.nodeBreakdown.map((d) => d.label))}
compact
/>
)}
</>
) : isLiveSynology ? (
<>
<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.
</>
)}
{tailscaleExtra && tailscaleExtra.osBreakdown.length > 0 && (
<BreakdownBar
data={tailscaleExtra.osBreakdown}
colors={paletteColors(tailscaleExtra.osBreakdown.map((d) => d.label))}
compact
/>
)}
</>
) : isLiveGitea ? (
<>
<div className="row g-2 mb-3">
<div className="col-4">
<MiniStat label="Repositories" value={giteaSummary!.repoCount} />
</div>
<div className="col-4">
<MiniStat label="Private" value={giteaSummary!.privateCount} />
</div>
<div className="col-4">
<MiniStat
label="Failing builds"
value={giteaSummary!.failing}
accent={giteaSummary!.failing > 0 ? "#ef4444" : undefined}
/>
</div>
</div>
{giteaSummary!.statusBreakdown.length > 0 && (
<BreakdownBar data={giteaSummary!.statusBreakdown} colors={REPO_STATUS_COLORS} compact />
)}
</>
) : isLiveDockhand ? (
<>
<div className="row g-2 mb-3">
<div className="col-4">
<MiniStat label="Running / Total" value={`${dockhandSummary!.running}/${dockhandSummary!.total}`} />
</div>
<div className="col-4">
<MiniStat label="Hosts" value={dockhandSummary!.hostCount} />
</div>
<div className="col-4">
<MiniStat
label="Updates"
value={dockhandSummary!.updatesAvailable}
accent={dockhandSummary!.updatesAvailable > 0 ? "#f59e0b" : undefined}
/>
</div>
</div>
{dockhandSummary!.stateBreakdown.length > 0 && (
<BreakdownBar data={dockhandSummary!.stateBreakdown} colors={CONTAINER_STATE_COLORS} compact />
)}
</>
) : isLiveSemaphore ? (
<>
<div className="row g-2 mb-3">
<div className="col-6">
<MiniStat label="Templates" value={semaphoreSummary!.total} />
</div>
<div className="col-6">
<MiniStat
label="Last failed"
value={semaphoreSummary!.failing}
accent={semaphoreSummary!.failing > 0 ? "#ef4444" : undefined}
/>
</div>
</div>
{semaphoreSummary!.statusBreakdown.length > 0 && (
<BreakdownBar data={semaphoreSummary!.statusBreakdown} colors={TASK_STATUS_COLORS} compact />
)}
</>
) : isLiveProxmox ? (
<>
<div className="row g-2 mb-3">
<div className="col-4">
<MiniStat label="Running / Total" value={`${proxmoxSummary!.running}/${proxmoxSummary!.total}`} />
</div>
<div className="col-4">
<MiniStat label="VMs" value={proxmoxSummary!.vmCount} />
</div>
<div className="col-4">
<MiniStat label="LXCs" value={proxmoxSummary!.lxcCount} />
</div>
</div>
{proxmoxSummary!.nodeBreakdown.length > 1 && (
<BreakdownBar
data={proxmoxSummary!.nodeBreakdown}
colors={paletteColors(proxmoxSummary!.nodeBreakdown.map((d) => d.label))}
compact
/>
)}
</>
) : isLiveSynology ? (
<>
<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>
</div>
)}
</WidgetCard>
);
})}
</div>