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
|
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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user