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

+114 -1
View File
@@ -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<SecretRecord["type"], string> = {
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> = {
Online: "#4ade80",
Offline: "#f87171",
@@ -257,8 +265,14 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
const [secrets, setSecrets] = useState<SecretRecord[] | null>(null);
const [serverSummary, setServerSummary] = useState<ServerSummary | null>(null);
const [serverError, setServerError] = useState<string | null>(null);
const [domainList, setDomainList] = useState<DomainList | null>(null);
const [domainError, setDomainError] = useState<string | null>(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 }) {
</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">
<WidgetCard
label="DNS"
@@ -608,6 +633,94 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
</>
)}
</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>
<SectionHeader