clawdie-ai/scripts/dashboard.ts
Sam & Claude c1560e108d Harden hostd auth and operator password hashing
---
Build: pass | Tests: FAIL — 4 failed (pre-existing controlplane-api tenant fixture cases)
2026-05-03 06:50:06 +02:00

511 lines
21 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 path from 'path';
import {
CMS_WEBROOT,
CONTROLPLANE_SHARED_SECRET,
CONTROLPLANE_DASHBOARD_DIR,
CONTROLPLANE_INTERNAL_DOMAIN,
} from '../src/config.js';
import { parseBastilleList } from '../src/bastille-list.js';
import {
buildDashboardSummary,
buildDashboardSurfaceRows,
buildDashboardTenantRows,
buildDashboardTenantSiteRows,
} from '../src/dashboard-view.js';
import { formatDisplayDate } from '../src/display-date.js';
import { hostd } from '../src/hostd/client.js';
import { loadTenantRegistry } from '../src/tenant-registry.js';
import { hostVisibleJailPath } from '../src/tenant-site-publish.js';
import { readEnvFile } from '../src/env.js';
const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, '');
const FALLBACK_OUT_DIR = path.join(ROOT, 'html', 'dashboard');
const PREFERRED_OUT_DIR = CONTROLPLANE_DASHBOARD_DIR || FALLBACK_OUT_DIR;
const CP_PORT = parseInt(process.env.CONTROLPLANE_PORT ?? '3100', 10);
const CP_HOST = (process.env.CONTROLPLANE_BIND_HOST === '0.0.0.0'
? '127.0.0.1'
: process.env.CONTROLPLANE_BIND_HOST) ?? '127.0.0.1';
// ---------------------------------------------------------------------------
// Data fetching
// ---------------------------------------------------------------------------
interface HostdResp { ok: boolean; output?: string; error?: string; }
function callHostd(op: string, params: Record<string, string> = {}): Promise<HostdResp> {
return hostd(op, params, { timeoutMs: 3000 });
}
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,
headers: {
host: CONTROLPLANE_INTERNAL_DOMAIN,
...(CONTROLPLANE_SHARED_SECRET
? { authorization: `Bearer ${CONTROLPLANE_SHARED_SECRET}` }
: {}),
},
}, (res) => {
let body = '';
res.on('data', (c: Buffer) => { body += c.toString(); });
res.on('end', () => {
if ((res.statusCode ?? 500) >= 400) {
reject(new Error(`http ${res.statusCode ?? 500}`));
return;
}
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 safePct = Number.isFinite(pct) ? Math.max(0, pct) : 0;
const color = safePct > 85 ? '#e74c3c' : safePct > 65 ? '#f39c12' : '#2ecc71';
return `<div style="display:flex;align-items:center;gap:8px;min-width:0">
<div style="background:#2a2a2a;border-radius:4px;height:8px;width:120px;max-width:100%;flex:0 1 120px;overflow:hidden">
<div style="background:${color};height:100%;width:${Math.min(safePct, 100)}%;border-radius:4px"></div>
</div>
<span style="font-size:0.85em;color:#aaa;white-space:nowrap">${safePct}%</span>
</div>`;
}
function statusPill(label: string, tone: 'good' | 'warn' | 'neutral' = 'neutral'): string {
const colors: Record<typeof tone, string> = {
good: '#2ecc71',
warn: '#f39c12',
neutral: '#4b5563',
};
return `<span style="background:${colors[tone]};color:#fff;padding:2px 8px;border-radius:999px;font-size:0.8em">${esc(label)}</span>`;
}
function resolveCmsJailName(): string {
const envOverrides = readEnvFile(['CMS_JAIL_NAME']);
return (
process.env.CMS_JAIL_NAME ||
envOverrides.CMS_JAIL_NAME ||
'cms'
).trim();
}
// ---------------------------------------------------------------------------
// Main render
// ---------------------------------------------------------------------------
interface Task {
id?: string;
task_id?: string;
title: string;
status: string;
assigned_to?: string;
created_at?: string;
}
interface Approval { id: string; description: string; requested_at: string; }
interface ControlplaneStateResponse {
budget?: {
daily_tokens?: number;
spent_today?: number;
remaining?: number;
hard_limit_exceeded?: boolean;
allocation?: Record<string, number>;
};
agents?: Array<{ id: string; role: string; heartbeat_enabled?: boolean }>;
}
async function main(): Promise<void> {
const generated = formatDisplayDate(new Date(), {
includeTime: true,
});
const registry = loadTenantRegistry();
const cmsJailName = resolveCmsJailName();
const dashboardWebroot = hostVisibleJailPath(cmsJailName, CMS_WEBROOT);
const surfaceRows = buildDashboardSurfaceRows(registry);
const tenantSiteRows = buildDashboardTenantSiteRows(registry, dashboardWebroot);
const tenantRows = buildDashboardTenantRows(tenantSiteRows);
const summary = buildDashboardSummary(tenantSiteRows);
// 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<ControlplaneStateResponse>('/api/controlplane/state').catch(() => ({})),
cpGet<{
pending?: Approval[];
approved?: Approval[];
rejected?: Approval[];
}>('/api/controlplane/approvals').catch(() => ({})),
]);
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[] = [];
let hiddenSnapshotCount = 0;
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 dataset = p[0] ?? '-';
if (dataset.includes('@')) {
hiddenSnapshotCount += 1;
continue;
}
const used = parseSize(p[1] ?? '0');
const avail = parseSize(p[2] ?? '0');
const total = used + avail;
zfsRows.push({
dataset,
used: p[1] ?? '-',
avail: p[2] ?? '-',
pct: total > 0 ? Math.round((used / total) * 100) : 0,
});
}
}
// --- Budget ---
const budget = (stateResp as ControlplaneStateResponse).budget;
const budgetDaily = budget?.daily_tokens ?? 0;
const budgetSpent = budget?.spent_today ?? 0;
const budgetRemaining = budget?.remaining ?? Math.max(0, budgetDaily - budgetSpent);
const budgetPct = budgetDaily > 0
? Math.round((budgetSpent / budgetDaily) * 100) : null;
// --- Tasks ---
const tasks: Task[] = (tasksResp as { tasks?: Task[] }).tasks ?? [];
const approvalsPending = (approvalsResp as {
pending?: Approval[];
}).pending ?? [];
// --- HTML ---
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>AI Controlplane 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; }
a { color: #9ad; text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { color: #fff; margin-bottom: 4px; font-size: 1.6em; }
.meta { color: #666; font-size: 0.8em; margin-bottom: 24px; }
.hero { display: grid; grid-template-columns: 1.4fr 1fr; gap: 24px; margin-bottom: 24px; align-items: start; }
.hero-card, .summary-strip { background: #141414; border: 1px solid #262626; border-radius: 10px; padding: 20px; }
.hero-card p { color: #aaa; max-width: 70ch; line-height: 1.45; }
.summary-strip { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.metric-card { background: #181818; border: 1px solid #242424; border-radius: 8px; padding: 14px; }
.metric-label { color: #7d7d7d; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px; }
.metric-value { color: #fff; font-size: 1.35em; }
.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; }
.tenant-stack { display: grid; gap: 16px; }
.tenant-card { background: #141414; border: 1px solid #262626; border-radius: 8px; padding: 16px; }
.tenant-card h3 { color: #fff; margin-bottom: 6px; font-size: 1.05em; }
.tenant-meta { color: #999; font-size: 0.9em; margin-bottom: 12px; }
.site-stack { display: grid; gap: 10px; }
.site-item { border-top: 1px solid #242424; padding-top: 10px; }
.site-item:first-child { border-top: none; padding-top: 0; }
.site-header { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 6px; align-items: center; }
.site-title { color: #fff; }
.site-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 8px; font-size: 0.9em; }
.dataset-cell { max-width: 0; overflow-wrap: anywhere; word-break: break-word; }
.dataset-note { color: #8a8a8a; font-size: 0.85em; margin-bottom: 10px; }
@media (max-width: 980px) {
.hero { grid-template-columns: 1fr; }
.summary-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>
</head>
<body>
<h1>AI Controlplane Dashboard</h1>
<div class="meta">${esc(CONTROLPLANE_INTERNAL_DOMAIN)} &nbsp;·&nbsp; Generated ${esc(generated)}${!hostdReachable ? ' &nbsp;·&nbsp; <span style="color:#e74c3c">hostd unreachable</span>' : ''}</div>
<div class="hero">
<section class="hero-card">
<h2 style="border:none;padding:0;margin-bottom:12px">Platform Summary</h2>
<p>Operator surface for the stable hostname model. This dashboard is controlplane-owned, independent of CMS health, and now surfaces tenant publish state alongside the lower-level host/runtime facts.</p>
</section>
<section class="summary-strip">
<div class="metric-card">
<div class="metric-label">Tenants</div>
<div class="metric-value">${summary.tenantCount}</div>
</div>
<div class="metric-card">
<div class="metric-label">Sites</div>
<div class="metric-value">${summary.siteCount}</div>
</div>
<div class="metric-card">
<div class="metric-label">Available</div>
<div class="metric-value">${summary.availableSiteCount}</div>
</div>
<div class="metric-card">
<div class="metric-label">Strapi Snapshots</div>
<div class="metric-value">${summary.strapiSnapshotSiteCount}</div>
</div>
<div class="metric-card">
<div class="metric-label">Generated Sites</div>
<div class="metric-value">${summary.generatedSiteCount}</div>
</div>
</section>
</div>
<div class="grid">
<!-- Platform -->
<div class="card">
<h2>Platform</h2>
<table>
<tbody>
${surfaceRows.map((surface) => `<tr><td>${esc(surface.label)}</td><td><a href="${esc(surface.href)}">${esc(surface.host)}</a></td><td>${esc(surface.exposure)}</td></tr>`).join('\n ')}
</tbody>
</table>
</div>
<!-- Operations -->
<div class="card">
<h2>Operations</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>
<tr><td>Hostd</td><td>${hostdReachable ? '<span class="pf-ok">reachable</span>' : '<span class="pf-warn">unreachable</span>'}</td></tr>
${budget ? `<tr><td>Budget today</td><td class="budget-row">${usageBar(budgetPct ?? 0)} &nbsp; ${budgetSpent.toLocaleString()} / ${budgetDaily.toLocaleString()} tokens</td></tr>` : ''}
${budget ? `<tr><td>Remaining</td><td>${Math.max(0, budgetRemaining).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>${approvalsPending.length}</td></tr>
</tbody>
</table>
</div>
<!-- Tenants -->
<div class="card">
<h2>Tenants</h2>
<div class="tenant-stack">
${tenantRows.map((tenant) => {
const sites = tenantSiteRows.filter((site) => site.tenantId === tenant.tenantId);
return `<article class="tenant-card">
<h3>${esc(tenant.tenantDisplayName)}</h3>
<p class="tenant-meta">${esc(tenant.tenantId)} · <a href="http://${esc(tenant.homeHost)}">${esc(tenant.homeHost)}</a> · ${tenant.siteCount} site(s) · ${tenant.availableSiteCount} available · ${tenant.plannedSiteCount} planned</p>
<p class="tenant-meta">${tenant.internalSiteCount} internal · ${tenant.publicSiteCount} public · ${tenant.disabledSiteCount} disabled · ${tenant.strapiSnapshotSiteCount} Strapi snapshot · ${tenant.generatedSiteCount} generated fallback</p>
<div class="site-stack">
${sites.map((site) => `<div class="site-item">
<div class="site-header">
<strong class="site-title">${esc(site.siteTitle)}</strong>
<div>${statusPill(site.availability, site.availability === 'available' ? 'good' : 'warn')} ${statusPill(site.contentSource, site.contentSource === 'strapi-snapshot' ? 'good' : 'warn')}</div>
</div>
<div style="color:#999;font-size:0.9em">${esc(site.siteHost)} · ${esc(site.exposure)}</div>
<div style="color:#8a8a8a;font-size:0.88em;margin-top:6px">
${site.publishResult && site.lastPublishedAt
? `Last publish: ${esc(formatDisplayDate(new Date(site.lastPublishedAt), { includeTime: true }))} · ${esc(site.publishResult)}`
: 'Last publish: no recorded publish yet'}
</div>
<div style="color:#8a8a8a;font-size:0.88em;margin-top:6px">
${site.availability === 'available'
? 'Site output exists in the publish webroot and is being served on this hostname.'
: 'Site is declared in the registry, but no served output was detected in the publish webroot yet.'}
</div>
<div class="site-actions">
<a href="${esc(site.href)}">Open site</a>
<a href="http://${esc(tenant.homeHost)}">Tenant home</a>
<a href="http://cms.home.arpa/admin/">CMS admin</a>
</div>
</div>`).join('\n ') || '<p class="unreachable">No sites declared</p>'}
</div>
</article>`;
}).join('\n ')}
</div>
</div>
<!-- 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>
<!-- 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 || t.task_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>
${approvalsPending.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>
${approvalsPending.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>
` : ''}
<!-- ZFS -->
<div class="card">
<h2>ZFS Storage</h2>
${zfsRows.length === 0
? '<p class="unreachable">No data — hostd unreachable</p>'
: `<table>
${hiddenSnapshotCount > 0 ? `<p class="dataset-note">Showing live datasets only. ${hiddenSnapshotCount} snapshot entr${hiddenSnapshotCount === 1 ? 'y' : 'ies'} hidden.</p>` : ''}
<thead><tr><th>Dataset</th><th>Used</th><th>Avail</th><th>Usage</th></tr></thead>
<tbody>
${zfsRows.map((z) => `<tr><td class="dataset-cell">${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>
</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>`;
let writtenPath = '';
try {
fs.mkdirSync(PREFERRED_OUT_DIR, { recursive: true });
writtenPath = path.join(PREFERRED_OUT_DIR, 'dashboard.html');
fs.writeFileSync(writtenPath, html, 'utf-8');
} catch (err) {
if (PREFERRED_OUT_DIR === FALLBACK_OUT_DIR) {
throw err;
}
fs.mkdirSync(FALLBACK_OUT_DIR, { recursive: true });
writtenPath = path.join(FALLBACK_OUT_DIR, 'dashboard.html');
fs.writeFileSync(writtenPath, html, 'utf-8');
console.warn(
`Dashboard fallback: could not write ${PREFERRED_OUT_DIR}; wrote ${writtenPath} instead.`,
);
}
console.log(`Dashboard written to ${writtenPath}`);
if (!hostdReachable) console.log(' (hostd unreachable — jail/ZFS sections are empty)');
}
main().catch((err: Error) => {
console.error(err.message);
process.exit(1);
});