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 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-17 23:06:42 +02:00
1 parent 655862c94e
commit a5ec099c13
5 files changed
+295 -5

No files matched your search

+7
View File
@@ -17,6 +17,13 @@ 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
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 - **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
+48
View File
@@ -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<string, number>();
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),
};
}
+7
View File
@@ -13,6 +13,7 @@ import {
} from "../dns/providerSchemas.js"; } from "../dns/providerSchemas.js";
import { createDnsAdapter } from "../dns/registry.js"; import { createDnsAdapter } from "../dns/registry.js";
import { loadDnsProviderConfig, getDnsAdapterForProvider } from "../dns/loadProvider.js"; import { loadDnsProviderConfig, getDnsAdapterForProvider } from "../dns/loadProvider.js";
import { getDnsStats } from "../dns/stats.js";
import { asyncHandler } from "../utils/asyncHandler.js"; import { asyncHandler } from "../utils/asyncHandler.js";
import { notifyDnsRecordAdded, notifyDnsRecordUpdated, notifyDnsRecordDeleted } from "../services/notify.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 }); res.json({ fields: DNS_PROVIDER_FIELDS });
}); });
// ─── Dashboard stats ─────────────────────────────────────────────────────────
dnsRouter.get("/stats", asyncHandler(async (_req, res) => {
res.json(await getDnsStats());
}));
// ─── Providers ─────────────────────────────────────────────────────────────── // ─── Providers ───────────────────────────────────────────────────────────────
dnsRouter.get("/providers", asyncHandler(async (_req, res) => { dnsRouter.get("/providers", asyncHandler(async (_req, res) => {
+9
View File
@@ -573,6 +573,15 @@ export const api = {
dns: { dns: {
providerFields: () => providerFields: () =>
request<{ fields: Record<DnsProviderType, DnsProviderField[]> }>("/api/dns/provider-fields"), request<{ fields: Record<DnsProviderType, DnsProviderField[]> }>("/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: { providers: {
list: () => request<{ providers: DnsProviderSummary[] }>("/api/dns/providers"), list: () => request<{ providers: DnsProviderSummary[] }>("/api/dns/providers"),
create: (data: { providerType: DnsProviderType; name: string; config: Record<string, string | boolean> }) => create: (data: { providerType: DnsProviderType; name: string; config: Record<string, string | boolean> }) =>
+224 -5
View File
@@ -1,6 +1,105 @@
import { useEffect, useState } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { Link } from "react-router-dom"; 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<string, string> = {
A: "#60a5fa",
AAAA: "#93c5fd",
CNAME: "#c084fc",
MX: "#4ade80",
TXT: "#fb923c",
NS: "#94a3b8",
SRV: "#a3e635",
CAA: "#f472b6",
PTR: "#facc15",
};
const SECRET_TYPE_LABELS: Record<SecretRecord["type"], string> = {
api_token: "API Token",
ssl_certificate: "SSL Certificate",
password: "Password",
generic: "Generic",
};
const SECRET_TYPE_COLORS: Record<SecretRecord["type"], string> = {
api_token: "#6366f1",
ssl_certificate: "#22d3ee",
password: "#f472b6",
generic: "#94a3b8",
};
function SectionHeader({ title, hint }: { title: string; hint?: ReactNode }) {
return (
<div className="mb-3 mt-4">
<h3 className="mb-0">{title}</h3>
{hint && <div className="text-secondary small mt-1">{hint}</div>}
</div>
);
}
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 TypeBreakdown({ title, data, colors }: { title: string; data: { label: string; count: number }[]; colors: Record<string, string> }) {
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 }}>
{data.map((d) => (
<div
key={d.label}
className="progress-bar"
style={{ width: `${(d.count / total) * 100}%`, backgroundColor: colors[d.label] ?? "#94a3b8" }}
title={`${d.label}: ${d.count}`}
/>
))}
</div>
<div className="d-flex flex-wrap gap-3">
{data.map((d) => (
<div key={d.label} className="d-flex align-items-center gap-2 small">
<span
style={{
width: 10,
height: 10,
borderRadius: 2,
backgroundColor: colors[d.label] ?? "#94a3b8",
display: "inline-block",
flexShrink: 0,
}}
/>
<span>
{d.label} <span className="text-secondary">({d.count})</span>
</span>
</div>
))}
</div>
</div>
</div>
);
}
const WIDGETS: { type: IntegrationType; label: string }[] = [ const WIDGETS: { type: IntegrationType; label: string }[] = [
{ type: "proxmox", label: "Proxmox" }, { type: "proxmox", label: "Proxmox" },
@@ -46,6 +145,21 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
const [proxmoxSummary, setProxmoxSummary] = useState<ProxmoxSummary | null>(null); const [proxmoxSummary, setProxmoxSummary] = useState<ProxmoxSummary | null>(null);
const [synologySummary, setSynologySummary] = useState<SynologySummary | null>(null); const [synologySummary, setSynologySummary] = useState<SynologySummary | null>(null);
const [dnsStats, setDnsStats] = useState<Awaited<ReturnType<typeof api.dns.stats>> | null>(null);
const [dnsError, setDnsError] = useState<string | null>(null);
const [secrets, setSecrets] = useState<SecretRecord[] | null>(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(() => { useEffect(() => {
api.integrations.list().then((res) => setIntegrations(res.integrations)); api.integrations.list().then((res) => setIntegrations(res.integrations));
}, []); }, []);
@@ -133,18 +247,123 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
.catch(() => setSynologySummary(null)); .catch(() => setSynologySummary(null));
}, [integrations]); }, [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<Record<string, number>>((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 ( return (
<> <>
<div className="row row-cards mb-3"> <div className="row row-cards mb-3">
<div className="col-12"> <div className="col-12">
<h2 className="page-title mb-3">Dashboard</h2> <h2 className="page-title mb-3">Dashboard</h2>
<p className="text-secondary"> <p className="text-secondary">
Welcome back, {user.name ?? user.email ?? user.sub}. Live status widgets for Proxmox, Welcome back, {user.name ?? user.email ?? user.sub}.
Synology, Semaphore, Tailscale, Gitea, and Dockhand will appear here as each
integration is connected.
</p> </p>
</div> </div>
</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("; ")}
</div>
)}
{dnsStats && (
<TypeBreakdown
title="Cached Records by Type"
data={dnsStats.recordsByType.map((r) => ({ label: r.type, count: r.count }))}
colors={DNS_TYPE_COLORS}
/>
)}
</>
)}
<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" />
</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} />
</>
)}
<SectionHeader
title="Integrations"
hint="Live status widgets for Proxmox, Synology, Semaphore, Tailscale, Gitea, and Dockhand will appear here as each integration is connected."
/>
<div className="row row-cards"> <div className="row row-cards">
{WIDGETS.map(({ type, label }) => { {WIDGETS.map(({ type, label }) => {
const integration = integrations?.find((i) => i.type === type && i.enabled); const integration = integrations?.find((i) => i.type === type && i.enabled);