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:
1 parent
91796a3c3a
commit
9229ea2ed6
2 files changed
+117
-1
No files matched your search
@@ -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
|
||||
|
||||
+114
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user