Alert when a server goes silent, a disk fills up, or a Synology volume degrades
The data was all being collected (agent last-seen, per-disk usage, Proxmox storage, Synology volume/disk health) but nothing acted on it, so a dead server or a full disk was only noticed by opening the right page. A new health pass runs every 15 minutes (matching the agent's default report interval) and raises one notification when a problem starts and one when it clears: a server's agent silent past a threshold (default 60 min), a server disk / Proxmox storage or root filesystem / Synology volume at or above a usage threshold (default 90%), and a Synology volume or disk that isn't "normal", has bad SMART, bad sectors past the threshold, or life remaining below it. Both thresholds and an on/off toggle live under Settings -> Notifications. The parts that make this trustworthy rather than noisy: - A problem is keyed by identity, so it alerts once and not every run; a shared Proxmox storage listed by every node is one problem, not one per node. - Active problems persist across restarts, so a rebuild doesn't re-alert everything already known. - If a source can't be read on a given run (Proxmox/Synology unreachable, one node lacking privileges) its existing problems are held, not reported "cleared" and then re-alerted when it comes back — the integration-failure alert already owns "the integration is down". - For 20 minutes after startup server-derived problems are held too: agents couldn't report while the app was down, so judging them then would report every server offline after any restart. - An offline server's disk figures are stale and are not judged; a server that never reported has no agent and raises nothing. - Tracking continues while the toggle is off (only sending is gated), so turning it back on doesn't dump every long-standing problem. Timestamps without a zone (SQLite's format) are read as UTC; the test runs on a UTC+2 machine, where reading them as local time gives a different answer. Verified with 32 checks: the evaluation rules and the state diff as pure functions (exact thresholds, the proxmox:1 vs proxmox:10 prefix trap, the flapping sequence), then a whole pass against a real Proxmox adapter talking to a fake HTTPS cluster (one node returning 403, the whole API down, a shared storage on two nodes, a node that recovers), a webhook receiver, the real DB, and the persisted state. Not exercised end-to-end: the Synology collection path — its rules are tested on data shaped exactly like the adapter's output types, but I did not stand up a fake DSM. Real dev database mtime untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bf03a15337
commit
1688de3ea2
9 files changed
+420
-1
No files matched your search
@@ -135,6 +135,13 @@ real API response (fixed to derive them from `connectedToControl` and
|
|||||||
`enabledRoutes`). See the git log for the full verification notes per
|
`enabledRoutes`). See the git log for the full verification notes per
|
||||||
integration.
|
integration.
|
||||||
|
|
||||||
|
Server and storage health is watched every 15 minutes: a server whose agent
|
||||||
|
stops reporting, a server disk / Proxmox storage / Synology volume passing a
|
||||||
|
usage threshold, and a Synology volume or disk that's degraded or failing each
|
||||||
|
raise one notification when the problem starts and one when it clears (both
|
||||||
|
thresholds are set under Settings → Notifications). Active problems are
|
||||||
|
remembered across restarts, so a rebuild doesn't re-alert them.
|
||||||
|
|
||||||
The app is installable as a PWA — "Install app" / "Add to Home Screen" from the
|
The app is installable as a PWA — "Install app" / "Add to Home Screen" from the
|
||||||
browser gives it its own icon and a standalone window on phone or desktop. This
|
browser gives it its own icon and a standalone window on phone or desktop. This
|
||||||
needs the site to be served over HTTPS (browsers only offer install on secure
|
needs the site to be served over HTTPS (browsers only offer install on secure
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js";
|
|||||||
import { initDockerUpdateScheduler } from "./services/dockerUpdateScheduler.js";
|
import { initDockerUpdateScheduler } from "./services/dockerUpdateScheduler.js";
|
||||||
import { initProxmoxBackupScheduler } from "./services/proxmoxBackupScheduler.js";
|
import { initProxmoxBackupScheduler } from "./services/proxmoxBackupScheduler.js";
|
||||||
import { initQuietHoursScheduler } from "./services/quietHoursScheduler.js";
|
import { initQuietHoursScheduler } from "./services/quietHoursScheduler.js";
|
||||||
|
import { initHealthScheduler } from "./services/healthScheduler.js";
|
||||||
|
|
||||||
warnIfAuthNotConfigured();
|
warnIfAuthNotConfigured();
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
@@ -38,6 +39,7 @@ await initLogRetentionScheduler();
|
|||||||
await initDockerUpdateScheduler();
|
await initDockerUpdateScheduler();
|
||||||
await initProxmoxBackupScheduler();
|
await initProxmoxBackupScheduler();
|
||||||
await initQuietHoursScheduler();
|
await initQuietHoursScheduler();
|
||||||
|
await initHealthScheduler();
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const webDist = join(__dirname, "..", "..", "web", "dist");
|
const webDist = join(__dirname, "..", "..", "web", "dist");
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ const updateSchema = z.object({
|
|||||||
tailscaleKeyCheck: z.boolean(),
|
tailscaleKeyCheck: z.boolean(),
|
||||||
dockerUpdateCheck: z.boolean(),
|
dockerUpdateCheck: z.boolean(),
|
||||||
proxmoxBackupCheck: z.boolean(),
|
proxmoxBackupCheck: z.boolean(),
|
||||||
|
healthAlerts: z.boolean(),
|
||||||
secretCheckTime: z.string().regex(/^\d{2}:\d{2}$/),
|
secretCheckTime: z.string().regex(/^\d{2}:\d{2}$/),
|
||||||
timezone: z.string(),
|
timezone: z.string(),
|
||||||
integrationFailureAlerts: z.boolean(),
|
integrationFailureAlerts: z.boolean(),
|
||||||
@@ -85,6 +86,10 @@ const updateSchema = z.object({
|
|||||||
.object({ enabled: z.boolean(), retentionDays: z.number().int().min(1).max(3650), intervalHours: z.number().int().min(1).max(720) })
|
.object({ enabled: z.boolean(), retentionDays: z.number().int().min(1).max(3650), intervalHours: z.number().int().min(1).max(720) })
|
||||||
.partial()
|
.partial()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
healthChecks: z
|
||||||
|
.object({ serverOfflineMinutes: z.number().int().min(15).max(10080), diskUsagePercent: z.number().int().min(50).max(99) })
|
||||||
|
.partial()
|
||||||
|
.optional(),
|
||||||
quietHours: z
|
quietHours: z
|
||||||
.object({ enabled: z.boolean(), start: z.string().regex(/^\d{2}:\d{2}$/), end: z.string().regex(/^\d{2}:\d{2}$/) })
|
.object({ enabled: z.boolean(), start: z.string().regex(/^\d{2}:\d{2}$/), end: z.string().regex(/^\d{2}:\d{2}$/) })
|
||||||
.partial()
|
.partial()
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { integrations, servers } from "../db/schema.js";
|
||||||
|
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
|
||||||
|
import { createProxmoxAdapter, type ProxmoxNodeStats } from "../integrations/proxmox/adapter.js";
|
||||||
|
import { createSynologyAdapter, type SynologyStorageInfo } from "../integrations/synology/adapter.js";
|
||||||
|
import { notifyHealthIssues, notifyHealthRecovered } from "./notify.js";
|
||||||
|
import { getInternalFlag, getSettings, setInternalFlag } from "./settingsStore.js";
|
||||||
|
|
||||||
|
const STATE_FLAG = "healthActiveConditions";
|
||||||
|
/** After a restart the agents haven't had a chance to report yet (they run every 15 min), so server-derived conditions are held rather than judged. */
|
||||||
|
const STARTUP_GRACE_MS = 20 * 60 * 1000;
|
||||||
|
const PROCESS_START = Date.now();
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface HealthCondition {
|
||||||
|
/** Stable identity of the problem — the same problem always produces the same key, so it alerts once, not every run. */
|
||||||
|
key: string;
|
||||||
|
/** Where the data came from, hierarchically ("server", "proxmox:3", "proxmox:3:pve1"). Used to hold a condition when its source can't be reached. */
|
||||||
|
source: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerSnapshot {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
disks: { mount: string; sizeBytes: number; usedBytes: number }[];
|
||||||
|
}
|
||||||
|
export interface ProxmoxSnapshot {
|
||||||
|
integrationId: number;
|
||||||
|
integrationName: string;
|
||||||
|
nodes: ProxmoxNodeStats[];
|
||||||
|
}
|
||||||
|
export interface SynologySnapshot {
|
||||||
|
integrationId: number;
|
||||||
|
integrationName: string;
|
||||||
|
storage: SynologyStorageInfo;
|
||||||
|
}
|
||||||
|
export interface HealthSnapshot {
|
||||||
|
servers: ServerSnapshot[];
|
||||||
|
proxmox: ProxmoxSnapshot[];
|
||||||
|
synology: SynologySnapshot[];
|
||||||
|
}
|
||||||
|
export interface Thresholds {
|
||||||
|
serverOfflineMinutes: number;
|
||||||
|
diskUsagePercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Formatting helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||||
|
let value = bytes;
|
||||||
|
let unit = 0;
|
||||||
|
while (value >= 1024 && unit < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unit++;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(value >= 100 || unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
const minutes = Math.floor(ms / 60_000);
|
||||||
|
if (minutes < 60) return `${minutes} min`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 48) return `${hours} h`;
|
||||||
|
return `${Math.floor(hours / 24)} days`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Timestamps this app writes are ISO strings, but a bare SQLite "YYYY-MM-DD HH:MM:SS" (UTC, no zone) must not be read as local time. */
|
||||||
|
function parseTimestamp(value: string): number {
|
||||||
|
return Date.parse(/[zZ]|[+-]\d{2}:?\d{2}$/.test(value) ? value : `${value.replace(" ", "T")}Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function usage(used: number | null, total: number | null): number | null {
|
||||||
|
if (used === null || total === null || total <= 0) return null;
|
||||||
|
return (used / total) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Evaluation (pure) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a snapshot of what every source currently reports into the list of
|
||||||
|
* problems that exist right now. Pure — no I/O, no clock other than `now` —
|
||||||
|
* so the rules can be tested exactly.
|
||||||
|
*/
|
||||||
|
export function evaluateHealth(snapshot: HealthSnapshot, thresholds: Thresholds, now: number, opts: { skipServers?: boolean } = {}): HealthCondition[] {
|
||||||
|
const out: HealthCondition[] = [];
|
||||||
|
const limit = thresholds.diskUsagePercent;
|
||||||
|
const pct = (n: number) => `${Math.round(n)}%`;
|
||||||
|
|
||||||
|
if (!opts.skipServers) {
|
||||||
|
for (const s of snapshot.servers) {
|
||||||
|
let offline = false;
|
||||||
|
if (s.lastSeenAt) {
|
||||||
|
const age = now - parseTimestamp(s.lastSeenAt);
|
||||||
|
if (Number.isFinite(age) && age > thresholds.serverOfflineMinutes * 60_000) {
|
||||||
|
offline = true;
|
||||||
|
out.push({
|
||||||
|
key: `offline:server:${s.id}`,
|
||||||
|
source: "server",
|
||||||
|
message: `${s.name} hasn't reported for ${formatDuration(age)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A server that never reported has no agent to go quiet; and an offline one's disk figures are stale, so neither is judged on disks.
|
||||||
|
if (s.lastSeenAt && !offline) {
|
||||||
|
for (const d of s.disks) {
|
||||||
|
const p = usage(d.usedBytes, d.sizeBytes);
|
||||||
|
if (p !== null && p >= limit) {
|
||||||
|
out.push({
|
||||||
|
key: `disk:server:${s.id}:${d.mount}`,
|
||||||
|
source: "server",
|
||||||
|
message: `${s.name}: ${d.mount} is ${pct(p)} full (${formatBytes(d.usedBytes)} of ${formatBytes(d.sizeBytes)})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const px of snapshot.proxmox) {
|
||||||
|
const seenShared = new Set<string>();
|
||||||
|
for (const node of px.nodes) {
|
||||||
|
if (node.error) continue; // couldn't be read — held by the caller, not judged
|
||||||
|
const rootP = usage(node.rootfsUsedBytes, node.rootfsTotalBytes);
|
||||||
|
if (rootP !== null && rootP >= limit) {
|
||||||
|
out.push({
|
||||||
|
key: `disk:proxmox:${px.integrationId}:${node.node}:rootfs`,
|
||||||
|
source: `proxmox:${px.integrationId}:${node.node}`,
|
||||||
|
message: `${px.integrationName} / ${node.node}: root filesystem is ${pct(rootP)} full`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const st of node.storages) {
|
||||||
|
if (!st.active) continue;
|
||||||
|
const p = usage(st.usedBytes, st.totalBytes);
|
||||||
|
if (p === null || p < limit) continue;
|
||||||
|
if (st.shared) {
|
||||||
|
// A shared storage is listed by every node — report it once, not once per node.
|
||||||
|
if (seenShared.has(st.id)) continue;
|
||||||
|
seenShared.add(st.id);
|
||||||
|
out.push({
|
||||||
|
key: `disk:proxmox:${px.integrationId}:storage:${st.id}`,
|
||||||
|
source: `proxmox:${px.integrationId}:shared`,
|
||||||
|
message: `${px.integrationName}: shared storage "${st.id}" is ${pct(p)} full (${formatBytes(st.usedBytes ?? 0)} of ${formatBytes(st.totalBytes ?? 0)})`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
out.push({
|
||||||
|
key: `disk:proxmox:${px.integrationId}:${node.node}:storage:${st.id}`,
|
||||||
|
source: `proxmox:${px.integrationId}:${node.node}`,
|
||||||
|
message: `${px.integrationName} / ${node.node}: storage "${st.id}" is ${pct(p)} full (${formatBytes(st.usedBytes ?? 0)} of ${formatBytes(st.totalBytes ?? 0)})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const syn of snapshot.synology) {
|
||||||
|
const src = `synology:${syn.integrationId}`;
|
||||||
|
for (const v of syn.storage.volumes) {
|
||||||
|
if (v.status && v.status.toLowerCase() !== "normal") {
|
||||||
|
out.push({
|
||||||
|
key: `synology-volume:${syn.integrationId}:${v.id}`,
|
||||||
|
source: src,
|
||||||
|
message: `${syn.integrationName}: volume ${v.id} status is "${v.status}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const p = usage(v.sizeUsed, v.sizeTotal);
|
||||||
|
if (p !== null && p >= limit) {
|
||||||
|
out.push({
|
||||||
|
key: `disk:synology:${syn.integrationId}:${v.id}`,
|
||||||
|
source: src,
|
||||||
|
message: `${syn.integrationName}: volume ${v.id} is ${pct(p)} full (${formatBytes(v.sizeUsed ?? 0)} of ${formatBytes(v.sizeTotal ?? 0)})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const d of syn.storage.disks) {
|
||||||
|
const problems: string[] = [];
|
||||||
|
if (d.status && d.status.toLowerCase() !== "normal") problems.push(`status "${d.status}"`);
|
||||||
|
if (/warn|crit|fail|danger|bad/i.test(d.smartStatus ?? "")) problems.push(`SMART "${d.smartStatus}"`);
|
||||||
|
if (d.exceedBadSectorThreshold) problems.push("bad sectors above threshold");
|
||||||
|
if (d.belowRemainLifeThreshold) problems.push("remaining life below threshold");
|
||||||
|
if (problems.length > 0) {
|
||||||
|
out.push({
|
||||||
|
key: `synology-disk:${syn.integrationId}:${d.id}`,
|
||||||
|
source: src,
|
||||||
|
message: `${syn.integrationName}: disk ${d.name || d.id} — ${problems.join(", ")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── State diff (pure) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ActiveState = Record<string, { message: string; source: string }>;
|
||||||
|
|
||||||
|
/** `held` is a set of hierarchical sources that couldn't be read this run; matches the source itself or anything beneath it. */
|
||||||
|
function isHeld(source: string, held: Set<string>): boolean {
|
||||||
|
for (const h of held) if (source === h || source.startsWith(`${h}:`)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares this run's conditions with what was already being reported.
|
||||||
|
* A condition present now but not before is new; one present before but not
|
||||||
|
* now is resolved — except when its source couldn't be read this run, in
|
||||||
|
* which case it's carried over untouched. Without that, one failed poll would
|
||||||
|
* report a recovery and then re-alert the same problem on the next poll.
|
||||||
|
*/
|
||||||
|
export function diffConditions(
|
||||||
|
previous: ActiveState,
|
||||||
|
current: HealthCondition[],
|
||||||
|
held: Set<string> = new Set(),
|
||||||
|
): { added: HealthCondition[]; resolved: { key: string; message: string }[]; next: ActiveState } {
|
||||||
|
const next: ActiveState = {};
|
||||||
|
for (const c of current) next[c.key] = { message: c.message, source: c.source };
|
||||||
|
|
||||||
|
const added = current.filter((c) => !(c.key in previous));
|
||||||
|
const resolved: { key: string; message: string }[] = [];
|
||||||
|
for (const [key, entry] of Object.entries(previous)) {
|
||||||
|
if (key in next) continue;
|
||||||
|
if (isHeld(entry.source, held)) {
|
||||||
|
next[key] = entry;
|
||||||
|
} else {
|
||||||
|
resolved.push({ key, message: entry.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { added, resolved, next };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Collection (I/O) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function collectSnapshot(): Promise<{ snapshot: HealthSnapshot; held: Set<string> }> {
|
||||||
|
const held = new Set<string>();
|
||||||
|
const snapshot: HealthSnapshot = { servers: [], proxmox: [], synology: [] };
|
||||||
|
|
||||||
|
for (const s of await db.select().from(servers)) {
|
||||||
|
let disks: ServerSnapshot["disks"] = [];
|
||||||
|
try {
|
||||||
|
disks = s.disks ? JSON.parse(s.disks) : [];
|
||||||
|
} catch {
|
||||||
|
// a malformed report shouldn't stop every other server being checked
|
||||||
|
}
|
||||||
|
snapshot.servers.push({ id: s.id, name: s.name, lastSeenAt: s.lastSeenAt, disks });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: integrations.id, name: integrations.name, type: integrations.type })
|
||||||
|
.from(integrations)
|
||||||
|
.where(eq(integrations.enabled, true));
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.type !== "proxmox" && row.type !== "synology") continue;
|
||||||
|
try {
|
||||||
|
const loaded = await loadIntegrationConfig(row.id);
|
||||||
|
if (!loaded) continue;
|
||||||
|
if (row.type === "proxmox") {
|
||||||
|
const nodes = await createProxmoxAdapter(loaded.config as any).listNodeStats();
|
||||||
|
snapshot.proxmox.push({ integrationId: row.id, integrationName: row.name, nodes });
|
||||||
|
for (const n of nodes) if (n.error) held.add(`proxmox:${row.id}:${n.node}`);
|
||||||
|
if (nodes.length > 0 && nodes.every((n) => n.error)) held.add(`proxmox:${row.id}`);
|
||||||
|
} else {
|
||||||
|
const storage = await createSynologyAdapter(loaded.config as any).getStorageInfo();
|
||||||
|
snapshot.synology.push({ integrationId: row.id, integrationName: row.name, storage });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[health] couldn't read ${row.type} integration ${row.id}:`, err instanceof Error ? err.message : err);
|
||||||
|
held.add(`${row.type}:${row.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { snapshot, held };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── The scheduled pass ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadState(): Promise<ActiveState> {
|
||||||
|
const raw = await getInternalFlag(STATE_FLAG);
|
||||||
|
if (!raw) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runHealthCheck(now: number = Date.now()): Promise<{ added: number; resolved: number }> {
|
||||||
|
const { healthChecks } = await getSettings();
|
||||||
|
const { snapshot, held } = await collectSnapshot();
|
||||||
|
|
||||||
|
const inGrace = now - PROCESS_START < STARTUP_GRACE_MS;
|
||||||
|
if (inGrace) held.add("server");
|
||||||
|
|
||||||
|
const current = evaluateHealth(snapshot, healthChecks, now, { skipServers: inGrace });
|
||||||
|
const { added, resolved, next } = diffConditions(await loadState(), current, held);
|
||||||
|
await setInternalFlag(STATE_FLAG, JSON.stringify(next));
|
||||||
|
|
||||||
|
// State is tracked even when the alert toggle is off (the notify functions check it),
|
||||||
|
// so turning alerts back on doesn't dump every long-standing condition at once.
|
||||||
|
if (added.length > 0) await notifyHealthIssues(added);
|
||||||
|
if (resolved.length > 0) await notifyHealthRecovered(resolved);
|
||||||
|
return { added: added.length, resolved: resolved.length };
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { runHealthCheck } from "./healthMonitor.js";
|
||||||
|
|
||||||
|
// 15 minutes matches the agent's default report interval, so a check never sees "stale" data that's just waiting for the next report.
|
||||||
|
const INTERVAL_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
async function pass(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await runHealthCheck();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[health] check failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initHealthScheduler(): Promise<void> {
|
||||||
|
if (timer) return;
|
||||||
|
timer = setInterval(() => void pass(), INTERVAL_MS);
|
||||||
|
// Not awaited: the first pass talks to Proxmox/Synology and must not delay server startup.
|
||||||
|
void pass();
|
||||||
|
console.log(`[health] checking server/storage health every ${INTERVAL_MS / 60000} minutes`);
|
||||||
|
}
|
||||||
@@ -277,6 +277,24 @@ export async function notifyProxmoxUncoveredGuests(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function notifyHealthIssues(issues: { message: string }[]): Promise<void> {
|
||||||
|
if (issues.length === 0) return;
|
||||||
|
if (!(await eventEnabled("healthAlerts"))) return;
|
||||||
|
await notify(
|
||||||
|
"Homelab Manager — Health Alert",
|
||||||
|
`${issues.length} new issue${issues.length !== 1 ? "s" : ""}:\n\n${issues.map((i) => `⚠ ${i.message}`).join("\n")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyHealthRecovered(resolved: { message: string }[]): Promise<void> {
|
||||||
|
if (resolved.length === 0) return;
|
||||||
|
if (!(await eventEnabled("healthAlerts"))) return;
|
||||||
|
await notify(
|
||||||
|
"Homelab Manager — Health Recovered",
|
||||||
|
`${resolved.length} issue${resolved.length !== 1 ? "s" : ""} cleared:\n\n${resolved.map((i) => `✓ ${i.message}`).join("\n")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function notifyTailscaleKeyExpiry(
|
export async function notifyTailscaleKeyExpiry(
|
||||||
expiring: { integrationName: string; deviceLabel: string; daysLeft: number }[],
|
expiring: { integrationName: string; deviceLabel: string; daysLeft: number }[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface NotificationEvents {
|
|||||||
tailscaleKeyCheck: boolean;
|
tailscaleKeyCheck: boolean;
|
||||||
dockerUpdateCheck: boolean;
|
dockerUpdateCheck: boolean;
|
||||||
proxmoxBackupCheck: boolean;
|
proxmoxBackupCheck: boolean;
|
||||||
|
healthAlerts: boolean;
|
||||||
secretCheckTime: string; // "HH:MM" — shared by the secret-expiry, Tailscale key-expiry, Docker update, and Proxmox backup checks
|
secretCheckTime: string; // "HH:MM" — shared by the secret-expiry, Tailscale key-expiry, Docker update, and Proxmox backup checks
|
||||||
timezone: string;
|
timezone: string;
|
||||||
integrationFailureAlerts: boolean;
|
integrationFailureAlerts: boolean;
|
||||||
@@ -78,6 +79,13 @@ export interface QuietHoursSettings {
|
|||||||
end: string;
|
end: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HealthCheckSettings {
|
||||||
|
/** A server whose agent hasn't reported for this long is considered offline (agent default interval is 15 min). */
|
||||||
|
serverOfflineMinutes: number;
|
||||||
|
/** Disk / storage / volume usage at or above this percentage is reported. */
|
||||||
|
diskUsagePercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
gotify: GotifySettings;
|
gotify: GotifySettings;
|
||||||
ntfy: NtfySettings;
|
ntfy: NtfySettings;
|
||||||
@@ -89,6 +97,7 @@ export interface AppSettings {
|
|||||||
display: DisplaySettings;
|
display: DisplaySettings;
|
||||||
logRetention: LogRetentionSettings;
|
logRetention: LogRetentionSettings;
|
||||||
quietHours: QuietHoursSettings;
|
quietHours: QuietHoursSettings;
|
||||||
|
healthChecks: HealthCheckSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS: AppSettings = {
|
const DEFAULTS: AppSettings = {
|
||||||
@@ -104,6 +113,7 @@ const DEFAULTS: AppSettings = {
|
|||||||
tailscaleKeyCheck: true,
|
tailscaleKeyCheck: true,
|
||||||
dockerUpdateCheck: true,
|
dockerUpdateCheck: true,
|
||||||
proxmoxBackupCheck: true,
|
proxmoxBackupCheck: true,
|
||||||
|
healthAlerts: true,
|
||||||
secretCheckTime: "08:00",
|
secretCheckTime: "08:00",
|
||||||
timezone: "UTC",
|
timezone: "UTC",
|
||||||
integrationFailureAlerts: true,
|
integrationFailureAlerts: true,
|
||||||
@@ -114,6 +124,7 @@ const DEFAULTS: AppSettings = {
|
|||||||
display: { dateFormat: "ymd", timeFormat: "24h", pageSize: 20 },
|
display: { dateFormat: "ymd", timeFormat: "24h", pageSize: 20 },
|
||||||
logRetention: { enabled: false, retentionDays: 90, intervalHours: 24 },
|
logRetention: { enabled: false, retentionDays: 90, intervalHours: 24 },
|
||||||
quietHours: { enabled: false, start: "22:00", end: "07:00" },
|
quietHours: { enabled: false, start: "22:00", end: "07:00" },
|
||||||
|
healthChecks: { serverOfflineMinutes: 60, diskUsagePercent: 90 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[];
|
const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[];
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ export interface NotificationEvents {
|
|||||||
tailscaleKeyCheck: boolean;
|
tailscaleKeyCheck: boolean;
|
||||||
dockerUpdateCheck: boolean;
|
dockerUpdateCheck: boolean;
|
||||||
proxmoxBackupCheck: boolean;
|
proxmoxBackupCheck: boolean;
|
||||||
|
healthAlerts: boolean;
|
||||||
secretCheckTime: string;
|
secretCheckTime: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
integrationFailureAlerts: boolean;
|
integrationFailureAlerts: boolean;
|
||||||
@@ -183,6 +184,11 @@ export interface LogRetentionSettings {
|
|||||||
intervalHours: number;
|
intervalHours: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HealthCheckSettings {
|
||||||
|
serverOfflineMinutes: number;
|
||||||
|
diskUsagePercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface QuietHoursSettings {
|
export interface QuietHoursSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
start: string;
|
start: string;
|
||||||
@@ -200,6 +206,7 @@ export interface AppSettings {
|
|||||||
display: DisplaySettings;
|
display: DisplaySettings;
|
||||||
logRetention: LogRetentionSettings;
|
logRetention: LogRetentionSettings;
|
||||||
quietHours: QuietHoursSettings;
|
quietHours: QuietHoursSettings;
|
||||||
|
healthChecks: HealthCheckSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial<AppSettings[K]> };
|
export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial<AppSettings[K]> };
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type WebhookSettings,
|
type WebhookSettings,
|
||||||
type NotificationEvents,
|
type NotificationEvents,
|
||||||
type QuietHoursSettings,
|
type QuietHoursSettings,
|
||||||
|
type HealthCheckSettings,
|
||||||
} from "../../api/client";
|
} from "../../api/client";
|
||||||
|
|
||||||
const TIMEZONES = [
|
const TIMEZONES = [
|
||||||
@@ -45,12 +46,14 @@ const DEFAULT_NOTIFICATIONS: NotificationEvents = {
|
|||||||
tailscaleKeyCheck: true,
|
tailscaleKeyCheck: true,
|
||||||
dockerUpdateCheck: true,
|
dockerUpdateCheck: true,
|
||||||
proxmoxBackupCheck: true,
|
proxmoxBackupCheck: true,
|
||||||
|
healthAlerts: true,
|
||||||
secretCheckTime: "08:00",
|
secretCheckTime: "08:00",
|
||||||
timezone: "UTC",
|
timezone: "UTC",
|
||||||
integrationFailureAlerts: true,
|
integrationFailureAlerts: true,
|
||||||
integrationFailureThreshold: 3,
|
integrationFailureThreshold: 3,
|
||||||
};
|
};
|
||||||
const DEFAULT_QUIET_HOURS: QuietHoursSettings = { enabled: false, start: "22:00", end: "07:00" };
|
const DEFAULT_QUIET_HOURS: QuietHoursSettings = { enabled: false, start: "22:00", end: "07:00" };
|
||||||
|
const DEFAULT_HEALTH_CHECKS: HealthCheckSettings = { serverOfflineMinutes: 60, diskUsagePercent: 90 };
|
||||||
|
|
||||||
type TestResult = { ok: boolean; message: string } | null;
|
type TestResult = { ok: boolean; message: string } | null;
|
||||||
|
|
||||||
@@ -76,6 +79,7 @@ export default function NotificationSettings() {
|
|||||||
const [webhook, setWebhook] = useState<WebhookSettings>(DEFAULT_WEBHOOK);
|
const [webhook, setWebhook] = useState<WebhookSettings>(DEFAULT_WEBHOOK);
|
||||||
const [notifications, setNotifications] = useState<NotificationEvents>(DEFAULT_NOTIFICATIONS);
|
const [notifications, setNotifications] = useState<NotificationEvents>(DEFAULT_NOTIFICATIONS);
|
||||||
const [quietHours, setQuietHours] = useState<QuietHoursSettings>(DEFAULT_QUIET_HOURS);
|
const [quietHours, setQuietHours] = useState<QuietHoursSettings>(DEFAULT_QUIET_HOURS);
|
||||||
|
const [healthChecks, setHealthChecks] = useState<HealthCheckSettings>(DEFAULT_HEALTH_CHECKS);
|
||||||
const [queuedCount, setQueuedCount] = useState<number | null>(null);
|
const [queuedCount, setQueuedCount] = useState<number | null>(null);
|
||||||
const [flushing, setFlushing] = useState(false);
|
const [flushing, setFlushing] = useState(false);
|
||||||
const [flushResult, setFlushResult] = useState<string | null>(null);
|
const [flushResult, setFlushResult] = useState<string | null>(null);
|
||||||
@@ -99,6 +103,7 @@ export default function NotificationSettings() {
|
|||||||
setWebhook(res.settings.webhook);
|
setWebhook(res.settings.webhook);
|
||||||
setNotifications(res.settings.notifications);
|
setNotifications(res.settings.notifications);
|
||||||
setQuietHours(res.settings.quietHours);
|
setQuietHours(res.settings.quietHours);
|
||||||
|
setHealthChecks(res.settings.healthChecks);
|
||||||
})
|
})
|
||||||
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)))
|
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
@@ -113,7 +118,7 @@ export default function NotificationSettings() {
|
|||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
try {
|
try {
|
||||||
await api.settings.update({ gotify, ntfy, smtp, webhook, notifications, quietHours });
|
await api.settings.update({ gotify, ntfy, smtp, webhook, notifications, quietHours, healthChecks });
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
setTimeout(() => setSaved(false), 3000);
|
setTimeout(() => setSaved(false), 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -520,6 +525,7 @@ export default function NotificationSettings() {
|
|||||||
{ key: "tailscaleKeyCheck" as const, label: "Tailscale key expiry reminder" },
|
{ key: "tailscaleKeyCheck" as const, label: "Tailscale key expiry reminder" },
|
||||||
{ key: "dockerUpdateCheck" as const, label: "Docker image update available" },
|
{ key: "dockerUpdateCheck" as const, label: "Docker image update available" },
|
||||||
{ key: "proxmoxBackupCheck" as const, label: "Proxmox backup failed or a guest has no coverage" },
|
{ key: "proxmoxBackupCheck" as const, label: "Proxmox backup failed or a guest has no coverage" },
|
||||||
|
{ key: "healthAlerts" as const, label: "Server offline, disk nearly full, or Synology volume/disk problem" },
|
||||||
{ key: "integrationFailureAlerts" as const, label: "Integration/DNS provider failing repeatedly" },
|
{ key: "integrationFailureAlerts" as const, label: "Integration/DNS provider failing repeatedly" },
|
||||||
].map(({ key, label }) => (
|
].map(({ key, label }) => (
|
||||||
<label key={key} className="form-check mb-2">
|
<label key={key} className="form-check mb-2">
|
||||||
@@ -552,6 +558,40 @@ export default function NotificationSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="row g-2 mt-2">
|
||||||
|
<div className="col-6">
|
||||||
|
<label className="form-label">Server offline after</label>
|
||||||
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
min={15}
|
||||||
|
max={10080}
|
||||||
|
value={healthChecks.serverOfflineMinutes}
|
||||||
|
disabled={!notifications.healthAlerts}
|
||||||
|
onChange={(e) => setHealthChecks((h) => ({ ...h, serverOfflineMinutes: Number(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">min</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-hint">Agents report every 15 min by default.</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-6">
|
||||||
|
<label className="form-label">Disk usage alert at</label>
|
||||||
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control"
|
||||||
|
min={50}
|
||||||
|
max={99}
|
||||||
|
value={healthChecks.diskUsagePercent}
|
||||||
|
disabled={!notifications.healthAlerts}
|
||||||
|
onChange={(e) => setHealthChecks((h) => ({ ...h, diskUsagePercent: Number(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">%</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-hint">Servers, Proxmox storage, Synology volumes. Checked every 15 min; a recovery notice follows.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const dailyChecksEnabled =
|
const dailyChecksEnabled =
|
||||||
notifications.secretCheck || notifications.tailscaleKeyCheck || notifications.dockerUpdateCheck || notifications.proxmoxBackupCheck;
|
notifications.secretCheck || notifications.tailscaleKeyCheck || notifications.dockerUpdateCheck || notifications.proxmoxBackupCheck;
|
||||||
|
|||||||
Reference in new issue
Block a user