619 lines
24 KiB
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>
|