From a5ec099c13c24f7e095ed0e6b90ced345e246f76 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Thu, 17 Sep 2026 23:06:42 +0200 Subject: [PATCH] Bring Sloth Manager's DNS/Secrets dashboard here, alongside integrations Sloth Manager's dashboard showed per-system stats for DNS and Secrets (domains/records by type, expiry counts by type) that this app's Dashboard never had -- it only ever showed the six live integration widgets. Port those two sections over, generalized to sit alongside the integrations this app added that Sloth Manager never had. New server/src/dns/stats.ts (getDnsStats(), used by new GET /api/dns/stats): zone counts are fetched live per enabled provider (matching what the DNS page itself shows, since the zone cache table only gets a row once a zone has been synced and would undercount) -- one unreachable provider surfaces its error without blanking the rest. Record-type breakdown comes from the local cache instead, since records are only ever shown from cache elsewhere in this app too (fetching every zone's records live on every dashboard load would be far more expensive for no real accuracy gain). Dashboard.tsx gained three sections under clear headers: DNS (domain/ provider/record stat tiles + a "records by type" breakdown), Secrets (monitored/expiring/expired tiles + a "secrets by type" breakdown, computed client-side from the already-fetched secrets list, same as Sloth Manager did), and the existing Integrations widgets grouped under their own header for visual parity with the two new sections. Breakdowns render as a stacked proportion bar with a color-coded legend rather than a pie chart -- this app has no charting library anywhere yet, and a plain CSS progress bar (an idiom Tabler itself uses) gets the same "see the mix at a glance" value without adding one for a single dashboard. Verified getDnsStats() against a temp SQLite DB with real migrations and an enabled + a disabled DNS provider: enabled/total provider counts, live zone counting, and cached-record-type aggregation (sorted by count) all came back correct, and the disabled provider was correctly excluded from the zone count while still counting toward the total. Co-Authored-By: Claude Sonnet 5 --- README.md | 7 ++ server/src/dns/stats.ts | 48 ++++++++ server/src/routes/dns.ts | 7 ++ web/src/api/client.ts | 9 ++ web/src/pages/Dashboard.tsx | 229 +++++++++++++++++++++++++++++++++++- 5 files changed, 295 insertions(+), 5 deletions(-) create mode 100644 server/src/dns/stats.ts diff --git a/README.md b/README.md index 711a527..661c6f7 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,13 @@ 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. - **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 diff --git a/server/src/dns/stats.ts b/server/src/dns/stats.ts new file mode 100644 index 0000000..39170c1 --- /dev/null +++ b/server/src/dns/stats.ts @@ -0,0 +1,48 @@ +import { db } from "../db/client.js"; +import { dnsProviders, dnsRecordsCache } from "../db/schema.js"; +import { getDnsAdapterForProvider } from "./loadProvider.js"; + +export async function getDnsStats() { + const providers = await db.select().from(dnsProviders); + const enabledProviders = providers.filter((p) => p.enabled); + + // Zone counts are live (matching what the DNS page itself shows) since + // dnsZonesCache only gets a row once a zone has been synced at least once + // and would otherwise undercount. One unreachable provider shouldn't blank + // the whole dashboard, so failures are caught per-provider. + const perProvider = await Promise.all( + enabledProviders.map(async (p) => { + try { + const found = await getDnsAdapterForProvider(p.id); + const zones = found ? await found.adapter.listZones() : []; + return { providerId: p.id, name: p.name, providerType: p.providerType, zoneCount: zones.length, error: null as string | null }; + } catch (err) { + return { + providerId: p.id, + name: p.name, + providerType: p.providerType, + zoneCount: 0, + error: err instanceof Error ? err.message : String(err), + }; + } + }), + ); + + // Records, unlike zones, are only ever shown from the local cache elsewhere + // in this app (never fetched live per-zone), so the breakdown here matches + // that same "as of last sync" scope rather than hitting every zone's API. + const recordRows = await db.select({ type: dnsRecordsCache.type }).from(dnsRecordsCache); + const recordsByType = new Map(); + for (const r of recordRows) recordsByType.set(r.type, (recordsByType.get(r.type) ?? 0) + 1); + + return { + totalProviders: providers.length, + enabledProviders: enabledProviders.length, + totalZones: perProvider.reduce((sum, p) => sum + p.zoneCount, 0), + perProvider, + totalRecords: recordRows.length, + recordsByType: [...recordsByType.entries()] + .map(([type, count]) => ({ type, count })) + .sort((a, b) => b.count - a.count), + }; +} diff --git a/server/src/routes/dns.ts b/server/src/routes/dns.ts index 36108b6..99c4763 100644 --- a/server/src/routes/dns.ts +++ b/server/src/routes/dns.ts @@ -13,6 +13,7 @@ import { } from "../dns/providerSchemas.js"; import { createDnsAdapter } from "../dns/registry.js"; import { loadDnsProviderConfig, getDnsAdapterForProvider } from "../dns/loadProvider.js"; +import { getDnsStats } from "../dns/stats.js"; import { asyncHandler } from "../utils/asyncHandler.js"; import { notifyDnsRecordAdded, notifyDnsRecordUpdated, notifyDnsRecordDeleted } from "../services/notify.js"; @@ -35,6 +36,12 @@ dnsRouter.get("/provider-fields", (_req, res) => { res.json({ fields: DNS_PROVIDER_FIELDS }); }); +// ─── Dashboard stats ───────────────────────────────────────────────────────── + +dnsRouter.get("/stats", asyncHandler(async (_req, res) => { + res.json(await getDnsStats()); +})); + // ─── Providers ─────────────────────────────────────────────────────────────── dnsRouter.get("/providers", asyncHandler(async (_req, res) => { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index dd9c3f7..c8553b2 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -573,6 +573,15 @@ export const api = { dns: { providerFields: () => request<{ fields: Record }>("/api/dns/provider-fields"), + stats: () => + request<{ + totalProviders: number; + enabledProviders: number; + totalZones: number; + perProvider: { providerId: number; name: string; providerType: DnsProviderType; zoneCount: number; error: string | null }[]; + totalRecords: number; + recordsByType: { type: string; count: number }[]; + }>("/api/dns/stats"), providers: { list: () => request<{ providers: DnsProviderSummary[] }>("/api/dns/providers"), create: (data: { providerType: DnsProviderType; name: string; config: Record }) => diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index f31065e..847b19f 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -1,6 +1,105 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { Link } from "react-router-dom"; -import { api, type CurrentUser, type IntegrationSummary, type IntegrationType, type TailscaleDevicesResponse } from "../api/client"; +import { + api, + type CurrentUser, + type IntegrationSummary, + type IntegrationType, + type SecretRecord, + type TailscaleDevicesResponse, +} from "../api/client"; + +const DNS_TYPE_COLORS: Record = { + A: "#60a5fa", + AAAA: "#93c5fd", + CNAME: "#c084fc", + MX: "#4ade80", + TXT: "#fb923c", + NS: "#94a3b8", + SRV: "#a3e635", + CAA: "#f472b6", + PTR: "#facc15", +}; + +const SECRET_TYPE_LABELS: Record = { + api_token: "API Token", + ssl_certificate: "SSL Certificate", + password: "Password", + generic: "Generic", +}; + +const SECRET_TYPE_COLORS: Record = { + api_token: "#6366f1", + ssl_certificate: "#22d3ee", + password: "#f472b6", + generic: "#94a3b8", +}; + +function SectionHeader({ title, hint }: { title: string; hint?: ReactNode }) { + return ( +
+

{title}

+ {hint &&
{hint}
} +
+ ); +} + +function StatTile({ label, value, sub, accent }: { label: string; value: ReactNode; sub?: ReactNode; accent?: string }) { + return ( +
+
+
{label}
+
+ {value} +
+ {sub &&
{sub}
} +
+
+ ); +} + +function TypeBreakdown({ title, data, colors }: { title: string; data: { label: string; count: number }[]; colors: Record }) { + const total = data.reduce((s, d) => s + d.count, 0); + if (total === 0) return null; + return ( +
+
+

{title}

+
+
+
+ {data.map((d) => ( +
+ ))} +
+
+ {data.map((d) => ( +
+ + + {d.label} ({d.count}) + +
+ ))} +
+
+
+ ); +} const WIDGETS: { type: IntegrationType; label: string }[] = [ { type: "proxmox", label: "Proxmox" }, @@ -46,6 +145,21 @@ export default function Dashboard({ user }: { user: CurrentUser }) { const [proxmoxSummary, setProxmoxSummary] = useState(null); const [synologySummary, setSynologySummary] = useState(null); + const [dnsStats, setDnsStats] = useState> | null>(null); + const [dnsError, setDnsError] = useState(null); + const [secrets, setSecrets] = useState(null); + + useEffect(() => { + api.dns + .stats() + .then(setDnsStats) + .catch((err) => setDnsError(err instanceof Error ? err.message : String(err))); + api.secrets + .list() + .then((res) => setSecrets(res.secrets)) + .catch(() => setSecrets([])); + }, []); + useEffect(() => { api.integrations.list().then((res) => setIntegrations(res.integrations)); }, []); @@ -133,18 +247,123 @@ export default function Dashboard({ user }: { user: CurrentUser }) { .catch(() => setSynologySummary(null)); }, [integrations]); + const totalSecrets = secrets?.length ?? 0; + const expiredSecrets = secrets?.filter((s) => s.status === "expired").length ?? 0; + const expiringSecrets = secrets?.filter((s) => s.status === "expiring").length ?? 0; + const secretsByType = Object.entries( + (secrets ?? []).reduce>((acc, s) => { + acc[s.type] = (acc[s.type] ?? 0) + 1; + return acc; + }, {}), + ).map(([type, count]) => ({ label: SECRET_TYPE_LABELS[type as SecretRecord["type"]] ?? type, count })); + return ( <>

Dashboard

- Welcome back, {user.name ?? user.email ?? user.sub}. Live status widgets for Proxmox, - Synology, Semaphore, Tailscale, Gitea, and Dockhand will appear here as each - integration is connected. + Welcome back, {user.name ?? user.email ?? user.sub}.

+ + + {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("; ")} +
+ )} + {dnsStats && ( + ({ label: r.type, count: r.count }))} + colors={DNS_TYPE_COLORS} + /> + )} + + )} + + + {secrets && totalSecrets === 0 ? ( +
+
+ No secrets tracked yet. Add one under Secrets. +
+
+ ) : ( + <> +
+
+ +
+
+ 0 ? "#f59e0b" : undefined} + /> +
+
+ 0 ? "#ef4444" : undefined} + /> +
+
+ + + )} + +
{WIDGETS.map(({ type, label }) => { const integration = integrations?.find((i) => i.type === type && i.enabled);