From 369562f49fe20fc0ec4b860fd4c4873765f425dc Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 16 Oct 2026 13:50:30 +0200 Subject: [PATCH] feat(regions): add activity health and hourly traffic trends --- docs/regions-observed.md | 62 +++++++++- public/regions-history.js | 51 ++++++++- public/regions-map.js | 230 +++++++++++++++++++++++++++++++++----- public/style.css | 28 +++++ test-regions-map-e2e.js | 180 +++++++++++++++++++++++++++++ test-regions-map.js | 132 +++++++++++++++++++++- 6 files changed, 648 insertions(+), 35 deletions(-) diff --git a/docs/regions-observed.md b/docs/regions-observed.md index 23f71725d..baec190ad 100644 --- a/docs/regions-observed.md +++ b/docs/regions-observed.md @@ -82,12 +82,58 @@ and scopes. Every truncation is exposed. Its retained-history floor and loading state are captured with the relay evidence, rather than taken from a newer store state when a request arrives. +## Region activity health and traffic trends + +**Health & trends** is a separate view, leaving Latest's rolling-window map +and Timeline's playback unchanged. It uses the same bounded history snapshot +as Timeline, completed UTC hours, interval selection and scope filter. The +view, selected hour, period, scope and comparison checkbox are shareable in +the URL, for example `#/regions?view=health&window=24h&scope=%23example`. +Switching between Health and Timeline preserves the selected hour. Health +does not play automatically and does not render the map. + +The summary shows distinct relay nodes observed in the selected interval, +relay appearances and the most recent observed relay timestamp **within that +scope and interval**. Nodes without usable positions are included. A node +seen in several hours or scopes remains one node in the interval total; +hourly node counts must not be added to obtain that total. A missing last +relay timestamp means not observed in the interval, not an outage. + +The traffic chart uses disjoint one-hour bins: one, 24 or 168 bins for the +1h, 24h and 7d periods. These are UTC hours, not local days or extrapolated +daily totals. The exact-values table provides the same timestamps and counts +as the chart. A zero is shown only for a fully retained, uncapped hour. +Partial, unknown, loading or capped evidence produces a gap, not a fabricated +zero; retained observations can still be disclosed explicitly as incomplete +evidence. The mean appearances/hour is available only when the entire +selected interval is complete and uncapped. Partial counts are never divided +by the full requested duration. + +The optional comparison reuses Timeline's immediately preceding equal +interval and its completeness checks. A complete selected period may have +an hourly mean even when the reference is incomplete, but changes, percentages +and new/not-observed classifications still require **both** periods to be +complete and uncapped. A zero reference has no defined percentage increase. +The expected set for "not observed" is the preceding interval, not all nodes +ever present in the catalogue. + +Health describes observed activity and evidence availability, not service +availability or radio quality. No healthy/degraded/offline score or alarm +threshold is inferred. More traffic can reflect spam, changed routing or +observer reception; it does not establish improved network health. The +snapshot timestamp and retained-history limitations remain visible. + +The selected scope's hourly activity is indexed once per payload or scope +change into bounded arrays spanning the existing 504-hour horizon. Selecting +an interval reads at most 168 hourly bins and reuses the existing interval +comparison; it does not rescan raw packets, fetch each node or request a new +payload while scrubbing. No new endpoint, persistence, retention setting, +dependency or operator configuration is introduced. Any future health +thresholds must be evidence-based and exposed through the customizer. + ## Following milestones -1. **Region health:** add active relay counts, last relay and traffic trends - over comparable periods. Quiet relays should be labelled as not observed in - the period; lack of reception alone does not establish that a node is offline. -2. **Connections between regions:** add observed paths with direction and +1. **Connections between regions:** add observed paths with direction and distinct-transmission counts. Preserve scope transitions, distinguish ambiguous hop identities and avoid interpreting shared scope membership as proof of a direct radio link. Route changes belong to this path-evidence @@ -113,3 +159,11 @@ stable map bounds, keyboard operation, no per-frame requests, and playback cleanup on manual changes, hidden documents and navigation. Performance checks cover background snapshot construction, bounded retained history and warm bulk responses without acquiring the packet-store lock. + +Health tests additionally cover cross-scope and cross-hour node deduplication, +scoped interval-specific last relay timestamps, complete-zero versus missing +bins, all coverage/truncation states, hourly means, comparison parity, fixed +UTC boundaries and no sparse-cell rescans after activity indexing. Browser +checks include Health deep links, local filter/time changes, Health/Timeline +transitions, missing-data charts, exact values, themes, teardown and narrow +mobile/desktop layouts. diff --git a/public/regions-history.js b/public/regions-history.js index ab5509102..4e09845bc 100644 --- a/public/regions-history.js +++ b/public/regions-history.js @@ -134,7 +134,56 @@ percentChange: comparable && previousAppearances > 0 ? (currentAppearances - previousAppearances) / previousAppearances * 100 : null }; } - const api = { indexHistory, frameIndex, frameTime, interval, selectFrame }; + // Build one selected-scope activity index per payload/scope change. Dense + // arrays are bounded to the 504-hour snapshot, never one array per scope. + // A node can carry several scopes in an hour but remains one observed relay. + function indexActivity(history, scope) { + const appearances = new Float64Array(MAX_BUCKETS), nodes = new Uint16Array(MAX_BUCKETS); + const times = new Array(MAX_BUCKETS).fill(''), seen = new Uint16Array(MAX_BUCKETS); + let generation = 0; + for (const node of history.nodes) { + generation++; + for (const entry of node.scopes) { + if (scope && entry.name !== scope) continue; + for (let i = 0; i < entry.indices.length; i++) { + const index = entry.indices[i]; + appearances[index] += entry.prefix[i + 1] - entry.prefix[i]; + if (seen[index] !== generation) { nodes[index]++; seen[index] = generation; } + if (entry.times[i] > times[index]) times[index] = entry.times[i]; + } + } + } + return { history, scope: scope || '', appearances, nodes, times }; + } + + // Playback reads only bounded hourly arrays. Partial/unknown history has + // observed evidence, but no chart value or whole-window activity rate. + function selectActivity(activity, history, frame) { + if (!activity || activity.history !== history) throw new Error('Activity index does not match history'); + const start = millis(frame.interval.start), end = millis(frame.interval.end); + const hours = (end - start) / HOUR; + if (![1, 24, 168].includes(hours) || start % HOUR || end % HOUR || start < history.start || end > history.end) { + throw new Error('Invalid activity interval'); + } + const first = (start - history.start) / HOUR; + const bins = []; + let lastRelayed = '', total = 0; + for (let i = 0; i < hours; i++) { + const index = first + i, binStart = start + i * HOUR; + const coverage = coverageState(history, binStart, binStart + HOUR); + const observedAppearances = activity.appearances[index], observedNodes = activity.nodes[index]; + const complete = coverage === 'complete' && !history.limited; + bins.push({ start: new Date(binStart).toISOString(), end: new Date(binStart + HOUR).toISOString(), coverage, + observedAppearances, observedNodes, appearances: complete ? observedAppearances : null, + nodes: complete ? observedNodes : null }); + total += observedAppearances; + if (activity.times[index] > lastRelayed) lastRelayed = activity.times[index]; + } + return { bins, lastRelayed, + ratePerHour: frame.currentCoverage === 'complete' && !history.limited ? total / hours : null }; + } + + const api = { indexHistory, frameIndex, frameTime, interval, selectFrame, indexActivity, selectActivity }; if (typeof module !== 'undefined' && module.exports) module.exports = api; root.RegionsHistory = api; })(typeof window === 'undefined' ? globalThis : window); diff --git a/public/regions-map.js b/public/regions-map.js index d1cf3c3a9..3b25457c4 100644 --- a/public/regions-map.js +++ b/public/regions-map.js @@ -118,8 +118,8 @@ const scope = params.get('scope') || ''; const window = params.get('window'); const selection = { window: Object.prototype.hasOwnProperty.call(WINDOWS, window) ? window : '24h', scope: validScope(scope) ? scope : '' }; - if (params.get('view') === 'timeline') { - selection.view = 'timeline'; + if (params.get('view') === 'timeline' || params.get('view') === 'health') { + selection.view = params.get('view'); selection.at = isoTime(params.get('at')); selection.compare = params.get('compare') === '1'; } @@ -133,8 +133,8 @@ function scopeHash(scope, window = '24h', selection = {}) { const params = new URLSearchParams({ window: Object.prototype.hasOwnProperty.call(WINDOWS, window) ? window : '24h' }); if (validScope(scope)) params.set('scope', scope); - if (selection.view === 'timeline') { - params.set('view', 'timeline'); + if (selection.view === 'timeline' || selection.view === 'health') { + params.set('view', selection.view); const at = isoTime(selection.at); if (at) params.set('at', at); if (selection.compare) params.set('compare', '1'); @@ -216,6 +216,139 @@ } } + function createActivityPanel() { + const panel = element('section', 'regions-health'); + panel.hidden = true; + panel.setAttribute('aria-label', 'Region health and traffic trends'); + const cards = element('div', 'regions-health-cards'); + const metrics = {}; + for (const [key, label] of [['nodes', 'Observed relay nodes'], ['appearances', 'Observed relay appearances'], ['last-relayed', 'Last relay in selected interval']]) { + const card = element('div', 'regions-health-card'); + card.dataset.metric = key; + card.appendChild(element('span', 'regions-health-label', label)); + const value = element('strong', 'regions-health-value', '—'); + const detail = element('span', 'regions-health-detail'); + card.append(value, detail); cards.appendChild(card); + metrics[key] = { value, detail }; + } + metrics.appearances.detail.classList.add('regions-health-rate'); + panel.appendChild(cards); + panel.appendChild(element('h2', '', 'Hourly traffic')); + const chartStatus = element('p', 'regions-trend-status'); + panel.appendChild(chartStatus); + function svgElement(tag, className) { + const node = document.createElementNS('http://www.w3.org/2000/svg', tag); + if (className) node.setAttribute('class', className); + return node; + } + function attr(node, name, value) { + const text = String(value); + if (node.getAttribute(name) !== text) node.setAttribute(name, text); + } + const chart = svgElement('svg', 'regions-trend'); + chart.setAttribute('viewBox', '0 0 656 176'); + chart.setAttribute('preserveAspectRatio', 'none'); + chart.setAttribute('role', 'img'); + const baseline = svgElement('line', 'regions-trend-axis'); + baseline.setAttribute('x1', '0'); baseline.setAttribute('x2', '656'); + baseline.setAttribute('y1', '160'); baseline.setAttribute('y2', '160'); + const bars = svgElement('g'); + const scale = element('div', 'regions-trend-scale'); + const maximum = element('span'), zero = element('span', '', '0'); + scale.append(maximum, zero); + chart.append(baseline, bars); + const plot = element('div', 'regions-trend-plot'); + plot.append(scale, chart); panel.appendChild(plot); + const bounds = element('div', 'regions-trend-bounds'); + const startLabel = element('span'), endLabel = element('span'); + bounds.append(startLabel, endLabel); panel.appendChild(bounds); + panel.appendChild(element('p', 'regions-trend-legend', 'Solid bars: complete hours. Dashed gaps: incomplete or unavailable values. A complete zero hour has no observed relay traffic.')); + const details = element('details', 'regions-hourly-details'); + details.appendChild(element('summary', '', 'Hourly values')); + const tableScroll = element('div', 'regions-hourly-scroll'); + tableScroll.setAttribute('tabindex', '0'); + tableScroll.setAttribute('role', 'region'); + tableScroll.setAttribute('aria-label', 'Scrollable hourly activity values'); + const table = element('table', 'regions-hourly-table'); + table.appendChild(element('caption', '', 'Completed UTC hours; retained observations are shown separately when complete values are unavailable.')); + const head = element('thead'), header = element('tr'); + for (const name of ['UTC hour', 'Relay appearances', 'Relay nodes', 'History', 'Retained observations']) { + const cell = element('th', '', name); cell.scope = 'col'; header.appendChild(cell); + } + head.appendChild(header); + const body = element('tbody'); + table.append(head, body); tableScroll.appendChild(table); details.appendChild(tableScroll); panel.appendChild(details); + panel.appendChild(element('p', 'regions-health-note', 'Traffic increase does not establish better health. Spam, observer reception and routing changes can affect observed counts. Relay appearances can count the same transmission via several relays; this is not a global packet total.')); + const rows = new Map(); + + function update(frame, activity, scope) { + panel.hidden = false; + metrics.nodes.value.textContent = frame.currentCount.toLocaleString(); + const missing = frame.rows.filter(row => row.count && row.node.positionSource !== 'reported').length; + metrics.nodes.detail.textContent = missing + ' without usable positions'; + metrics.appearances.value.textContent = frame.currentAppearances.toLocaleString(); + metrics.appearances.detail.textContent = activity.ratePerHour == null ? 'Rate unavailable: incomplete or limited history.' : + activity.ratePerHour.toFixed(2) + ' relay appearances per hour'; + metrics['last-relayed'].value.replaceChildren(); + if (activity.lastRelayed) metrics['last-relayed'].value.appendChild(timeElement(activity.lastRelayed)); + else metrics['last-relayed'].value.textContent = 'Not observed'; + metrics['last-relayed'].detail.textContent = activity.lastRelayed ? 'Within the selected interval' : 'No relay in retained observations for this interval'; + const bins = activity.bins; + const available = bins.filter(bin => bin.appearances != null); + chartStatus.textContent = available.length + ' of ' + bins.length + ' complete hourly values' + (scope ? ' for ' + scope : ' across observed scopes') + + '. Missing values are gaps, not zero traffic.'; + chart.setAttribute('aria-label', 'Hourly relay appearances' + (scope ? ' for ' + scope : ' across observed scopes') + + ': ' + available.length + ' of ' + bins.length + ' complete hours, ' + frame.interval.start + ' to ' + frame.interval.end + '. Exact values follow.'); + const upper = Math.max(1, ...available.map(bin => bin.appearances)); + maximum.textContent = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(upper); + startLabel.textContent = frame.interval.start.slice(5, 16).replace('T', ' ') + ' UTC'; + endLabel.textContent = frame.interval.end.slice(5, 16).replace('T', ' ') + ' UTC'; + const wanted = new Set(bins.map(bin => bin.start)); + for (const [key, record] of rows) if (!wanted.has(key)) { + record.row.remove(); record.rect.remove(); rows.delete(key); + } + let cursor = body.firstChild; + for (let index = 0; index < bins.length; index++) { + const bin = bins[index]; + let record = rows.get(bin.start); + if (!record) { + const row = element('tr'); row.dataset.start = bin.start; + const cells = {}; + for (const key of ['hour', 'appearances', 'nodes', 'coverage', 'retained']) { + const cell = element('td'); cell.dataset.column = key; cells[key] = cell; row.appendChild(cell); + } + const rect = svgElement('rect'); + const title = svgElement('title'); rect.appendChild(title); bars.appendChild(rect); + record = { row, cells, rect, title }; rows.set(bin.start, record); + } + const complete = bin.appearances != null && bin.nodes != null; + const state = frame.limited ? 'limited' : bin.coverage; + record.cells.hour.textContent = bin.start.slice(0, 16).replace('T', ' ') + ' — ' + bin.end.slice(11, 16); + record.cells.appearances.textContent = complete ? bin.appearances.toLocaleString() : '—'; + record.cells.nodes.textContent = complete ? bin.nodes.toLocaleString() : '—'; + record.cells.coverage.textContent = state; + record.cells.retained.textContent = bin.observedAppearances.toLocaleString() + ' appearances; ' + bin.observedNodes + ' relay nodes'; + if (record.row !== cursor) body.insertBefore(record.row, cursor); + cursor = record.row.nextSibling; + attr(record.rect, 'class', complete ? 'regions-trend-bar' : 'regions-trend-gap'); + attr(record.rect, 'data-hour-start', bin.start); + if (complete) attr(record.rect, 'data-count', bin.appearances); else record.rect.removeAttribute('data-count'); + const width = 656 / Math.max(1, bins.length); + const height = complete ? (bin.appearances === 0 ? 0 : Math.max(2, bin.appearances / upper * 156)) : 156; + attr(record.rect, 'x', index * width + 1); + attr(record.rect, 'width', Math.max(1, width - 2)); + attr(record.rect, 'y', complete ? 160 - height : 4); + attr(record.rect, 'height', height); + record.title.textContent = record.cells.hour.textContent + ' UTC: ' + (complete ? bin.appearances + ' relay appearances, ' + bin.nodes + ' relay nodes.' : + 'Complete value unavailable (' + state + '); retained observations: ' + record.cells.retained.textContent + '.'); + } + panel.setAttribute('data-ready', 'true'); + } + + function clear() { panel.hidden = true; panel.removeAttribute('data-ready'); } + return { panel, update, clear }; + } + async function init(container) { destroy(); const token = ++generation; @@ -227,7 +360,7 @@ const viewLabel = element('label', '', 'View '); const view = element('select'); view.setAttribute('aria-label', 'Region view'); - view.append(new Option('Latest', 'latest'), new Option('Timeline', 'timeline')); + view.append(new Option('Latest', 'latest'), new Option('Timeline', 'timeline'), new Option('Health & trends', 'health')); view.value = selection.view || 'latest'; viewLabel.appendChild(view); controls.appendChild(viewLabel); @@ -251,7 +384,7 @@ controls.appendChild(fitButton); shell.appendChild(controls); const timelineControls = element('div', 'regions-timeline'); - timelineControls.hidden = selection.view !== 'timeline'; + timelineControls.hidden = selection.view !== 'timeline' && selection.view !== 'health'; const transport = element('div', 'regions-transport'); function transportButton(text, label) { const button = element('button', '', text); @@ -292,6 +425,8 @@ shell.appendChild(snapshotMeta); const coverage = element('p', 'regions-coverage'); shell.appendChild(coverage); + const activityPanel = createActivityPanel(); + shell.appendChild(activityPanel.panel); const details = element('details', 'regions-details'); details.appendChild(element('summary', '', 'Data & position details')); details.appendChild(element('p', '', 'Repeaters and rooms are shown where scoped relay traffic was observed. Positions are self-reported; their accuracy is unverified. Nodes without usable positions are counted but not mapped. Dashed outlines join the outermost node positions and do not measure RF coverage or administrative boundaries.')); @@ -305,18 +440,13 @@ mapEl.setAttribute('aria-label', 'Observed region relay map'); shell.appendChild(mapEl); container.replaceChildren(shell); - if (typeof L === 'undefined') { - status.textContent = 'Map library unavailable.'; - return; - } // Leaflet 1.9.4 leaves its 250ms zoom-transition timeout alive after // remove() deletes the map pane. Synchronous zoom also makes immediate // period/hash navigation safe when a user just pressed a zoom control. - leafletMap = L.map(mapEl, { zoomAnimation: false }).setView([55.7, 10.5], 6); - const map = leafletMap; + let map = null, layer = null; let redrawMap = null; const applyTiles = function () { - if (token !== generation || leafletMap !== map) return; + if (!map || selection.view === 'health' || token !== generation || leafletMap !== map) return; if (tileLayer) map.removeLayer(tileLayer); const theme = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark'; const tile = typeof root.MC_getTileSpec === 'function' ? root.MC_getTileSpec(theme) : null; @@ -328,16 +458,37 @@ if (redrawMap) redrawMap(); }; let tileLayer = null; - applyTiles(); + function ensureMap() { + if (map) return true; + if (typeof L === 'undefined') { status.textContent = 'Map library unavailable.'; return false; } + map = L.map(mapEl, { zoomAnimation: false }).setView([55.7, 10.5], 6); + leafletMap = map; + layer = L.layerGroup().addTo(map); + applyTiles(); + return true; + } themeObserver = new MutationObserver(applyTiles); themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); - const layer = L.layerGroup().addTo(map); let requestGeneration = 0, snapshot = null, nodes = [], catalog = { scopes: [], truncated: false }; let indexedHistory = null, timer = null, lastFrame = null, scrubFrame = null; + let activity = null, activityScope = null; const markers = new Map(); let hullLayer = null; const historyTools = root.RegionsHistory; + function historicalView() { return selection.view === 'timeline' || selection.view === 'health'; } + + function updateView() { + const health = selection.view === 'health'; + timelineControls.hidden = !historicalView(); + play.hidden = health; + fitButton.hidden = health; + mapEl.hidden = health; + shell.classList.toggle('regions-page-health', health); + if (!health) activityPanel.clear(); + if (health) shell.insertBefore(comparison, details); else shell.insertBefore(comparison, status); + } + function pause() { if (timer !== null) clearTimeout(timer); if (scrubFrame !== null) cancelAnimationFrame(scrubFrame); @@ -352,11 +503,12 @@ document.addEventListener('visibilitychange', visibilityListener); function clearPoints() { - layer.clearLayers(); markers.clear(); hullLayer = null; lastFrame = null; + if (layer) layer.clearLayers(); + markers.clear(); hullLayer = null; lastFrame = null; } function timelineEnabled(ready) { - const enabled = ready && selection.view === 'timeline'; + const enabled = ready && historicalView(); for (const control of [slider, compare, previous, next, play]) control.disabled = !enabled; if (enabled) { previous.disabled = Number(slider.value) <= 0; @@ -502,7 +654,18 @@ coverage.setAttribute('data-coverage-state', frame.currentCoverage); exactBounds.replaceChildren(element('p', '', 'Selected interval (UTC): ' + frame.interval.start + ' — ' + frame.interval.end), element('p', '', 'History dataset (UTC): ' + new Date(indexedHistory.start).toISOString() + ' — ' + new Date(indexedHistory.end).toISOString())); - renderTimelinePoints(frame); + if (selection.view === 'health') { + if (!activity || activityScope !== selection.scope) { + activity = historyTools.indexActivity(indexedHistory, selection.scope); + activityScope = selection.scope; + } + activityPanel.update(frame, historyTools.selectActivity(activity, indexedHistory, frame), selection.scope); + status.textContent = frame.currentCount + ' observed relay nodes; ' + frame.currentAppearances.toLocaleString() + + ' observed relay appearances in the selected interval. ' + (frame.currentCoverage === 'complete' && !frame.limited ? + (frame.currentCount ? 'Observed activity; this does not measure RF coverage or observer uptime.' : + 'No scoped relay activity observed in this complete interval; this does not establish an outage.') : + 'Retained observations only; history is incomplete or limited.'); + } else renderTimelinePoints(frame); renderComparison(frame); } @@ -519,24 +682,29 @@ } function draw(fit) { - if (selection.view === 'timeline') { + if (historicalView()) { if (!indexedHistory) return; + if (selection.view === 'timeline' && !ensureMap()) return; drawTimeline(); - if (fit) fitTimeline(); + if (selection.view === 'timeline' && fit) fitTimeline(); return; } if (!snapshot) return; + if (!ensureMap()) return; renderPoints(layer, map, nodes, selection.scope, status, snapshot.truncated || snapshot.nodes.length > MAX_POINTS, snapshot.scopesTruncated || catalog.truncated, fit); } - redrawMap = () => { if (selection.view === 'timeline' && indexedHistory) renderTimelinePoints(lastFrame); }; + redrawMap = () => { if (selection.view === 'timeline' && indexedHistory && lastFrame) renderTimelinePoints(lastFrame); }; async function load() { pause(); const request = ++requestGeneration, window = selection.window; - const historical = selection.view === 'timeline'; + const historical = historicalView(); + updateView(); snapshot = null; indexedHistory = null; + activity = null; activityScope = null; + activityPanel.clear(); nodes = []; catalog = { scopes: [], truncated: false }; clearPoints(); @@ -605,21 +773,25 @@ pause(); selection.window = period.value; saveSelection(); - if (selection.view === 'timeline') draw(false); else load(); + if (historicalView()) draw(false); else load(); }); select.addEventListener('change', () => { pause(); selection.scope = select.value; saveSelection(); - draw(selection.view !== 'timeline'); + draw(!historicalView()); }); view.addEventListener('change', () => { pause(); + const previousHistorical = historicalView(); selection.view = view.value; - if (selection.view !== 'timeline') { selection.at = ''; selection.compare = false; compare.checked = false; } - timelineControls.hidden = selection.view !== 'timeline'; + if (!historicalView()) { selection.at = ''; selection.compare = false; compare.checked = false; } + updateView(); saveSelection(); - load(); + if (previousHistorical && historicalView() && indexedHistory) { + draw(selection.view === 'timeline'); + if (map && selection.view === 'timeline') { applyTiles(); map.invalidateSize(); } + } else load(); }); compare.addEventListener('change', () => { pause(); @@ -652,7 +824,7 @@ } play.addEventListener('click', () => { if (timer !== null) { pause(); return; } - if (!indexedHistory || document.hidden || Number(slider.value) >= 168) return; + if (selection.view !== 'timeline' || !indexedHistory || document.hidden || Number(slider.value) >= 168) return; play.textContent = 'Pause'; play.setAttribute('aria-label', 'Pause timeline'); play.setAttribute('aria-pressed', 'true'); @@ -664,7 +836,7 @@ }); refresh.addEventListener('click', load); load(); - setTimeout(() => { if (token === generation && leafletMap === map) map.invalidateSize(); }, 0); + setTimeout(() => { if (map && selection.view !== 'health' && token === generation && leafletMap === map) map.invalidateSize(); }, 0); } function destroy() { diff --git a/public/style.css b/public/style.css index 837ab91e6..6f94ceb7a 100644 --- a/public/style.css +++ b/public/style.css @@ -5943,6 +5943,7 @@ body.embed #app.app-fixed { /* Regions map: an observed-relay footprint, deliberately distinct from RF coverage. */ .regions-page { display: flex; flex-direction: column; height: 100%; min-height: 420px; padding: 16px; gap: 8px; } .regions-page > :not(.regions-leaflet) { flex-shrink: 0; } +.regions-page-health { overflow-y: auto; } .regions-page h1, .regions-page p { margin: 0; } .regions-note { color: var(--text-muted); max-width: 85ch; } .regions-controls { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; } @@ -5974,6 +5975,30 @@ body.embed #app.app-fixed { .regions-change-list summary { cursor: pointer; } .regions-change-list ul { margin: 6px 0; padding-left: 20px; max-height: 180px; overflow-y: auto; } .regions-controls :focus-visible, .regions-timeline :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.regions-health { display: flex; flex-direction: column; gap: 8px; min-width: 0; padding: 12px; border: 1px solid var(--border); border-radius: 8px; } +.regions-health[hidden], .regions-leaflet[hidden] { display: none; } +.regions-health h2 { margin: 0; font-size: 1rem; } +.regions-health-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } +.regions-health-card { display: flex; flex-direction: column; gap: 4px; padding: 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); min-width: 0; overflow-wrap: anywhere; } +.regions-health-label, .regions-health-detail { color: var(--text-muted); font-size: 0.8rem; } +.regions-health-value { font-size: 1.3rem; line-height: 1.2; } +.regions-health-card[data-metric="last-relayed"] .regions-health-value { font-size: 1rem; } +.regions-trend-status, .regions-trend-legend, .regions-health-note { color: var(--text-muted); font-size: 0.8rem; } +.regions-trend-plot { display: grid; grid-template-columns: 36px minmax(0, 1fr); gap: 4px; min-width: 0; } +.regions-trend-scale { display: flex; flex-direction: column; justify-content: space-between; align-items: flex-end; padding: 2px 0 12px; color: var(--text-muted); font-size: 0.75rem; } +.regions-trend { display: block; width: 100%; height: 176px; min-width: 0; } +.regions-trend-axis { stroke: var(--border); stroke-width: 1; vector-effect: non-scaling-stroke; } +.regions-trend-bar { fill: var(--accent); } +.regions-trend-gap { fill: var(--surface); stroke: var(--text-muted); stroke-width: 1; stroke-dasharray: 3 3; vector-effect: non-scaling-stroke; } +.regions-trend-bounds { display: flex; justify-content: space-between; gap: 12px; padding-left: 40px; color: var(--text-muted); font-size: 0.75rem; overflow-wrap: anywhere; } +.regions-trend-bounds span:last-child { text-align: right; } +.regions-hourly-details summary { cursor: pointer; font-size: 0.85rem; } +.regions-hourly-scroll { overflow-x: auto; max-width: 100%; margin-top: 8px; } +.regions-hourly-scroll:focus-visible, .regions-hourly-details summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.regions-hourly-table { width: 100%; border-collapse: collapse; min-width: 650px; font-size: 0.8rem; } +.regions-hourly-table caption { text-align: left; color: var(--text-muted); margin-bottom: 6px; } +.regions-hourly-table th, .regions-hourly-table td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); } +.regions-hourly-table th { color: var(--text-muted); } .regions-status { color: var(--text-muted); min-height: 2.4em; } .regions-snapshot, .regions-coverage, .regions-details { color: var(--text-muted); font-size: 0.85em; overflow-wrap: anywhere; } .regions-details summary { cursor: pointer; } @@ -5990,4 +6015,7 @@ body.embed #app.app-fixed { .regions-transport button { flex: 1; min-height: 36px; } .regions-compare-label { min-height: 32px; } .regions-leaflet { flex-shrink: 0; } + .regions-health { padding: 10px; } + .regions-health-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .regions-health-card[data-metric="last-relayed"] { grid-column: 1 / -1; } } diff --git a/test-regions-map-e2e.js b/test-regions-map-e2e.js index 95bf38763..64c81b008 100644 --- a/test-regions-map-e2e.js +++ b/test-regions-map-e2e.js @@ -64,6 +64,7 @@ async function main() { page.on('pageerror', e => errors.push(e.stack || String(e))); let holdHour = false, heldHour = null, failWeek = false, coverageOverride = null; let historyOverride = {}, failHistory = false; + let holdHistory = false, heldHistory = null; let historyRequests = 0; const requests = []; const waitForHeldHour = async () => { @@ -83,6 +84,7 @@ async function main() { }); await page.route('**/api/regions/history*', async r => { historyRequests++; + if (holdHistory) { holdHistory = false; heldHistory = r; return; } if (failHistory) return r.fulfill({ status: 500, body: '{}' }); await r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(historySnapshot(historyOverride)) }); }); @@ -396,6 +398,184 @@ async function main() { await page.waitForFunction(() => document.querySelector('.regions-comparison')?.getAttribute('data-comparable') === 'true'); }); + await test('health deep links share the UTC timeline interval and show observed activity cards', async () => { + await page.evaluate(() => { location.hash = '#/regions?view=health&window=24h&scope=%23example&at=2026-10-16T14%3A48%3A00%2B02%3A00&compare=1'; }); + await page.waitForFunction(() => document.querySelector('.regions-health')?.getAttribute('data-ready') === 'true'); + assert.strictEqual(await page.getByLabel('Region view').inputValue(), 'health'); + assert.strictEqual(await page.locator('.regions-leaflet').isVisible(), false); + assert.strictEqual(await page.getByRole('button', { name: 'Play timeline', exact: true }).isVisible(), false); + assert.strictEqual(await page.getByRole('button', { name: 'Fit map', exact: true }).isVisible(), false); + assert.strictEqual(await page.locator('[data-metric="nodes"] .regions-health-value').textContent(), '4'); + assert.strictEqual(await page.locator('[data-metric="appearances"] .regions-health-value').textContent(), '15'); + assert.match(await page.locator('.regions-health-rate').textContent(), /0\.63 relay appearances per hour/); + assert.strictEqual(await page.locator('[data-metric="last-relayed"] time').getAttribute('datetime'), '2026-10-16T11:00:01.000Z'); + assert.strictEqual(await page.locator('.regions-trend-bar').count(), 24); + assert.strictEqual(await page.locator('.regions-trend-gap').count(), 0); + assert.strictEqual(await page.locator('.regions-trend-bar[data-count="0"]').count(), 22, 'complete zero hours are valid measurements'); + assert.match(await page.locator('.regions-health-note').textContent(), /spam|observer|routing/); + const params = new URLSearchParams((await page.evaluate(() => location.hash)).split('?')[1]); + assert.strictEqual(params.get('view'), 'health'); + assert.strictEqual(params.get('at'), '2026-10-16T12:00:00.000Z'); + }); + + await test('health filters, slider and timeline parity reuse one history payload', async () => { + const before = historyRequests; + await page.evaluate(() => { window.__regionsFitCount = 0; }); + await page.getByLabel('Region period').selectOption('7d'); + assert.strictEqual(await page.locator('.regions-trend-bar').count(), 168); + await page.getByLabel('Region scope').selectOption('#other'); + assert.strictEqual(await page.locator('[data-metric="nodes"] .regions-health-value').textContent(), '1'); + assert.strictEqual(await page.locator('[data-metric="appearances"] .regions-health-value').textContent(), '10'); + await page.getByLabel('Region scope').selectOption(''); + assert.strictEqual(await page.locator('[data-metric="nodes"] .regions-health-value').textContent(), '4', 'node appears in multiple scopes and hours only once in interval total'); + assert.strictEqual(await page.locator('[data-metric="appearances"] .regions-health-value').textContent(), '25'); + await page.getByLabel('Region period').selectOption('1h'); + await page.getByLabel('Region scope').selectOption('#example'); + await page.getByLabel('Timeline interval ending').focus(); + await page.getByLabel('Timeline interval ending').press('ArrowLeft'); + await page.waitForFunction(() => document.querySelector('[data-metric="appearances"] .regions-health-value')?.textContent === '7'); + assert.strictEqual(await page.locator('[data-metric="nodes"] .regions-health-value').textContent(), '2'); + assert.strictEqual(await page.locator('.regions-trend-bar').count(), 1); + assert.match(await page.locator('.regions-appearance-change').textContent(), /percentage unavailable/); + assert.strictEqual(await page.evaluate(() => window.__regionsFitCount), 0, 'health rendering must not fit the hidden map'); + const at = await page.evaluate(() => new URLSearchParams(location.hash.split('?')[1]).get('at')); + await page.getByLabel('Region view').selectOption('timeline'); + assert.match(await page.locator('.regions-status').textContent(), /7 relay appearances/); + assert.strictEqual(await page.locator('.regions-leaflet').isVisible(), true); + assert.strictEqual(await page.evaluate(() => new URLSearchParams(location.hash.split('?')[1]).get('at')), at); + await page.getByLabel('Region view').selectOption('health'); + assert.strictEqual(await page.locator('[data-metric="appearances"] .regions-health-value').textContent(), '7'); + assert.strictEqual(historyRequests, before, 'historical views and filters made another API request'); + await page.getByLabel('Timeline interval ending').press('End'); + await page.waitForFunction(() => document.querySelector('[data-metric="appearances"] .regions-health-value')?.textContent === '8'); + }); + + await test('health renders without Leaflet or hidden map initialization', async () => { + await page.evaluate(() => { + window.__regionsSavedL = window.L; window.L = undefined; + location.hash = '#/regions?view=health&window=1h&scope=%23example&compare=1'; + }); + try { + await page.waitForFunction(() => document.querySelector('.regions-health')?.getAttribute('data-ready') === 'true'); + assert.strictEqual(await page.locator('[data-metric="appearances"] .regions-health-value').textContent(), '8'); + assert.strictEqual(await page.locator('.regions-leaflet.leaflet-container').count(), 0); + assert.strictEqual(await page.locator('.regions-leaflet .leaflet-tile').count(), 0); + } finally { await page.evaluate(() => { window.L = window.__regionsSavedL; delete window.__regionsSavedL; }); } + }); + + await test('health chart separates unavailable history from zero and withholds limited rates and deltas', async () => { + await page.getByLabel('Region scope').selectOption('#other'); + await page.getByLabel('Timeline interval ending').focus(); + await page.getByLabel('Timeline interval ending').press('ArrowLeft'); + await page.waitForFunction(() => document.querySelector('[data-metric="appearances"] .regions-health-value')?.textContent === '0'); + assert.match(await page.locator('.regions-status').textContent(), /No scoped relay activity observed in this complete interval/); + assert.strictEqual(await page.locator('.regions-trend-bar[data-count="0"]').getAttribute('height'), '0'); + assert.strictEqual(await page.locator('.regions-trend-gap').count(), 0); + assert.match(await page.locator('.regions-health-rate').textContent(), /0\.00 relay appearances per hour/); + await page.getByLabel('Timeline interval ending').press('End'); + await page.getByLabel('Region scope').selectOption('#example'); + await page.getByLabel('Region period').selectOption('24h'); + historyOverride = { coverage: { start: '2026-10-16T10:30:00Z', loading: false } }; + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await page.waitForFunction(() => document.querySelector('.regions-health')?.getAttribute('data-ready') === 'true'); + assert.strictEqual(await page.locator('.regions-trend-gap').count(), 23); + assert.strictEqual(await page.locator('.regions-trend-bar').count(), 1); + assert.match(await page.locator('.regions-health-rate').textContent(), /unavailable/); + assert.strictEqual(await page.locator('.regions-appearance-change').count(), 0); + await page.locator('.regions-hourly-details summary').click(); + assert.strictEqual(await page.locator('.regions-hourly-table tbody tr').count(), 24); + const partial = page.locator('.regions-hourly-table tbody tr[data-start="2026-10-16T10:00:00.000Z"]'); + assert.match(await partial.textContent(), /partial/i); + assert.match(await partial.textContent(), /7 appearances; 2 relay nodes/); + assert.strictEqual(await partial.locator('[data-column="appearances"]').textContent(), '—'); + for (const override of [{ coverage: { start: null, loading: false } }, { coverage: { start: null, loading: true } }, + { truncated: true }, { scopesTruncated: true }, { bucketsTruncated: true }]) { + historyOverride = override; + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await page.waitForFunction(() => document.querySelector('.regions-health')?.getAttribute('data-ready') === 'true'); + assert.strictEqual(await page.locator('.regions-trend-gap').count(), 24); + assert.strictEqual(await page.locator('.regions-trend-bar').count(), 0); + assert.match(await page.locator('.regions-health-rate').textContent(), /unavailable/); + assert.strictEqual(await page.locator('.regions-appearance-change').count(), 0); + assert.strictEqual(await page.locator('[data-metric="appearances"] .regions-health-value').textContent(), '15', 'retained counts stay explicit'); + } + historyOverride = {}; + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await page.waitForFunction(() => document.querySelector('.regions-health')?.getAttribute('data-ready') === 'true'); + }); + + await test('health SVG and exact-value table are accessible, themed and retain keyed rows', async () => { + const chart = page.getByRole('img', { name: /Hourly relay appearances/ }); + assert.strictEqual(await chart.count(), 1); + assert.match(await chart.getAttribute('aria-label'), /#example/); + const wasOpen = await page.locator('.regions-hourly-details').evaluate(details => details.open); + await page.locator('.regions-hourly-details summary').focus(); + await page.locator('.regions-hourly-details summary').press('Enter'); + assert.strictEqual(await page.locator('.regions-hourly-table').isVisible(), !wasOpen, 'keyboard did not toggle exact hourly values'); + if (wasOpen) await page.locator('.regions-hourly-details summary').press('Enter'); + assert.strictEqual(await page.locator('.regions-hourly-table').isVisible(), true); + await page.evaluate(() => { window.__healthRow = document.querySelector('.regions-hourly-table tbody tr:last-child'); }); + await page.getByLabel('Region scope').selectOption('#other'); + assert.strictEqual(await page.evaluate(() => window.__healthRow === document.querySelector('.regions-hourly-table tbody tr:last-child')), true, 'scope filtering rebuilt unchanged hourly rows'); + await page.getByLabel('Region scope').selectOption('#example'); + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')); + const fill = await page.locator('.regions-trend-bar').last().evaluate(element => getComputedStyle(element).fill); + assert(fill && fill !== 'none'); + assert.strictEqual(await page.evaluate(() => window.__xss), undefined); + assert.strictEqual(await page.locator('.regions-health img').count(), 0); + assert.match(await page.locator('.regions-health').textContent(), /traffic increase does not establish better health/i); + }); + + if (process.env.REGIONS_SCREENSHOT_DIR) { + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.screenshot({ path: path.join(process.env.REGIONS_SCREENSHOT_DIR, 'regions-health-desktop.png'), fullPage: true }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ path: path.join(process.env.REGIONS_SCREENSHOT_DIR, 'regions-health-mobile.png'), fullPage: true }); + await test('health chart and controls fit the mobile viewport', async () => { + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + const bounds = await page.locator('.regions-trend').boundingBox(); + assert(bounds.width <= 370 && bounds.height >= 120); + }); + await page.locator('.regions-trend').scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(process.env.REGIONS_SCREENSHOT_DIR, 'regions-health-mobile-chart.png'), fullPage: true }); + await page.setViewportSize({ width: 1440, height: 1000 }); + } + + await test('a delayed health refresh cannot replace a newer timeline selection', async () => { + holdHistory = true; + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + const deadline = Date.now() + 5000; + while (!heldHistory) { + if (Date.now() > deadline) throw new Error('Timed out waiting for delayed history'); + await page.waitForTimeout(10); + } + assert.strictEqual(await page.locator('.regions-health').isVisible(), false, 'old health cards stayed visible while refreshing'); + await page.getByLabel('Region view').selectOption('timeline'); + await page.waitForFunction(() => document.querySelector('.regions-status')?.textContent.includes('15 relay appearances')); + const request = heldHistory.request(); + const finished = page.waitForResponse(response => response.request() === request); + await heldHistory.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(historySnapshot({ nodes: [] })) }); + heldHistory = null; + await finished; + await page.waitForTimeout(100); + assert.strictEqual(await page.getByLabel('Region view').inputValue(), 'timeline'); + assert.match(await page.locator('.regions-status').textContent(), /15 relay appearances/); + assert.strictEqual(await page.locator('.regions-health').isVisible(), false); + await page.getByLabel('Region view').selectOption('health'); + assert.strictEqual(await page.locator('[data-metric="appearances"] .regions-health-value').textContent(), '15'); + }); + + await test('health returns to Latest and teardown stops historical controls', async () => { + await page.getByLabel('Region view').selectOption('latest'); + await page.waitForFunction(() => document.querySelector('.regions-status')?.textContent.includes('3 positioned')); + assert.strictEqual(await page.locator('.regions-health').isVisible(), false); + assert.strictEqual(await page.locator('.regions-leaflet').isVisible(), true); + await page.getByLabel('Region view').selectOption('health'); + await page.waitForFunction(() => document.querySelector('.regions-health')?.getAttribute('data-ready') === 'true'); + await page.getByLabel('Region view').selectOption('timeline'); + assert.strictEqual(await page.locator('.regions-health').isVisible(), false); + }); + await test('destroy() tears down the map and the theme observer', async () => { // Navigation during Leaflet's 250ms zoom transition used to leave a // callback touching a removed map pane. Exercise the real zoom control. diff --git a/test-regions-map.js b/test-regions-map.js index 5cc2e75f1..acb5bb555 100644 --- a/test-regions-map.js +++ b/test-regions-map.js @@ -58,6 +58,15 @@ assert.equal(regions.scopeHash('#dk', '1h', { view: 'timeline', at: '2026-10-16T '#/regions?window=1h&scope=%23dk&view=timeline&at=2026-10-16T12%3A00%3A00.000Z&compare=1'); assert.equal(regions.scopeHash('#dk', '1h', { view: 'latest', at: '2026-10-16T12:00:00Z', compare: true }), '#/regions?window=1h&scope=%23dk', 'latest URLs omit timeline state'); +assert.deepEqual(regions.parseSelection('#/regions?view=health&window=7d&scope=%23dk&at=2026-10-16T11%3A37%3A00Z&compare=1'), + { window: '7d', scope: '#dk', view: 'health', at: '2026-10-16T11:37:00.000Z', compare: true }); +assert.deepEqual(regions.parseSelection('#/regions?view=health&window=bad&scope=%3Cbad%3E&at=bad&compare=true'), + { window: '24h', scope: '', view: 'health', at: '', compare: false }); +assert.equal(regions.scopeHash('#dk', '24h', { view: 'health', at: '2026-10-16T12:00:00Z', compare: true }), + '#/regions?window=24h&scope=%23dk&view=health&at=2026-10-16T12%3A00%3A00.000Z&compare=1'); +const healthBookmark = { window: '24h', scope: '#se', view: 'health', at: '2026-10-16T10:00:00.000Z', compare: false }; +assert.deepEqual(regions.parseSelection(regions.scopeHash(healthBookmark.scope, healthBookmark.window, healthBookmark)), healthBookmark, + 'health selection round-trips through its bookmark'); const snapshot = { window: '24h', windowStart: '2026-10-15T12:00:00Z', windowEnd: '2026-10-16T12:00:00Z', generatedAt: '2026-10-16T12:00:00Z', coverage: { start: '2026-10-16T09:00:00Z', complete: false, loading: false } }; @@ -140,4 +149,125 @@ assert(indexed.nodes.reduce((total, n) => total + n.scopes.reduce((sum, s) => su assert.throws(() => history.indexHistory(historyData({ bucketCount: 505 }), []), /history/i); assert.throws(() => history.indexHistory(historyData({ historyEnd: '2026-10-16T12:30:00Z' }), []), /history/i); -console.log('regions map and timeline unit tests passed'); +const healthNodes = [historical('health-a', [], { scopes: [ + { name: '#dk', buckets: [bucket(502, 2), bucket(503, 3)] }, + { name: '#se', buckets: [{ ...bucket(503, 4), lastRelayed: '2026-10-16T11:59:00Z' }] }, +] }), historical('health-b', [{ ...bucket(503, 5), lastRelayed: '2026-10-16T11:00:03Z' }]), +historical('health-unpositioned', [], { lat: null, lon: null, positionSource: 'none', metadataSource: 'archived', + scopes: [{ name: '#no', buckets: [bucket(502, 7)] }] })]; +const healthHistory = history.indexHistory(historyData(), healthNodes); +const activity = history.indexActivity(healthHistory, ''); +const healthFrame = history.selectFrame(healthHistory, { window: '1h', compare: true }); +const frameBeforeHealth = JSON.stringify(healthFrame); +let health = history.selectActivity(activity, healthHistory, healthFrame); +assert.equal(health.bins.length, 1); +assert.deepEqual(health.bins[0], { start: '2026-10-16T11:00:00.000Z', end: '2026-10-16T12:00:00.000Z', + coverage: 'complete', observedAppearances: 12, observedNodes: 2, appearances: 12, nodes: 2 }); +assert.equal(health.lastRelayed, '2026-10-16T11:59:00.000Z'); +assert.equal(health.ratePerHour, 12); +assert.equal(JSON.stringify(healthFrame), frameBeforeHealth, 'health derivation does not change shared comparison results'); +const dkActivity = history.indexActivity(healthHistory, '#dk'); +const dkFrame = history.selectFrame(healthHistory, { window: '1h', scope: '#dk', compare: true }); +health = history.selectActivity(dkActivity, healthHistory, dkFrame); +assert.equal(health.bins[0].appearances, 8, 'scope selection excludes other scoped appearances'); +assert.equal(health.bins[0].nodes, 2, 'all-scopes aggregation counts the same relay once per hour'); +assert.equal(health.lastRelayed, '2026-10-16T11:00:03.000Z', 'a newer relay in another scope cannot advance the selected scope timestamp'); +for (const [window, hours] of [['1h', 1], ['24h', 24], ['7d', 168]]) { + const selected = history.selectFrame(healthHistory, { window }); + const view = history.selectActivity(activity, healthHistory, selected); + assert.equal(view.bins.length, hours); + assert.equal(view.bins.reduce((sum, bin) => sum + bin.observedAppearances, 0), selected.currentAppearances); + assert.equal(view.ratePerHour, selected.currentAppearances / hours); + for (let i = 1; i < view.bins.length; i++) assert.equal(view.bins[i - 1].end, view.bins[i].start, 'hourly bins are disjoint and contiguous'); +} +health = history.selectActivity(activity, healthHistory, history.selectFrame(healthHistory, { window: '1h', at: '2026-10-16T11:00:00Z' })); +assert.equal(health.bins[0].appearances, 9, 'selected end excludes the next hour'); +assert.equal(health.bins[0].nodes, 2, 'unpositioned archived relays contribute activity evidence'); +assert.equal(health.lastRelayed, '2026-10-16T10:00:01.000Z', 'last relay is restricted to selected interval'); +health = history.selectActivity(activity, healthHistory, history.selectFrame(healthHistory, { window: '1h', at: '2026-10-16T10:00:00Z' })); +assert.equal(health.bins[0].appearances, 0, 'complete empty hours are measured zero'); +assert.equal(health.bins[0].nodes, 0); +assert.equal(health.lastRelayed, ''); +assert.equal(health.ratePerHour, 0); +for (const [coverage, expected] of [ + [{ start: null, loading: false }, 'unknown'], [{ start: historyStart, loading: true }, 'loading'], + [{ start: '2026-10-16T12:00:00Z', loading: false }, 'unavailable'], + [{ start: '2026-10-16T11:30:00Z', loading: false }, 'partial'], +]) { + const h = history.indexHistory(historyData({ coverage }), healthNodes); + const selected = history.selectFrame(h, { window: '1h' }); + const view = history.selectActivity(history.indexActivity(h, ''), h, selected); + assert.equal(view.bins[0].coverage, expected); + assert.equal(view.bins[0].appearances, null, 'incomplete history cannot become chart zero'); + assert.equal(view.bins[0].nodes, null); + assert.equal(view.bins[0].observedAppearances, 12, 'partial observed evidence stays available'); + assert.equal(view.ratePerHour, null, 'incomplete intervals have no hourly rate'); +} +for (const flag of ['truncated', 'scopesTruncated', 'bucketsTruncated']) { + const h = history.indexHistory(historyData({ [flag]: true }), healthNodes); + const view = history.selectActivity(history.indexActivity(h, ''), h, history.selectFrame(h, { window: '1h' })); + assert.equal(view.bins[0].coverage, 'complete', 'retention coverage remains distinct from safety limits'); + assert.equal(view.bins[0].appearances, null); + assert.equal(view.bins[0].nodes, null); + assert.equal(view.ratePerHour, null); +} +const mixedCoverage = history.indexHistory(historyData({ coverage: { start: '2026-10-16T10:30:00Z', loading: false } }), healthNodes); +const mixedActivity = history.indexActivity(mixedCoverage, ''); +const mixedFrame = history.selectFrame(mixedCoverage, { window: '1h', compare: true }); +assert.equal(mixedFrame.comparable, false); +assert.equal(history.selectActivity(mixedActivity, mixedCoverage, mixedFrame).ratePerHour, 12, + 'complete selected rate does not require complete reference history'); +health = history.selectActivity(mixedActivity, mixedCoverage, history.selectFrame(mixedCoverage, { window: '24h' })); +assert.equal(health.bins[22].coverage, 'partial'); +assert.equal(health.bins[22].appearances, null); +assert.equal(health.bins[22].observedAppearances, 9); +assert.equal(health.bins[23].appearances, 12, 'complete bins remain available inside a partial interval'); +assert.equal(health.ratePerHour, null); +const isolatedActivity = history.indexActivity(healthHistory, '#absent'); +health = history.selectActivity(isolatedActivity, healthHistory, history.selectFrame(healthHistory, { window: '1h', scope: '#absent' })); +assert.equal(health.bins[0].appearances, 0); +assert.equal(health.lastRelayed, ''); +assert.throws(() => history.selectActivity(activity, mixedCoverage, mixedFrame), /activity/i, 'stale aggregate cannot be paired with a different history snapshot'); +for (const bounds of [ + { start: '2026-10-16T10:00:00Z', end: '2026-10-16T12:00:00Z' }, + { start: '2026-10-16T11:30:00Z', end: '2026-10-16T12:30:00Z' }, + { start: '2026-09-24T11:00:00Z', end: '2026-09-24T12:00:00Z' }, + { start: '2026-10-16T12:00:00Z', end: '2026-10-16T13:00:00Z' }, + { start: 'invalid', end: 'invalid' }, +]) assert.throws(() => history.selectActivity(activity, healthHistory, { ...healthFrame, interval: bounds }), /activity interval/i); + +// Reading the sparse payload after indexing must not be part of chart playback. +const playbackFrame = history.selectFrame(healthHistory, { window: '7d' }); +Object.defineProperty(healthHistory, 'nodes', { get() { throw new Error('sparse node rescan'); } }); +assert.equal(history.selectActivity(activity, healthHistory, playbackFrame).bins.length, 168); +const capNodes = Array.from({ length: 500 }, (_, i) => historical('cap-health-' + i, Array.from({ length: 500 }, (_, index) => bucket(index + 4)))); +const capHistory = history.indexHistory(historyData(), capNodes); +const capActivity = history.indexActivity(capHistory, ''); +const capFrame = history.selectFrame(capHistory, { window: '7d' }); +health = history.selectActivity(capActivity, capHistory, capFrame); +assert.equal(capHistory.limited, false); +assert.equal(health.bins.length, 168); +assert.equal(health.bins[0].nodes, 500); +assert.equal(health.bins[0].appearances, 500); +assert.equal(health.bins.reduce((total, bin) => total + bin.appearances, 0), capFrame.currentAppearances); + +const allDayNode = historical('same-relay-all-day', Array.from({ length: 24 }, (_, index) => bucket(480 + index, 2))); +const allDayHistory = history.indexHistory(historyData(), [allDayNode]); +const allDayFrame = history.selectFrame(allDayHistory, { window: '24h' }); +health = history.selectActivity(history.indexActivity(allDayHistory, ''), allDayHistory, allDayFrame); +assert.equal(allDayFrame.currentCount, 1, 'one relay remains one unique relay across the whole selected interval'); +assert(health.bins.every(bin => bin.nodes === 1)); +assert.equal(health.bins.reduce((total, bin) => total + bin.nodes, 0), 24, 'summing hourly relay counts is not a unique interval relay count'); +for (const ending of ['2026-10-26T12:00:00Z', '2026-11-02T12:00:00Z']) { + const end = Date.parse(ending), start = end - 504 * 3600000; + const dstData = historyData({ historyStart: new Date(start).toISOString(), historyEnd: ending, + generatedAt: ending, coverage: { start: new Date(start).toISOString(), loading: false } }); + const dstHistory = history.indexHistory(dstData, []); + const dstFrame = history.selectFrame(dstHistory, { window: '7d' }); + const dstHealth = history.selectActivity(history.indexActivity(dstHistory, ''), dstHistory, dstFrame); + assert.equal(dstHealth.bins.length, 168, 'UTC week keeps 168 hours across European and US DST changes'); + assert(dstHealth.bins.every(bin => Date.parse(bin.end) - Date.parse(bin.start) === 3600000)); + assert.equal(dstHealth.bins[167].end, new Date(end).toISOString()); +} + +console.log('regions map, timeline and activity unit tests passed');