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);