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 `
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) => `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 `No physical disk details exposed by Linux.
'}${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 ? `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
Live host resources, 30-day performance history, and six months of storage trends.
Last six months · recorded observations
Inventory detected on the gateway host