Add a Servers widget to the dashboard
A Servers card in the dashboard's Overview row, in the same shape as the DNS and Secrets cards: a status badge (N offline / N disks nearly full / All online / No agent data / Not configured), total, online and offline counts, an online/offline/no-data bar, and the details that matter -- which servers are offline and for how long (the first three, linking to their pages), which disks are at or above the usage threshold, and a Linux/Windows split when there are Windows machines. The numbers come from a new GET /api/servers/summary, computed on the server with the health monitor's own evaluateHealth. That means "offline" and "disk full" are decided by exactly the rules the alerts use, so the widget can't say a server is fine while an alert says it isn't, and it follows the thresholds set in Settings, which viewers can't read themselves and so couldn't have applied client-side. Servers under a maintenance window are marked as such. A server that has never reported (no agent, or tracked only through Proxmox) counts as "no data" rather than offline, an offline server's stale disk figure isn't reported, and a garbled report doesn't blank the widget. Readable by every signed-in user, like the dashboard itself. Verified with 23 backend checks (offline/online/no-data classification including the bare SQLite timestamp format, ordering, colons in Windows mounts, threshold changes, maintenance flags, agreement with the alert path's own offline and disk sets, empty and garbled inputs, and the route as a viewer following Settings) and in a browser against the real router across five states: problems, disks only, all healthy, no data, and empty. Real dev database mtime untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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<string> = 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<number>();
|
||||
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 },
|
||||
};
|
||||
}
|
||||
@@ -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<ServerSummary>("/api/servers/summary"),
|
||||
detail: (id: number) => request<ServerDetail>(`/api/servers/${id}/detail`),
|
||||
remove: (id: number) => request<void>(`/api/servers/${id}`, { method: "DELETE" }),
|
||||
setTags: (id: number, tags: string[]) =>
|
||||
|
||||
+109
-1
@@ -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<string, string> = {
|
||||
A: "#60a5fa",
|
||||
@@ -35,6 +37,12 @@ const SECRET_TYPE_COLORS: Record<SecretRecord["type"], string> = {
|
||||
generic: "#94a3b8",
|
||||
};
|
||||
|
||||
const SERVER_STATE_COLORS: Record<string, string> = {
|
||||
Online: "#4ade80",
|
||||
Offline: "#f87171",
|
||||
"No data": "#94a3b8",
|
||||
};
|
||||
|
||||
const CONTAINER_STATE_COLORS: Record<string, string> = {
|
||||
running: "#4ade80",
|
||||
exited: "#f87171",
|
||||
@@ -247,8 +255,14 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
const [dnsStats, setDnsStats] = useState<Awaited<ReturnType<typeof api.dns.stats>> | null>(null);
|
||||
const [dnsError, setDnsError] = useState<string | null>(null);
|
||||
const [secrets, setSecrets] = useState<SecretRecord[] | null>(null);
|
||||
const [serverSummary, setServerSummary] = useState<ServerSummary | null>(null);
|
||||
const [serverError, setServerError] = useState<string | null>(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 }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionHeader title="Overview" hint="DNS and Secrets are built into this app rather than connected integrations, but get the same at-a-glance treatment." />
|
||||
<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." />
|
||||
<div className="row row-cards mb-3">
|
||||
<WidgetCard
|
||||
label="DNS"
|
||||
@@ -500,6 +514,100 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
</>
|
||||
)}
|
||||
</WidgetCard>
|
||||
|
||||
<WidgetCard
|
||||
label="Servers"
|
||||
badge={
|
||||
!serverSummary ? undefined : serverSummary.total === 0 ? (
|
||||
<span className="badge bg-secondary-lt text-secondary">Not configured</span>
|
||||
) : serverSummary.offline.length > 0 ? (
|
||||
<span className="badge bg-red-lt text-red">{serverSummary.offline.length} offline</span>
|
||||
) : serverSummary.fullDisks.length > 0 ? (
|
||||
<span className="badge bg-yellow-lt text-yellow">
|
||||
{serverSummary.fullDisks.length} disk{serverSummary.fullDisks.length === 1 ? "" : "s"} nearly full
|
||||
</span>
|
||||
) : serverSummary.online > 0 ? (
|
||||
<span className="badge bg-green-lt text-green">All online</span>
|
||||
) : (
|
||||
<span className="badge bg-secondary-lt text-secondary">No agent data</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
{serverError && <div className="alert alert-danger mb-2">{serverError}</div>}
|
||||
{!serverSummary ? (
|
||||
!serverError && <div className="text-secondary">Fetching status…</div>
|
||||
) : serverSummary.total === 0 ? (
|
||||
<div className="text-secondary">
|
||||
Add a server from the <Link to="/servers">Servers</Link> page.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="row g-2 mb-3">
|
||||
<div className="col-4">
|
||||
<MiniStat label="Servers" value={<Link to="/servers" className="text-reset text-decoration-none">{serverSummary.total}</Link>} />
|
||||
</div>
|
||||
<div className="col-4">
|
||||
<MiniStat
|
||||
label="Online"
|
||||
value={serverSummary.online + serverSummary.offline.length === 0 ? "—" : `${serverSummary.online}/${serverSummary.online + serverSummary.offline.length}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-4">
|
||||
<MiniStat
|
||||
label="Offline"
|
||||
value={serverSummary.offline.length}
|
||||
accent={serverSummary.offline.length > 0 ? "#ef4444" : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<BreakdownBar
|
||||
data={[
|
||||
{ label: "Online", count: serverSummary.online },
|
||||
{ label: "Offline", count: serverSummary.offline.length },
|
||||
{ label: "No data", count: serverSummary.noData },
|
||||
].filter((d) => d.count > 0)}
|
||||
colors={SERVER_STATE_COLORS}
|
||||
compact
|
||||
/>
|
||||
{serverSummary.offline.length > 0 && (
|
||||
<div className="small mt-2">
|
||||
<span className="text-red">Offline:</span>{" "}
|
||||
{serverSummary.offline.slice(0, 3).map((s, i) => (
|
||||
<span key={s.id}>
|
||||
{i > 0 && ", "}
|
||||
<Link to={`/servers/${s.id}`}>{s.name}</Link>{" "}
|
||||
<span className="text-secondary">
|
||||
({formatAgo(s.lastSeenAt)}
|
||||
{s.inMaintenance ? ", in maintenance" : ""})
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{serverSummary.offline.length > 3 && <span className="text-secondary"> +{serverSummary.offline.length - 3} more</span>}
|
||||
</div>
|
||||
)}
|
||||
{serverSummary.fullDisks.length > 0 && (
|
||||
<div className="small mt-2">
|
||||
<span className="text-yellow">Disk ≥ {serverSummary.thresholds.diskPercent}%:</span>{" "}
|
||||
{serverSummary.fullDisks.slice(0, 3).map((d, i) => (
|
||||
<span key={`${d.id}:${d.mount}`}>
|
||||
{i > 0 && ", "}
|
||||
<Link to={`/servers/${d.id}`}>{d.name}</Link>{" "}
|
||||
<span className="text-secondary">
|
||||
{d.mount} {d.percent}%
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{serverSummary.fullDisks.length > 3 && <span className="text-secondary"> +{serverSummary.fullDisks.length - 3} more</span>}
|
||||
</div>
|
||||
)}
|
||||
{serverSummary.byOs.windows > 0 && (
|
||||
<div className="text-secondary small mt-2">
|
||||
{serverSummary.byOs.linux} Linux · {serverSummary.byOs.windows} Windows
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</WidgetCard>
|
||||
</div>
|
||||
|
||||
<SectionHeader
|
||||
|
||||
Reference in New Issue
Block a user