feat(dashboard): expand operator tenant and publish view

---
Build: FAIL | Tests: FAIL

---
Build: FAIL | Tests: FAIL
This commit is contained in:
Operator & Codex 2026-04-26 08:48:51 +02:00
parent 3d33482c14
commit 1e87f34121

View file

@ -19,18 +19,33 @@ import net from 'net';
import path from 'path';
import {
CMS_WEBROOT,
CONTROLPLANE_SHARED_SECRET,
CONTROLPLANE_DASHBOARD_DIR,
CONTROLPLANE_INTERNAL_DOMAIN,
PLATFORM_RUNTIME_USER,
} 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 { SOCKET_PATH as DEFAULT_HOSTD_SOCKET_PATH } from '../src/hostd/types.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 OUT_DIR = CONTROLPLANE_DASHBOARD_DIR || path.join(ROOT, 'html', 'dashboard');
const OUT_FILE = path.join(OUT_DIR, 'dashboard.html');
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 ?? '127.0.0.1';
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';
const SOCKET_PATH = process.env.HOSTD_SOCKET ?? DEFAULT_HOSTD_SOCKET_PATH;
// ---------------------------------------------------------------------------
@ -70,10 +85,31 @@ function callHostd(op: string, params: Record<string, string> = {}): Promise<Hos
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) => {
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', () => { try { resolve(JSON.parse(body) as T); } catch { reject(new Error('non-json')); } });
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')); });
@ -112,23 +148,69 @@ function taskStatusTag(status: string): string {
}
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>`;
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; title: string; status: string; assigned_to?: string; created_at?: string; }
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([
@ -136,8 +218,12 @@ async function main(): Promise<void> {
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 })),
cpGet<ControlplaneStateResponse>('/api/controlplane/state').catch(() => ({})),
cpGet<{
pending?: Approval[];
approved?: Approval[];
rejected?: Approval[];
}>('/api/controlplane/approvals').catch(() => ({})),
]);
const hostdReachable = jailResp.ok || zfsResp.ok;
@ -155,25 +241,41 @@ async function main(): Promise<void> {
// --- 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: p[0] ?? '-', used: p[1] ?? '-', avail: p[2] ?? '-', pct: total > 0 ? Math.round((used / total) * 100) : 0 });
zfsRows.push({
dataset,
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;
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 approvals: Approval[] = (approvalsResp as { approvals?: Approval[] }).approvals ?? [];
const approvalsPending = (approvalsResp as {
pending?: Approval[];
}).pending ?? [];
// --- HTML ---
const html = `<!DOCTYPE html>
@ -185,8 +287,17 @@ async function main(): Promise<void> {
<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; }
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; }
@ -198,14 +309,127 @@ async function main(): Promise<void> {
.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>
@ -219,36 +443,6 @@ async function main(): Promise<void> {
</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>
@ -258,7 +452,7 @@ async function main(): Promise<void> {
<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 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>
@ -267,14 +461,14 @@ async function main(): Promise<void> {
</table>`}
</div>
${approvals.length > 0 ? `
${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>
${approvals.map((a) => `<tr>
${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>
@ -287,6 +481,20 @@ async function main(): Promise<void> {
</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>
@ -305,10 +513,23 @@ async function main(): Promise<void> {
</body>
</html>`;
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.writeFileSync(OUT_FILE, html, 'utf-8');
console.log(`Dashboard written to ${OUT_FILE}`);
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)');
}