From 9229ea2ed6502c89bda8d94d94b90b8d7575994b Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Sun, 27 Sep 2026 02:52:05 +0200 Subject: [PATCH] Add a Domains widget to the dashboard A Domains card completing the Overview row (DNS, Secrets, Servers, Domains), shaped like the Secrets card: a status badge (N expired / N expiring / All OK / No expiry dates / Not configured), tracked, expiring and expired counts, an OK/expiring/expired/no-date bar, and details -- the expired domains and the soonest-expiring ones (first three, soonest first), the next one to expire when everything is fine, and a note when some domains couldn't be refreshed, linking to the Domains page. Purely front end: the existing /api/domains already carries each domain's status, days left and last-check error, computed against the warning window from Settings. A registry that doesn't publish expiry dates (.de, .eu) shows as "no date" and is not counted as a failed refresh; anything else that stopped a refresh is. Verified in a browser against the real domains router across four states: problems (expired, several expiring, an undated one, a failed refresh), all healthy, only undated domains, and none tracked. Web build only; no backend or database changes. Co-Authored-By: Claude Sonnet 5 --- README.md | 3 + web/src/pages/Dashboard.tsx | 115 +++++++++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 812463d..2e1b4c3 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,9 @@ All modules from the original plan are built: usage threshold — decided by the same rules as the health alerts, so the widget and the notifications always agree, and following the thresholds in Settings even for viewers who can't open them. + The Domains widget shows how many registrations are tracked, which are + expired or expiring (soonest first), the next one to expire, and how many + couldn't be refreshed. - **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/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index e2b4dfa..99f554e 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -3,6 +3,7 @@ import { Link } from "react-router-dom"; import { api, type CurrentUser, + type DomainList, type IntegrationSummary, type IntegrationType, type SecretRecord, @@ -37,6 +38,13 @@ const SECRET_TYPE_COLORS: Record = { generic: "#94a3b8", }; +const DOMAIN_STATE_COLORS: Record = { + OK: "#4ade80", + Expiring: "#fbbf24", + Expired: "#f87171", + "No date": "#94a3b8", +}; + const SERVER_STATE_COLORS: Record = { Online: "#4ade80", Offline: "#f87171", @@ -257,8 +265,14 @@ export default function Dashboard({ user }: { user: CurrentUser }) { const [secrets, setSecrets] = useState(null); const [serverSummary, setServerSummary] = useState(null); const [serverError, setServerError] = useState(null); + const [domainList, setDomainList] = useState(null); + const [domainError, setDomainError] = useState(null); useEffect(() => { + api.domains + .list() + .then(setDomainList) + .catch((err) => setDomainError(err instanceof Error ? err.message : String(err))); api.servers .summary() .then(setServerSummary) @@ -407,6 +421,17 @@ export default function Dashboard({ user }: { user: CurrentUser }) { .catch(() => setSynologySummary(null)); }, [integrations]); + const domains = domainList?.domains ?? []; + const expiredDomains = domains.filter((d) => d.status === "expired"); + const expiringDomains = domains.filter((d) => d.status === "expiring"); + const okDomains = domains.filter((d) => d.status === "ok").length; + const undatedDomains = domains.filter((d) => d.status === "unknown").length; + // A registry that simply doesn't publish expiry dates isn't a failure; anything else that stopped a refresh is. + const failingDomainChecks = domains.filter((d) => d.lastCheckError && !/doesn't publish/i.test(d.lastCheckError)).length; + const nextDomain = domains + .filter((d) => d.expiresAt && d.daysLeft !== null && d.daysLeft >= 0) + .sort((a, b) => a.daysLeft! - b.daysLeft!)[0]; + 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; @@ -428,7 +453,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) { - +
)} + + Not configured + ) : expiredDomains.length > 0 ? ( + {expiredDomains.length} expired + ) : expiringDomains.length > 0 ? ( + {expiringDomains.length} expiring + ) : okDomains > 0 ? ( + All OK + ) : ( + No expiry dates + ) + } + > + {domainError &&
{domainError}
} + {!domainList ? ( + !domainError &&
Fetching status…
+ ) : domains.length === 0 ? ( +
+ Domains for your DNS zones appear on the Domains page after the next daily check, or add one there. +
+ ) : ( + <> +
+
+ {domains.length}} /> +
+
+ 0 ? "#f59e0b" : undefined} /> +
+
+ 0 ? "#ef4444" : undefined} /> +
+
+ d.count > 0)} + colors={DOMAIN_STATE_COLORS} + compact + /> + {expiredDomains.length > 0 && ( +
+ Expired:{" "} + {expiredDomains.slice(0, 3).map((d, i) => ( + + {i > 0 && ", "} + {d.name} ({Math.abs(d.daysLeft ?? 0)} d ago) + + ))} + {expiredDomains.length > 3 && +{expiredDomains.length - 3} more} +
+ )} + {expiringDomains.length > 0 && ( +
+ Expiring:{" "} + {expiringDomains + .slice() + .sort((a, b) => a.daysLeft! - b.daysLeft!) + .slice(0, 3) + .map((d, i) => ( + + {i > 0 && ", "} + {d.name} ({d.daysLeft} d) + + ))} + {expiringDomains.length > 3 && +{expiringDomains.length - 3} more} +
+ )} + {nextDomain && nextDomain.status === "ok" && ( +
+ Next to expire: {nextDomain.name} in {nextDomain.daysLeft} days ({nextDomain.expiresAt}) +
+ )} + {failingDomainChecks > 0 && ( +
+ {failingDomainChecks} domain{failingDomainChecks === 1 ? "" : "s"} couldn't be refreshed — see the Domains page. +
+ )} + + )} +