--- Build: pass | Tests: FAIL — 4 failed (pre-existing controlplane-api tenant fixture cases)
511 lines
21 KiB
TypeScript
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, '&').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 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)} · Generated ${esc(generated)}${!hostdReachable ? ' · <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)} ${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);
|
|
});
|