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 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-27 02:52:05 +02:00
1 parent 91796a3c3a
commit 9229ea2ed6
2 files changed
+117 -1

No files matched your search

+3
View File
@@ -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 usage threshold — decided by the same rules as the health alerts, so the
widget and the notifications always agree, and following the thresholds in widget and the notifications always agree, and following the thresholds in
Settings even for viewers who can't open them. 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 - **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
+114 -1
View File
@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import { import {
api, api,
type CurrentUser, type CurrentUser,
type DomainList,
type IntegrationSummary, type IntegrationSummary,
type IntegrationType, type IntegrationType,
type SecretRecord, type SecretRecord,
@@ -37,6 +38,13 @@ const SECRET_TYPE_COLORS: Record<SecretRecord["type"], string> = {
generic: "#94a3b8", generic: "#94a3b8",
}; };
const DOMAIN_STATE_COLORS: Record<string, string> = {
OK: "#4ade80",
Expiring: "#fbbf24",
Expired: "#f87171",
"No date": "#94a3b8",
};
const SERVER_STATE_COLORS: Record<string, string> = { const SERVER_STATE_COLORS: Record<string, string> = {
Online: "#4ade80", Online: "#4ade80",
Offline: "#f87171", Offline: "#f87171",
@@ -257,8 +265,14 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
const [secrets, setSecrets] = useState<SecretRecord[] | null>(null); const [secrets, setSecrets] = useState<SecretRecord[] | null>(null);
const [serverSummary, setServerSummary] = useState<ServerSummary | null>(null); const [serverSummary, setServerSummary] = useState<ServerSummary | null>(null);
const [serverError, setServerError] = useState<string | null>(null); const [serverError, setServerError] = useState<string | null>(null);
const [domainList, setDomainList] = useState<DomainList | null>(null);
const [domainError, setDomainError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
api.domains
.list()
.then(setDomainList)
.catch((err) => setDomainError(err instanceof Error ? err.message : String(err)));
api.servers api.servers
.summary() .summary()
.then(setServerSummary) .then(setServerSummary)
@@ -407,6 +421,17 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
.catch(() => setSynologySummary(null)); .catch(() => setSynologySummary(null));
}, [integrations]); }, [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 totalSecrets = secrets?.length ?? 0;
const expiredSecrets = secrets?.filter((s) => s.status === "expired").length ?? 0; const expiredSecrets = secrets?.filter((s) => s.status === "expired").length ?? 0;
const expiringSecrets = secrets?.filter((s) => s.status === "expiring").length ?? 0; const expiringSecrets = secrets?.filter((s) => s.status === "expiring").length ?? 0;
@@ -428,7 +453,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
</div> </div>
</div> </div>
<SectionHeader title="Overview" hint="DNS, Secrets and Servers are built into this app rather than connected integrations, but get the same at-a-glance treatment." /> <SectionHeader title="Overview" hint="DNS, Secrets, Servers and Domains are built into this app rather than connected integrations, but get the same at-a-glance treatment." />
<div className="row row-cards mb-3"> <div className="row row-cards mb-3">
<WidgetCard <WidgetCard
label="DNS" label="DNS"
@@ -608,6 +633,94 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
</> </>
)} )}
</WidgetCard> </WidgetCard>
<WidgetCard
label="Domains"
badge={
!domainList ? undefined : domains.length === 0 ? (
<span className="badge bg-secondary-lt text-secondary">Not configured</span>
) : expiredDomains.length > 0 ? (
<span className="badge bg-red-lt text-red">{expiredDomains.length} expired</span>
) : expiringDomains.length > 0 ? (
<span className="badge bg-yellow-lt text-yellow">{expiringDomains.length} expiring</span>
) : okDomains > 0 ? (
<span className="badge bg-green-lt text-green">All OK</span>
) : (
<span className="badge bg-secondary-lt text-secondary">No expiry dates</span>
)
}
>
{domainError && <div className="alert alert-danger mb-2">{domainError}</div>}
{!domainList ? (
!domainError && <div className="text-secondary">Fetching status…</div>
) : domains.length === 0 ? (
<div className="text-secondary">
Domains for your DNS zones appear on the <Link to="/domains">Domains</Link> page after the next daily check, or add one there.
</div>
) : (
<>
<div className="row g-2 mb-3">
<div className="col-4">
<MiniStat label="Tracked" value={<Link to="/domains" className="text-reset text-decoration-none">{domains.length}</Link>} />
</div>
<div className="col-4">
<MiniStat label="Expiring" value={expiringDomains.length} accent={expiringDomains.length > 0 ? "#f59e0b" : undefined} />
</div>
<div className="col-4">
<MiniStat label="Expired" value={expiredDomains.length} accent={expiredDomains.length > 0 ? "#ef4444" : undefined} />
</div>
</div>
<BreakdownBar
data={[
{ label: "OK", count: okDomains },
{ label: "Expiring", count: expiringDomains.length },
{ label: "Expired", count: expiredDomains.length },
{ label: "No date", count: undatedDomains },
].filter((d) => d.count > 0)}
colors={DOMAIN_STATE_COLORS}
compact
/>
{expiredDomains.length > 0 && (
<div className="small mt-2">
<span className="text-red">Expired:</span>{" "}
{expiredDomains.slice(0, 3).map((d, i) => (
<span key={d.id}>
{i > 0 && ", "}
<Link to="/domains">{d.name}</Link> <span className="text-secondary">({Math.abs(d.daysLeft ?? 0)} d ago)</span>
</span>
))}
{expiredDomains.length > 3 && <span className="text-secondary"> +{expiredDomains.length - 3} more</span>}
</div>
)}
{expiringDomains.length > 0 && (
<div className="small mt-2">
<span className="text-yellow">Expiring:</span>{" "}
{expiringDomains
.slice()
.sort((a, b) => a.daysLeft! - b.daysLeft!)
.slice(0, 3)
.map((d, i) => (
<span key={d.id}>
{i > 0 && ", "}
<Link to="/domains">{d.name}</Link> <span className="text-secondary">({d.daysLeft} d)</span>
</span>
))}
{expiringDomains.length > 3 && <span className="text-secondary"> +{expiringDomains.length - 3} more</span>}
</div>
)}
{nextDomain && nextDomain.status === "ok" && (
<div className="text-secondary small mt-2">
Next to expire: {nextDomain.name} in {nextDomain.daysLeft} days ({nextDomain.expiresAt})
</div>
)}
{failingDomainChecks > 0 && (
<div className="text-secondary small mt-2">
{failingDomainChecks} domain{failingDomainChecks === 1 ? "" : "s"} couldn't be refreshed — see the <Link to="/domains">Domains</Link> page.
</div>
)}
</>
)}
</WidgetCard>
</div> </div>
<SectionHeader <SectionHeader