fester/index.html

619 lines
24 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fester — Dashboard</title>
<link rel="stylesheet" href="/style.css">
<style>
/* page-specific layout */
body { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
#main {
flex: 1;
display: grid;
grid-template-columns: 320px 1fr 340px;
grid-template-rows: 1fr;
gap: 12px;
padding: 12px;
overflow: hidden;
min-height: 0;
}
#main > section { display: flex; flex-direction: column; min-height: 0; }
#main > section > .body { flex: 1; overflow: auto; min-height: 0; }
.node-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: center;
padding: 8px 10px;
border-bottom: 1px solid var(--line);
}
.node-row:last-child { border-bottom: 0; }
.node-row .name { font-weight: 600; color: var(--fg-0); }
.node-row .host { font-family: var(--font-mono); font-size: 10px; color: var(--fg-3); }
.node-row .heat-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
font-size: 10px;
font-family: var(--font-mono);
color: var(--fg-2);
}
.node-row .heat-bar {
flex: 1;
height: 4px;
border-radius: 2px;
background: var(--bg-0);
overflow: hidden;
}
.node-row .heat-bar > div {
height: 100%;
transition: width 0.4s, background 0.4s;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.stat {
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 10px;
}
.stat .label { font-size: 10px; color: var(--fg-3); text-transform: uppercase; letter-spacing: 0.5px; }
.stat .value { font-family: var(--font-mono); font-size: 18px; color: var(--fg-0); margin-top: 2px; }
.stat .value.ok { color: var(--ok); }
.stat .value.warn { color: var(--warn); }
.stat .value.err { color: var(--err); }
.stream-wrap { flex: 1; min-height: 0; }
.stream { height: 100%; }
.build-progress { padding: 8px 10px; border-bottom: 1px solid var(--line); }
.build-progress:last-child { border-bottom: 0; }
.build-progress .head {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
font-size: 12px;
}
.build-progress .name { font-weight: 600; color: var(--fg-0); }
.target-toggles { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.target-toggles label {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: var(--radius);
font-family: var(--font-mono);
text-transform: none;
letter-spacing: 0;
color: var(--fg-1);
cursor: pointer;
font-size: 11px;
}
.target-toggles label.enabled { border-color: var(--ok); color: var(--ok); }
.target-toggles input { width: auto; }
@media (max-width: 1100px) {
#main { grid-template-columns: 280px 1fr; }
#main > section.right { display: none; }
}
@media (max-width: 700px) {
#main { grid-template-columns: 1fr; grid-template-rows: auto auto; padding: 8px; }
body { overflow: auto; height: auto; }
}
</style>
</head>
<body data-fester-shell="dashboard">
<section id="main">
<!-- LEFT: Cluster overview -->
<section class="panel">
<div class="head">
<h3>Cluster</h3>
<div class="row" style="gap:4px;">
<button class="ghost" id="probe-all" title="Probe all node agents">📡 Probe All</button>
<button class="ghost" id="refresh-nodes" title="Refresh"></button>
</div>
</div>
<div class="body">
<div class="stat-grid" style="margin-bottom:12px;">
<div class="stat">
<div class="label">Nodes Online</div>
<div class="value ok" id="stat-nodes"></div>
</div>
<div class="stat">
<div class="label">Avg Heat</div>
<div class="value" id="stat-heat"></div>
</div>
<div class="stat">
<div class="label">Active Jobs</div>
<div class="value warn" id="stat-jobs"></div>
</div>
<div class="stat">
<div class="label">Cache Hit %</div>
<div class="value" id="stat-cache"></div>
</div>
</div>
<h3 style="margin: 12px 0 6px;">Nodes</h3>
<div id="node-list"></div>
<h3 style="margin: 16px 0 6px;">Scheduler Mode</h3>
<select id="scheduler-mode">
<option value="unified">unified</option>
<option value="weighted-thermal-aware" selected>weighted-thermal-aware</option>
<option value="cache-first">cache-first</option>
<option value="target-isolated">target-isolated</option>
<option value="experimental-intelligence">experimental intelligence</option>
</select>
</div>
</section>
<!-- CENTER: Pipeline controls + Live event stream -->
<section class="panel">
<div class="head">
<h3>Live Event Stream</h3>
<div class="row" style="gap:6px;">
<button class="ghost" id="pause-pipeline" title="Pause pipeline">⏸ Pause Pipeline</button>
<button class="ghost" id="resume-pipeline" title="Resume pipeline">▶ Resume</button>
<button class="ghost" id="pause-stream">⏸ Pause Stream</button>
<button class="ghost" id="clear-stream">Clear</button>
</div>
</div>
<div class="body">
<div class="filters" id="filters" style="display:flex;gap:4px;flex-wrap:wrap;margin-bottom:8px;font-size:10px;"></div>
<div class="stream-wrap">
<div class="stream" id="stream"></div>
</div>
</div>
</section>
<!-- RIGHT: Quick actions + targets -->
<section class="panel right">
<div class="head">
<h3>Quick Actions</h3>
</div>
<div class="body">
<div class="field">
<label>Build Command</label>
<input id="cmd" type="text" value="make -j$(nproc)">
</div>
<div class="field">
<label>Working Directory</label>
<input id="dir" type="text" value="/home/user/linux">
</div>
<div class="row" style="margin-bottom:12px;">
<button class="primary full" id="run-build">▶ Run Build</button>
</div>
<div class="field">
<label>Forgejo Repo</label>
<input id="repo" type="url" value="https://forgejo.local/project.git">
</div>
<button class="full" id="run-release">Run Release Build</button>
<hr>
<h3>Targets</h3>
<div class="target-toggles" id="target-toggles"></div>
<hr>
<h3>Pipeline Control</h3>
<div class="field">
<label>Retry Action</label>
<input id="retry-action" type="text" placeholder="action name">
<button class="full" id="btn-retry" style="margin-top:4px;">↻ Retry</button>
</div>
<div class="field">
<label>Force Action to Node</label>
<input id="force-action" type="text" placeholder="action name" style="margin-bottom:4px;">
<input id="force-node" type="text" placeholder="node name">
<button class="full" id="btn-force" style="margin-top:4px;">⚡ Force</button>
</div>
<hr>
<h3>Release Control</h3>
<pre class="json" id="release-output" style="max-height:120px;"></pre>
</div>
</section>
</section>
<script src="/ui/app.js"></script>
<script>
(function () {
'use strict';
const $ = (id) => document.getElementById(id);
/* ---------- Node list rendering ---------- */
function renderNodes(nodes) {
const list = $('node-list');
if (!nodes || !nodes.length) {
list.innerHTML = '<div class="dim center" style="padding:20px;">No nodes reported</div>';
return;
}
list.innerHTML = nodes.map(n => {
const heat = n.heat ?? (n.agent?.heat) ?? 0;
const jobs = n.jobs ?? (n.agent?.jobs) ?? 0;
const max = n.max_jobs ?? (n.agent?.max_jobs) ?? 1;
const state = n.state || (n.agent ? 'online' : 'offline');
const heatPct = Math.min(100, heat);
const policy = n.policy || 'neutral';
const policyBadge = policy === 'preferred' ? 'ok' : policy === 'avoid' ? 'err' : '';
return `
<div class="node-row">
<div>
<div class="name">${n.name} <span class="badge ${policyBadge}" style="font-size:9px;">${policy}</span></div>
<div class="host">${n.host || '—'} · ${state}</div>
<div class="heat-row">
<span style="width:24px;">HEAT</span>
<div class="heat-bar"><div style="width:${heatPct}%;background:${Fester.heatColor(heat)};"></div></div>
<span style="width:30px;text-align:right;">${Math.round(heat)}°</span>
</div>
<div class="heat-row">
<span style="width:24px;">JOBS</span>
<div class="heat-bar"><div style="width:${(jobs/Math.max(1,max))*100}%;background:var(--info);"></div></div>
<span style="width:30px;text-align:right;">${jobs}/${max}</span>
</div>
<div class="row" style="gap:4px;margin-top:6px;">
<select data-policy-node="${n.name}" style="width:auto;font-size:10px;padding:2px 4px;">
<option value="neutral" ${policy === 'neutral' ? 'selected' : ''}>neutral</option>
<option value="preferred" ${policy === 'preferred' ? 'selected' : ''}>preferred</option>
<option value="avoid" ${policy === 'avoid' ? 'selected' : ''}>avoid</option>
</select>
<button data-probe-node="${n.name}" class="ghost" style="padding:2px 8px;font-size:10px;">Probe</button>
<button data-shell-node="${n.name}" class="ghost" style="padding:2px 8px;font-size:10px;" title="Get mosh/ssh command">⌘ Shell</button>
</div>
</div>
<div>
${Fester.stateBadge(state)}
</div>
</div>
`;
}).join('');
// Wire policy dropdowns
list.querySelectorAll('[data-policy-node]').forEach(sel => {
sel.addEventListener('change', async (e) => {
const name = e.target.dataset.policyNode;
const policy = e.target.value;
try {
await Fester.api(`/api/nodes/${encodeURIComponent(name)}/policy`, {
method: 'POST',
body: JSON.stringify({ policy }),
});
Fester.toast(`${name}${policy}`, 'ok');
loadNodes(); // refresh to show new badge
} catch (err) {
Fester.toast(`Policy set failed: ${err.message}`, 'err');
}
});
});
// Wire probe buttons
list.querySelectorAll('[data-probe-node]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const name = e.target.dataset.probeNode;
try {
btn.disabled = true;
btn.textContent = '...';
const res = await Fester.api(`/api/nodes/${encodeURIComponent(name)}/probe`, { method: 'POST' });
if (res.status === 'ok') {
Fester.toast(`${name} probed (temp ${Math.round(res.state.temp)}°, jobs ${res.state.active_jobs})`, 'ok');
} else {
Fester.toast(`${name} unreachable`, 'warn');
}
loadNodes();
} catch (err) {
Fester.toast(`Probe failed: ${err.message}`, 'err');
} finally {
btn.disabled = false;
btn.textContent = 'Probe';
}
});
});
// Wire shell buttons
list.querySelectorAll('[data-shell-node]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const name = e.target.dataset.shellNode;
try {
const res = await Fester.api(`/api/nodes/${encodeURIComponent(name)}/shell`);
const mosh = res.commands.mosh;
const ssh = res.commands.ssh;
const moshAvail = res.mosh_available;
const sshAvail = res.ssh_available;
const cmd = moshAvail ? mosh : (sshAvail ? ssh : null);
if (!cmd) {
Fester.toast(`Neither mosh nor ssh available on server`, 'err');
return;
}
// Try to copy to clipboard
try {
await navigator.clipboard.writeText(cmd);
Fester.toast(`Copied to clipboard: ${cmd}`, 'ok', 5000);
} catch (clipErr) {
// Clipboard API requires HTTPS — fall back to prompt
window.prompt(`Run this in your terminal (mosh${moshAvail ? '' : ' unavailable, using ssh'}):`, cmd);
}
} catch (err) {
Fester.toast(`Shell failed: ${err.message}`, 'err');
}
});
});
}
function updateStats(nodes) {
if (!nodes || !nodes.length) return;
const online = nodes.filter(n => (n.state || '').includes('online')).length;
const avgHeat = nodes.reduce((s, n) => s + (n.heat ?? 0), 0) / nodes.length;
const activeJobs = nodes.reduce((s, n) => s + (n.jobs ?? 0), 0);
$('stat-nodes').textContent = `${online}/${nodes.length}`;
$('stat-heat').textContent = Math.round(avgHeat) + '°';
$('stat-heat').className = 'value ' + (avgHeat > 80 ? 'err' : avgHeat > 60 ? 'warn' : 'ok');
$('stat-jobs').textContent = activeJobs;
$('stat-cache').textContent = '—';
}
async function loadNodes() {
try {
const data = await Fester.api('/api/nodes');
const nodes = data.nodes || [];
renderNodes(nodes);
updateStats(nodes);
} catch (e) {
$('node-list').innerHTML = `<div class="dim center" style="padding:20px;">Failed to load nodes<br><span class="dim">${e.message}</span></div>`;
}
}
/* ---------- Targets ---------- */
async function loadTargets() {
try {
const targets = await Fester.api('/api/targets');
const host = $('target-toggles');
host.innerHTML = targets.map(t => `
<label class="${t.enabled ? 'enabled' : ''}" data-target="${t.name}">
<input type="checkbox" ${t.enabled ? 'checked' : ''}>
<span>${t.name}</span>
</label>
`).join('');
host.querySelectorAll('label').forEach(label => {
const cb = label.querySelector('input');
cb.addEventListener('change', async () => {
const name = label.dataset.target;
try {
await Fester.api(`/api/targets/${name}`, {
method: 'POST',
body: JSON.stringify({ enabled: cb.checked }),
});
label.classList.toggle('enabled', cb.checked);
Fester.toast(`Target ${name} ${cb.checked ? 'enabled' : 'disabled'}`, 'ok');
} catch (e) {
Fester.toast(`Failed to toggle ${name}: ${e.message}`, 'err');
cb.checked = !cb.checked;
}
});
});
} catch (e) {
$('target-toggles').innerHTML = `<div class="dim">No targets configured</div>`;
}
}
/* ---------- Event stream ---------- */
const streamEl = $('stream');
let paused = false;
const MAX_ROWS = 200;
let rowCount = 0;
function appendEvent(event) {
if (paused) return;
const type = event.type || 'event';
// Apply filter
if (!activeFilters.has(type)) return;
const ts = event.timestamp || Date.now() / 1000;
const d = event.data || event;
const msg = formatEventMessage(type, d);
const row = document.createElement('div');
row.className = 'row';
row.innerHTML = `
<span class="ts">${Fester.fmtTime(ts)}</span>
<span class="tag ${type}">${type}</span>
<span class="msg">${msg}</span>
`;
streamEl.appendChild(row);
rowCount++;
if (rowCount > MAX_ROWS) {
streamEl.removeChild(streamEl.firstChild);
rowCount--;
}
streamEl.scrollTop = streamEl.scrollHeight;
}
function formatEventMessage(type, d) {
switch (type) {
case 'node':
return `${d.name || d.node || '—'}${d.state || '?'} (heat ${d.heat ?? '?'}, jobs ${d.jobs ?? '?'})`;
case 'pipeline':
return `${d.action || '?'}${d.state || '?'} on ${d.node || 'any'}${d.cache === 'hit' ? ' (cache hit)' : ''}`;
case 'failure':
return `${d.action || '?'} failed: ${d.reason || 'unknown'}`;
case 'cache':
return `${d.action || '?'} ${d.state || ''}`;
case 'debug':
return `${d.action || '?'} ${d.message || ''}`;
case 'policy':
return `${d.key} = ${d.value}`;
case 'session':
return `${d.action || '?'} session ${d.id || '?'}`;
default:
return JSON.stringify(d).slice(0, 200);
}
}
// Subscribe to global event stream
Fester.subscribe(appendEvent);
/* ---------- Button handlers ---------- */
$('refresh-nodes').addEventListener('click', loadNodes);
$('probe-all').addEventListener('click', async () => {
try {
const res = await Fester.api('/api/nodes/probe-all', { method: 'POST' });
const reached = (res.results || []).filter(r => r.reachable).length;
const total = (res.results || []).length;
Fester.toast(`Probed ${reached}/${total} nodes`, reached === total ? 'ok' : 'warn');
loadNodes();
} catch (e) {
Fester.toast(`Probe-all failed: ${e.message}`, 'err');
}
});
$('pause-stream').addEventListener('click', (e) => {
paused = !paused;
e.target.textContent = paused ? '▶ Resume' : '⏸ Pause';
Fester.toast(paused ? 'Stream paused' : 'Stream resumed', 'info');
});
$('clear-stream').addEventListener('click', () => {
streamEl.innerHTML = '';
rowCount = 0;
});
/* ---------- Event filters ---------- */
const FILTER_TYPES = ['pipeline', 'pipeline_update', 'task_update', 'node', 'node_update', 'failure', 'cache', 'cache_update', 'debug', 'policy', 'session'];
const activeFilters = new Set(FILTER_TYPES);
function renderFilters() {
const host = $('filters');
host.innerHTML = FILTER_TYPES.map(t => {
const on = activeFilters.has(t);
return `<button class="badge ${on ? 'info' : ''}" data-filter="${t}" style="cursor:pointer;">${t}</button>`;
}).join('');
host.querySelectorAll('button').forEach(b => {
b.addEventListener('click', () => {
const t = b.dataset.filter;
if (activeFilters.has(t)) activeFilters.delete(t);
else activeFilters.add(t);
renderFilters();
// re-apply filter to existing rows
streamEl.querySelectorAll('.row').forEach(r => {
const tag = r.querySelector('.tag')?.textContent;
r.style.display = activeFilters.has(tag) ? '' : 'none';
});
});
});
}
renderFilters();
$('run-build').addEventListener('click', async () => {
const cmd = $('cmd').value;
const dir = $('dir').value;
try {
const res = await Fester.api('/api/build', {
method: 'POST',
body: JSON.stringify({ cmd, dir }),
});
Fester.toast(`Build started: ${res.build_id || res.status || 'ok'}`, 'ok');
} catch (e) {
Fester.toast(`Build failed: ${e.message}`, 'err');
}
});
$('run-release').addEventListener('click', async () => {
const repo = $('repo').value;
$('release-output').textContent = 'Starting release...';
try {
const res = await Fester.api('/api/release', {
method: 'POST',
body: JSON.stringify({ repo }),
});
$('release-output').textContent = JSON.stringify(res, null, 2);
Fester.toast('Release build started', 'ok');
} catch (e) {
$('release-output').textContent = e.message;
Fester.toast(`Release failed: ${e.message}`, 'err');
}
});
$('scheduler-mode').addEventListener('change', async (e) => {
try {
await Fester.api('/api/policy/set', {
method: 'POST',
body: JSON.stringify({ key: 'scheduler.mode', value: e.target.value }),
});
Fester.toast(`Scheduler → ${e.target.value}`, 'ok');
} catch (e) {
Fester.toast(`Failed to set mode: ${e.message}`, 'err');
}
});
/* ---------- Pipeline control buttons ---------- */
$('pause-pipeline').addEventListener('click', async () => {
try {
await Fester.api('/api/pipeline/pause', { method: 'POST' });
Fester.toast('Pipeline paused', 'warn');
} catch (e) {
Fester.toast(`Pause failed: ${e.message}`, 'err');
}
});
$('resume-pipeline').addEventListener('click', async () => {
try {
await Fester.api('/api/pipeline/resume', { method: 'POST' });
Fester.toast('Pipeline resumed', 'ok');
} catch (e) {
Fester.toast(`Resume failed: ${e.message}`, 'err');
}
});
$('btn-retry').addEventListener('click', async () => {
const action = $('retry-action').value.trim();
if (!action) { Fester.toast('Enter an action name', 'warn'); return; }
try {
await Fester.api('/api/pipeline/retry', {
method: 'POST',
body: JSON.stringify({ action }),
});
Fester.toast(`Retry queued for ${action}`, 'ok');
} catch (e) {
Fester.toast(`Retry failed: ${e.message}`, 'err');
}
});
$('btn-force').addEventListener('click', async () => {
const action = $('force-action').value.trim();
const node = $('force-node').value.trim();
if (!action || !node) { Fester.toast('Enter both action and node', 'warn'); return; }
try {
await Fester.api('/api/pipeline/force-node', {
method: 'POST',
body: JSON.stringify({ action, node }),
});
Fester.toast(`Forced ${action}${node}`, 'ok');
} catch (e) {
Fester.toast(`Force failed: ${e.message}`, 'err');
}
});
/* ---------- Init ---------- */
loadNodes();
loadTargets();
setInterval(loadNodes, 5000); // refresh node stats every 5s
})();
</script>
</body>
</html>