feat(master): Ledger UI for Services and Agents (#18)
Restyles the master UI after the Ledger design: a sidebar with live counts, a machine filter, a Live box and a light/dark toggle; the overview groups services per Swarm cluster and machine with icons, search and the Icon / Icon + text / Text switch; the agents page gets a revoke popover and an add-agent panel with a connect step. Jobs, Job and Service pages get the new shell and tokens only. - fonts and Simple Icons are served by the master (no CDN) - the overview reads agents too, to show offline banners and cluster managers - revoking an agent also publishes the agents event - the SSE keepalive is a named event (ping) the Live box counts - the master passes its build version to the UI so agents on another version are marked old Claude-Session: https://claude.ai/code/session_01GAZsp3P7Qto6JnhipxWUJQ
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
// Behaviour the server-rendered pages need in the browser: theme, display
|
||||
// mode, search and machine filter, the Live box, local times, copy buttons and
|
||||
// the revoke popovers. The pages work without it; htmx and the SSE extension
|
||||
// do the live updates. Everything stored in the browser is wrapped in
|
||||
// try/catch because storage can be blocked.
|
||||
(function () {
|
||||
'use strict';
|
||||
var root = document.documentElement;
|
||||
|
||||
function load(area, key) {
|
||||
try { return window[area].getItem(key); } catch (e) { return null; }
|
||||
}
|
||||
function save(area, key, value) {
|
||||
try { window[area].setItem(key, value); } catch (e) { /* storage is blocked */ }
|
||||
}
|
||||
function each(selector, fn) {
|
||||
Array.prototype.forEach.call(document.querySelectorAll(selector), fn);
|
||||
}
|
||||
|
||||
// Theme: the head script set the initial class; the toggle overrides it.
|
||||
var colorScheme = window.matchMedia ? window.matchMedia('(prefers-color-scheme: light)') : null;
|
||||
var themeButton = document.getElementById('theme-toggle');
|
||||
if (themeButton) {
|
||||
themeButton.addEventListener('click', function () {
|
||||
var light = !root.classList.contains('t-light');
|
||||
root.classList.toggle('t-light', light);
|
||||
save('localStorage', 'pu-theme', light ? 'light' : 'dark');
|
||||
});
|
||||
}
|
||||
if (colorScheme && colorScheme.addEventListener) {
|
||||
colorScheme.addEventListener('change', function (e) {
|
||||
if (!load('localStorage', 'pu-theme')) root.classList.toggle('t-light', e.matches);
|
||||
});
|
||||
}
|
||||
|
||||
// Display mode: icon, icon + text, or text.
|
||||
function setDisplay(mode) {
|
||||
['icon', 'both', 'text'].forEach(function (m) { root.classList.toggle('d-' + m, m === mode); });
|
||||
each('[data-display]', function (b) { b.setAttribute('aria-pressed', String(b.dataset.display === mode)); });
|
||||
}
|
||||
setDisplay(root.classList.contains('d-icon') ? 'icon' : root.classList.contains('d-text') ? 'text' : 'both');
|
||||
|
||||
// Search and machine filter. The rows are all in the page; this hides them.
|
||||
var filter = { q: '', machine: '' };
|
||||
try {
|
||||
var saved = JSON.parse(load('sessionStorage', 'pu-filter') || '{}');
|
||||
if (typeof saved.q === 'string') filter.q = saved.q;
|
||||
if (typeof saved.machine === 'string') filter.machine = saved.machine;
|
||||
} catch (e) { /* ignore a broken saved filter */ }
|
||||
var search = document.getElementById('q');
|
||||
if (search) search.value = filter.q;
|
||||
|
||||
function applyFilter() {
|
||||
var box = document.getElementById('services');
|
||||
if (!box) return;
|
||||
var groups = box.querySelectorAll('.grp');
|
||||
var known = Array.prototype.some.call(groups, function (g) { return g.dataset.key === filter.machine; });
|
||||
if (filter.machine && !known) filter.machine = '';
|
||||
var q = filter.q.trim().toLowerCase();
|
||||
var any = false;
|
||||
Array.prototype.forEach.call(groups, function (g) {
|
||||
var inMachine = !filter.machine || g.dataset.key === filter.machine;
|
||||
var shown = 0;
|
||||
Array.prototype.forEach.call(g.querySelectorAll('.row'), function (row) {
|
||||
var match = inMachine && (!q || (row.dataset.search || '').indexOf(q) !== -1);
|
||||
row.hidden = !match;
|
||||
if (match) shown++;
|
||||
});
|
||||
g.hidden = shown === 0;
|
||||
if (shown > 0) any = true;
|
||||
});
|
||||
var none = document.getElementById('nomatch');
|
||||
if (none) none.hidden = any || groups.length === 0;
|
||||
each('#machines a', function (a) { a.classList.toggle('cur', a.dataset.machine === filter.machine); });
|
||||
}
|
||||
function saveFilter() { save('sessionStorage', 'pu-filter', JSON.stringify(filter)); }
|
||||
|
||||
if (search) {
|
||||
search.addEventListener('input', function () { filter.q = search.value; saveFilter(); applyFilter(); });
|
||||
}
|
||||
|
||||
// Live box: counts any event on the stream, including the server's ping.
|
||||
var live = document.getElementById('live');
|
||||
var liveAge = document.getElementById('live-age');
|
||||
var lastEvent = null;
|
||||
var down = false;
|
||||
var wired = typeof WeakSet === 'function' ? new WeakSet() : null;
|
||||
function touch() { lastEvent = Date.now(); }
|
||||
document.addEventListener('htmx:sseOpen', function (e) {
|
||||
var source = e.detail && e.detail.source;
|
||||
touch();
|
||||
down = false;
|
||||
if (source && wired && !wired.has(source)) {
|
||||
wired.add(source);
|
||||
['ping', 'services', 'agents', 'jobs'].forEach(function (name) { source.addEventListener(name, touch); });
|
||||
}
|
||||
});
|
||||
document.addEventListener('htmx:sseError', function () { down = true; });
|
||||
function renderLive() {
|
||||
if (!live || !liveAge) return;
|
||||
live.classList.toggle('down', down);
|
||||
if (down) { liveAge.textContent = 'Reconnecting…'; return; }
|
||||
if (lastEvent === null) { liveAge.textContent = 'connecting…'; return; }
|
||||
var s = Math.max(0, Math.round((Date.now() - lastEvent) / 1000));
|
||||
liveAge.textContent = 'last event ' + (s < 60 ? s + ' s ago' : Math.floor(s / 60) + ' min ago');
|
||||
}
|
||||
|
||||
// Times: the server writes <time datetime="..."> with its own clock as
|
||||
// fallback text; show them in the browser's time zone.
|
||||
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
function pad(n) { return (n < 10 ? '0' : '') + n; }
|
||||
function localiseTimes() {
|
||||
each('time[datetime]', function (t) {
|
||||
var d = new Date(t.getAttribute('datetime'));
|
||||
if (isNaN(d.getTime())) return;
|
||||
var text;
|
||||
if (t.hasAttribute('data-rel')) {
|
||||
var min = Math.floor((Date.now() - d.getTime()) / 60000);
|
||||
text = min < 1 ? 'just now' : min < 60 ? min + ' min ago' : min < 1440 ? Math.floor(min / 60) + ' h ago' : Math.floor(min / 1440) + ' d ago';
|
||||
t.title = d.toLocaleString();
|
||||
} else {
|
||||
var clock = pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
text = d.toDateString() === new Date().toDateString() ? clock : months[d.getMonth()] + ' ' + d.getDate() + ' ' + clock;
|
||||
}
|
||||
if (t.textContent !== text) t.textContent = text;
|
||||
});
|
||||
}
|
||||
|
||||
// Copy buttons: data-copy holds the text, data-copy-target the id of the element to copy.
|
||||
function flash(button, text) {
|
||||
if (!button.dataset.label) button.dataset.label = button.textContent;
|
||||
button.textContent = text;
|
||||
setTimeout(function () { button.textContent = button.dataset.label; }, 2000);
|
||||
}
|
||||
function copyFallback(button, text) {
|
||||
var target = button.dataset.copyTarget ? document.getElementById(button.dataset.copyTarget) : null;
|
||||
if (target) {
|
||||
var range = document.createRange();
|
||||
range.selectNodeContents(target);
|
||||
var selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
flash(button, 'Press Ctrl+C');
|
||||
return;
|
||||
}
|
||||
var area = document.createElement('textarea');
|
||||
area.value = text;
|
||||
area.setAttribute('readonly', '');
|
||||
area.style.position = 'fixed';
|
||||
area.style.opacity = '0';
|
||||
document.body.appendChild(area);
|
||||
area.select();
|
||||
var ok = false;
|
||||
try { ok = document.execCommand('copy'); } catch (e) { /* not allowed */ }
|
||||
document.body.removeChild(area);
|
||||
flash(button, ok ? 'Copied' : 'Copy failed');
|
||||
}
|
||||
|
||||
// Revoke popovers are <details>. A swap of the agent list would close them, so reopen those that were open.
|
||||
var openRevokes = [];
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var display = e.target.closest('[data-display]');
|
||||
if (display) {
|
||||
setDisplay(display.dataset.display);
|
||||
save('localStorage', 'pu-display', display.dataset.display);
|
||||
return;
|
||||
}
|
||||
var machine = e.target.closest('#machines a');
|
||||
if (machine) {
|
||||
e.preventDefault();
|
||||
filter.machine = filter.machine === machine.dataset.machine ? '' : machine.dataset.machine;
|
||||
saveFilter();
|
||||
applyFilter();
|
||||
return;
|
||||
}
|
||||
var close = e.target.closest('[data-close]');
|
||||
if (close) {
|
||||
var details = close.closest('details');
|
||||
if (details) details.open = false;
|
||||
return;
|
||||
}
|
||||
var copy = e.target.closest('[data-copy], [data-copy-target]');
|
||||
if (copy) {
|
||||
var text = copy.dataset.copy;
|
||||
if (text === undefined) {
|
||||
var el = document.getElementById(copy.dataset.copyTarget);
|
||||
text = el ? el.textContent : '';
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(function () { flash(copy, 'Copied'); }, function () { copyFallback(copy, text); });
|
||||
} else {
|
||||
copyFallback(copy, text);
|
||||
}
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') each('details.rv[open]', function (d) { d.open = false; });
|
||||
});
|
||||
document.addEventListener('htmx:beforeSwap', function () {
|
||||
openRevokes = [];
|
||||
each('details.rv[open]', function (d) { openRevokes.push(d.dataset.id); });
|
||||
});
|
||||
document.addEventListener('htmx:afterSwap', function () {
|
||||
each('details.rv', function (d) { if (openRevokes.indexOf(d.dataset.id) !== -1) d.open = true; });
|
||||
});
|
||||
document.addEventListener('htmx:afterSettle', function () { applyFilter(); localiseTimes(); });
|
||||
|
||||
applyFilter();
|
||||
localiseTimes();
|
||||
renderLive();
|
||||
setInterval(renderLive, 1000);
|
||||
setInterval(localiseTimes, 15000);
|
||||
})();
|
||||
Reference in New Issue
Block a user