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 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-17 23:14:18 +02:00
1 parent a5ec099c13
commit 16d64c42d8
2 files changed
+314 -69

No files matched your search

+9 -7
View File
@@ -17,13 +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, not just the - **Dashboard** — an overview of every system this app tracks: DNS (domain/
six integrations: DNS (domain/record counts per provider, cached records record counts per provider, cached records broken down by type), Secrets
broken down by type), Secrets (monitored/expiring/expired counts, broken (monitored/expiring/expired counts, broken down by type), and a widget per
down by type), and the existing live per-integration widgets integration with a small stat row plus its own breakdown — Tailscale by
(Proxmox/Synology/Semaphore/Tailscale/Gitea/Dockhand). Breakdowns render OS, Proxmox by node (VM/LXC counts too), Dockhand by container state (plus
as a stacked proportion bar with a legend — no charting library, matching host count), Semaphore and Gitea by last-run status (plus private-repo
the rest of the app's plain-Tabler-CSS approach. 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 - **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,
Dockhand), success or failure, with latency and the error message if it Dockhand), success or failure, with latency and the error message if it
+305 -62
View File
@@ -35,6 +35,57 @@ const SECRET_TYPE_COLORS: Record<SecretRecord["type"], string> = {
generic: "#94a3b8", generic: "#94a3b8",
}; };
const CONTAINER_STATE_COLORS: Record<string, string> = {
running: "#4ade80",
exited: "#f87171",
dead: "#f87171",
paused: "#fbbf24",
restarting: "#60a5fa",
};
const TASK_STATUS_COLORS: Record<string, string> = {
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<string, string> = {
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<string, string> {
const colors: Record<string, string> = {};
labels.forEach((label, i) => {
colors[label] = GENERIC_PALETTE[i % GENERIC_PALETTE.length];
});
return colors;
}
function healthColors(labels: string[]): Record<string, string> {
const colors: Record<string, string> = {};
for (const label of labels) colors[label] = label === "normal" ? "#4ade80" : "#f87171";
return colors;
}
function SectionHeader({ title, hint }: { title: string; hint?: ReactNode }) { function SectionHeader({ title, hint }: { title: string; hint?: ReactNode }) {
return ( return (
<div className="mb-3 mt-4"> <div className="mb-3 mt-4">
@@ -58,16 +109,21 @@ function StatTile({ label, value, sub, accent }: { label: string; value: ReactNo
); );
} }
function TypeBreakdown({ title, data, colors }: { title: string; data: { label: string; count: number }[]; colors: Record<string, string> }) { function breakdownFrom<T>(items: T[], keyOf: (item: T) => string): { label: string; count: number }[] {
const counts = new Map<string, number>();
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<string, string>; compact?: boolean }) {
const total = data.reduce((s, d) => s + d.count, 0); const total = data.reduce((s, d) => s + d.count, 0);
if (total === 0) return null; if (total === 0) return null;
return ( return (
<div className="card mb-3"> <div>
<div className="card-header"> <div className="progress" style={{ height: compact ? 6 : 10 }}>
<h3 className="card-title">{title}</h3>
</div>
<div className="card-body">
<div className="progress mb-3" style={{ height: 10 }}>
{data.map((d) => ( {data.map((d) => (
<div <div
key={d.label} key={d.label}
@@ -77,26 +133,50 @@ function TypeBreakdown({ title, data, colors }: { title: string; data: { label:
/> />
))} ))}
</div> </div>
<div className="d-flex flex-wrap gap-3"> <div className={`d-flex flex-wrap gap-${compact ? "2" : "3"} mt-2`}>
{data.map((d) => ( {data.map((d) => (
<div key={d.label} className="d-flex align-items-center gap-2 small"> <div key={d.label} className={`d-flex align-items-center gap-1 ${compact ? "small" : ""}`}>
<span <span
style={{ style={{
width: 10, width: compact ? 8 : 10,
height: 10, height: compact ? 8 : 10,
borderRadius: 2, borderRadius: 2,
backgroundColor: colors[d.label] ?? "#94a3b8", backgroundColor: colors[d.label] ?? "#94a3b8",
display: "inline-block", display: "inline-block",
flexShrink: 0, flexShrink: 0,
}} }}
/> />
<span> <span className={compact ? "text-secondary" : ""}>
{d.label} <span className="text-secondary">({d.count})</span> {d.label} {compact ? `(${d.count})` : <span className="text-secondary">({d.count})</span>}
</span> </span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
);
}
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>
<div className="h4 mb-0" style={accent ? { color: accent } : undefined}>
{value}
</div>
<div className="text-secondary small">{label}</div>
</div> </div>
); );
} }
@@ -112,33 +192,62 @@ const WIDGETS: { type: IntegrationType; label: string }[] = [
interface GiteaSummary { interface GiteaSummary {
repoCount: number; repoCount: number;
privateCount: number;
failing: number; failing: number;
statusBreakdown: { label: string; count: number }[];
} }
interface DockhandSummary { interface DockhandSummary {
running: number; running: number;
total: number; total: number;
hostCount: number;
stateBreakdown: { label: string; count: number }[];
} }
interface SemaphoreSummary { interface SemaphoreSummary {
total: number; total: number;
failing: number; failing: number;
statusBreakdown: { label: string; count: number }[];
} }
interface ProxmoxSummary { interface ProxmoxSummary {
running: number; running: number;
total: number; total: number;
vmCount: number;
lxcCount: number;
nodeBreakdown: { label: string; count: number }[];
} }
interface SynologySummary { interface SynologySummary {
volumeCount: number; volumeCount: number;
volumesNotNormal: number; volumesNotNormal: number;
disksNotNormal: 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 }) { export default function Dashboard({ user }: { user: CurrentUser }) {
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null); const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
const [tailscaleSummary, setTailscaleSummary] = useState<TailscaleDevicesResponse["summary"] | null>(null); const [tailscaleSummary, setTailscaleSummary] = useState<TailscaleDevicesResponse["summary"] | null>(null);
const [tailscaleExtra, setTailscaleExtra] = useState<TailscaleExtra | null>(null);
const [giteaSummary, setGiteaSummary] = useState<GiteaSummary | null>(null); const [giteaSummary, setGiteaSummary] = useState<GiteaSummary | null>(null);
const [dockhandSummary, setDockhandSummary] = useState<DockhandSummary | null>(null); const [dockhandSummary, setDockhandSummary] = useState<DockhandSummary | null>(null);
const [semaphoreSummary, setSemaphoreSummary] = useState<SemaphoreSummary | null>(null); const [semaphoreSummary, setSemaphoreSummary] = useState<SemaphoreSummary | null>(null);
@@ -172,8 +281,14 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
} }
api.integrations.tailscale api.integrations.tailscale
.devices(tailscale.id) .devices(tailscale.id)
.then((res) => setTailscaleSummary(res.summary)) .then((res) => {
.catch(() => setTailscaleSummary(null)); setTailscaleSummary(res.summary);
setTailscaleExtra({ osBreakdown: breakdownFrom(res.devices, (d) => d.os || "unknown") });
})
.catch(() => {
setTailscaleSummary(null);
setTailscaleExtra(null);
});
}, [integrations]); }, [integrations]);
useEffect(() => { useEffect(() => {
@@ -187,7 +302,9 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
.then((res) => .then((res) =>
setGiteaSummary({ setGiteaSummary({
repoCount: res.repos.length, 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, 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)); .catch(() => setGiteaSummary(null));
@@ -201,7 +318,14 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
} }
api.integrations.dockhand api.integrations.dockhand
.containers(dockhand.id) .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)); .catch(() => setDockhandSummary(null));
}, [integrations]); }, [integrations]);
@@ -213,7 +337,12 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
} }
api.integrations.semaphore api.integrations.semaphore
.templates(semaphore.id) .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)); .catch(() => setSemaphoreSummary(null));
}, [integrations]); }, [integrations]);
@@ -225,7 +354,15 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
} }
api.integrations.proxmox api.integrations.proxmox
.guests(proxmox.id) .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)); .catch(() => setProxmoxSummary(null));
}, [integrations]); }, [integrations]);
@@ -235,14 +372,32 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
setSynologySummary(null); setSynologySummary(null);
return; 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 api.integrations.synology
.storage(synology.id) .storage(synology.id)
.then((res) => .then((res) =>
setSynologySummary({ setSynologySummary((prev) => ({
cpuLoadPercent: prev?.cpuLoadPercent ?? null,
memUsedBytes: prev?.memUsedBytes ?? null,
memTotalBytes: prev?.memTotalBytes ?? null,
volumeCount: res.summary.volumeCount, volumeCount: res.summary.volumeCount,
volumesNotNormal: res.summary.volumesNotNormal, volumesNotNormal: res.summary.volumesNotNormal,
disksNotNormal: res.summary.disksNotNormal, disksNotNormal: res.summary.disksNotNormal,
}), diskStatusBreakdown: breakdownFrom(res.disks, (d) => d.status),
})),
) )
.catch(() => setSynologySummary(null)); .catch(() => setSynologySummary(null));
}, [integrations]); }, [integrations]);
@@ -376,10 +531,10 @@ 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-sm-6 col-lg-4" key={type}> <div className="col-md-6" key={type}>
<div className="card"> <div className="card h-100">
<div className="card-body"> <div className="card-body">
<div className="d-flex align-items-center"> <div className="d-flex align-items-center mb-2">
<div className="subheader">{label}</div> <div className="subheader">{label}</div>
<div className="ms-auto"> <div className="ms-auto">
{isLive ? ( {isLive ? (
@@ -392,60 +547,148 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
</div> </div>
</div> </div>
{isLiveTailscale ? ( {isLiveTailscale ? (
<div className="mt-2"> <>
<div className="h3 mb-0"> <div className="row g-2 mb-3">
{tailscaleSummary!.online}/{tailscaleSummary!.total} <div className="col-4">
<MiniStat label="Online / Total" value={`${tailscaleSummary!.online}/${tailscaleSummary!.total}`} />
</div> </div>
<div className="text-secondary">devices online</div> <div className="col-4">
{tailscaleSummary!.unauthorized > 0 && ( <MiniStat
<div className="text-yellow small mt-1">{tailscaleSummary!.unauthorized} awaiting authorization</div> label="Unauthorized"
)} value={tailscaleSummary!.unauthorized}
{tailscaleSummary!.expiringSoon > 0 && ( accent={tailscaleSummary!.unauthorized > 0 ? "#f59e0b" : undefined}
<div className="text-yellow small mt-1">{tailscaleSummary!.expiringSoon} key(s) expiring soon</div> />
)}
</div> </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 ? ( ) : isLiveGitea ? (
<div className="mt-2"> <>
<div className="h3 mb-0">{giteaSummary!.repoCount}</div> <div className="row g-2 mb-3">
<div className="text-secondary">repositories</div> <div className="col-4">
{giteaSummary!.failing > 0 && ( <MiniStat label="Repositories" value={giteaSummary!.repoCount} />
<div className="text-red small mt-1">{giteaSummary!.failing} with a failing build</div>
)}
</div> </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 ? ( ) : isLiveDockhand ? (
<div className="mt-2"> <>
<div className="h3 mb-0"> <div className="row g-2 mb-3">
{dockhandSummary!.running}/{dockhandSummary!.total} <div className="col-4">
<MiniStat label="Running / Total" value={`${dockhandSummary!.running}/${dockhandSummary!.total}`} />
</div> </div>
<div className="text-secondary">containers running</div> <div className="col-4">
<MiniStat label="Hosts" value={dockhandSummary!.hostCount} />
</div> </div>
<div className="col-4">
<MiniStat
label="Not running"
value={dockhandSummary!.total - dockhandSummary!.running}
accent={dockhandSummary!.total - dockhandSummary!.running > 0 ? "#f59e0b" : undefined}
/>
</div>
</div>
{dockhandSummary!.stateBreakdown.length > 0 && (
<BreakdownBar data={dockhandSummary!.stateBreakdown} colors={CONTAINER_STATE_COLORS} compact />
)}
</>
) : isLiveSemaphore ? ( ) : isLiveSemaphore ? (
<div className="mt-2"> <>
<div className="h3 mb-0">{semaphoreSummary!.total}</div> <div className="row g-2 mb-3">
<div className="text-secondary">templates</div> <div className="col-6">
{semaphoreSummary!.failing > 0 && ( <MiniStat label="Templates" value={semaphoreSummary!.total} />
<div className="text-red small mt-1">{semaphoreSummary!.failing} last failed</div>
)}
</div> </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 ? ( ) : isLiveProxmox ? (
<div className="mt-2"> <>
<div className="h3 mb-0"> <div className="row g-2 mb-3">
{proxmoxSummary!.running}/{proxmoxSummary!.total} <div className="col-4">
<MiniStat label="Running / Total" value={`${proxmoxSummary!.running}/${proxmoxSummary!.total}`} />
</div> </div>
<div className="text-secondary">VMs/containers running</div> <div className="col-4">
<MiniStat label="VMs" value={proxmoxSummary!.vmCount} />
</div> </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 ? ( ) : isLiveSynology ? (
<div className="mt-2"> <>
<div className="h3 mb-0">{synologySummary!.volumeCount}</div> <div className="row g-2 mb-3">
<div className="text-secondary">volumes</div> <div className="col-4">
{(synologySummary!.volumesNotNormal > 0 || synologySummary!.disksNotNormal > 0) && ( <MiniStat label="Volumes" value={synologySummary!.volumeCount} />
<div className="text-red small mt-1"> </div>
{synologySummary!.volumesNotNormal > 0 && `${synologySummary!.volumesNotNormal} volume(s) unhealthy`} <div className="col-4">
{synologySummary!.volumesNotNormal > 0 && synologySummary!.disksNotNormal > 0 && ", "} <MiniStat
{synologySummary!.disksNotNormal > 0 && `${synologySummary!.disksNotNormal} disk(s) unhealthy`} 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> </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"> <div className="text-secondary mt-2">
{integration ? ( {integration ? (