- icons/gen writes to the current directory so `go generate ./internal/master/web/icons` works (output verified byte-identical) - share the registry-host check as imageref.IsRegistryHost (with test) - agents fragment refreshes the "N machines" header count out of band - ignore the saved machine filter at <=760px where the machine list is hidden - aria-current on the active machine filter link, aria-pressed on theme toggle - CLAUDE.md: icons package, SSE ping event, swarm_cluster_id Claude-Session: https://claude.ai/code/session_01GAZsp3P7Qto6JnhipxWUJQ
227 lines
9.5 KiB
JavaScript
227 lines
9.5 KiB
JavaScript
// 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);
|
|
themeButton.setAttribute('aria-pressed', String(light));
|
|
save('localStorage', 'pu-theme', light ? 'light' : 'dark');
|
|
});
|
|
themeButton.setAttribute('aria-pressed', String(root.classList.contains('t-light')));
|
|
}
|
|
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;
|
|
|
|
// The machine list is hidden on narrow screens; a filter that cannot be
|
|
// seen or cleared there is ignored. Search still applies.
|
|
var narrow = window.matchMedia ? window.matchMedia('(max-width:760px)') : null;
|
|
|
|
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 machine = narrow && narrow.matches ? '' : filter.machine;
|
|
var q = filter.q.trim().toLowerCase();
|
|
var any = false;
|
|
Array.prototype.forEach.call(groups, function (g) {
|
|
var inMachine = !machine || g.dataset.key === 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) {
|
|
var cur = a.dataset.machine === filter.machine;
|
|
a.classList.toggle('cur', cur);
|
|
if (cur) a.setAttribute('aria-current', 'true'); else a.removeAttribute('aria-current');
|
|
});
|
|
}
|
|
if (narrow && narrow.addEventListener) narrow.addEventListener('change', function () { applyFilter(); });
|
|
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);
|
|
})();
|