diff --git a/README.md b/README.md index 728885e..812463d 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,11 @@ All modules from the original plan are built: alongside its disk-health breakdown. Breakdowns render as a stacked proportion bar with a legend — no charting library, matching the rest of the app's plain-Tabler-CSS approach. + The Servers widget shows how many servers are online, offline, or have never + reported, which are offline and for how long, and any disk at or above 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 + Settings even for viewers who can't open them. - **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/server/src/routes/servers.ts b/server/src/routes/servers.ts index f607c2c..d88ab4b 100644 --- a/server/src/routes/servers.ts +++ b/server/src/routes/servers.ts @@ -11,6 +11,9 @@ import { loadIntegrationConfig } from "../integrations/loadIntegration.js"; import { createProxmoxAdapter, type ProxmoxGuestType } from "../integrations/proxmox/adapter.js"; import { serverPortsRouter } from "./serverPorts.js"; import { InvalidTagError, normalizeTags, parseTags } from "../services/serverTags.js"; +import { buildServerSummary } from "../services/serverSummary.js"; +import { activeSubjects } from "../services/maintenance.js"; +import { getSettings } from "../services/settingsStore.js"; export const serversRouter = Router(); @@ -157,6 +160,16 @@ serversRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req, res) res.json({ server: serverOut }); })); +// For the dashboard widget. Computed here, with the health monitor's own rules and the thresholds from Settings +// (which viewers can't read), so the widget and the alerts always agree about what "offline" and "full" mean. +serversRouter.get("/summary", asyncHandler(async (_req, res) => { + const rows = await db + .select({ id: servers.id, name: servers.name, osType: servers.osType, lastSeenAt: servers.lastSeenAt, disks: servers.disks }) + .from(servers); + const { healthChecks } = await getSettings(); + res.json(buildServerSummary(rows, healthChecks, Date.now(), await activeSubjects())); +})); + serversRouter.get("/:id/detail", asyncHandler(async (req, res) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); diff --git a/server/src/services/serverSummary.ts b/server/src/services/serverSummary.ts new file mode 100644 index 0000000..3537d26 --- /dev/null +++ b/server/src/services/serverSummary.ts @@ -0,0 +1,85 @@ +import { evaluateHealth, type Thresholds } from "./healthMonitor.js"; + +/** The columns of a server row the summary reads. */ +export interface SummaryRow { + id: number; + name: string; + osType: string; + lastSeenAt: string | null; + disks: string | null; // JSON, as the agent's report is stored +} + +export interface ServerSummary { + total: number; + /** Reported within the "server offline after" threshold. */ + online: number; + /** Has reported before, but not recently enough. */ + offline: { id: number; name: string; lastSeenAt: string; inMaintenance: boolean }[]; + /** Never reported: no agent, or not yet — a server tracked only through Proxmox lands here too. */ + noData: number; + byOs: { linux: number; windows: number }; + /** Reporting servers with a disk at or above the usage threshold. */ + fullDisks: { id: number; name: string; mount: string; percent: number }[]; + thresholds: { offlineMinutes: number; diskPercent: number }; +} + +function parseDisks(text: string | null): { mount: string; sizeBytes: number; usedBytes: number }[] { + if (!text) return []; + try { + const value = JSON.parse(text); + return Array.isArray(value) ? value : []; + } catch { + return []; // a garbled report shouldn't blank the whole widget + } +} + +/** + * What the dashboard's Servers widget shows. "Offline" and "disk full" are decided by the same function the alerts + * use (evaluateHealth), so the widget can never say a server is fine while an alert says it isn't, or the reverse — + * and it follows the thresholds set in Settings, which viewers can't read themselves. + */ +export function buildServerSummary(rows: SummaryRow[], thresholds: Thresholds, now: number, silencedSubjects: Set = new Set()): ServerSummary { + const snapshot = { + servers: rows.map((r) => ({ id: r.id, name: r.name, lastSeenAt: r.lastSeenAt, disks: parseDisks(r.disks) })), + proxmox: [], + synology: [], + }; + const conditions = evaluateHealth(snapshot, thresholds, now); + const byId = new Map(rows.map((r) => [r.id, r])); + + const offlineIds = new Set(); + const fullDisks: ServerSummary["fullDisks"] = []; + for (const c of conditions) { + let m = /^offline:server:(\d+)$/.exec(c.key); + if (m) { + offlineIds.add(Number(m[1])); + continue; + } + // The mount can contain a colon ("C:"), so take everything after the id. + m = /^disk:server:(\d+):(.*)$/.exec(c.key); + if (m) { + const row = byId.get(Number(m[1])); + const disk = row ? parseDisks(row.disks).find((d) => d.mount === m![2]) : undefined; + if (row && disk && disk.sizeBytes > 0) { + fullDisks.push({ id: row.id, name: row.name, mount: disk.mount, percent: Math.round((disk.usedBytes / disk.sizeBytes) * 100) }); + } + } + } + fullDisks.sort((a, b) => b.percent - a.percent || a.name.localeCompare(b.name)); + + const offline = rows + .filter((r) => offlineIds.has(r.id) && r.lastSeenAt) + .map((r) => ({ id: r.id, name: r.name, lastSeenAt: r.lastSeenAt!, inMaintenance: silencedSubjects.has(`server:${r.id}`) })) + .sort((a, b) => a.lastSeenAt.localeCompare(b.lastSeenAt)); // longest silent first + + const noData = rows.filter((r) => !r.lastSeenAt).length; + return { + total: rows.length, + online: rows.length - noData - offline.length, + offline, + noData, + byOs: { linux: rows.filter((r) => r.osType !== "windows").length, windows: rows.filter((r) => r.osType === "windows").length }, + fullDisks, + thresholds: { offlineMinutes: thresholds.serverOfflineMinutes, diskPercent: thresholds.diskUsagePercent }, + }; +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2b76192..1792416 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -578,6 +578,16 @@ export interface TagEntry { count: number; } +export interface ServerSummary { + total: number; + online: number; + offline: { id: number; name: string; lastSeenAt: string; inMaintenance: boolean }[]; + noData: number; + byOs: { linux: number; windows: number }; + fullDisks: { id: number; name: string; mount: string; percent: number }[]; + thresholds: { offlineMinutes: number; diskPercent: number }; +} + export interface DockhandContainer { id: string; name: string; @@ -923,6 +933,7 @@ export const api = { }), update: (id: number, data: ServerUpdateInput) => request<{ server: ServerRecord }>(`/api/servers/${id}`, { method: "PATCH", body: JSON.stringify(data) }), + summary: () => request("/api/servers/summary"), detail: (id: number) => request(`/api/servers/${id}/detail`), remove: (id: number) => request(`/api/servers/${id}`, { method: "DELETE" }), setTags: (id: number, tags: string[]) => diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 5ae9052..e2b4dfa 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -6,8 +6,10 @@ import { type IntegrationSummary, type IntegrationType, type SecretRecord, + type ServerSummary, type TailscaleDevicesResponse, } from "../api/client"; +import { formatAgo } from "../utils/duration"; const DNS_TYPE_COLORS: Record = { A: "#60a5fa", @@ -35,6 +37,12 @@ const SECRET_TYPE_COLORS: Record = { generic: "#94a3b8", }; +const SERVER_STATE_COLORS: Record = { + Online: "#4ade80", + Offline: "#f87171", + "No data": "#94a3b8", +}; + const CONTAINER_STATE_COLORS: Record = { running: "#4ade80", exited: "#f87171", @@ -247,8 +255,14 @@ export default function Dashboard({ user }: { user: CurrentUser }) { const [dnsStats, setDnsStats] = useState> | null>(null); const [dnsError, setDnsError] = useState(null); const [secrets, setSecrets] = useState(null); + const [serverSummary, setServerSummary] = useState(null); + const [serverError, setServerError] = useState(null); useEffect(() => { + api.servers + .summary() + .then(setServerSummary) + .catch((err) => setServerError(err instanceof Error ? err.message : String(err))); api.dns .stats() .then(setDnsStats) @@ -414,7 +428,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) { - +
)} + + Not configured + ) : serverSummary.offline.length > 0 ? ( + {serverSummary.offline.length} offline + ) : serverSummary.fullDisks.length > 0 ? ( + + {serverSummary.fullDisks.length} disk{serverSummary.fullDisks.length === 1 ? "" : "s"} nearly full + + ) : serverSummary.online > 0 ? ( + All online + ) : ( + No agent data + ) + } + > + {serverError &&
{serverError}
} + {!serverSummary ? ( + !serverError &&
Fetching status…
+ ) : serverSummary.total === 0 ? ( +
+ Add a server from the Servers page. +
+ ) : ( + <> +
+
+ {serverSummary.total}} /> +
+
+ +
+
+ 0 ? "#ef4444" : undefined} + /> +
+
+ d.count > 0)} + colors={SERVER_STATE_COLORS} + compact + /> + {serverSummary.offline.length > 0 && ( +
+ Offline:{" "} + {serverSummary.offline.slice(0, 3).map((s, i) => ( + + {i > 0 && ", "} + {s.name}{" "} + + ({formatAgo(s.lastSeenAt)} + {s.inMaintenance ? ", in maintenance" : ""}) + + + ))} + {serverSummary.offline.length > 3 && +{serverSummary.offline.length - 3} more} +
+ )} + {serverSummary.fullDisks.length > 0 && ( +
+ Disk ≥ {serverSummary.thresholds.diskPercent}%:{" "} + {serverSummary.fullDisks.slice(0, 3).map((d, i) => ( + + {i > 0 && ", "} + {d.name}{" "} + + {d.mount} {d.percent}% + + + ))} + {serverSummary.fullDisks.length > 3 && +{serverSummary.fullDisks.length - 3} more} +
+ )} + {serverSummary.byOs.windows > 0 && ( +
+ {serverSummary.byOs.linux} Linux · {serverSummary.byOs.windows} Windows +
+ )} + + )} +