feat(dashboard): expand operator tenant and publish view
--- Build: FAIL | Tests: FAIL --- Build: FAIL | Tests: FAIL
This commit is contained in:
parent
3d33482c14
commit
1e87f34121
1 changed files with 277 additions and 56 deletions
|
|
@ -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)} · 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>
|
||||
|
|
@ -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)} ${(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)');
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue