From 0ea9aaf754b3776d8c15b02a13027e21474625aa Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:30:39 +0200 Subject: [PATCH] web: /charts mesh-activity figure, mobile card strip, legend-toggle fix --- ACCEPTANCE.md | 31 ++++ CHANGELOG.md | 3 + SPEC.md | 15 ++ .../js/app/__tests__/charts-page.test.js | 30 +++ .../app/__tests__/mesh-activity-chart.test.js | 95 ++++++++++ .../assets/js/app/__tests__/node-page.test.js | 22 +++ web/public/assets/js/app/charts-page.js | 11 +- web/public/assets/js/app/main.js | 13 +- .../assets/js/app/mesh-activity-chart.js | 171 ++++++++++++++++++ .../js/app/node-page/telemetry-charts.js | 39 +++- web/public/assets/styles/base.css | 41 +++++ web/views/charts.erb | 2 +- 12 files changed, 459 insertions(+), 14 deletions(-) create mode 100644 web/public/assets/js/app/__tests__/mesh-activity-chart.test.js create mode 100644 web/public/assets/js/app/mesh-activity-chart.js diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md index 43a5620..02e1e07 100644 --- a/ACCEPTANCE.md +++ b/ACCEPTANCE.md @@ -4228,3 +4228,34 @@ soft to `null` (non-OK / network error / empty). `sparklinePathsFromSeries` maps `render(rates)` and `setSeries(series)` each repaint from the last-known other; the sparkline appears only once a real series arrives (no `data-placeholder`, no fake curve) and is omitted on failure while the live total/rows still render. + +### F2-A3 — `/charts` shows a protocol-aware Mesh activity figure — F2-5 +```bash +( cd web && node --test public/assets/js/app/__tests__/mesh-activity-chart.test.js \ + public/assets/js/app/__tests__/node-page.test.js \ + public/assets/js/app/__tests__/charts-page.test.js ) +``` +**Expected:** pass. `renderMeshActivityChart` draws a two-line (Meshtastic `#8856a7`, +MeshCore `#3182bd`) packets/hour figure with an **"Activity (pkt/h)"** y-axis, fed by +`fetchActivityChartBuckets` (`/api/stats/activity`, 7 d / 2 h; fails soft to `[]`). +`renderTelemetryCharts` accepts an `insertBefore` map that places the figure +immediately before the `environment` spec — i.e. **between** the channel-utilization +and environmental figures — and `initializeChartsPage` wires it there. The `/charts` +intro no longer names a single protocol (the aggregate is all-protocol). + +--- + +## Bugfix: Neighbor/trace legend toggles highlight when visible + +Maps to SPEC decision **NT1**. + +### NT-A1 — Neighbor/trace toggles are pressed when their lines are visible — NT1 +```bash +git grep -nE "aria-pressed', neighborLinesVisible \? 'true'|aria-pressed', traceLinesVisible \? 'true'" \ + -- web/public/assets/js/app/main.js +``` +**Expected:** both matches present — the neighbor- and trace-line legend toggles set +`aria-pressed` to `'true'` when their lines are **visible** (`…Visible ? 'true' : 'false'`), +so the highlighted state (`button.legend-item[aria-pressed="true"]`, LC2) marks *shown* +lines, consistent with the role chips and the meta-row protocol toggles. Previously +reversed (pressed when hidden). diff --git a/CHANGELOG.md b/CHANGELOG.md index b194b54..6d96688 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ table, and reported-only disclosure rows. ### Features * Web: `GET /api/stats/activity` — bucketed packets/hour time-series over `ingestor_activity` (snake_case `window_seconds`/`bucket_seconds`), feeding the map-card sparkline and a protocol-aware `/charts` figure (SPEC F2) +* Web: `/charts` gains a protocol-aware "Mesh activity" figure (packets/h per protocol, 7 d) between the channel-utilization and environmental charts; the aggregated-telemetry intro is de-scoped from "Meshtastic" to all protocols (SPEC F2-5) +* Web: mesh-activity map card collapses to a compact caption strip on phones (≤ 640 px) so it no longer covers a small map (design 1d) * Web: "Mesh activity" map card — bottom-left overlay showing packets/h (total + per-protocol split) from `/api/stats`, toggle-reactive and hidden when idle, with a 24 h sparkline drawn from `/api/stats/activity` (SPEC MA-F1…MA-F6, F2-4) * Web: join strip renders the radio settings newcomers need; new `MESHTASTIC_PRESET`/`MESHTASTIC_FREQ` + `MESHCORE_PRESET`/`MESHCORE_FREQ` env vars (`CHANNEL`/`FREQUENCY` deprecated but honoured) * Web: node freshness buckets (live/today/stale) on table rows and map markers, riding the shared relative-time tick @@ -24,6 +26,7 @@ table, and reported-only disclosure rows. * Web: nodes table gains grouped headers, curated mobile columns (Battery survives), a per-row disclosure of hidden fields, row hover/click, numeric alignment, captions/scopes ### Fixes +* Web: neighbor/trace line legend toggles highlight when their lines are visible (was reversed — highlighted when hidden), matching the role chips (SPEC NT1) * Web/data: `GET /api/stats` exposes the mesh-activity packets/hour rate as `.packets.hour` (folded into the S1 scope→metric→window tree) rather than a top-level `packets_per_hour` map, and `total.packets.hour` now **sums** the per-protocol rates instead of taking a single MAX vantage (distinct protocols never share the air, so they add); the ingestor announcement dogfeed reads the new path (SPEC MA4/MA5, pre-release amendment of the merged-but-unreleased #859) * Web: WCAG AA text contrast — role-badge text computed by background luminance; chat log entries, error text, links, and focus rings tokenised (`--danger`, single `--accent`) * Web: federation table's undefined CSS tokens aliased — row borders and the sticky header render again diff --git a/SPEC.md b/SPEC.md index ebd3462..9ced971 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1166,3 +1166,18 @@ contradicted. | **F2-4** | **Card real sparkline.** The MA-F5 placeholder is replaced by the real 24 h series from `/api/stats/activity`, fetched on a slower cadence with its own short cache (the trend moves slowly — not every refresh). On fetch failure the sparkline is omitted but the live total/rows (from `/api/stats`) still render — the card never regresses to a fake curve. | interview | | **F2-5** | **Charts page — protocol-aware, all-protocol.** A "Mesh activity" figure (packets/h per protocol, 7 d) is added to `/charts` **between** the channel-utilization and environmental figures. The intro no longer names "Meshtastic": the aggregated telemetry (`query_telemetry_buckets`) already carries **all** protocols (MeshCore telemetry included since the telemetry pull shipped) — the prior wording mislabelled all-protocol data. Invariant IV. | interview | | **F2-6** | **Engineering bar (D9).** 100 % unit tests (rspec for `query_activity_buckets` + the route; node --test for the card sparkline + charts figure), full RDoc/JSDoc, Apache headers, `rufo`/`black` clean; `CONTRACTS.md` documents the new endpoint; all suites stay green. | CLAUDE.md | + +--- + +## Bugfix: Neighbor/trace legend toggles highlight when visible + +The neighbor-line and trace-line legend toggles set `aria-pressed="true"` when their +lines were **hidden**, so `button.legend-item[aria-pressed="true"]` (the selected/ +highlighted style, LC2) painted them highlighted while the lines were off — reversed +relative to the role chips, which highlight when a role is **visible**. Fixed in +`web/public/assets/js/app/main.js` (`updateNeighborLinesToggleState` / +`updateTraceLinesToggleState`). Presentation-only; no data/API change. + +| # | Decision | Source | +| --- | --- | --- | +| **NT1** | The neighbor/trace line toggles set `aria-pressed="true"` when their lines are **visible** (highlighted = shown), consistent with the role chips (LC2) and the meta-row protocol toggles; previously reversed (pressed when hidden). Presentation-only, no data/API change. | review | diff --git a/web/public/assets/js/app/__tests__/charts-page.test.js b/web/public/assets/js/app/__tests__/charts-page.test.js index d5e676c..ca1931f 100644 --- a/web/public/assets/js/app/__tests__/charts-page.test.js +++ b/web/public/assets/js/app/__tests__/charts-page.test.js @@ -108,6 +108,36 @@ test('initializeChartsPage renders the telemetry charts when snapshots are avail assert.equal(/^\d{2}$/.test(label), true); }); +test('initializeChartsPage injects the mesh-activity figure before the environmental charts', async () => { + const container = { innerHTML: '' }; + const documentStub = { + getElementById(id) { + return id === 'chartsPage' ? container : null; + }, + }; + const nowSec = 1_700_000_000; + const fetchImpl = async url => { + if (url.includes('/api/stats/activity')) { + return createResponse(200, [ + { bucket_start: nowSec - 7200, meshtastic: 20, meshcore: 10 }, + { bucket_start: nowSec, meshtastic: 30, meshcore: 20 }, + ]); + } + return createResponse(200, [ + { bucket_start: nowSec, bucket_seconds: 300, aggregates: { temperature: { avg: 22 } } }, + ]); + }; + let receivedOptions = null; + const renderCharts = (node, options) => { + receivedOptions = options; + return '
Charts
'; + }; + await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts }); + assert.ok(receivedOptions.insertBefore, 'insertBefore option is passed'); + assert.ok(receivedOptions.insertBefore.environment, 'activity figure keyed to environment'); + assert.match(receivedOptions.insertBefore.environment, /Mesh activity/); +}); + test('initializeChartsPage shows an error message when fetching fails', async () => { const container = { innerHTML: '' }; const documentStub = { diff --git a/web/public/assets/js/app/__tests__/mesh-activity-chart.test.js b/web/public/assets/js/app/__tests__/mesh-activity-chart.test.js new file mode 100644 index 0000000..16443fb --- /dev/null +++ b/web/public/assets/js/app/__tests__/mesh-activity-chart.test.js @@ -0,0 +1,95 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + fetchActivityChartBuckets, + renderMeshActivityChart, +} from '../mesh-activity-chart.js'; + +const NOW = 1_700_000_000_000; // milliseconds +const TWO_HOURS = 7200; + +function bucketsFixture() { + const startSec = Math.floor(NOW / 1000) - 4 * TWO_HOURS; + return [ + { bucket_start: startSec, bucket_end: startSec + TWO_HOURS, total: 30, meshtastic: 20, meshcore: 10 }, + { bucket_start: startSec + TWO_HOURS, bucket_end: startSec + 2 * TWO_HOURS, total: 50, meshtastic: 30, meshcore: 20 }, + ]; +} + +test('fetchActivityChartBuckets requests the 7d/2h series and returns the array', async () => { + const calls = []; + const fetchImpl = async url => { + calls.push(url); + return { ok: true, async json() { return bucketsFixture(); } }; + }; + const buckets = await fetchActivityChartBuckets({ fetchImpl }); + assert.equal(buckets.length, 2); + assert.match(calls[0], /\/api\/stats\/activity\?window_seconds=604800&bucket_seconds=7200/); +}); + +test('fetchActivityChartBuckets fails soft to [] on non-OK, error, and non-array', async () => { + assert.deepEqual( + await fetchActivityChartBuckets({ fetchImpl: async () => ({ ok: false, status: 500 }) }), + [] + ); + assert.deepEqual( + await fetchActivityChartBuckets({ fetchImpl: async () => { throw new Error('down'); } }), + [] + ); + assert.deepEqual( + await fetchActivityChartBuckets({ + fetchImpl: async () => ({ ok: true, async json() { return { not: 'array' }; } }), + }), + [] + ); +}); + +test('renderMeshActivityChart draws a per-protocol figure with the Activity axis label', () => { + const html = renderMeshActivityChart(bucketsFixture(), NOW); + assert.match(html, /

Mesh activity<\/h4>/); + assert.match(html, /Activity \(pkt\/h\)/); // y-axis label (per the note) + assert.match(html, /Meshtastic/); + assert.match(html, /MeshCore/); + assert.match(html, /#8856a7/); // meshtastic colour + assert.match(html, /#3182bd/); // meshcore colour + assert.match(html, /node-detail__chart-trend/); // a trend line path + assert.match(html, /node-detail__chart-legend/); +}); + +test('renderMeshActivityChart returns empty string when there is no usable data', () => { + assert.equal(renderMeshActivityChart([], NOW), ''); + assert.equal(renderMeshActivityChart(null, NOW), ''); + // Buckets present but no finite per-protocol values → no lines. + assert.equal(renderMeshActivityChart([{ bucket_start: 'x', meshtastic: 'y' }], NOW), ''); +}); + +test('renderMeshActivityChart still renders when the whole series is zero', () => { + const startSec = Math.floor(NOW / 1000) - TWO_HOURS; + const html = renderMeshActivityChart( + [ + { bucket_start: startSec, meshtastic: 0, meshcore: 0 }, + { bucket_start: startSec + TWO_HOURS, meshtastic: 0, meshcore: 0 }, + ], + NOW + ); + // maxValue 0 → axis.max falls back to 1; the figure and its lines still draw. + assert.match(html, /

Mesh activity<\/h4>/); + assert.match(html, /node-detail__chart-trend/); +}); diff --git a/web/public/assets/js/app/__tests__/node-page.test.js b/web/public/assets/js/app/__tests__/node-page.test.js index bbcf94a..1f476fc 100644 --- a/web/public/assets/js/app/__tests__/node-page.test.js +++ b/web/public/assets/js/app/__tests__/node-page.test.js @@ -472,6 +472,28 @@ test('renderTelemetryCharts renders condensed scatter charts when telemetry exis assert.equal(html.includes('node-detail__chart-point'), true); }); +test('renderTelemetryCharts injects insertBefore figures before their target spec', () => { + const nowMs = CHART_NOW_MS; + const nowSeconds = CHART_NOW_SECONDS; + const node = makeAggregatedNode([ + { rx_time: nowSeconds - 60, telemetry_type: 'device', battery_level: 80, voltage: 4.1 }, + { rx_time: nowSeconds - 3_600, telemetry_type: 'environment', temperature: 18.4, relative_humidity: 52 }, + ]); + const html = renderTelemetryCharts(node, { + nowMs, + insertBefore: { + environment: '
', + 'no-such-spec': '
', + }, + }); + const injectedIdx = html.indexOf('injected-activity'); + const envIdx = html.indexOf('Environmental telemetry'); + assert.ok(injectedIdx > -1 && envIdx > -1); + assert.ok(injectedIdx < envIdx, 'injected figure precedes the environmental figure'); + // A figure whose target spec did not render is appended, never dropped. + assert.ok(html.includes('leftover-figure')); +}); + test('renderTelemetryCharts expands upper bounds when overflow metrics exceed defaults', () => { const nowMs = CHART_NOW_MS; const nowSeconds = CHART_NOW_SECONDS; diff --git a/web/public/assets/js/app/charts-page.js b/web/public/assets/js/app/charts-page.js index ba8bcd2..f80f296 100644 --- a/web/public/assets/js/app/charts-page.js +++ b/web/public/assets/js/app/charts-page.js @@ -15,6 +15,7 @@ */ import { renderTelemetryCharts } from './node-page.js'; +import { fetchActivityChartBuckets, renderMeshActivityChart } from './mesh-activity-chart.js'; const TELEMETRY_BUCKET_SECONDS = 60 * 60; const HOUR_MS = 60 * 60 * 1000; @@ -217,9 +218,16 @@ export async function initializeChartsPage(options = {}) { container.innerHTML = renderStatus('Telemetry snapshots are unavailable.'); return true; } + const nowMs = Date.now(); + // Mesh activity figure (SPEC F2-5), injected between the channel-utilization + // and environmental telemetry figures; drawn from /api/stats/activity. + const activityFigure = renderMeshActivityChart( + await fetchActivityChartBuckets({ fetchImpl }), + nowMs + ); const node = { rawSources: { telemetry: { snapshots } } }; const chartsHtml = renderCharts(node, { - nowMs: Date.now(), + nowMs, chartOptions: { windowMs, timeRangeLabel: 'Last 7 days', @@ -227,6 +235,7 @@ export async function initializeChartsPage(options = {}) { xAxisTickFormatter: formatDayOfMonthLabel, lineReducer: points => buildMovingAverageSeries(points, HOUR_MS), }, + insertBefore: activityFigure ? { environment: activityFigure } : {}, }); if (!chartsHtml) { container.innerHTML = renderStatus('Telemetry snapshots are unavailable.'); diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index 9f3e7b4..39c93e4 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -1739,9 +1739,10 @@ export function initializeApp(config) { // The toggle doubles as the legend key for the solid neighbor-line style // (SPEC UX7, audit D-014). neighborLinesToggleButton.innerHTML = `${legendLineSampleSvg('neighbor')} ${label}`; - // aria-pressed reflects whether the user has *activated* the toggle (i.e. lines are - // currently hidden). When lines are visible (default), the button is unpressed. - neighborLinesToggleButton.setAttribute('aria-pressed', neighborLinesVisible ? 'false' : 'true'); + // aria-pressed marks the *selected* (highlighted) state, consistent with the + // role chips (button.legend-item[aria-pressed="true"]): the toggle is pressed + // when its lines are visible, unpressed when hidden. (Previously reversed.) + neighborLinesToggleButton.setAttribute('aria-pressed', neighborLinesVisible ? 'true' : 'false'); neighborLinesToggleButton.setAttribute('aria-label', label); } @@ -1775,8 +1776,10 @@ export function initializeApp(config) { // The toggle doubles as the legend key for the dashed traceroute style // (SPEC UX7, audit D-014). traceLinesToggleButton.innerHTML = `${legendLineSampleSvg('trace')} ${label}`; - // aria-pressed reflects whether the user has *activated* the toggle (lines hidden). - traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'false' : 'true'); + // aria-pressed marks the *selected* (highlighted) state, consistent with the + // role chips: pressed when its lines are visible, unpressed when hidden. + // (Previously reversed.) + traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'true' : 'false'); traceLinesToggleButton.setAttribute('aria-label', label); } diff --git a/web/public/assets/js/app/mesh-activity-chart.js b/web/public/assets/js/app/mesh-activity-chart.js new file mode 100644 index 0000000..f1b09f0 --- /dev/null +++ b/web/public/assets/js/app/mesh-activity-chart.js @@ -0,0 +1,171 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Mesh-activity `/charts` figure (SPEC F2-5): packets/hour **per protocol** over + * the last 7 days. Built with the shared telemetry-chart helpers so it matches + * the surrounding chart chrome; `/charts` injects it between the + * channel-utilization and environmental figures. Data comes from the + * `/api/stats/activity` time-series (SPEC F2-1). + * + * @module mesh-activity-chart + */ + +import { escapeHtml } from './utils.js'; +import { createChartDimensions } from './node-page-charts/layout.js'; +import { + renderYAxis, + renderXAxis, + renderTelemetrySeries, +} from './node-page-charts/svg-renderers.js'; +import { buildMidnightTicks } from './node-page-charts/tick-builders.js'; + +const ACTIVITY_CHART_WINDOW_SECONDS = 7 * 24 * 3600; // 7 days +const ACTIVITY_CHART_BUCKET_SECONDS = 2 * 3600; // 2-hour buckets +const ACTIVITY_CHART_WINDOW_MS = ACTIVITY_CHART_WINDOW_SECONDS * 1000; + +/** + * Per-protocol lines, in draw/legend order. Colours match the design (option + * 1c): Meshtastic purple, MeshCore blue. + * + * @type {ReadonlyArray<{protocol: string, label: string, color: string}>} + */ +const ACTIVITY_CHART_LINES = Object.freeze([ + Object.freeze({ protocol: 'meshtastic', label: 'Meshtastic', color: '#8856a7' }), + Object.freeze({ protocol: 'meshcore', label: 'MeshCore', color: '#3182bd' }), +]); + +/** + * Fetch the per-protocol activity buckets for the `/charts` figure (7 d / 2 h). + * Fails soft to an empty array so the figure is simply omitted on any error. + * + * @param {{fetchImpl?: Function}} [params] Fetch parameters. + * @returns {Promise>} Ascending bucket objects, or `[]`. + */ +export async function fetchActivityChartBuckets({ fetchImpl = fetch } = {}) { + try { + const url = + `/api/stats/activity?window_seconds=${ACTIVITY_CHART_WINDOW_SECONDS}` + + `&bucket_seconds=${ACTIVITY_CHART_BUCKET_SECONDS}`; + const response = await fetchImpl(url, { cache: 'default' }); + if (!response?.ok) { + return []; + } + const payload = await response.json(); + return Array.isArray(payload) ? payload : []; + } catch (error) { + console.debug('Failed to fetch /api/stats/activity for /charts', error); + return []; + } +} + +/** + * Build a `{timestamp, value}` point list for one protocol from the buckets. + * + * @param {Array} buckets Bucket objects from the activity endpoint. + * @param {string} protocol Protocol key (`meshtastic` / `meshcore`). + * @returns {Array<{timestamp: number, value: number}>} Chart points (ms). + */ +function pointsForProtocol(buckets, protocol) { + const points = []; + for (const bucket of buckets) { + const start = Number(bucket?.bucket_start); + const value = Number(bucket?.[protocol]); + if (Number.isFinite(start) && Number.isFinite(value)) { + points.push({ timestamp: start * 1000, value: Math.max(0, value) }); + } + } + return points; +} + +/** + * Render the mesh-activity `/charts` figure (SPEC F2-5). + * + * @param {Array} buckets Per-protocol activity buckets (7 d / 2 h). + * @param {number} [nowMs] Reference timestamp in milliseconds. + * @returns {string} Figure markup, or an empty string when there is no data. + */ +export function renderMeshActivityChart(buckets, nowMs = Date.now()) { + if (!Array.isArray(buckets) || buckets.length === 0) { + return ''; + } + const lines = ACTIVITY_CHART_LINES.map(line => ({ + ...line, + points: pointsForProtocol(buckets, line.protocol), + })).filter(line => line.points.length > 0); + if (lines.length === 0) { + return ''; + } + + const domainEnd = nowMs; + const domainStart = nowMs - ACTIVITY_CHART_WINDOW_MS; + const maxValue = lines.reduce( + (peak, line) => line.points.reduce((max, point) => Math.max(max, point.value), peak), + 0 + ); + const dims = createChartDimensions({ axes: [{ position: 'left' }] }); + const axis = { + position: 'left', + scale: 'linear', + min: 0, + max: maxValue > 0 ? maxValue : 1, + ticks: 5, + label: 'Activity (pkt/h)', + visible: true, + }; + + const yAxisMarkup = renderYAxis(axis, dims); + const xAxisMarkup = renderXAxis( + dims, + domainStart, + domainEnd, + buildMidnightTicks(nowMs, ACTIVITY_CHART_WINDOW_MS) + ); + const seriesMarkup = lines + .map(line => + renderTelemetrySeries( + { color: line.color, valueFormatter: value => `${value} pkt/h` }, + line.points, + axis, + dims, + domainStart, + domainEnd + ) + ) + .join(''); + const legendMarkup = lines + .map(line => + '' + + `` + + `${escapeHtml(line.label)}` + + '' + ) + .join(''); + return ` +
+
+

Mesh activity

+ Last 7 days +
+ + ${yAxisMarkup} + ${xAxisMarkup} + ${seriesMarkup} + + +
+ `; +} diff --git a/web/public/assets/js/app/node-page/telemetry-charts.js b/web/public/assets/js/app/node-page/telemetry-charts.js index 6fb1e3e..6b029cc 100644 --- a/web/public/assets/js/app/node-page/telemetry-charts.js +++ b/web/public/assets/js/app/node-page/telemetry-charts.js @@ -33,10 +33,12 @@ import { stringOrNull } from '../value-helpers.js'; * exist. * * @param {Object} node Normalised node payload. - * @param {{ nowMs?: number, chartOptions?: Object }} [options] Rendering options. + * @param {{ nowMs?: number, chartOptions?: Object, insertBefore?: Object }} [options] + * Rendering options. ``insertBefore`` maps a chart spec id to HTML injected + * immediately before that spec's figure (SPEC F2-5); default none. * @returns {string} Chart grid markup or an empty string. */ -export function renderTelemetryCharts(node, { nowMs = Date.now(), chartOptions = {} } = {}) { +export function renderTelemetryCharts(node, { nowMs = Date.now(), chartOptions = {}, insertBefore = {} } = {}) { const telemetrySource = node?.rawSources?.telemetry; const snapshotHistory = Array.isArray(node?.rawSources?.telemetrySnapshots) && node.rawSources.telemetrySnapshots.length > 0 ? node.rawSources.telemetrySnapshots @@ -60,16 +62,39 @@ export function renderTelemetryCharts(node, { nowMs = Date.now(), chartOptions = return ''; } const isAggregated = snapshotHistory == null && aggregatedSnapshots != null; - const charts = TELEMETRY_CHART_SPECS - .map(spec => renderTelemetryChart(spec, entries, nowMs, { ...chartOptions, isAggregated })) - .filter(chart => stringOrNull(chart)); - if (charts.length === 0) { + const rendered = TELEMETRY_CHART_SPECS + .map(spec => ({ + specId: spec.id, + html: renderTelemetryChart(spec, entries, nowMs, { ...chartOptions, isAggregated }), + })) + .filter(chart => stringOrNull(chart.html)); + if (rendered.length === 0) { return ''; } + // Optional caller-supplied figures keyed by the spec id they precede (SPEC + // F2-5: /charts injects the Mesh-activity figure before ``environment``). + const injections = insertBefore && typeof insertBefore === 'object' ? insertBefore : {}; + const used = new Set(); + const parts = []; + for (const chart of rendered) { + const injected = stringOrNull(injections[chart.specId]); + if (injected && !used.has(chart.specId)) { + parts.push(injected); + used.add(chart.specId); + } + parts.push(chart.html); + } + // Any injection whose target spec did not render is appended rather than + // silently dropped. + for (const [specId, html] of Object.entries(injections)) { + if (!used.has(specId) && stringOrNull(html)) { + parts.push(html); + } + } return `
- ${charts.join('')} + ${parts.join('')}
`; diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index 537f653..c37e041 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -1969,6 +1969,47 @@ input[type="radio"] { } } +/* Mesh activity card → compact ticker on phones (design 1d): the full card eats + ~a third of a small map, so collapse it to a single-line strip that reads as + a caption (pulse dot · total · per-protocol icons + rates). */ +@media (max-width: 640px) { + .map-activity-card { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; + padding: 4px 9px; + min-width: 0; + line-height: 1.4; + } + .map-activity-card__header, + .map-activity-card__total { + margin-bottom: 0; + gap: 4px; + } + .map-activity-card__title { + display: none; /* the numbers make the label redundant in a strip */ + } + .map-activity-card__total-value { + font-size: 15px; + line-height: 1.4; + } + .map-activity-card__spark { + display: none; /* the trend line needs vertical room the strip lacks */ + } + .map-activity-card__rows { + flex-direction: row; + gap: 8px; + } + .map-activity-card__row { + gap: 3px; + } + .map-activity-card__row-name, + .map-activity-card__row-bar { + display: none; /* icon + rate is enough in the strip */ + } +} + .legend-header { display: flex; align-items: center; diff --git a/web/views/charts.erb b/web/views/charts.erb index c17c796..5be4e39 100644 --- a/web/views/charts.erb +++ b/web/views/charts.erb @@ -16,7 +16,7 @@

Network telemetry trends

-

Aggregated telemetry snapshots from every Meshtastic node in the past week.

+

Aggregated telemetry snapshots from all nodes in the past week.

Loading aggregated telemetry charts…