mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-10 02:42:49 +02:00
web: /charts mesh-activity figure, mobile card strip, legend-toggle fix
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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 `<scope>.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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 '<section class="node-detail__charts">Charts</section>';
|
||||
};
|
||||
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 = {
|
||||
|
||||
@@ -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, /<h4>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, /<h4>Mesh activity<\/h4>/);
|
||||
assert.match(html, /node-detail__chart-trend/);
|
||||
});
|
||||
@@ -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: '<figure class="injected-activity"></figure>',
|
||||
'no-such-spec': '<figure class="leftover-figure"></figure>',
|
||||
},
|
||||
});
|
||||
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;
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Array<Object>>} 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<Object>} 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<Object>} 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 =>
|
||||
'<span class="node-detail__chart-legend-item">' +
|
||||
`<span class="node-detail__chart-legend-swatch" style="background:${line.color}"></span>` +
|
||||
`<span class="node-detail__chart-legend-text">${escapeHtml(line.label)}</span>` +
|
||||
'</span>'
|
||||
)
|
||||
.join('');
|
||||
return `
|
||||
<figure class="node-detail__chart">
|
||||
<figcaption class="node-detail__chart-header">
|
||||
<h4>Mesh activity</h4>
|
||||
<span>Last 7 days</span>
|
||||
</figcaption>
|
||||
<svg viewBox="0 0 ${dims.width} ${dims.height}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Mesh activity packets per hour per protocol over the last seven days">
|
||||
${yAxisMarkup}
|
||||
${xAxisMarkup}
|
||||
${seriesMarkup}
|
||||
</svg>
|
||||
<div class="node-detail__chart-legend" aria-hidden="true">${legendMarkup}</div>
|
||||
</figure>
|
||||
`;
|
||||
}
|
||||
@@ -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 `
|
||||
<section class="node-detail__charts">
|
||||
<div class="node-detail__charts-grid">
|
||||
${charts.join('')}
|
||||
${parts.join('')}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<section class="charts-page">
|
||||
<header class="charts-page__intro">
|
||||
<h2><img src="/assets/img/meshtastic.svg" alt="" aria-hidden="true" width="20" height="20" class="protocol-icon protocol-icon--meshtastic" loading="lazy" decoding="async" /> Network telemetry trends</h2>
|
||||
<p>Aggregated telemetry snapshots from every Meshtastic node in the past week.</p>
|
||||
<p>Aggregated telemetry snapshots from all nodes in the past week.</p>
|
||||
</header>
|
||||
<div id="chartsPage" class="charts-page__content">
|
||||
<p class="charts-page__status">Loading aggregated telemetry charts…</p>
|
||||
|
||||
Reference in New Issue
Block a user