dashboard: SPA shell — static index.html + app.js, /api/state JSON
This commit is contained in:
parent
8428c693e0
commit
6fc9862c3c
5 changed files with 464 additions and 280 deletions
268
hive-c0re/assets/app.js
Normal file
268
hive-c0re/assets/app.js
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
// Dashboard SPA. Renders containers + approvals from `/api/state`, wires
|
||||
// up async-form submission (URL-encoded POST + spinner + state refresh),
|
||||
// and tails the broker over `/messages/stream` SSE.
|
||||
|
||||
(() => {
|
||||
// ─── helpers ────────────────────────────────────────────────────────────
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
|
||||
({ '&':'&', '<':'<', '>':'>', '"':'"' }[c])
|
||||
);
|
||||
const el = (tag, attrs = {}, ...children) => {
|
||||
const e = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === 'class') e.className = v;
|
||||
else if (k === 'html') e.innerHTML = v;
|
||||
else if (k.startsWith('data-')) e.setAttribute(k, v);
|
||||
else e.setAttribute(k, v);
|
||||
}
|
||||
for (const c of children) {
|
||||
if (c == null) continue;
|
||||
e.append(c.nodeType ? c : document.createTextNode(c));
|
||||
}
|
||||
return e;
|
||||
};
|
||||
const form = (action, btnClass, btnLabel, confirmMsg, extra = {}) => {
|
||||
const f = el('form', {
|
||||
method: 'POST', action, class: 'inline', 'data-async': '',
|
||||
...(confirmMsg ? { 'data-confirm': confirmMsg } : {}),
|
||||
});
|
||||
for (const [name, value] of Object.entries(extra)) {
|
||||
f.append(el('input', { type: 'hidden', name, value }));
|
||||
}
|
||||
f.append(el('button', { type: 'submit', class: 'btn ' + btnClass }, btnLabel));
|
||||
return f;
|
||||
};
|
||||
|
||||
// ─── async forms ────────────────────────────────────────────────────────
|
||||
document.addEventListener('submit', async (e) => {
|
||||
const f = e.target;
|
||||
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
|
||||
e.preventDefault();
|
||||
if (f.dataset.confirm && !confirm(f.dataset.confirm)) return;
|
||||
const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline');
|
||||
const original = btn ? btn.innerHTML : '';
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner">◐</span>'; }
|
||||
try {
|
||||
const resp = await fetch(f.action, {
|
||||
method: f.method || 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(new FormData(f)),
|
||||
redirect: 'manual',
|
||||
});
|
||||
const ok = resp.ok || resp.type === 'opaqueredirect'
|
||||
|| (resp.status >= 200 && resp.status < 400);
|
||||
if (!ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
alert('action failed: ' + resp.status + (text ? '\n\n' + text : ''));
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
||||
return;
|
||||
}
|
||||
refreshState();
|
||||
} catch (err) {
|
||||
alert('action failed: ' + err);
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
||||
}
|
||||
});
|
||||
|
||||
// ─── state rendering ────────────────────────────────────────────────────
|
||||
function renderContainers(s) {
|
||||
const root = $('containers-section');
|
||||
root.innerHTML = '';
|
||||
|
||||
if (s.any_stale) {
|
||||
root.append(form(
|
||||
'/update-all', 'btn-rebuild', '↻ UPD4TE 4LL',
|
||||
'rebuild every stale container?',
|
||||
));
|
||||
}
|
||||
|
||||
const spawn = el('form', {
|
||||
method: 'POST', action: '/request-spawn',
|
||||
class: 'spawnform', 'data-async': '',
|
||||
});
|
||||
spawn.append(
|
||||
el('input', {
|
||||
name: 'name',
|
||||
placeholder: 'new agent name (≤9 chars)',
|
||||
maxlength: '9', required: '', autocomplete: 'off',
|
||||
}),
|
||||
el('button', { type: 'submit', class: 'btn btn-spawn' }, '◆ R3QU3ST SP4WN'),
|
||||
);
|
||||
root.append(spawn);
|
||||
root.append(el('p', { class: 'meta' },
|
||||
'spawn requests queue as approvals. operator approves below to actually create the container.',
|
||||
));
|
||||
|
||||
if (s.transients.length) {
|
||||
const ul = el('ul');
|
||||
for (const t of s.transients) {
|
||||
ul.append(el('li', {},
|
||||
el('span', { class: 'glyph spinner' }, '◐'), ' ',
|
||||
el('span', { class: 'agent' }, t.name), ' ',
|
||||
el('span', { class: 'role role-pending' }, t.kind + '…'), ' ',
|
||||
el('span', { class: 'meta' }, `nixos-container create + start (${t.secs}s)`),
|
||||
));
|
||||
}
|
||||
root.append(ul);
|
||||
}
|
||||
|
||||
if (!s.containers.length && !s.transients.length) {
|
||||
root.append(el('p', { class: 'empty' }, '▓ no managed containers ▓'));
|
||||
return;
|
||||
}
|
||||
|
||||
const ul = el('ul');
|
||||
for (const c of s.containers) {
|
||||
const url = `http://${s.hostname}:${c.port}/`;
|
||||
const li = el('li');
|
||||
li.append(
|
||||
el('span', { class: 'glyph' }, c.is_manager ? '▓█▓▒░' : '▒░▒░░'),
|
||||
' ',
|
||||
el('a', { href: url }, c.name),
|
||||
' ',
|
||||
el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' },
|
||||
c.is_manager ? 'm1nd' : 'ag3nt'),
|
||||
);
|
||||
if (c.needs_login) {
|
||||
li.append(' ', el('a',
|
||||
{ class: 'role role-pending', href: url }, 'needs login →'));
|
||||
}
|
||||
if (c.needs_update) {
|
||||
li.append(' ', form(
|
||||
'/rebuild/' + c.name, 'role role-pending btn-inline', 'needs update ↻',
|
||||
'rebuild ' + c.name + '? hot-reloads the container.',
|
||||
));
|
||||
}
|
||||
li.append(' ', el('span', { class: 'meta' }, `${c.container} :${c.port}`));
|
||||
|
||||
if (c.running) {
|
||||
li.append(
|
||||
' ',
|
||||
form('/restart/' + c.name, 'btn-restart', '↺ R3ST4RT', 'restart ' + c.name + '?'),
|
||||
);
|
||||
if (!c.is_manager) {
|
||||
li.append(
|
||||
' ',
|
||||
form('/kill/' + c.name, 'btn-stop', '■ ST0P', 'stop ' + c.name + '?'),
|
||||
);
|
||||
}
|
||||
}
|
||||
li.append(
|
||||
' ',
|
||||
form('/rebuild/' + c.name, 'btn-rebuild', '↻ R3BU1LD',
|
||||
'rebuild ' + c.name + '? hot-reloads the container.'),
|
||||
);
|
||||
if (!c.is_manager) {
|
||||
li.append(
|
||||
' ',
|
||||
form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y',
|
||||
'destroy ' + c.name + '? container is removed; state + creds kept.'),
|
||||
);
|
||||
}
|
||||
ul.append(li);
|
||||
}
|
||||
root.append(ul);
|
||||
}
|
||||
|
||||
function renderApprovals(s) {
|
||||
const root = $('approvals-section');
|
||||
root.innerHTML = '';
|
||||
if (!s.approvals.length) {
|
||||
root.append(el('p', { class: 'empty' }, '▓ queue empty ▓'));
|
||||
return;
|
||||
}
|
||||
const ul = el('ul', { class: 'approvals' });
|
||||
for (const a of s.approvals) {
|
||||
const li = el('li');
|
||||
const row = el('div', { class: 'row' });
|
||||
if (a.kind === 'apply_commit') {
|
||||
row.append(
|
||||
el('span', { class: 'glyph' }, '→'), ' ',
|
||||
el('span', { class: 'id' }, '#' + a.id), ' ',
|
||||
el('span', { class: 'agent' }, a.agent), ' ',
|
||||
el('span', { class: 'kind' }, 'apply'), ' ',
|
||||
el('code', {}, a.sha_short || ''),
|
||||
);
|
||||
} else {
|
||||
row.append(
|
||||
el('span', { class: 'glyph' }, '⊕'), ' ',
|
||||
el('span', { class: 'id' }, '#' + a.id), ' ',
|
||||
el('span', { class: 'agent' }, a.agent), ' ',
|
||||
el('span', { class: 'kind kind-spawn' }, 'spawn'), ' ',
|
||||
el('span', { class: 'meta' },
|
||||
'new sub-agent — container will be created on approve'),
|
||||
);
|
||||
}
|
||||
row.append(
|
||||
' ',
|
||||
form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE'),
|
||||
' ',
|
||||
form('/deny/' + a.id, 'btn-deny', 'DENY'),
|
||||
);
|
||||
li.append(row);
|
||||
if (a.diff_html) {
|
||||
const details = el('details');
|
||||
details.append(el('summary', {}, 'diff vs applied'));
|
||||
// diff_html is pre-rendered server-side (per-line class spans inside
|
||||
// a <pre>); inject as innerHTML.
|
||||
const pre = el('pre', { class: 'diff', html: a.diff_html });
|
||||
details.append(pre);
|
||||
li.append(details);
|
||||
}
|
||||
ul.append(li);
|
||||
}
|
||||
root.append(ul);
|
||||
}
|
||||
|
||||
// ─── state polling ──────────────────────────────────────────────────────
|
||||
let pollTimer = null;
|
||||
async function refreshState() {
|
||||
try {
|
||||
const resp = await fetch('/api/state');
|
||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||
const s = await resp.json();
|
||||
renderContainers(s);
|
||||
renderApprovals(s);
|
||||
// Auto-refresh while a spawn is in flight; otherwise back off.
|
||||
const next = s.transients.length ? 2000 : 0;
|
||||
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
||||
if (next) pollTimer = setTimeout(refreshState, next);
|
||||
} catch (err) {
|
||||
console.error('refreshState failed', err);
|
||||
pollTimer = setTimeout(refreshState, 5000);
|
||||
}
|
||||
}
|
||||
refreshState();
|
||||
|
||||
// ─── message flow SSE ───────────────────────────────────────────────────
|
||||
(() => {
|
||||
const flow = $('msgflow');
|
||||
if (!flow) return;
|
||||
flow.innerHTML = '';
|
||||
const es = new EventSource('/messages/stream');
|
||||
const MAX_ROWS = 200;
|
||||
const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
|
||||
es.onmessage = (e) => {
|
||||
let m;
|
||||
try { m = JSON.parse(e.data); } catch { return; }
|
||||
const row = document.createElement('div');
|
||||
row.className = 'msgrow ' + m.kind;
|
||||
const kind = m.kind === 'sent' ? '→' : '✓';
|
||||
row.innerHTML =
|
||||
'<span class="msg-ts">' + tsFmt(m.at) + '</span>' +
|
||||
'<span class="msg-arrow">' + kind + '</span>' +
|
||||
'<span class="msg-from">' + esc(m.from) + '</span>' +
|
||||
'<span class="msg-sep">→</span>' +
|
||||
'<span class="msg-to">' + esc(m.to) + '</span>' +
|
||||
'<span class="msg-body">' + esc(m.body) + '</span>';
|
||||
flow.insertBefore(row, flow.firstChild);
|
||||
while (flow.childNodes.length > MAX_ROWS) flow.removeChild(flow.lastChild);
|
||||
};
|
||||
es.onerror = () => {
|
||||
flow.insertBefore(Object.assign(document.createElement('div'), {
|
||||
className: 'msgrow meta', textContent: '[connection lost — retrying]',
|
||||
}), flow.firstChild);
|
||||
};
|
||||
})();
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue