Feat v0.6.4 admin health/metrics tab + cluster merge
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m40s
CI/CD / test-sqlite (pull_request) Successful in 2m46s
CI/CD / build-and-deploy (pull_request) Successful in 1m1s
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m40s
CI/CD / test-sqlite (pull_request) Successful in 2m46s
CI/CD / build-and-deploy (pull_request) Successful in 1m1s
Admin Health tab under Monitoring with auto-refreshing runtime, DB, cluster, and extension metrics panels. New GET /api/v1/admin/metrics endpoint. Fattened heartbeat JSONB with stack, GC CPU%, extension count, sandbox stats, trigger fires. Sandbox runner and trigger engine now track cumulative execution counters. Event bus tracks publish/deliver counts. Health endpoints consolidated via shared builder. Block renderers (mermaid, katex, csv-table, diff-viewer) no longer require chat. cluster-dashboard package retired — merged into admin Health tab. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -163,6 +163,7 @@ export function createDomains(restClient) {
|
||||
// ── 18. Admin ──────────────────────────
|
||||
admin: {
|
||||
stats: () => rc.get('/api/v1/admin/stats'),
|
||||
metrics: () => rc.get('/api/v1/admin/metrics'),
|
||||
|
||||
users: {
|
||||
list: (opts) => rc.get('/api/v1/admin/users' + _qs(opts)),
|
||||
|
||||
206
src/js/sw/surfaces/admin/health.js
Normal file
206
src/js/sw/surfaces/admin/health.js
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Admin > Monitoring > Health
|
||||
*
|
||||
* Polls GET /api/v1/admin/metrics and displays runtime, DB, cluster,
|
||||
* and extension metrics. Cluster panel only shows on PG multi-node.
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useRef, useCallback } = hooks;
|
||||
|
||||
// ── Formatting helpers ──────────────────────────
|
||||
|
||||
function formatBytes(b) {
|
||||
if (b == null) return '\u2014';
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB';
|
||||
if (b < 1024 * 1024 * 1024) return (b / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
return (b / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
function formatDuration(sec) {
|
||||
if (sec == null) return '\u2014';
|
||||
if (sec < 60) return Math.floor(sec) + 's';
|
||||
if (sec < 3600) return Math.floor(sec / 60) + 'm ' + Math.floor(sec % 60) + 's';
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
if (h < 24) return h + 'h ' + m + 'm';
|
||||
const d = Math.floor(h / 24);
|
||||
return d + 'd ' + (h % 24) + 'h';
|
||||
}
|
||||
|
||||
function formatMs(ms) {
|
||||
if (ms == null) return '\u2014';
|
||||
if (ms < 1) return '<1 ms';
|
||||
if (ms < 1000) return ms.toFixed(1) + ' ms';
|
||||
return (ms / 1000).toFixed(2) + ' s';
|
||||
}
|
||||
|
||||
function formatPct(v) {
|
||||
if (v == null) return '\u2014';
|
||||
return v.toFixed(2) + '%';
|
||||
}
|
||||
|
||||
function formatNum(v) {
|
||||
if (v == null) return '\u2014';
|
||||
if (typeof v === 'number') return v.toLocaleString();
|
||||
return String(v);
|
||||
}
|
||||
|
||||
// ── Stat card ───────────────────────────────────
|
||||
|
||||
function Stat({ label, value }) {
|
||||
return html`
|
||||
<div style="padding:8px 12px;background:var(--bg-secondary, #f5f5f5);border-radius:6px;min-width:120px;">
|
||||
<div style="font-size:11px;color:var(--text-muted, #888);margin-bottom:2px;">${label}</div>
|
||||
<div style="font-size:16px;font-weight:600;font-variant-numeric:tabular-nums;">${value}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Panels ──────────────────────────────────────
|
||||
|
||||
function PanelHeader({ title }) {
|
||||
return html`<h3 style="margin:0 0 12px;font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted, #888);">${title}</h3>`;
|
||||
}
|
||||
|
||||
function RuntimePanel({ m }) {
|
||||
return html`
|
||||
<div style="margin-bottom:24px;">
|
||||
<${PanelHeader} title="Runtime" />
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px;">
|
||||
<${Stat} label="Uptime" value=${formatDuration(m.uptime_sec)} />
|
||||
<${Stat} label="Goroutines" value=${formatNum(m.goroutines)} />
|
||||
<${Stat} label="Heap Alloc" value=${formatBytes(m.heap_alloc)} />
|
||||
<${Stat} label="Heap Sys" value=${formatBytes(m.heap_sys)} />
|
||||
<${Stat} label="Stack In Use" value=${formatBytes(m.stack_in_use)} />
|
||||
<${Stat} label="GC Cycles" value=${formatNum(m.gc_cycles)} />
|
||||
<${Stat} label="Last GC Pause" value=${formatMs((m.gc_pause_ns || 0) / 1e6)} />
|
||||
<${Stat} label="GC CPU" value=${formatPct(m.gc_cpu_pct)} />
|
||||
<${Stat} label="WS Clients" value=${formatNum(m.ws_clients)} />
|
||||
<${Stat} label="Extensions" value=${formatNum(m.extensions_loaded)} />
|
||||
<${Stat} label="Open FDs" value=${m.open_fds >= 0 ? formatNum(m.open_fds) : '\u2014'} />
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function DBPanel({ m }) {
|
||||
return html`
|
||||
<div style="margin-bottom:24px;">
|
||||
<${PanelHeader} title="Database" />
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px;">
|
||||
<${Stat} label="Latency" value=${formatMs(m.latency_ms)} />
|
||||
<${Stat} label="Pool Active" value=${formatNum(m.pool_active)} />
|
||||
<${Stat} label="Pool Idle" value=${formatNum(m.pool_idle)} />
|
||||
<${Stat} label="Pool Max" value=${formatNum(m.pool_max)} />
|
||||
<${Stat} label="Wait Count" value=${formatNum(m.wait_count)} />
|
||||
<${Stat} label="Wait Duration" value=${formatMs(m.wait_duration_ms)} />
|
||||
${m.dead_tuples != null && html`<${Stat} label="Dead Tuples" value=${formatNum(m.dead_tuples)} />`}
|
||||
${m.active_backends != null && html`<${Stat} label="Active Backends" value=${formatNum(m.active_backends)} />`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function ClusterPanel({ cluster }) {
|
||||
return html`
|
||||
<div style="margin-bottom:24px;">
|
||||
<${PanelHeader} title=${'Cluster \u2014 ' + cluster.size + ' node' + (cluster.size !== 1 ? 's' : '')} />
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px;">
|
||||
${cluster.nodes.map(n => html`<${NodeCard} key=${n.node_id} node=${n} />`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function NodeCard({ node }) {
|
||||
const stats = node.stats || {};
|
||||
const ageLabel = node.heartbeat_age_ms < 15000
|
||||
? Math.floor(node.heartbeat_age_ms / 1000) + 's ago'
|
||||
: Math.floor(node.heartbeat_age_ms / 60000) + 'm ago';
|
||||
const ageColor = node.heartbeat_age_ms < 15000 ? 'var(--accent, #2a6)' : 'var(--warning, #c80)';
|
||||
|
||||
return html`
|
||||
<div style="border:1px solid var(--border, #ddd);border-radius:8px;padding:12px;background:var(--bg-primary, #fff);">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||
<strong style="font-size:13px;">${node.node_id}</strong>
|
||||
<span style="font-size:11px;color:${ageColor};font-weight:600;">${ageLabel}</span>
|
||||
</div>
|
||||
${node.endpoint && html`<div style="font-size:11px;color:var(--text-muted, #888);margin-bottom:8px;">${node.endpoint}</div>`}
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px;">
|
||||
<${Stat} label="Uptime" value=${formatDuration(stats.uptime_sec)} />
|
||||
<${Stat} label="WS Clients" value=${formatNum(stats.ws_clients)} />
|
||||
<${Stat} label="Goroutines" value=${formatNum(stats.goroutines)} />
|
||||
<${Stat} label="Heap" value=${formatBytes(stats.heap_alloc)} />
|
||||
<${Stat} label="GC Pause" value=${formatMs((stats.gc_pause_ns || 0) / 1e6)} />
|
||||
<${Stat} label="GC Cycles" value=${formatNum(stats.gc_cycles)} />
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function ExtensionPanel({ m }) {
|
||||
return html`
|
||||
<div style="margin-bottom:24px;">
|
||||
<${PanelHeader} title="Extensions" />
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px;">
|
||||
<${Stat} label="Starlark Execs" value=${formatNum(m.starlark_exec_total)} />
|
||||
<${Stat} label="Starlark Errors" value=${formatNum(m.starlark_errors_total)} />
|
||||
<${Stat} label="Avg Duration" value=${formatMs(m.starlark_avg_duration_ms)} />
|
||||
<${Stat} label="Trigger Fires" value=${formatNum(m.trigger_fires_total)} />
|
||||
<${Stat} label="Events Published" value=${formatNum(m.event_bus_published)} />
|
||||
<${Stat} label="Events Delivered" value=${formatNum(m.event_bus_delivered)} />
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Main component ──────────────────────────────
|
||||
|
||||
export default function HealthSection() {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [interval, setInterval_] = useState(10);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.admin.metrics();
|
||||
setData(resp);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
timerRef.current = setInterval(load, interval * 1000);
|
||||
return () => clearInterval(timerRef.current);
|
||||
}, [load, interval]);
|
||||
|
||||
if (error && !data) {
|
||||
return html`<div class="settings-placeholder" style="color:var(--error,red);">Failed to load metrics: ${error}</div>`;
|
||||
}
|
||||
if (!data) {
|
||||
return html`<div class="settings-placeholder">Loading metrics\u2026</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;justify-content:flex-end;align-items:center;margin-bottom:16px;gap:8px;">
|
||||
<span style="font-size:11px;color:var(--text-muted, #888);">Refresh</span>
|
||||
<select style="font-size:12px;padding:2px 6px;" value=${interval} onChange=${e => setInterval_(+e.target.value)}>
|
||||
<option value="5">5s</option>
|
||||
<option value="10">10s</option>
|
||||
<option value="30">30s</option>
|
||||
<option value="60">60s</option>
|
||||
</select>
|
||||
</div>
|
||||
<${RuntimePanel} m=${data.runtime} />
|
||||
<${DBPanel} m=${data.db} />
|
||||
${data.cluster && html`<${ClusterPanel} cluster=${data.cluster} />`}
|
||||
<${ExtensionPanel} m=${data.extensions} />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ const ADMIN_SECTIONS = {
|
||||
people: ['users', 'teams', 'groups'],
|
||||
workflows: ['workflows'],
|
||||
system: ['settings', 'storage', 'packages', 'connections', 'broadcast', 'backup'],
|
||||
monitoring: ['audit'],
|
||||
monitoring: ['health', 'audit'],
|
||||
};
|
||||
|
||||
const ADMIN_LABELS = {
|
||||
@@ -31,7 +31,7 @@ const ADMIN_LABELS = {
|
||||
settings: 'Settings', storage: 'Storage', packages: 'Packages',
|
||||
connections: 'Connections', broadcast: 'Broadcast',
|
||||
backup: 'Backup',
|
||||
audit: 'Audit',
|
||||
health: 'Health', audit: 'Audit',
|
||||
};
|
||||
|
||||
// ── Extension config sections ──────
|
||||
@@ -52,7 +52,7 @@ const CATEGORY_META = {
|
||||
people: { label: 'People', first: 'users', icon: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2|C9 7 4' },
|
||||
workflows: { label: 'Workflows', first: 'workflows', icon: 'P16 3 21 3 21 8|L4 20 21 3|P21 16 21 21 16 21|L15 15 21 21|L4 4 9 9' },
|
||||
system: { label: 'System', first: 'settings', icon: 'C12 12 3|M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09' },
|
||||
monitoring: { label: 'Monitoring', first: 'audit', icon: 'L18 20 18 10|L12 20 12 4|L6 20 6 14' },
|
||||
monitoring: { label: 'Monitoring', first: 'health', icon: 'L18 20 18 10|L12 20 12 4|L6 20 6 14' },
|
||||
};
|
||||
|
||||
// ── Lazy section imports ────────────────────
|
||||
@@ -69,6 +69,7 @@ const sectionModules = {
|
||||
connections: () => import(`./connections.js${_v}`),
|
||||
broadcast: () => import(`./broadcast.js${_v}`),
|
||||
backup: () => import(`./backup.js${_v}`),
|
||||
health: () => import(`./health.js${_v}`),
|
||||
audit: () => import(`./audit.js${_v}`),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user