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
- 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
+305 -62
View File
@@ -35,6 +35,57 @@ const SECRET_TYPE_COLORS: Record<SecretRecord["type"], string> = {
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 }) {
return (
<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);
if (total === 0) return null;
return (
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title">{title}</h3>
</div>
<div className="card-body">
<div className="progress mb-3" style={{ height: 10 }}>
<div>
<div className="progress" style={{ height: compact ? 6 : 10 }}>
{data.map((d) => (
<div
key={d.label}
@@ -77,26 +133,50 @@ function TypeBreakdown({ title, data, colors }: { title: string; data: { label:
/>
))}
</div>
<div className="d-flex flex-wrap gap-3">
<div className={`d-flex flex-wrap gap-${compact ? "2" : "3"} mt-2`}>
{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
style={{
width: 10,
height: 10,
width: compact ? 8 : 10,
height: compact ? 8 : 10,
borderRadius: 2,
backgroundColor: colors[d.label] ?? "#94a3b8",
display: "inline-block",
flexShrink: 0,
}}
/>
<span>
{d.label} <span className="text-secondary">({d.count})</span>
<span className={compact ? "text-secondary" : ""}>
{d.label} {compact ? `(${d.count})` : <span className="text-secondary">({d.count})</span>}
</span>
</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>
);
}
@@ -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<IntegrationSummary[] | 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 [dockhandSummary, setDockhandSummary] = useState<DockhandSummary | null>(null);
const [semaphoreSummary, setSemaphoreSummary] = useState<SemaphoreSummary | null>(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 (
<div className="col-sm-6 col-lg-4" key={type}>
<div className="card">
<div className="col-md-6" key={type}>
<div className="card h-100">
<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="ms-auto">
{isLive ? (
@@ -392,60 +547,148 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
</div>
</div>
{isLiveTailscale ? (
<div className="mt-2">
<div className="h3 mb-0">
{tailscaleSummary!.online}/{tailscaleSummary!.total}
<>
<div className="row g-2 mb-3">
<div className="col-4">
<MiniStat label="Online / Total" value={`${tailscaleSummary!.online}/${tailscaleSummary!.total}`} />
</div>
<div className="text-secondary">devices online</div>
{tailscaleSummary!.unauthorized > 0 && (
<div className="text-yellow small mt-1">{tailscaleSummary!.unauthorized} awaiting authorization</div>
)}
{tailscaleSummary!.expiringSoon > 0 && (
<div className="text-yellow small mt-1">{tailscaleSummary!.expiringSoon} key(s) expiring soon</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="mt-2">
<div className="h3 mb-0">{giteaSummary!.repoCount}</div>
<div className="text-secondary">repositories</div>
{giteaSummary!.failing > 0 && (
<div className="text-red small mt-1">{giteaSummary!.failing} with a failing build</div>
)}
<>
<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="mt-2">
<div className="h3 mb-0">
{dockhandSummary!.running}/{dockhandSummary!.total}
<>
<div className="row g-2 mb-3">
<div className="col-4">
<MiniStat label="Running / Total" value={`${dockhandSummary!.running}/${dockhandSummary!.total}`} />
</div>
<div className="text-secondary">containers running</div>
<div className="col-4">
<MiniStat label="Hosts" value={dockhandSummary!.hostCount} />
</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 ? (
<div className="mt-2">
<div className="h3 mb-0">{semaphoreSummary!.total}</div>
<div className="text-secondary">templates</div>
{semaphoreSummary!.failing > 0 && (
<div className="text-red small mt-1">{semaphoreSummary!.failing} last failed</div>
)}
<>
<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="mt-2">
<div className="h3 mb-0">
{proxmoxSummary!.running}/{proxmoxSummary!.total}
<>
<div className="row g-2 mb-3">
<div className="col-4">
<MiniStat label="Running / Total" value={`${proxmoxSummary!.running}/${proxmoxSummary!.total}`} />
</div>
<div className="text-secondary">VMs/containers running</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="mt-2">
<div className="h3 mb-0">{synologySummary!.volumeCount}</div>
<div className="text-secondary">volumes</div>
{(synologySummary!.volumesNotNormal > 0 || synologySummary!.disksNotNormal > 0) && (
<div className="text-red small mt-1">
{synologySummary!.volumesNotNormal > 0 && `${synologySummary!.volumesNotNormal} volume(s) unhealthy`}
{synologySummary!.volumesNotNormal > 0 && synologySummary!.disksNotNormal > 0 && ", "}
{synologySummary!.disksNotNormal > 0 && `${synologySummary!.disksNotNormal} disk(s) unhealthy`}
<>
<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>
)}
</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 ? (