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)
315 lines
13 KiB
TypeScript
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
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 ? ' · <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)} ${(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);
|
|
});
|