clawdie-ai/scripts/dashboard.ts
Mevy Assistant 0d801e6ecf refactor(multitenant): move shared runtime names to platform scope
Continue the platform runtime split by moving shared watchdog and controlplane defaults off tenant-derived names. Operator-facing dashboard and controlplane defaults now use the platform service identity, with tests covering the new config and socket behavior.

---
Build: pass | Tests: pass — 103 passed (6 files)
2026-04-23 09:26:36 +02:00

315 lines
13 KiB
TypeScript

#!/usr/bin/env npx tsx
/**
* scripts/dashboard.ts — Operator dashboard HTML generator.
*
* Pulls live data from hostd + controlplane, emits a single self-contained
* HTML file. No LLM. No runtime JS — pure server-side render.
*
* Usage:
* just dashboard
*
* Output: $CONTROLPLANE_DASHBOARD_DIR/dashboard.html
* (fallback: html/dashboard/dashboard.html)
*
* Skips silently if hostd is unreachable. Never crashes.
*/
import fs from 'fs';
import http from 'http';
import net from 'net';
import path from 'path';
import { PLATFORM_SERVICE_NAME } from '../src/config.js';
const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, '');
const OUT_DIR =
process.env.CONTROLPLANE_DASHBOARD_DIR || path.join(ROOT, 'html', 'dashboard');
const OUT_FILE = path.join(OUT_DIR, 'dashboard.html');
const CP_PORT = parseInt(process.env.CONTROLPLANE_PORT ?? '3100', 10);
const CP_HOST = process.env.CONTROLPLANE_BIND_HOST ?? '127.0.0.1';
const SOCKET_PATH =
process.env.HOSTD_SOCKET ?? `/var/run/${PLATFORM_SERVICE_NAME}-hostd.sock`;
// ---------------------------------------------------------------------------
// Data fetching
// ---------------------------------------------------------------------------
interface HostdResp { ok: boolean; output?: string; error?: string; }
function callHostd(op: string, params: Record<string, string> = {}): Promise<HostdResp> {
return new Promise((resolve) => {
let settled = false;
const id = Math.random().toString(36).slice(2);
const timer = setTimeout(() => {
if (!settled) { settled = true; socket.destroy(); resolve({ ok: false, error: 'timeout' }); }
}, 3000);
const socket = net.createConnection(SOCKET_PATH);
let buf = '';
socket.on('connect', () => socket.write(JSON.stringify({ id, op, params }) + '\n'));
socket.on('data', (d) => {
buf += d.toString();
const lines = buf.split('\n');
buf = lines.pop() ?? '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const r = JSON.parse(line) as HostdResp & { id?: string };
if (r.id === id && !settled) {
settled = true; clearTimeout(timer); socket.end(); resolve(r);
}
} catch { /* partial */ }
}
});
socket.on('error', (e) => { if (!settled) { settled = true; clearTimeout(timer); resolve({ ok: false, error: e.message }); } });
socket.on('close', () => { if (!settled) { settled = true; clearTimeout(timer); resolve({ ok: false, error: 'closed' }); } });
});
}
function cpGet<T>(urlPath: string): Promise<T> {
return new Promise((resolve, reject) => {
const req = http.get({ hostname: CP_HOST, port: CP_PORT, path: urlPath, timeout: 2000 }, (res) => {
let body = '';
res.on('data', (c: Buffer) => { body += c.toString(); });
res.on('end', () => { try { resolve(JSON.parse(body) as T); } catch { reject(new Error('non-json')); } });
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
});
}
function parseSize(s: string): number {
const m = /^([\d.]+)([KMGT]?)$/i.exec(s.trim());
if (!m) return 0;
const units: Record<string, number> = { K: 1e3, M: 1e6, G: 1e9, T: 1e12 };
return parseFloat(m[1]) * (units[m[2].toUpperCase()] ?? 1);
}
// ---------------------------------------------------------------------------
// HTML helpers
// ---------------------------------------------------------------------------
function esc(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function stateTag(state: string): string {
const color = state === 'running' ? '#2ecc71' : state === 'stopped' ? '#e74c3c' : '#f39c12';
return `<span style="background:${color};color:#fff;padding:2px 8px;border-radius:3px;font-size:0.85em">${esc(state)}</span>`;
}
function taskStatusTag(status: string): string {
const colors: Record<string, string> = {
done: '#2ecc71', completed: '#2ecc71',
in_progress: '#3498db', running: '#3498db',
failed: '#e74c3c', error: '#e74c3c',
pending: '#f39c12',
};
const c = colors[status] ?? '#95a5a6';
return `<span style="background:${c};color:#fff;padding:2px 8px;border-radius:3px;font-size:0.85em">${esc(status)}</span>`;
}
function usageBar(pct: number): string {
const color = pct > 85 ? '#e74c3c' : pct > 65 ? '#f39c12' : '#2ecc71';
return `<div style="background:#2a2a2a;border-radius:4px;height:8px;width:160px;display:inline-block;vertical-align:middle">
<div style="background:${color};height:100%;width:${Math.min(pct, 100)}%;border-radius:4px"></div>
</div> <span style="font-size:0.85em;color:#aaa">${pct}%</span>`;
}
// ---------------------------------------------------------------------------
// Main render
// ---------------------------------------------------------------------------
interface Task { id: string; title: string; status: string; assigned_to?: string; created_at?: string; }
interface Approval { id: string; description: string; requested_at: string; }
async function main(): Promise<void> {
const generated = new Date().toLocaleString('en-GB', {
dateStyle: 'medium', timeStyle: 'short',
});
// Fetch all data concurrently, never throw
const [jailResp, zfsResp, pfResp, tasksResp, stateResp, approvalsResp] = await Promise.all([
callHostd('bastille-list'),
callHostd('zfs-list'),
callHostd('service-status', { name: 'pf' }),
cpGet<{ tasks?: Task[] }>('/api/controlplane/tasks').catch(() => ({ tasks: undefined })),
cpGet<{ budget?: { spentToday?: number; dailyLimit?: number }; agents?: Array<{ id: string; status: string }> }>('/api/controlplane/state').catch(() => ({})),
cpGet<{ approvals?: Approval[] }>('/api/controlplane/approvals').catch(() => ({ approvals: undefined })),
]);
const hostdReachable = jailResp.ok || zfsResp.ok;
// --- Parse jails ---
type JailRow = { name: string; ip: string; jid: string; state: string };
const jails: JailRow[] = [];
if (jailResp.ok) {
for (const j of parseBastilleList(jailResp.output ?? '')) {
jails.push({ jid: j.jid, name: j.name, state: j.state, ip: j.ip });
}
}
// --- Parse ZFS ---
type ZfsRow = { dataset: string; used: string; avail: string; pct: number };
const zfsRows: ZfsRow[] = [];
if (zfsResp.ok) {
for (const line of (zfsResp.output ?? '').split('\n')) {
if (!line.trim() || /^NAME/i.test(line)) continue;
const p = line.trim().split(/\s+/);
const used = parseSize(p[1] ?? '0');
const avail = parseSize(p[2] ?? '0');
const total = used + avail;
zfsRows.push({ dataset: p[0] ?? '-', used: p[1] ?? '-', avail: p[2] ?? '-', pct: total > 0 ? Math.round((used / total) * 100) : 0 });
}
}
// --- Budget ---
const budget = (stateResp as { budget?: { spentToday?: number; dailyLimit?: number } }).budget;
const budgetPct = (budget?.spentToday && budget?.dailyLimit)
? Math.round((budget.spentToday / budget.dailyLimit) * 100) : null;
// --- Tasks ---
const tasks: Task[] = (tasksResp as { tasks?: Task[] }).tasks ?? [];
const approvals: Approval[] = (approvalsResp as { approvals?: Approval[] }).approvals ?? [];
// --- HTML ---
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Clawdie Dashboard</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Courier New', monospace; background: #0f0f0f; color: #ddd; padding: 24px; }
h1 { color: #fff; margin-bottom: 4px; font-size: 1.4em; }
.meta { color: #666; font-size: 0.8em; margin-bottom: 32px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); gap: 24px; }
.card { background: #181818; border: 1px solid #2a2a2a; border-radius: 6px; padding: 20px; }
.card h2 { font-size: 0.95em; color: #aaa; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px; border-bottom: 1px solid #2a2a2a; padding-bottom: 8px; }
table { width: 100%; border-collapse: collapse; font-size: 0.9em; }
td, th { padding: 6px 8px; text-align: left; border-bottom: 1px solid #222; }
th { color: #666; font-weight: normal; font-size: 0.8em; text-transform: uppercase; }
.unreachable { color: #666; font-style: italic; font-size: 0.9em; }
.approve-btn { background: #2ecc71; color: #000; border: none; padding: 4px 12px; border-radius: 3px; cursor: pointer; font-size: 0.8em; margin-right: 4px; }
.reject-btn { background: #e74c3c; color: #fff; border: none; padding: 4px 12px; border-radius: 3px; cursor: pointer; font-size: 0.8em; }
.pf-ok { color: #2ecc71; } .pf-warn { color: #e74c3c; }
.budget-row { margin-top: 8px; }
</style>
</head>
<body>
<h1>Clawdie Dashboard</h1>
<div class="meta">Generated ${esc(generated)}${!hostdReachable ? ' &nbsp;·&nbsp; <span style="color:#e74c3c">hostd unreachable</span>' : ''}</div>
<div class="grid">
<!-- Jails -->
<div class="card">
<h2>Jails</h2>
${jails.length === 0
? '<p class="unreachable">No data — hostd unreachable or no jails configured</p>'
: `<table>
<thead><tr><th>Name</th><th>IP</th><th>JID</th><th>State</th></tr></thead>
<tbody>
${jails.map((j) => `<tr><td>${esc(j.name)}</td><td>${esc(j.ip)}</td><td>${esc(j.jid)}</td><td>${stateTag(j.state)}</td></tr>`).join('\n ')}
</tbody>
</table>`}
</div>
<!-- ZFS -->
<div class="card">
<h2>ZFS Storage</h2>
${zfsRows.length === 0
? '<p class="unreachable">No data — hostd unreachable</p>'
: `<table>
<thead><tr><th>Dataset</th><th>Used</th><th>Avail</th><th>Usage</th></tr></thead>
<tbody>
${zfsRows.map((z) => `<tr><td>${esc(z.dataset)}</td><td>${esc(z.used)}</td><td>${esc(z.avail)}</td><td>${usageBar(z.pct)}</td></tr>`).join('\n ')}
</tbody>
</table>`}
</div>
<!-- System -->
<div class="card">
<h2>System</h2>
<table>
<tbody>
<tr><td>PF firewall</td><td>${
pfResp.ok
? `<span class="pf-ok">enabled</span>`
: `<span class="pf-warn">unknown</span>`
}</td></tr>
${budget ? `<tr><td>Budget today</td><td class="budget-row">${usageBar(budgetPct ?? 0)} &nbsp; ${(budget.spentToday ?? 0).toLocaleString()} / ${(budget.dailyLimit ?? 0).toLocaleString()} tokens</td></tr>` : ''}
<tr><td>Active tasks</td><td>${tasks.filter((t) => t.status === 'in_progress' || t.status === 'running').length}</td></tr>
<tr><td>Pending approvals</td><td>${approvals.length}</td></tr>
</tbody>
</table>
</div>
<!-- Tasks -->
<div class="card">
<h2>Recent Tasks</h2>
${tasks.length === 0
? '<p class="unreachable">Controlplane not reachable or no tasks</p>'
: `<table>
<thead><tr><th>ID</th><th>Title</th><th>Agent</th><th>Status</th></tr></thead>
<tbody>
${tasks.slice(0, 15).map((t) => `<tr>
<td style="color:#666;font-size:0.8em">${esc(t.id.slice(0, 8))}</td>
<td>${esc(t.title.slice(0, 40))}</td>
<td style="color:#888">${esc(t.assigned_to ?? '—')}</td>
<td>${taskStatusTag(t.status)}</td>
</tr>`).join('\n ')}
</tbody>
</table>`}
</div>
${approvals.length > 0 ? `
<!-- Approvals -->
<div class="card">
<h2>Pending Approvals</h2>
<table>
<thead><tr><th>ID</th><th>Description</th><th>Actions</th></tr></thead>
<tbody>
${approvals.map((a) => `<tr>
<td style="color:#666;font-size:0.8em">${esc(a.id.slice(0, 8))}</td>
<td>${esc(a.description.slice(0, 50))}</td>
<td>
<button class="approve-btn" onclick="decide('${esc(a.id)}','approve')">Approve</button>
<button class="reject-btn" onclick="decide('${esc(a.id)}','reject')">Reject</button>
</td>
</tr>`).join('\n ')}
</tbody>
</table>
</div>
` : ''}
</div>
<script>
async function decide(id, action) {
try {
const r = await fetch('/api/controlplane/approvals/' + id, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decision: action }),
});
if (r.ok) location.reload();
else alert('Failed: ' + await r.text());
} catch (e) { alert('Error: ' + e.message); }
}
</script>
</body>
</html>`;
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.writeFileSync(OUT_FILE, html, 'utf-8');
console.log(`Dashboard written to ${OUT_FILE}`);
if (!hostdReachable) console.log(' (hostd unreachable — jail/ZFS sections are empty)');
}
main().catch((err: Error) => {
console.error(err.message);
process.exit(1);
});