Add ISAC system metrics
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user