From 14ba14dfab4349764709d7efbdd96b92697bd562 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 25 Jul 2026 04:08:01 -0400 Subject: [PATCH] Add ISAC system metrics --- database/schema.sql | 2 + docs/DESIGN_SYSTEM.md | 8 +++ docs/architecture.md | 17 +++++ src/app.css | 5 +- src/routes/admin/+layout.svelte | 7 +- src/routes/admin/+page.server.ts | 1 + src/routes/admin/audit/+page.svelte | 4 +- src/routes/admin/metrics/+page.server.ts | 91 ++++++++++++++++++++++++ src/routes/admin/metrics/+page.svelte | 39 ++++++++++ 9 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 src/routes/admin/metrics/+page.server.ts create mode 100644 src/routes/admin/metrics/+page.svelte diff --git a/database/schema.sql b/database/schema.sql index 53b4f39..a0c9443 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -124,6 +124,7 @@ INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users- INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-identities','users.identities','View and manage linked external identities'); INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-audit-view','audit.view','View and search the immutable ISAC audit history'); INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-audit-revert','audit.revert','Attempt confirmed compensating reversals of supported ISAC audit events'); +INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-metrics-view','metrics.view','View read-only application, runtime, and database health metrics'); INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-isac-access'); INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-data-manage'); INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-user-operator','permission-isac-access'); @@ -137,6 +138,7 @@ INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-admin INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-auditor','Auditor','Read-only access to ISAC audit history'); INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-auditor','permission-isac-access'); INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-auditor','permission-audit-view'); +INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-auditor','permission-metrics-view'); CREATE TABLE IF NOT EXISTS oauth_states (state_hash TEXT PRIMARY KEY, provider TEXT NOT NULL, mode TEXT NOT NULL, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS discord_profiles ( user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index c837778..efe8ce5 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -230,6 +230,14 @@ Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps before-snapshot; newer work blocks the operation. Successful reversions append a new `audit.reverted` event and never alter the original history. `audit.view` remains read-only; `audit.revert` is independently assigned. +- System Metrics is the second read-only System Oversight surface. It uses a + compact KPI row for headline process health and a two-column grid of dense + runtime, host, database, and service-volume panels. Refresh is explicit and + duplicate-safe; optional auto-refresh is visibly selected and uses a + restrained ten-second interval. Metrics must not expose environment values, + credentials, full filesystem paths, or other deployment secrets. The + `metrics.view` permission is independent of mutation access and is included + in the read-only Auditor role. - Account deletion uses a collapsed danger zone, explicit identity confirmation, and plain-language retention guidance. Soft-deleted accounts retain authored content under a deleted-agent identity while releasing credentials, usernames, diff --git a/docs/architecture.md b/docs/architecture.md index fdc1b45..c639498 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,3 +113,20 @@ Unsupported destructive identity operations, account deletion, incomplete snapshots, stale targets, and events whose dependencies no longer exist are marked non-reversible or rejected. Recovery for those cases requires a specialized workflow rather than inferred database writes. + +# System metrics + +The read-only `/admin/metrics` route requires `metrics.view` and collects a +point-in-time process-local health snapshot. Node and operating-system APIs +provide process CPU and memory, event-loop utilization and delay, host memory, +load average, core count, platform, and uptime. Embedded Turso pragmas provide +page allocation, page size, and free-page information; aggregate SQL queries +provide application record volumes. + +The interface refreshes on demand and may poll every ten seconds while the +operator keeps auto-refresh enabled. CPU and event-loop values are deltas from +the previous server-side sample, so the first or infrequently requested sample +is directional rather than a monitoring-grade time series. Production alerting +and historical analysis should eventually use a dedicated external telemetry +system. This surface deliberately omits secrets, environment-variable values, +and full database paths. diff --git a/src/app.css b/src/app.css index 9f47810..b2a693c 100644 --- a/src/app.css +++ b/src/app.css @@ -105,6 +105,7 @@ .legal-table-wrap{overflow-x:auto;margin:18px 0}.legal-table{width:100%;min-width:720px;border-collapse:collapse;border:1px solid var(--shd-border);background:#0b0e0f}.legal-table th,.legal-table td{padding:12px 14px;border:1px solid var(--shd-border);text-align:left;vertical-align:top}.legal-table th{color:var(--shd-orange);background:#111516;font-size:7px;letter-spacing:1px}.legal-table td{color:#a1a7a8;font:12px/1.55 Georgia,serif}.legal-table code,.legal-document code{color:#d9dedf;font:11px Consolas,monospace}.cookie-notice{position:fixed;z-index:1000;right:24px;bottom:24px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:22px;width:min(620px,calc(100vw - 48px));padding:18px 20px;border:1px solid var(--shd-orange);background:rgba(10,13,14,.98);box-shadow:0 14px 48px #000c,4px 0 0 var(--shd-orange)}.cookie-notice .eyebrow{margin:0 0 8px}.cookie-notice div>p:not(.eyebrow){margin:0;color:var(--shd-text-muted);font:12px/1.5 Georgia,serif}.cookie-notice a{display:inline-block;margin-top:10px;color:var(--shd-orange);font-size:7px;font-weight:900;letter-spacing:1px}.cookie-notice button{min-width:84px}@media(max-width:600px){.cookie-notice{right:12px;bottom:12px;grid-template-columns:1fr;width:calc(100vw - 24px)}.cookie-notice button{width:100%}} .kpi-filters{height:auto}.kpi-filters>a{position:relative;min-height:70px;padding:11px 13px;background:#111415;border:1px solid #2d3233;border-top:2px solid #3a4041;display:grid;color:inherit;cursor:pointer}.kpi-filters>a:hover,.kpi-filters>a:focus-visible{border-color:#8a672b;border-top-color:var(--shd-orange);background:#171a1b}.kpi-filters>a.active{border-color:#8a672b;border-top-color:var(--shd-orange);background:#211a0e;box-shadow:inset 0 -2px #f6a821}.kpi-filters>a:active{transform:translateY(1px);filter:brightness(.88)}.kpi-filters>a span{font-size:7px;letter-spacing:1.3px;color:#737a7c}.kpi-filters>a strong{font-size:23px;line-height:1.1;margin:3px 0}.kpi-filters>a small{font-size:7px;color:#555d5f}.kpi-filters>a.active span,.kpi-filters>a.active strong{color:var(--shd-orange)} .role-kpis{grid-template-columns:repeat(5,minmax(105px,1fr))}.role-identity{grid-template-columns:minmax(220px,.7fr) minmax(320px,1.3fr)!important}.permission-catalogue{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-top:8px}.permission-catalogue label{min-height:54px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:9px;padding:8px 10px;border:1px solid #303536;background:#0d1011;cursor:pointer}.permission-catalogue label:hover,.permission-catalogue label:focus-within{border-color:#8a672b;background:#151819}.permission-catalogue label:active{transform:translateY(1px);filter:brightness(.88)}.permission-catalogue input{width:auto;margin:0;accent-color:var(--shd-orange)}.permission-catalogue span{display:grid;gap:4px}.permission-catalogue b{color:#d7d9d5;font-size:9px}.permission-catalogue small{color:#626a6c;font-size:7px;line-height:1.35}.permission-catalogue em{color:#596164;font-size:6px;font-style:normal;font-weight:900;letter-spacing:.7px}.permission-catalogue label:has(input:checked){border-color:#6e5226;background:#211a0e}.permission-catalogue label:has(input:checked) em{color:var(--shd-orange)}.role-row{min-width:960px;grid-template-columns:1.1fr .55fr 2fr .65fr .3fr}.role-permission-summary{display:flex!important;flex-wrap:wrap;gap:3px}.role-permission-summary b{padding:3px 5px;border:1px solid #3a4840;color:#75c08c;font-size:6px;letter-spacing:.5px}@media(max-width:1000px){.role-kpis{grid-template-columns:repeat(3,minmax(105px,1fr))}}@media(max-width:700px){.permission-catalogue,.role-identity{grid-template-columns:1fr!important}.role-kpis{grid-template-columns:1fr 1fr}} -.audit-filter{height:auto;display:grid;grid-template-columns:minmax(220px,1fr) 210px 240px auto;gap:7px;margin:10px 0}.audit-filter input,.audit-filter select,.audit-filter button{height:40px;margin:0}.audit-row{min-width:980px;grid-template-columns:1fr 1fr 1.3fr .85fr .35fr}.audit-entry{border-top:1px solid #272c2d}.audit-entry:first-of-type{border-top:0}.audit-entry summary{border:0;cursor:pointer;list-style:none}.audit-entry summary::-webkit-details-marker{display:none}.audit-entry summary:hover{background:#15191a}.audit-entry summary:active{transform:translateY(1px);filter:brightness(.88)}.audit-entry summary>b{justify-self:end;color:var(--shd-orange);font-size:7px;letter-spacing:.7px}.audit-entry[open] summary{background:#171a1b;box-shadow:inset 2px 0 var(--shd-orange)}.audit-comparison{display:grid;grid-template-columns:1fr 1fr;gap:1px;padding:1px;background:#303536}.audit-comparison section{min-width:0;padding:12px;background:#0b0e0f}.audit-comparison span{color:var(--shd-orange);font-size:7px;font-weight:900;letter-spacing:1px}.audit-comparison pre{max-height:320px;overflow:auto;margin:9px 0 0;color:#aeb4b5;font:10px/1.5 Consolas,monospace;white-space:pre-wrap;word-break:break-word}@media(max-width:900px){.audit-filter{grid-template-columns:1fr 1fr}.audit-comparison{grid-template-columns:1fr}}@media(max-width:560px){.audit-filter{grid-template-columns:1fr}} -.audit-revert-bar{min-height:48px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border-top:1px solid #4c302d;background:#1c1111}.audit-revert-bar>span{color:#a87870;font-size:7px;letter-spacing:.8px}.audit-revert-bar button{height:32px}.audit-revert-bar.completed{border-color:#36563f;background:#101713}.audit-revert-bar.completed>span{color:var(--shd-success)}.audit-revert-modal>p{color:#969d9f;font:13px/1.6 Georgia,serif}.audit-revert-modal code{color:#d7d9d5;font:11px Consolas,monospace}.audit-revert-summary{display:grid;grid-template-columns:1fr 1fr 1.4fr;gap:7px;margin:14px 0}.audit-revert-summary>div{min-width:0;display:grid;gap:5px;padding:9px;border:1px solid #303536;background:#0d1011}.audit-revert-summary span{color:#626a6c;font-size:6px;letter-spacing:.8px}.audit-revert-summary b{overflow:hidden;text-overflow:ellipsis;color:#d7d9d5;font-size:8px;white-space:nowrap}@media(max-width:600px){.audit-revert-bar{align-items:stretch;flex-direction:column}.audit-revert-summary{grid-template-columns:1fr}} +.audit-toolbar>div:last-child{display:block}.audit-filter{height:auto;display:grid;grid-template-columns:minmax(220px,1fr) 210px 240px auto;gap:7px;margin:10px 0}.audit-filter input,.audit-filter select,.audit-filter button{height:40px;margin:0}.audit-filter button{display:inline-flex;align-items:center;justify-content:center;padding:0 14px;line-height:1}.audit-row{min-width:980px;grid-template-columns:1fr 1fr 1.3fr .85fr .35fr}.audit-entry{border-top:1px solid #272c2d}.audit-entry:first-of-type{border-top:0}.audit-entry summary{border:0;cursor:pointer;list-style:none}.audit-entry summary::-webkit-details-marker{display:none}.audit-entry summary:hover{background:#15191a}.audit-entry summary:active{transform:translateY(1px);filter:brightness(.88)}.audit-entry summary>b{justify-self:end;color:var(--shd-orange);font-size:7px;letter-spacing:.7px}.audit-entry[open] summary{background:#171a1b;box-shadow:inset 2px 0 var(--shd-orange)}.audit-comparison{display:grid;grid-template-columns:1fr 1fr;gap:1px;padding:1px;background:#303536}.audit-comparison section{min-width:0;padding:12px;background:#0b0e0f}.audit-comparison span{color:var(--shd-orange);font-size:7px;font-weight:900;letter-spacing:1px}.audit-comparison pre{max-height:320px;overflow:auto;margin:9px 0 0;color:#aeb4b5;font:10px/1.5 Consolas,monospace;white-space:pre-wrap;word-break:break-word}@media(max-width:900px){.audit-filter{grid-template-columns:1fr 1fr}.audit-comparison{grid-template-columns:1fr}}@media(max-width:560px){.audit-filter{grid-template-columns:1fr}} +.audit-revert-bar{min-height:56px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border-top:1px solid #4c302d;background:#1c1111}.audit-revert-bar>span{color:#a87870;font-size:7px;letter-spacing:.8px}.audit-revert-button{min-width:174px;height:40px;padding:0 14px}.audit-revert-button>b{font-size:15px;line-height:1}.audit-revert-button:hover,.audit-revert-button:focus-visible{box-shadow:inset 3px 0 var(--shd-danger)}.audit-revert-bar.completed{border-color:#36563f;background:#101713}.audit-revert-bar.completed>span{color:var(--shd-success)}.audit-revert-modal>p{color:#969d9f;font:13px/1.6 Georgia,serif}.audit-revert-modal code{color:#d7d9d5;font:11px Consolas,monospace}.audit-revert-summary{display:grid;grid-template-columns:1fr 1fr 1.4fr;gap:7px;margin:14px 0}.audit-revert-summary>div{min-width:0;display:grid;gap:5px;padding:9px;border:1px solid #303536;background:#0d1011}.audit-revert-summary span{color:#626a6c;font-size:6px;letter-spacing:.8px}.audit-revert-summary b{overflow:hidden;text-overflow:ellipsis;color:#d7d9d5;font-size:8px;white-space:nowrap}@media(max-width:600px){.audit-revert-bar{align-items:stretch;flex-direction:column}.audit-revert-button{width:100%}.audit-revert-summary{grid-template-columns:1fr}} +.metrics-auto{height:40px;display:flex!important;align-items:center;gap:7px;padding:0 10px;border:1px solid var(--shd-border);background:#0d1011;color:#747c7e;font-size:7px;font-weight:900;letter-spacing:.8px;cursor:pointer}.metrics-auto input{width:auto;margin:0;accent-color:var(--shd-orange)}.metrics-auto:has(input:checked){border-color:#6e5226;background:#211a0e;color:var(--shd-orange)}.metrics-refresh{min-width:154px}.metrics-kpis strong{font-size:18px}.metrics-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.metrics-panel{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.metrics-panel>header{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:14px;padding:11px 13px;border-bottom:1px solid var(--shd-border);background:#0d1011}.metrics-panel h3{margin:4px 0 0;font-size:14px;letter-spacing:.5px}.metrics-panel>header>b{color:var(--shd-orange);font-size:7px;letter-spacing:1px}.metric-lines{display:grid}.metric-lines>div{min-height:40px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:8px 12px;border-top:1px solid #252a2b}.metric-lines>div:first-child{border-top:0}.metric-lines span{color:#697174;font-size:7px;letter-spacing:.9px}.metric-lines b{max-width:65%;overflow:hidden;color:#d5d8d5;font-size:9px;text-align:right;text-overflow:ellipsis;white-space:nowrap}.metrics-foot{margin:10px 0 0;padding:9px;border:1px solid #292e2f;color:#596164;font-size:6px;letter-spacing:.8px}@media(max-width:850px){.metrics-grid{grid-template-columns:1fr}}@media(max-width:560px){.metrics-auto,.metrics-refresh{width:100%}.metric-lines b{max-width:58%}} diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index 0a2b275..5c60bd3 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -14,7 +14,10 @@ { href: '/admin/roles', label: 'Roles & permissions', code: 'ROLE', permission: 'users.roles' } ]; const visibleUserSections = $derived(userSections.filter((section) => can(section.permission))); - const oversightSections = [{ href: '/admin/audit', label: 'Audit history', code: 'AUDT', permission: 'audit.view' }]; + const oversightSections = [ + { href: '/admin/audit', label: 'Audit history', code: 'AUDT', permission: 'audit.view' }, + { href: '/admin/metrics', label: 'System metrics', code: 'MTRX', permission: 'metrics.view' } + ]; const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission))); @@ -36,7 +39,7 @@ {/if} {#if visibleOversightSections.length} - + {/if} diff --git a/src/routes/admin/+page.server.ts b/src/routes/admin/+page.server.ts index aa59741..691f949 100644 --- a/src/routes/admin/+page.server.ts +++ b/src/routes/admin/+page.server.ts @@ -9,5 +9,6 @@ export const load: PageServerLoad = async ({ cookies }) => { if (permissions.includes('users.roles')) redirect(303, '/admin/roles'); if (permissions.includes('data.manage')) redirect(303, '/admin/stats'); if (permissions.includes('audit.view')) redirect(303, '/admin/audit'); + if (permissions.includes('metrics.view')) redirect(303, '/admin/metrics'); redirect(303, '/account'); }; diff --git a/src/routes/admin/audit/+page.svelte b/src/routes/admin/audit/+page.svelte index a97dd1b..ff5069e 100644 --- a/src/routes/admin/audit/+page.svelte +++ b/src/routes/admin/audit/+page.svelte @@ -19,7 +19,7 @@ Audit history — ISAC -

SYSTEM OVERSIGHT

AUDIT HISTORY

Immutable operator activity, target records, and before/after change evidence.

+

SYSTEM OVERSIGHT

AUDIT HISTORY

Immutable operator activity, target records, and before/after change evidence.

{String(entry.action).toUpperCase()}{String(entry.entity_type).replaceAll('_',' ').toUpperCase()}
{entry.actor_name}{entry.actor_login ?? entry.actor_id}
{entry.entity_id}EVENT {String(entry.id).slice(0,8).toUpperCase()}
{date(entry.created_at)}DETAILS +
BEFORE
{pretty(entry.before_json)}
AFTER
{pretty(entry.after_json)}
- {#if entry.reversible}
SUPPORTED COMPENSATING ACTION · CURRENT STATE WILL BE VERIFIED FIRST
+ {#if entry.reversible}
SUPPORTED COMPENSATING ACTION · CURRENT STATE WILL BE VERIFIED FIRST
{:else if entry.reverted}
THIS EVENT HAS ALREADY BEEN REVERTED
{/if} {:else}
NO AUDIT EVENTS FOUNDAdjust the category, action, or search value.
{/each} diff --git a/src/routes/admin/metrics/+page.server.ts b/src/routes/admin/metrics/+page.server.ts new file mode 100644 index 0000000..12f7934 --- /dev/null +++ b/src/routes/admin/metrics/+page.server.ts @@ -0,0 +1,91 @@ +import { requirePermission } from '$lib/server/auth/authorization'; +import { getSessionUser } from '$lib/server/auth/session'; +import { getDatabase } from '$lib/server/database'; +import { env } from '$env/dynamic/private'; +import { stat } from 'node:fs/promises'; +import { basename, resolve } from 'node:path'; +import { arch, availableParallelism, cpus, freemem, loadavg, platform, release, totalmem, uptime } from 'node:os'; +import { monitorEventLoopDelay, performance } from 'node:perf_hooks'; +import type { PageServerLoad } from './$types'; + +const loopDelay = monitorEventLoopDelay({ resolution: 20 }); +loopDelay.enable(); +let previousCpu = process.cpuUsage(); +let previousCpuAt = performance.now(); +let previousElu = performance.eventLoopUtilization(); + +const numericPragma = (row: Record | undefined) => + Number(Object.values(row ?? {}).find((value) => typeof value === 'number') ?? 0); + +export const load: PageServerLoad = async ({ cookies }) => { + await requirePermission(await getSessionUser(cookies), 'metrics.view'); + const startedAt = performance.now(); + const database = await getDatabase(); + const databasePath = resolve(env.TDR_DATABASE_PATH || './data/shd-build-development.db'); + const now = performance.now(); + const cpu = process.cpuUsage(previousCpu); + const elapsedMicros = Math.max(1, (now - previousCpuAt) * 1000); + previousCpu = process.cpuUsage(); + previousCpuAt = now; + const elu = performance.eventLoopUtilization(previousElu); + previousElu = performance.eventLoopUtilization(); + const memory = process.memoryUsage(); + + const [pageCount, pageSize, freePages, tableCount, counts, databaseFile] = await Promise.all([ + (await database.prepare('PRAGMA page_count')).get(), + (await database.prepare('PRAGMA page_size')).get(), + (await database.prepare('PRAGMA freelist_count')).get(), + (await database.prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")).get(), + (await database.prepare(`SELECT + (SELECT COUNT(*) FROM users) AS users, + (SELECT COUNT(*) FROM sessions WHERE expires_at>?) AS active_sessions, + (SELECT COUNT(*) FROM builds) AS builds, + (SELECT COUNT(*) FROM comments) AS comments, + (SELECT COUNT(*) FROM audit_log) AS audit_events, + (SELECT COUNT(*) FROM stat_attributes)+(SELECT COUNT(*) FROM talents)+(SELECT COUNT(*) FROM brand_sets)+ + (SELECT COUNT(*) FROM gear)+(SELECT COUNT(*) FROM weapons) AS game_records`)).get(new Date().toISOString()), + stat(databasePath).catch(() => null) + ]); + const pages = numericPragma(pageCount); + const bytesPerPage = numericPragma(pageSize); + const unusedPages = numericPragma(freePages); + const hostTotalMemory = totalmem(); + const hostFreeMemory = freemem(); + const coreCount = availableParallelism?.() ?? cpus().length; + + return { + capturedAt: new Date().toISOString(), + collectionMs: Number((performance.now() - startedAt).toFixed(2)), + process: { + uptimeSeconds: process.uptime(), + cpuPercent: Number((((cpu.user + cpu.system) / elapsedMicros) * 100).toFixed(2)), + rss: memory.rss, + heapUsed: memory.heapUsed, + heapTotal: memory.heapTotal, + external: memory.external, + eventLoopUtilization: Number((elu.utilization * 100).toFixed(2)), + eventLoopP95Ms: Number((loopDelay.percentile(95) / 1e6).toFixed(2)) + }, + host: { + platform: platform(), release: release(), architecture: arch(), cores: coreCount, + uptimeSeconds: uptime(), loadAverage: loadavg(), totalMemory: hostTotalMemory, + freeMemory: hostFreeMemory, usedMemoryPercent: Number((((hostTotalMemory - hostFreeMemory) / hostTotalMemory) * 100).toFixed(2)) + }, + runtime: { + node: process.version, + environment: process.env.NODE_ENV === 'production' ? 'production' : 'development', + pid: process.pid + }, + database: { + filename: basename(databasePath), + fileSize: databaseFile?.size ?? pages * bytesPerPage, + modifiedAt: databaseFile?.mtime.toISOString() ?? null, + pageCount: pages, + pageSize: bytesPerPage, + freePages: unusedPages, + freePercent: pages ? Number(((unusedPages / pages) * 100).toFixed(2)) : 0, + tableCount: Number(tableCount?.count ?? 0) + }, + counts + }; +}; diff --git a/src/routes/admin/metrics/+page.svelte b/src/routes/admin/metrics/+page.svelte new file mode 100644 index 0000000..ed1084a --- /dev/null +++ b/src/routes/admin/metrics/+page.svelte @@ -0,0 +1,39 @@ + + +System metrics — ISAC +

SYSTEM OVERSIGHT

SYSTEM METRICS

Live application runtime, host capacity, database health, and service activity.

+ +
PROCESS UPTIME{duration(data.process.uptimeSeconds)}Current Node process
PROCESS CPU{data.process.cpuPercent}%Since previous sample
PROCESS MEMORY{bytes(data.process.rss)}Resident working set
DATABASE SIZE{bytes(data.database.fileSize)}{data.database.filename}
+ +
+

APPLICATION PROCESS

RUNTIME HEALTH

ONLINE
HEAP USED{bytes(data.process.heapUsed)} / {bytes(data.process.heapTotal)}
EXTERNAL MEMORY{bytes(data.process.external)}
EVENT LOOP UTILIZATION{data.process.eventLoopUtilization}%
EVENT LOOP P95 DELAY{data.process.eventLoopP95Ms} ms
NODE RUNTIME{data.runtime.node}
PROCESS ID{data.runtime.pid}
+

HOST CAPACITY

OPERATING ENVIRONMENT

{data.host.cores} CORES
PLATFORM{data.host.platform.toUpperCase()} · {data.host.architecture}
RELEASE{data.host.release}
HOST UPTIME{duration(data.host.uptimeSeconds)}
MEMORY USED{data.host.usedMemoryPercent}%
MEMORY AVAILABLE{bytes(data.host.freeMemory)} / {bytes(data.host.totalMemory)}
LOAD AVERAGE{data.host.loadAverage.map((value: number) => value.toFixed(2)).join(' · ')}
+

EMBEDDED TURSO

DATABASE HEALTH

{data.database.tableCount} TABLES
FILE{data.database.filename}
ALLOCATED PAGES{data.database.pageCount.toLocaleString()}
PAGE SIZE{bytes(data.database.pageSize)}
FREE PAGES{data.database.freePages.toLocaleString()} · {data.database.freePercent}%
LAST FILE WRITE{data.database.modifiedAt ? new Date(data.database.modifiedAt).toLocaleString() : 'UNAVAILABLE'}
METRIC QUERY{data.collectionMs} ms
+

SERVICE ACTIVITY

RECORD VOLUME

LIVE COUNTS
USERS{data.counts.users.toLocaleString()}
ACTIVE SESSIONS{data.counts.active_sessions.toLocaleString()}
BUILDS / GUIDES{data.counts.builds.toLocaleString()}
COMMENTS{data.counts.comments.toLocaleString()}
GAME-DATA RECORDS{data.counts.game_records.toLocaleString()}
AUDIT EVENTS{data.counts.audit_events.toLocaleString()}
+
+

CAPTURED {new Date(data.capturedAt).toLocaleString()} · VALUES ARE PROCESS-LOCAL OBSERVATIONS AND DO NOT REPLACE HOST-LEVEL ALERTING.