Add ISAC system metrics

This commit is contained in:
JB
2026-07-25 04:08:01 -04:00
parent 99fe6043ff
commit 14ba14dfab
9 changed files with 168 additions and 6 deletions
+2
View File
@@ -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,
+8
View File
@@ -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,
+17
View File
@@ -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.
+3 -2
View File
@@ -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%}}
+5 -2
View File
@@ -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)));
</script>
@@ -36,7 +39,7 @@
</nav>
{/if}
{#if visibleOversightSections.length}
<div class="sidebar-label section-label"><span>SYSTEM OVERSIGHT</span><b>01 MODULE</b></div>
<div class="sidebar-label section-label"><span>SYSTEM OVERSIGHT</span><b>{String(visibleOversightSections.length).padStart(2, '0')} MODULE{visibleOversightSections.length === 1 ? '' : 'S'}</b></div>
<nav aria-label="System oversight">{#each visibleOversightSections as section}<a class:active={page.url.pathname.startsWith(section.href)} href={section.href}><small>{section.code}</small><span>{section.label}</span><b>›</b></a>{/each}</nav>
{/if}
<div class="sidebar-foot"><span>ISAC NODE</span><strong>NYC-01</strong><small>Access policy synchronized</small></div>
+1
View File
@@ -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');
};
+2 -2
View File
@@ -19,7 +19,7 @@
</script>
<svelte:head><title>Audit history — ISAC</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">SYSTEM OVERSIGHT</p><h2>AUDIT HISTORY</h2><p>Immutable operator activity, target records, and before/after change evidence.</p></div></div>
<div class="module-toolbar audit-toolbar"><div><p class="eyebrow">SYSTEM OVERSIGHT</p><h2>AUDIT HISTORY</h2><p>Immutable operator activity, target records, and before/after change evidence.</p></div></div>
<KpiFilters parameter="category" active={data.filters.category} items={[
{value:'',label:'ALL EVENTS',count:data.summary.total,description:'Complete audit history'},
{value:'game_data',label:'GAME DATA',count:data.summary.game_data,description:'Builder-source mutations'},
@@ -39,7 +39,7 @@
<details class="audit-entry">
<summary class="dashboard-row audit-row"><div><strong>{String(entry.action).toUpperCase()}</strong><small>{String(entry.entity_type).replaceAll('_',' ').toUpperCase()}</small></div><div><strong>{entry.actor_name}</strong><small>{entry.actor_login ?? entry.actor_id}</small></div><div><strong>{entry.entity_id}</strong><small>EVENT {String(entry.id).slice(0,8).toUpperCase()}</small></div><span>{date(entry.created_at)}</span><b>DETAILS +</b></summary>
<div class="audit-comparison"><section><span>BEFORE</span><pre>{pretty(entry.before_json)}</pre></section><section><span>AFTER</span><pre>{pretty(entry.after_json)}</pre></section></div>
{#if entry.reversible}<div class="audit-revert-bar"><span>SUPPORTED COMPENSATING ACTION · CURRENT STATE WILL BE VERIFIED FIRST</span><button class="danger" type="button" onclick={() => confirming = entry}>ATTEMPT REVERT</button></div>
{#if entry.reversible}<div class="audit-revert-bar"><span>SUPPORTED COMPENSATING ACTION · CURRENT STATE WILL BE VERIFIED FIRST</span><button class="moderation-action danger audit-revert-button" type="button" onclick={() => confirming = entry}><b>↶</b> ATTEMPT REVERT</button></div>
{:else if entry.reverted}<div class="audit-revert-bar completed"><span>THIS EVENT HAS ALREADY BEEN REVERTED</span></div>{/if}
</details>
{:else}<div class="empty-dashboard"><strong>NO AUDIT EVENTS FOUND</strong><span>Adjust the category, action, or search value.</span></div>{/each}
+91
View File
@@ -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<string, unknown> | 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
};
};
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { onMount } from 'svelte';
let { data } = $props();
let refreshing = $state(false);
let autoRefresh = $state(false);
const bytes = (value: number) => {
const units = ['B','KB','MB','GB','TB'];
let amount = Number(value), index = 0;
while (amount >= 1024 && index < units.length - 1) { amount /= 1024; index++; }
return `${amount.toFixed(index ? 1 : 0)} ${units[index]}`;
};
const duration = (seconds: number) => {
const days = Math.floor(seconds / 86400), hours = Math.floor((seconds % 86400) / 3600), minutes = Math.floor((seconds % 3600) / 60);
return `${days ? `${days}d ` : ''}${hours}h ${minutes}m`;
};
async function refresh() {
if (refreshing) return;
refreshing = true;
try { await invalidateAll(); } finally { refreshing = false; }
}
onMount(() => {
const timer = window.setInterval(() => { if (autoRefresh && !refreshing) void refresh(); }, 10000);
return () => window.clearInterval(timer);
});
</script>
<svelte:head><title>System metrics — ISAC</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">SYSTEM OVERSIGHT</p><h2>SYSTEM METRICS</h2><p>Live application runtime, host capacity, database health, and service activity.</p></div><div><label class="metrics-auto"><input type="checkbox" bind:checked={autoRefresh}/><span>AUTO · 10S</span></label><button class="primary metrics-refresh" onclick={refresh} disabled={refreshing} aria-busy={refreshing}>{refreshing ? 'SAMPLING…' : 'REFRESH METRICS ↻'}</button></div></div>
<div class="module-kpis metrics-kpis"><div><span>PROCESS UPTIME</span><strong>{duration(data.process.uptimeSeconds)}</strong><small>Current Node process</small></div><div><span>PROCESS CPU</span><strong>{data.process.cpuPercent}%</strong><small>Since previous sample</small></div><div><span>PROCESS MEMORY</span><strong>{bytes(data.process.rss)}</strong><small>Resident working set</small></div><div><span>DATABASE SIZE</span><strong>{bytes(data.database.fileSize)}</strong><small>{data.database.filename}</small></div></div>
<div class="metrics-grid">
<section class="metrics-panel"><header><div><p class="eyebrow">APPLICATION PROCESS</p><h3>RUNTIME HEALTH</h3></div><b class="state-dot">ONLINE</b></header><div class="metric-lines"><div><span>HEAP USED</span><b>{bytes(data.process.heapUsed)} / {bytes(data.process.heapTotal)}</b></div><div><span>EXTERNAL MEMORY</span><b>{bytes(data.process.external)}</b></div><div><span>EVENT LOOP UTILIZATION</span><b>{data.process.eventLoopUtilization}%</b></div><div><span>EVENT LOOP P95 DELAY</span><b>{data.process.eventLoopP95Ms} ms</b></div><div><span>NODE RUNTIME</span><b>{data.runtime.node}</b></div><div><span>PROCESS ID</span><b>{data.runtime.pid}</b></div></div></section>
<section class="metrics-panel"><header><div><p class="eyebrow">HOST CAPACITY</p><h3>OPERATING ENVIRONMENT</h3></div><b>{data.host.cores} CORES</b></header><div class="metric-lines"><div><span>PLATFORM</span><b>{data.host.platform.toUpperCase()} · {data.host.architecture}</b></div><div><span>RELEASE</span><b>{data.host.release}</b></div><div><span>HOST UPTIME</span><b>{duration(data.host.uptimeSeconds)}</b></div><div><span>MEMORY USED</span><b>{data.host.usedMemoryPercent}%</b></div><div><span>MEMORY AVAILABLE</span><b>{bytes(data.host.freeMemory)} / {bytes(data.host.totalMemory)}</b></div><div><span>LOAD AVERAGE</span><b>{data.host.loadAverage.map((value: number) => value.toFixed(2)).join(' · ')}</b></div></div></section>
<section class="metrics-panel"><header><div><p class="eyebrow">EMBEDDED TURSO</p><h3>DATABASE HEALTH</h3></div><b>{data.database.tableCount} TABLES</b></header><div class="metric-lines"><div><span>FILE</span><b>{data.database.filename}</b></div><div><span>ALLOCATED PAGES</span><b>{data.database.pageCount.toLocaleString()}</b></div><div><span>PAGE SIZE</span><b>{bytes(data.database.pageSize)}</b></div><div><span>FREE PAGES</span><b>{data.database.freePages.toLocaleString()} · {data.database.freePercent}%</b></div><div><span>LAST FILE WRITE</span><b>{data.database.modifiedAt ? new Date(data.database.modifiedAt).toLocaleString() : 'UNAVAILABLE'}</b></div><div><span>METRIC QUERY</span><b>{data.collectionMs} ms</b></div></div></section>
<section class="metrics-panel"><header><div><p class="eyebrow">SERVICE ACTIVITY</p><h3>RECORD VOLUME</h3></div><b>LIVE COUNTS</b></header><div class="metric-lines"><div><span>USERS</span><b>{data.counts.users.toLocaleString()}</b></div><div><span>ACTIVE SESSIONS</span><b>{data.counts.active_sessions.toLocaleString()}</b></div><div><span>BUILDS / GUIDES</span><b>{data.counts.builds.toLocaleString()}</b></div><div><span>COMMENTS</span><b>{data.counts.comments.toLocaleString()}</b></div><div><span>GAME-DATA RECORDS</span><b>{data.counts.game_records.toLocaleString()}</b></div><div><span>AUDIT EVENTS</span><b>{data.counts.audit_events.toLocaleString()}</b></div></div></section>
</div>
<p class="metrics-foot">CAPTURED {new Date(data.capturedAt).toLocaleString()} · VALUES ARE PROCESS-LOCAL OBSERVATIONS AND DO NOT REPLACE HOST-LEVEL ALERTING.</p>