// 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