From de6ebd1e59f41495137aec4dcc7fe0ab02c8bd01 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Thu, 10 Sep 2026 15:56:57 +0300 Subject: [PATCH] feat: add host system health monitoring to admin dashboard Signed-off-by: Tiberiu Socaci --- AGENTS.md | 10 +- CHANGELOG.md | 3 + FEATURES.md | 17 ++ TEST-PLAN.md | 42 +++++ docs/OPERATIONS.md | 51 ++++++ public/admin-routes.js | 1 + public/admin-system-health.js | 231 +++++++++++++++++++++++++++ public/app.js | 3 + public/index.html | 2 + public/styles.css | 87 ++++++++++ scripts/system-health-soak.mjs | 115 +++++++++++++ src/db/migrations.js | 25 +++ src/gateway/shutdown.js | 6 + src/gateway/system-health-collect.js | 100 ++++++++++++ src/gateway/system-health-store.js | 123 ++++++++++++++ src/gateway/system-health.js | 98 ++++++++++++ src/server.js | 4 + src/web/routes/admin.js | 2 + src/web/routes/system-health.js | 22 +++ test/admin-navigation.test.js | 1 + test/admin-system-health.test.js | 112 +++++++++++++ test/runtime-lifecycle.test.js | 3 +- test/system-health-api.test.js | 70 ++++++++ test/system-health.test.js | 216 +++++++++++++++++++++++++ 24 files changed, 1341 insertions(+), 3 deletions(-) create mode 100644 public/admin-system-health.js create mode 100644 scripts/system-health-soak.mjs create mode 100644 src/gateway/system-health-collect.js create mode 100644 src/gateway/system-health-store.js create mode 100644 src/gateway/system-health.js create mode 100644 src/web/routes/system-health.js create mode 100644 test/admin-system-health.test.js create mode 100644 test/system-health-api.test.js create mode 100644 test/system-health.test.js diff --git a/AGENTS.md b/AGENTS.md index 38e2cb1..e85fd28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ post/edit the reply in the thread (degraded to the surface's capabilities) → u the changed keys), `dead-fields.js` (retired fields stripped on every write). - `src/db/` — `index.js` (the one lazy `node:sqlite` connection: WAL, `busy_timeout`, `foreign_keys`, migrations on open, the one-time legacy JSON import behind `_meta` flags), - `migrations.js` (versioned on `PRAGMA user_version`, currently 24 — append, never edit), + `migrations.js` (versioned on `PRAGMA user_version`, currently 25 — append, never edit), `import-legacy.js`, `fts.js` (the optional FTS5 `channel_memory_fts` index; without FTS5 memory search degrades to a scan). - `src/gateway/run.js` — the run orchestrator: engine adapter selection and precedence (per-run @@ -169,6 +169,11 @@ post/edit the reply in the thread (degraded to the surface's capabilities) → u stays available to every operator): one durable transaction and lock, a detached built-ins-only runner (preflight, smoke, snapshot, install → restart → verify, automatic rollback), results on `/api/health`; `restart_gateway` drains turns, jobs, API runs and update transactions first. +- `src/gateway/system-health.js` + `system-health-collect.js` + `system-health-store.js` — + daemon-side read-only Linux metrics, five-second samples, minute SQLite resource aggregates + (30 days), storage history (186 days, hourly after 30 days), hardware snapshots/change events, + and a data-dependent capacity forecast. Admin routes in `src/web/routes/system-health.js`, + browser page in `public/admin-system-health.js`, isolated canary in `scripts/system-health-soak.mjs`. - Smaller gateway modules: `sessions.js` + `thread-engine.js` (thread key ↔ engine session, per-thread pins), `session-adopt.js` + `session-carry.js` (`/resume` of a local session whose cwd is this channel; host → container carry-over), `stopped-turns.js`, `active-runs.js` @@ -307,7 +312,8 @@ through the control MCP. `skill_revision_files`, `skill_sources`, `skill_templates`, `skill_usage`, `skill_proposals`, `skill_access_tokens`); Composio SDK (`composio_sessions`); licensing (`license_usage`); dashboard data (`usage`, `usage_components`, `usage_requests`, `usage_repair_batches`, - `events` — typed, indexed columns for day/week/month/channel/user rollups); plus `_meta` + `events` — typed, indexed columns for day/week/month/channel/user rollups); system telemetry + (`system_health_resources`, `system_health_storage`, `system_health_hardware`, `system_health_changes`); plus `_meta` (key/value, created at open) and the optional FTS5 `channel_memory_fts`. Config-shaped rows (`channel_meta`, `users`, `bg_jobs`) keep their full record in a JSON `data` blob so every field survives without a migration; `dead-fields.js` strips retired fields on write. diff --git a/CHANGELOG.md b/CHANGELOG.md index bd573e3..d282ead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog — ChannelGate +- Add System health to the admin UI with real CPU/RAM/load and storage metrics, historical + charts and peaks, storage warnings and capacity estimates, and refreshed hardware inventory. + - Fix MCP configuration selection when a plugin package supplies both engine manifests. Each engine uses its own inline declarations or referenced files, avoiding wrong endpoints and duplicate server errors. diff --git a/FEATURES.md b/FEATURES.md index 15619b3..97cd244 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,5 +1,22 @@ # ChannelGate — Features +## System health + +The last admin navigation item, **System health** (`/system-health`), shows daemon-side Linux +CPU, RAM, swap, filesystem capacity and system load. The collector runs every five seconds +independently of open browsers; the page supports pause, refresh and Live/1h/24h/7d/30d ranges. +Minute aggregates retain resource averages and peaks for 30 days. Storage history lasts 186 days, +with older samples reduced to hourly aggregates. The capacity forecast reports insufficient +history until an observed trend supports it; no historical points are generated to fill gaps. +Storage warnings start at 85%, critical at 95%. Peaks and collection status precede hardware. + +Hardware inventory refreshes at startup, every five minutes and on manual refresh, with a bounded +change log. Unavailable hardware fields stay unknown; temperatures, serial numbers, credentials, +and hardware-management writes are excluded. Metrics use the filesystem containing the gateway +runtime root, and the collector runs in the host daemon without widening any engine container's +mounts. All metrics endpoints require the existing admin session. See `docs/OPERATIONS.md` and +the engine-independent System health acceptance gates in `TEST-PLAN.md`. + ## Plugin packages through skill sources Git and folder skill sources recognize Claude/Codex plugin manifests and store a complete package diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 02709f5..3a4482c 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,5 +1,47 @@ # ChannelGate — Test Plan +## System health — engine-independent acceptance + +These cases exercise the daemon collector and authenticated browser, not an engine turn; +Claude/Codex selection cannot affect collection, authorization or rendering. +Automated regressions: `test/system-health.test.js`, `test/system-health-api.test.js`, +`test/admin-system-health.test.js`, and the admin navigation suite. + +- **SYS-01 — live and historical metrics.** Start a disposable Linux admin instance with an empty + runtime root and an admin password. Open `/system-health` after login. CPU's first delta may be + unknown; within two samples require current CPU/RAM/load and filesystem capacity from that + instance. Compare RAM against `/proc/meminfo`, load against `/proc/loadavg`, and capacity against + the runtime root's filesystem. Wait across a minute boundary; reload and restart the fixture. + Require persisted history and real peaks, no invented pre-install points. Select every range, + pause/resume, manually refresh, leave the page and hide the tab. Network evidence must show + five-second polling only while active, visible and unpaused, without overlapping/stale rendering. +- **SYS-02 — history, retention and forecast.** Use the deterministic collector test fixtures + with data spanning at least 187 days and a capacity change. Require resources older than 30 days + purged, storage retained for 186 days with old minute samples downsampled, bounded chart responses + and preserved peaks. Empty/short history must not predict a date. A changed filesystem capacity + must not create a false growth forecast; missing samples must remain gaps. +- **SYS-03 — hardware, error and responsive states.** In a disposable fixture, change synthetic + memory/disk inventory, advance the five-minute clock, then manually refresh. Require new values + and one change event, no duplicate event for an unchanged snapshot. Missing DMI/device files + must appear unavailable. In Chromium at 1440px and 430px verify cards, keyboard chart tooltips, + ranges, warning/critical states and peaks/Collection before hardware, without horizontal page + overflow. Simulate a failed request: require a visible error/stale state and successful retry. +- **SYS-04 — boundary.** With a password absent, expect 403 for all metrics routes; with a password + configured but no session, expect 401, also for a valid run-API key. A logged-in admin can read + metrics; manual refresh requires the CSRF header and allowed Origin. Public `/api/health` must + not expose metrics/hardware. No endpoint accepts arbitrary filesystem paths or shell commands. +- **SYS-05 — 24-hour host canary (release/deployment gate).** Run the isolated soak procedure in + `docs/OPERATIONS.md` on the target Linux host for at least 24 hours. Record candidate commit, + actual elapsed time, collector errors, CPU, RSS, database plus WAL size, persisted sample count, + gaps and hardware visibility. The original design's CPU/RAM/I/O figures are estimates, not pass + evidence. Require no collection/persistence errors, retained samples across restart, bounded + memory/history, and metrics storage below the 100 MiB safety budget. Review measured overhead + before enabling on the served instance. After activation, verify authenticated page and new + samples on the real daemon. A shorter container smoke is not a host soak pass. + +Private deployment QA records and the 24-hour host canary must be recorded separately; an +unexecuted or unavailable check is not passing evidence. + ## Plugin source packages — acceptance gates Automated coverage: `test/skills-plugin-import.test.js`, `test/plugin-runtime.test.js`, diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 01bd93f..314a0cb 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -558,6 +558,57 @@ groups) so interrupted execution is recorded for reconciliation on the next boot turn may be recorded as a plain error before orderly state persistence. Existing installs: add `KillMode=mixed` under `[Service]` and `systemctl --user daemon-reload`. +## System health + +Open **System health**, the last admin navigation item (`/system-health`). The host daemon reads +Linux `/proc` and `/sys` without elevated privileges or subprocesses. It samples every five +seconds even when no browser is open; browsers poll while visible and active. Pause affects only +the page. Refresh also rescans hardware. CPU uses counter deltas (the first sample is unknown), +RAM uses `MemAvailable`, and load is the Linux 1/5/15-minute load average. Storage measures the +filesystem containing the configured gateway runtime root, not the sum of disks in the hardware +inventory. Used bytes exclude free blocks; available bytes exclude filesystem-reserved blocks; +the usage percentage is used / (used + available), as for an unprivileged process. +References: [Linux proc documentation](https://www.kernel.org/doc/html/v6.2/filesystems/proc.html) +and [Node filesystem statistics](https://nodejs.org/docs/latest-v22.x/api/fs.html#class-fsstatfs). + +Resource minute averages and peaks stay for 30 days. Storage stays for 186 days (at least six +calendar months); samples older than 30 days are reduced to hourly observations with preserved +peaks. Responses reduce long resource series to about 240 buckets and storage to daily points. +Empty history and missing metrics are reported without simulated values. Forecasts require at +least seven days of measured history and use only the segment since the last observed capacity +change. The estimate assumes continued growth at the fitted rate, not a guarantee of free space. + +Hardware is read at startup, every five minutes and on refresh. The current snapshot and at most +100 change events are retained; no temperatures, serial numbers, MAC addresses or machine IDs are +collected. DIMM information and some device details may be unavailable without host interfaces; +the page reports unknowns. RAM/disks recognized by Linux appear at the next scan. Distribution +changes appear on rescan; a newly installed kernel appears only after booting it. Hardware details +remain behind the admin session and never appear on the public liveness endpoint. + +Metrics use tables in the existing SQLite database. Retention applies only to these tables, +not audit/usage records. Do not impose a 100 MiB limit on the entire gateway database or force +global WAL truncation: other gateway records and readers share it. Measure incremental metrics +storage using an isolated canary, with 100 MiB for its database plus WAL as a safety budget. + +Before enabling on a served host, run the canary **on that Linux host** from the tested checkout: + +```sh +node scripts/system-health-soak.mjs --output /absolute/new-health-soak-report.json --storage-path /path/to/gateway-runtime-root +``` + +It runs in the foreground for 24 hours, reads the target filesystem, and writes metrics only to +a fresh temporary database. The JSON report updates every 30 seconds and records actual elapsed +time, errors, gaps, CPU, peak RSS, database plus WAL bytes, hardware visibility and persistence +after reopening. The report path must be new; the scratch database is removed at completion. +Use the service manager or a daemon-owned job to keep it alive when a session cannot remain open. +`--duration-seconds 65` is a quick smoke; it explicitly cannot pass the 24-hour gate. A report +with `containerDetected: true` describes the container's visible interfaces and is not evidence +of a host deployment. Even when false, independently verify where the process ran. + +Review the measured overhead rather than treating the prototype estimates as guarantees. After +the host canary passes, use the normal serialized beta landing and safe restart procedure, log in, +and verify current samples, hardware visibility and history persistence on the actual daemon. + ## Retention and log rotation Run `npm run maintenance` daily from the service manager. `CG_RETENTION_DAYS` defaults to 30 and diff --git a/public/admin-routes.js b/public/admin-routes.js index e423edb..7f35d80 100644 --- a/public/admin-routes.js +++ b/public/admin-routes.js @@ -9,6 +9,7 @@ export const ADMIN_VIEWS = Object.freeze({ skills: Object.freeze({ path: "/skills", title: "Skills" }), api: Object.freeze({ path: "/api-docs", title: "HTTP run API" }), settings: Object.freeze({ path: "/settings", title: "Settings" }), + "system-health": Object.freeze({ path: "/system-health", title: "System health" }), }); export const ADMIN_VIEW_PATHS = Object.freeze(Object.values(ADMIN_VIEWS).map(({ path }) => path)); diff --git a/public/admin-system-health.js b/public/admin-system-health.js new file mode 100644 index 0000000..194c76f --- /dev/null +++ b/public/admin-system-health.js @@ -0,0 +1,231 @@ +import { api } from "./admin-api.js"; +import { escapeHtml as esc } from "./admin-view.js"; + +const RANGES = ["live", "1h", "24h", "7d", "30d"]; +const finite = (value) => typeof value === "number" && Number.isFinite(value); +const number = (value, digits = 1) => finite(value) ? value.toFixed(digits) : "—"; +const percent = (value) => finite(value) ? `${number(value)}%` : "—"; +const time = (value) => value && Number.isFinite(new Date(value).getTime()) ? new Date(value).toLocaleString() : "—"; +export function healthBytes(value) { + if (!finite(value)) return "—"; + const unit = value >= 1024 ** 4 ? 4 : value >= 1024 ** 3 ? 3 : value >= 1024 ** 2 ? 2 : value >= 1024 ? 1 : 0; + return `${number(value / 1024 ** unit)} ${["B", "KiB", "MiB", "GiB", "TiB"][unit]}`; +} +export function storageSeverity(value) { return !finite(value) ? "unknown" : value >= 95 ? "critical" : value >= 85 ? "warning" : "normal"; } + +// One cancellable batch at a time. Navigation, visibility, pause and range changes invalidate +// earlier responses even if the transport ignores AbortSignal. Timers begin after completion. +export function createHealthPoller({ request = api, onUpdate, visible = () => !document.hidden, schedule = setTimeout, unschedule = clearTimeout, now = Date.now }) { + let active = false, paused = false, range = "live", generation = 0, timer = null, flight = null, controller = null, pending = false, manual = false, lastFull = 0; + let data = { current: null, history: null, storage: null, hardware: null, errors: {} }; + const publish = () => onUpdate({ ...data, range, paused, loading: !!flight }); + function invalidate() { + generation++; + unschedule(timer); + timer = null; + controller?.abort(); + } + async function run() { + if (!active || !visible() || flight || (!pending && paused)) return; + pending = false; + const version = generation, selectedRange = range, refreshHardware = manual; + manual = false; + const full = refreshHardware || !lastFull || now() - lastFull >= 60_000; + controller = new AbortController(); + const options = { signal: controller.signal }; + const jobs = [["current", "/api/system-health/current", options]]; + if (full) { + jobs.push(["history", `/api/system-health/history?range=${selectedRange}`, options]); + jobs.push(["storage", "/api/system-health/storage", options]); + jobs.push(["hardware", `/api/system-health/hardware${refreshHardware ? "/refresh" : ""}`, { ...options, ...(refreshHardware ? { method: "POST" } : {}) }]); + } + flight = Promise.allSettled(jobs.map(([, url, opts]) => Promise.resolve().then(() => request(url, opts)))); + publish(); + const results = await flight; + flight = null; + if (version === generation && active && visible()) { + const errors = { ...data.errors }; + results.forEach((result, index) => { + const key = jobs[index][0]; + if (result.status === "fulfilled") { data[key] = result.value; delete errors[key]; } + else if (result.reason?.name !== "AbortError") errors[key] = result.reason?.message || "Request failed"; + }); + data.errors = errors; + if (full && results.every((result) => result.status === "fulfilled")) lastFull = now(); + publish(); + } + if (pending) { run(); return; } + if (version !== generation && active && visible()) publish(); + if (active && visible() && !paused) timer = schedule(run, 5_000); + } + function reload({ hardware = false, full = true } = {}) { + invalidate(); + if (full) lastFull = 0; + pending = true; + manual ||= hardware; + run(); + } + return { + enter() { active = true; reload(); }, + leave() { active = false; pending = false; manual = false; invalidate(); }, + visibilityChanged() { invalidate(); if (active && visible() && !paused) { pending = true; lastFull = 0; run(); } }, + togglePause() { paused = !paused; pending = false; manual = false; invalidate(); publish(); if (!paused) reload(); }, + setRange(value) { if (!RANGES.includes(value) || range === value) return; range = value; data.history = null; publish(); reload(); }, + refresh() { reload({ hardware: true }); }, + }; +} + +function meter(value, tone = "") { return `
`; } +function metric(label, value, detail, meterValue, tone = "", state = "") { + return `
${label}${esc(state)}
${esc(value)}
${esc(detail)}
${meter(meterValue, tone)}
`; +} +function pairs(rows) { return `
${rows.map(([label, value]) => `
${esc(label)}
${esc(value ?? "—")}
`).join("")}
`; } +export function forecastCopy(forecast) { + if (!forecast || ["insufficient_data", "insufficient_history"].includes(forecast.status)) return { title: "Building the storage trend", detail: "Not enough measured history for a capacity forecast yet." }; + if (forecast.status === "full") return { title: "Storage is full", detail: "Free space or increase capacity." }; + if (finite(forecast.daysToFull)) return { title: `About ${Math.max(0, Math.round(forecast.daysToFull))} days to capacity`, detail: `${healthBytes(forecast.bytesPerDay)} growth per day over ${number(forecast.observedDays, 0)} measured days. Estimate assumes the same growth and capacity.` }; + if (["stable", "declining", "no_growth"].includes(forecast.status)) return { title: "No projected capacity limit", detail: `Measured usage is stable or declining across ${number(forecast.observedDays, 0)} days. Future growth can change this.` }; + return { title: "Forecast unavailable", detail: "A reliable projection is not available for the collected history." }; +} + +// Time is the horizontal scale: irregular samples and missing observations never become +// evenly spaced synthetic history. Missing metrics break paths instead of plotting zero. +export function healthChart(points = [], { storage = false, start, end } = {}) { + const usable = points.filter((p) => finite(p.timestamp)).sort((a, b) => a.timestamp - b.timestamp); + if (!usable.length) return '

No observations in this period yet.

'; + const from = finite(start) ? start : usable[0].timestamp; + const to = finite(end) && end > from ? end : Math.max(from + 1, usable.at(-1).timestamp); + const width = 720, height = storage ? 170 : 260, left = 38, right = 12, top = 14, bottom = 24; + const x = (t) => left + (t - from) / (to - from) * (width - left - right); + const y = (v) => top + (1 - Math.min(100, Math.max(0, v)) / 100) * (height - top - bottom); + const fields = storage ? [["storagePercent", "disk", "Storage"]] : [["cpuPercent", "cpu", "CPU"], ["memoryPercent", "memory", "RAM"]]; + const grid = [0, 25, 50, 75, 100].map((v) => `${v}%`).join(""); + const paths = fields.map(([field, cls]) => { + let pen = false, previous = null; + const typicalGap = storage ? 3 * 86_400_000 : Math.max(finite(start) ? 120_000 : 15_000, (to - from) / 120); + const path = usable.map((point) => { + if (!finite(point[field])) { pen = false; return ""; } + const move = !pen || previous !== null && point.timestamp - previous > typicalGap; + pen = true; previous = point.timestamp; + return `${move ? "M" : "L"}${x(point.timestamp).toFixed(2)},${y(point[field]).toFixed(2)}`; + }).join(" "); + return ``; + }).join(""); + const targets = usable.map((point) => { + const label = `${time(point.timestamp)} · ${fields.map(([field, , name]) => `${name} ${percent(point[field])}`).join(" · ")}${storage ? ` · ${healthBytes(point.storageUsedBytes)} used` : ""}`; + const dots = fields.filter(([field]) => finite(point[field])).map(([field, cls]) => ``).join(""); + return `${esc(label)}${dots}`; + }).join(""); + return `${grid}${paths}${targets}
${esc(new Date(from).toLocaleDateString())} ${storage ? "" : esc(new Date(from).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }))}${esc(new Date(to).toLocaleDateString())} ${storage ? "" : esc(new Date(to).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }))}
`; +} + +function installTooltip(host, points, storage) { + const svg = host.querySelector("svg"), tip = host.querySelector(".sh-tooltip"); + if (!svg || !tip || !points?.length) return; + const targets = [...svg.querySelectorAll("g")]; + targets.forEach((target, i) => { + const show = (event) => { + tip.textContent = target.querySelector("title").textContent; + tip.hidden = false; + const rect = host.getBoundingClientRect(); + tip.style.left = `${Math.max(0, Math.min(rect.width - Math.min(220, rect.width), (event.clientX || rect.left + rect.width / 2) - rect.left))}px`; + tip.style.top = "20px"; + }; + target.addEventListener("pointermove", show); + target.addEventListener("pointerleave", () => { tip.hidden = true; }); + // One focusable chart; arrows navigate observations without hundreds of tab stops. + if (i === 0) { + svg.setAttribute("tabindex", "0"); + svg.setAttribute("aria-label", `${storage ? "Storage" : "CPU and RAM"} usage. Use left and right arrows for observations.`); + } + }); + let selected = targets.length - 1; + svg.addEventListener("keydown", (event) => { + if (!["ArrowLeft", "ArrowRight", "Escape"].includes(event.key)) return; + event.preventDefault(); + if (event.key === "Escape") { tip.hidden = true; return; } + selected = Math.max(0, Math.min(targets.length - 1, selected + (event.key === "ArrowLeft" ? -1 : 1))); + tip.textContent = targets[selected].querySelector("title").textContent; + tip.hidden = false; tip.style.left = "38px"; tip.style.top = "20px"; + }); + svg.addEventListener("blur", () => { tip.hidden = true; }); +} + +function hardwareMarkup(data) { + if (!data?.snapshot) return '

Hardware inventory has not been collected yet.

'; + const h = data.snapshot; + // Hardware values are escaped at the display boundary; missing optional Linux interfaces + // are explicit unknowns and never replaced with the prototype's server specification. + const groups = [ + ["Processor", h.cpuModel, [["Architecture", h.architecture], ["Sockets", h.sockets], ["Physical cores", h.physicalCores], ["Logical CPUs", h.logicalCpus]]], + ["Memory", healthBytes(h.memoryTotalBytes), [["Swap", healthBytes(h.swapTotalBytes)], ["DIMM details", "Not collected"]]], + ["Operating system", h.osName, [["Kernel", h.kernel], ["Filesystem", h.filesystem]]], + ["Mainboard", h.boardName, [["Vendor", h.boardVendor], ["BIOS", [h.biosVendor, h.biosVersion].filter(Boolean).join(" ")]]], + ]; + return `
${groups.map(([label, model, rows]) => `
${label}${esc(model || "Not exposed by Linux")}${pairs(rows)}
`).join("")}
+

Storage devices

${h.disks?.length ? h.disks.map((d) => `
${esc(d.model || d.name)}${esc([d.name, healthBytes(d.sizeBytes), d.rotational === true ? "HDD" : d.rotational === false ? "SSD" : null].filter(Boolean).join(" · "))}
`).join("") : '

No physical disk details exposed by Linux.

'}

Network & graphics

${esc(h.network?.map((n) => typeof n === "string" ? n : n.name).join(", ") || "Network inventory unavailable")}

${esc(h.gpus?.map((g) => typeof g === "string" ? g : [g.vendor, g.device, g.driver].filter(Boolean).join(" ")).join(", ") || "Graphics inventory unavailable")}

+

Scanned ${esc(time(data.collectedAt))}. Inventory refreshes every 5 minutes and on manual refresh. Only devices exposed by Linux are shown.

+ ${data.changes?.length ? `
Recent hardware changes (${data.changes.length})
    ${data.changes.map((change) => `
  • ${esc(time(change.timestamp))} · ${esc(change.fields?.join(", "))}
  • `).join("")}
` : '

No hardware changes recorded.

'}`; +} + +let view = null; +export function setSystemHealthActive(active) { + // Async admin bootstrap can finish after the user has already navigated elsewhere. + active = active && document.getElementById("view-system-health")?.classList.contains("active"); + if (!view && active) view = mountSystemHealth(document.getElementById("view-system-health")); + if (active) view?.enter(); else view?.leave(); +} +function mountSystemHealth(root) { + root.innerHTML = `

Infrastructure

System health

Live host resources, 30-day performance history, and six months of storage trends.

Connecting…
+
+
${RANGES.map((range) => ``).join("")}
Waiting for the first sample…
+

Resource usage

CPURAM

Storage trend

Last six months · recorded observations

Used
Capacity forecast

+

Selected-period peaks

Collection

+

Server hardware

Inventory detected on the gateway host

`; + const $ = (selector) => root.querySelector(selector); + function paint(state) { + const current = state.current, sample = current?.sample, collection = current?.collection; + const stale = !!sample && Date.now() - sample.timestamp > Math.max(15_000, (collection?.sampleIntervalMs || 5_000) * 3); + const errors = Object.entries(state.errors).map(([key, error]) => `${key}: ${error}`); + if (collection?.error) errors.push(`Collector: ${collection.error}`); + if (stale) errors.push("The last sample is stale. Displayed values are from the timestamp below."); + $("#sh-error").hidden = !errors.length; + $("#sh-error").textContent = errors.join(" · "); + $("#sh-status").textContent = state.paused ? "Paused" : errors.length ? "Updates interrupted" : sample ? "Live · every 5s" : state.loading ? "Connecting…" : "Awaiting first sample"; + $("#sh-status").classList.toggle("sh-status-warn", state.paused || !!errors.length); + $("#sh-pause").textContent = state.paused ? "Resume" : "Pause"; + $("#sh-pause").setAttribute("aria-pressed", String(state.paused)); + $("#sh-refresh").disabled = state.loading; + const severity = storageSeverity(sample?.storagePercent); + $("#sh-metrics").innerHTML = metric("CPU usage", percent(sample?.cpuPercent), `${sample?.logicalCpus ?? "—"} logical CPUs`, sample?.cpuPercent) + + metric("Memory", percent(sample?.memoryPercent), `${healthBytes(sample?.memoryUsedBytes)} of ${healthBytes(sample?.memoryTotalBytes)}`, sample?.memoryPercent, "sh-memory") + + metric("Storage", percent(sample?.storagePercent), `${healthBytes(sample?.storageUsedBytes)} used · ${healthBytes(sample?.storageAvailableBytes)} available`, sample?.storagePercent, `sh-${severity}`, severity === "unknown" ? "" : severity) + + metric("System load", number(sample?.load1, 2), `${number(sample?.load5, 2)} / ${number(sample?.load15, 2)} at 5 / 15 min`, sample?.logicalCpus ? sample.load1 / sample.logicalCpus * 100 : null); + const warning = ["warning", "critical"].includes(severity); + $("#sh-storage-alert").hidden = !warning; + $("#sh-storage-alert").classList.toggle("sh-critical", severity === "critical"); + $("#sh-storage-alert").textContent = warning ? `Storage ${severity === "critical" ? "critical" : "above 85%"} · ${percent(sample.storagePercent)} used on ${sample.storagePath || "the gateway filesystem"}. ${healthBytes(sample.storageAvailableBytes)} available. Review disk usage and plan capacity.` : ""; + $("#sh-updated").textContent = sample ? `Sample: ${time(sample.timestamp)}${state.paused ? " · paused" : ""}` : "Waiting for the first sample…"; + root.querySelectorAll("[data-range]").forEach((button) => button.setAttribute("aria-pressed", String(button.dataset.range === state.range))); + const points = state.range === "live" ? current?.recent || [] : state.history?.points || []; + const history = state.range === "live" ? {} : state.history || {}; + $("#sh-period").textContent = state.range === "live" ? "Recent live samples · 5-second observations" : `${state.range} period · aggregated observations`; + $("#sh-resource-chart").innerHTML = healthChart(points, history); + installTooltip($("#sh-resource-chart"), points, false); + $("#sh-storage-chart").innerHTML = healthChart(state.storage?.points || [], { ...state.storage, storage: true }); + installTooltip($("#sh-storage-chart"), state.storage?.points, true); + const forecast = forecastCopy(state.storage?.forecast); + $("#sh-forecast-title").textContent = forecast.title; + $("#sh-forecast-detail").textContent = forecast.detail; + const peaks = state.range === "live" ? Object.fromEntries(["cpuPercent", "memoryPercent", "load1", "storagePercent"].map((field) => { const values = points.map((p) => p[field]).filter(finite); return [field, values.length ? Math.max(...values) : null]; })) : state.history?.peaks; + $("#sh-peaks").innerHTML = pairs([["CPU", percent(peaks?.cpuPercent)], ["RAM", percent(peaks?.memoryPercent)], ["System load (1 min)", number(peaks?.load1, 2)], ["Storage", percent(peaks?.storagePercent)]]); + $("#sh-collection").innerHTML = pairs([["Live sampling", collection ? `${number(collection.sampleIntervalMs / 1000, 0)} seconds` : "—"], ["Persisted aggregates", collection ? `${number(collection.aggregateIntervalMs / 1000, 0)} seconds` : "—"], ["Resource retention", collection ? `${collection.resourceRetentionDays} days` : "—"], ["Storage retention", collection ? `${collection.storageRetentionDays} days` : "—"], ["Last persisted", time(collection?.lastPersistedAt)], ["Swap in use", `${healthBytes(sample?.swapUsedBytes)} / ${healthBytes(sample?.swapTotalBytes)}`]]); + $("#sh-hardware-body").innerHTML = hardwareMarkup(state.hardware); + } + const controller = createHealthPoller({ onUpdate: paint }); + $("#sh-pause").addEventListener("click", controller.togglePause); + $("#sh-refresh").addEventListener("click", controller.refresh); + root.querySelectorAll("[data-range]").forEach((button) => button.addEventListener("click", () => controller.setRange(button.dataset.range))); + document.addEventListener("visibilitychange", controller.visibilityChanged); + return controller; +} diff --git a/public/app.js b/public/app.js index 2ddd762..8cb17b9 100644 --- a/public/app.js +++ b/public/app.js @@ -15,6 +15,7 @@ import { } from "./admin-state.js"; import { activeSectionFor, filterSettings } from "./admin-settings-search.js"; import { api } from "./admin-api.js"; +import { setSystemHealthActive } from "./admin-system-health.js"; import { attachReveal, confirmDialog, escapeHtml, infoDialog, openDialog, paintReveal, passwordDialog, revealSecret, tokenValue } from "./admin-view.js"; import { loadSkills } from "./admin-skills.js"; import { mountSkillAssignmentPicker } from "./skill-assignment-picker.js"; @@ -275,6 +276,7 @@ function capLabelOf(m = {}) { // ── View switching + URL history ──────────────────────────────────────────────── function loadView(name) { + if (name === "system-health") setSystemHealthActive(true); if (name === "channels" && !viewLoaded.channels) { viewLoaded.channels = true; loadConversations().catch(() => {}); } if (name === "users" && !viewLoaded.users) { viewLoaded.users = true; loadUsers().catch(() => {}); } if (name === "settings" && !viewLoaded.settings) { viewLoaded.settings = true; loadSettings().catch(() => {}); } @@ -300,6 +302,7 @@ function setView(name, { history = "push", load = true } = {}) { const path = pathForView(view); if (history === "replace") window.history.replaceState({ view }, "", path); else if (history === "push" && window.location.pathname !== path) window.history.pushState({ view }, "", path); + if (view !== "system-health") setSystemHealthActive(false); if (load) loadView(view); if (view === "settings") enterSettingsView(hash); } diff --git a/public/index.html b/public/index.html index e7904a7..8af1580 100644 --- a/public/index.html +++ b/public/index.html @@ -27,6 +27,7 @@ Skills API Settings + System health