diff --git a/internal/collector/sysinfo_test.go b/internal/collector/sysinfo_test.go index 42c965a..ed2b6c0 100644 --- a/internal/collector/sysinfo_test.go +++ b/internal/collector/sysinfo_test.go @@ -100,7 +100,7 @@ func TestCPUModel_MissingFile(t *testing.T) { func TestKernelRelease_NonEmpty(t *testing.T) { // This exercises the real syscall.Uname on the test host; it should // always succeed on Linux and return a non-empty release string. - if got := kernelRelease(); got == "" { + if kernelRelease() == "" { t.Fatal("expected non-empty kernel release on Linux") } } diff --git a/internal/httpapi/middleware_test.go b/internal/httpapi/middleware_test.go index 7997951..4bfaf29 100644 --- a/internal/httpapi/middleware_test.go +++ b/internal/httpapi/middleware_test.go @@ -44,7 +44,7 @@ func TestSecurityHeaders_SetOnUnauthorizedResponses(t *testing.T) { if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { t.Errorf("X-Content-Type-Options = %q, want %q on 401 response", got, "nosniff") } - if got := rec.Header().Get("Content-Security-Policy"); got == "" { + if rec.Header().Get("Content-Security-Policy") == "" { t.Error("Content-Security-Policy missing on 401 response") } } diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js index 9ce5217..e0b686e 100644 --- a/internal/web/assets/app.js +++ b/internal/web/assets/app.js @@ -31,6 +31,7 @@ try { return localStorage.getItem(API_KEY_STORAGE) || ''; } catch (e) { + // Private browsing or blocked storage: fall back to the in-memory key. return ''; } } @@ -54,6 +55,8 @@ const v = localStorage.getItem(THEME_KEY); return v === 'light' || v === 'dark' ? v : null; } catch (e) { + // Private browsing or blocked storage: behave as if nothing was + // stored, falling back to the OS prefers-color-scheme setting. return null; } } @@ -61,7 +64,7 @@ function effectiveTheme() { const stored = storedTheme(); if (stored) return stored; - return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches + return window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ? 'dark' : 'light'; } @@ -179,7 +182,7 @@ function renderVersion() { // A release is tagged "vX.Y.Z"; show it without the leading "v". // Unversioned local builds report "dev", which is displayed as-is. - const raw = (config && config.version) || 'dev'; + const raw = config?.version || 'dev'; setText('app-version', raw.replace(/^v(?=\d)/, '')); } @@ -199,13 +202,13 @@ setText('cpu-overall', snap.cpu.overall_percent.toFixed(1) + ' %'); document.getElementById('cpu-overall').className = 'metric-value ' + levelClass(snap.cpu.overall_percent, t.cpu_warn_percent, t.cpu_crit_percent); - if (snap.cpu.per_core_percent && snap.cpu.per_core_percent.length) { + if (snap.cpu.per_core_percent?.length) { setText('cpu-per-core', snap.cpu.per_core_percent.map((v, i) => 'C' + i + ': ' + v.toFixed(0) + '%').join(' ')); } lastCPUCount = snap.cpu_count || (snap.cpu.per_core_percent || []).length || 1; // CPU details: core count plus model name where the kernel exposes it. - const cpuModel = snap.system && snap.system.cpu_model; + const cpuModel = snap.system?.cpu_model; setText('cpu-info', lastCPUCount + (lastCPUCount === 1 ? ' core' : ' cores') + (cpuModel ? ' · ' + cpuModel : '')); // Load average gauges @@ -215,7 +218,7 @@ // Temperature const tempEl = document.getElementById('temp-value'); - if (snap.temperature && snap.temperature.celsius) { + if (snap.temperature?.celsius) { setText('temp-value', snap.temperature.celsius.toFixed(1) + ' °C'); tempEl.className = 'metric-value ' + levelClass(snap.temperature.celsius, t.temperature_warn_c, t.temperature_crit_c); } else { @@ -240,7 +243,7 @@ // Network const networkCard = document.getElementById('card-network'); - if (config.network_enabled && snap.network && snap.network.length) { + if (config.network_enabled && snap.network?.length) { networkCard.classList.remove('hidden'); renderList('network-list', snap.network, n => { const row = document.createElement('div'); @@ -281,7 +284,7 @@ showBtn.textContent = latestPackages.length === 1 ? 'Show 1 update' : 'Show all ' + latestPackages.length + ' updates'; // Keep the open modal's contents in sync with fresh data. - if (!document.getElementById('updates-modal').classList.contains('hidden')) { + if (document.getElementById('updates-modal').open) { renderUpdatesTable(); } } @@ -305,75 +308,38 @@ }); } - // Shared modal focus handling. The element focused before a modal opened is - // remembered so focus can return to it on close (e.g. back to the card that - // opened the detail view), and focus is moved into the dialog on open. + // Shared modal focus handling. A native shown via showModal() + // handles top-layer promotion, focus trapping, the ::backdrop, and + // Escape-to-dismiss on its own; we only need to remember and restore the + // triggering element's focus, and route each dialog's side effects (e.g. + // clearing the open detail metric) through its native "close" event so + // they run no matter how it was dismissed (button, Escape, or backdrop + // click). let modalReturnFocus = null; - function focusablesIn(el) { - return Array.from( - el.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])') - ).filter(node => node.offsetParent !== null); - } - - function openModal(backdrop, initialFocus) { + function openModal(dialog, initialFocus) { modalReturnFocus = document.activeElement; - backdrop.classList.remove('hidden'); - const target = initialFocus || backdrop.querySelector('.modal-close'); + dialog.showModal(); + const target = initialFocus || dialog.querySelector('.modal-close'); if (target) target.focus(); } - function closeModal(backdrop) { - backdrop.classList.add('hidden'); - if (modalReturnFocus && typeof modalReturnFocus.focus === 'function') { - modalReturnFocus.focus(); - } - modalReturnFocus = null; - } - - // The one visible modal backdrop, if any (only ever one is open at a time). - function visibleModal() { - return document.querySelector('.modal-backdrop:not(.hidden)'); - } - - // Route a dismiss request to the matching close function so its side effects - // (e.g. clearing the open detail metric) still run. The API key prompt is - // deliberately not dismissible: without a valid key every card stays empty, - // so closing it would just leave a broken-looking page. - function dismissModal(backdrop) { - if (backdrop.id === 'apikey-modal') return; - if (backdrop.id === 'detail-modal') closeDetailModal(); - else if (backdrop.id === 'updates-modal') closeUpdatesModal(); - else closeModal(backdrop); - } - - // Keep Tab focus inside the open dialog, as an aria-modal dialog should. - function trapTab(backdrop, e) { - const focusables = focusablesIn(backdrop); - if (!focusables.length) return; - const first = focusables[0]; - const last = focusables[focusables.length - 1]; - if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); - first.focus(); - } + // Restore focus once a dialog actually closes, regardless of how. + function wireModalFocusReturn(dialog) { + dialog.addEventListener('close', () => { + if (modalReturnFocus && typeof modalReturnFocus.focus === 'function') { + modalReturnFocus.focus(); + } + modalReturnFocus = null; + }); } - // A single document-level handler serves whichever modal is open, rather - // than one Escape listener per modal. - function wireModalKeys() { - document.addEventListener('keydown', e => { - const modal = visibleModal(); - if (!modal) return; - if (e.key === 'Escape') { - e.preventDefault(); - dismissModal(modal); - } else if (e.key === 'Tab') { - trapTab(modal, e); - } + // Close when a click lands on the dialog element itself rather than its + // content, i.e. the ::backdrop area — a native has no separate + // backdrop element to attach a listener to. + function wireBackdropDismiss(dialog) { + dialog.addEventListener('click', e => { + if (e.target === dialog) dialog.close(); }); } @@ -382,16 +348,12 @@ openModal(document.getElementById('updates-modal')); } - function closeUpdatesModal() { - closeModal(document.getElementById('updates-modal')); - } - // API key prompt: shown when the server answers 401 (an api_key is // configured). The entered key is validated against GET /api/v1/config // before being persisted, then all data is reloaded with it. function openAPIKeyModal() { const modal = document.getElementById('apikey-modal'); - if (!modal.classList.contains('hidden')) return; + if (modal.open) return; setText('header-subtitle', 'API key required'); document.getElementById('apikey-error').classList.add('hidden'); openModal(modal, document.getElementById('apikey-input')); @@ -415,21 +377,26 @@ sessionAPIKey = key; input.value = ''; errEl.classList.add('hidden'); - closeModal(document.getElementById('apikey-modal')); + document.getElementById('apikey-modal').close(); await reloadAll(); } function wireAPIKeyModal() { + const dialog = document.getElementById('apikey-modal'); document.getElementById('apikey-form').addEventListener('submit', submitAPIKey); + // Deliberately not dismissible: without a valid key every card stays + // empty, so allowing Escape to close it would just leave a + // broken-looking page. + dialog.addEventListener('cancel', e => e.preventDefault()); + wireModalFocusReturn(dialog); } function wireUpdatesModal() { + const dialog = document.getElementById('updates-modal'); document.getElementById('updates-show').addEventListener('click', openUpdatesModal); - document.getElementById('updates-modal-close').addEventListener('click', closeUpdatesModal); - document.getElementById('updates-modal').addEventListener('click', e => { - // Close when clicking the backdrop, but not the dialog itself. - if (e.target === e.currentTarget) closeUpdatesModal(); - }); + document.getElementById('updates-modal-close').addEventListener('click', () => dialog.close()); + wireBackdropDismiss(dialog); + wireModalFocusReturn(dialog); } // Metric detail view: clicking a card opens a modal with a larger chart of @@ -476,8 +443,8 @@ // Keep only the points within the last `minutes`, measured back from the // most recent sample's timestamp (the Pi clock), not the browser's clock. function pointsWithinRange(points, minutes) { - if (!points || !points.length) return []; - const latest = new Date(points[points.length - 1].t).getTime(); + if (!points?.length) return []; + const latest = new Date(points.at(-1).t).getTime(); const cutoff = latest - minutes * 60000; return points.filter(p => new Date(p.t).getTime() >= cutoff); } @@ -509,7 +476,7 @@ return; } const vals = points.map(p => p.v); - const cur = vals[vals.length - 1]; + const cur = vals.at(-1); const min = Math.min(...vals); const max = Math.max(...vals); const avg = vals.reduce((a, b) => a + b, 0) / vals.length; @@ -529,25 +496,17 @@ renderDetailChart(); } - function closeDetailModal() { - openDetailMetric = null; - closeModal(document.getElementById('detail-modal')); - } - function wireDetailModal() { + const dialog = document.getElementById('detail-modal'); document.querySelectorAll('[data-metric]').forEach(card => { card.addEventListener('click', () => openDetailModal(card.dataset.metric)); - card.addEventListener('keydown', e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - openDetailModal(card.dataset.metric); - } - }); - }); - document.getElementById('detail-modal-close').addEventListener('click', closeDetailModal); - document.getElementById('detail-modal').addEventListener('click', e => { - if (e.target === e.currentTarget) closeDetailModal(); }); + document.getElementById('detail-modal-close').addEventListener('click', () => dialog.close()); + wireBackdropDismiss(dialog); + wireModalFocusReturn(dialog); + // Clear the open metric whenever the dialog closes, however it was + // dismissed, so a stale metric doesn't linger for the next open. + dialog.addEventListener('close', () => { openDetailMetric = null; }); document.querySelectorAll('#detail-ranges .range-button').forEach(b => { b.addEventListener('click', () => { detailRangeMinutes = Number(b.dataset.minutes); @@ -671,7 +630,6 @@ async function main() { wireThemeToggle(); - wireModalKeys(); wireUpdatesModal(); wireDetailModal(); wireAPIKeyModal(); diff --git a/internal/web/assets/gauge.js b/internal/web/assets/gauge.js index da301b3..50ba3ff 100644 --- a/internal/web/assets/gauge.js +++ b/internal/web/assets/gauge.js @@ -7,6 +7,12 @@ // right, PI/2 = down, PI = left, 3*PI/2 = up). The visible gauge arc runs // over the top of the circle, i.e. clockwise from PI (left) to 2*PI // (right) passing through 3*PI/2 (up) - so `anticlockwise` must be false. +function gaugeColorVar(colorClass) { + if (colorClass === 'metric-crit') return '--crit'; + if (colorClass === 'metric-warn') return '--warn'; + return '--ok'; +} + function drawGauge(canvas, value, max, colorClass) { const dpr = window.devicePixelRatio || 1; const cssWidth = canvas.clientWidth || 80; @@ -35,8 +41,7 @@ function drawGauge(canvas, value, max, colorClass) { const endAngle = 2 * Math.PI; // right (3 o'clock), via the top const trackColor = getComputedStyle(document.documentElement).getPropertyValue('--gauge-track').trim() || 'rgba(0, 0, 0, 0.14)'; - const colorVar = colorClass === 'metric-crit' ? '--crit' : colorClass === 'metric-warn' ? '--warn' : '--ok'; - const fillColor = getComputedStyle(document.documentElement).getPropertyValue(colorVar).trim(); + const fillColor = getComputedStyle(document.documentElement).getPropertyValue(gaugeColorVar(colorClass)).trim(); // Background track (full top semicircle). ctx.beginPath(); diff --git a/internal/web/assets/index.html b/internal/web/assets/index.html index 44b8b2d..f1eda01 100644 --- a/internal/web/assets/index.html +++ b/internal/web/assets/index.html @@ -45,7 +45,7 @@

⏱️ Uptime

-

CPU Usage

+

@@ -53,7 +53,7 @@

-

🚦 Load Average

+

@@ -74,7 +74,7 @@

-

🌡️ Temperature

+

@@ -84,7 +84,7 @@

-

🧠 Memory & Swap

+

RAM @@ -115,60 +115,55 @@

🌐 Network

-

-
-