mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-23 18:32:26 +00:00
Fixes #661 (the narrowed scope from the 2026-10-14 comment). ## What changes - `GET /api/observers/metrics/summary` adds `tx_airtime_pct_1h` and `rx_airtime_pct_1h` per observer: the airtime share over the hour before now, whatever `window` is, from the cumulative `tx_air_secs` / `rx_air_secs` counters in `observer_metrics`. - A counter that goes down marks a reboot; only the stretch after the last reset counts. - Under 15 minutes of samples (one sample, or a reboot late in the hour) returns `null`. - Clamped to 0..100, rounded to 2 decimals. - The response also carries `duty_cycle_warn_pct`, from the new config key `dutyCycleWarnPct` (default 10, the EU 868 MHz g3 sub-band limit). Added to `config.example.json`. - RF Health tiles show `TX 1h: x%`; the observer list gets a sortable numeric `TX 1h` column. Both use the shared `public/observer-airtime.js` helper and flag values at or over the threshold with `.airtime-over` (`var(--status-red-text)`). - The handler now returns a named `MetricsSummaryResponse` struct instead of `map[string]interface{}` (one occurrence fewer). ## Why the warning matters Repeater and room-server firmware default to `airtime_factor = 1.0` (`examples/simple_repeater/MyMesh.cpp:890`), which `Dispatcher.cpp:26` turns into a 50% TX budget per 1 h window. The device therefore does not keep itself under the EU 10% limit; this view is where an operator can see it. ## Cost - Server: one extra query per summary request, `SELECT observer_id, timestamp, tx_air_secs, rx_air_secs FROM observer_metrics WHERE timestamp >= now-1h`, on the existing `idx_observer_metrics_timestamp`, then one O(rows) pass. On our instance that is 35 observers x about 12 samples (5 min interval). - Observer list: one extra bulk call `summary?window=1h` per load (client TTL 30 s, failure ignored), not a call per observer. ## Tests - Go: `TestRollingAirtimePct` (steady, reboot mid-hour, single sample, span under 15 min, reboot leaving under 15 min, nil counters, clamp, rounding), `TestGetMetricsSummaryAirtime1h` (samples older than 1 h ignored inside a 24 h window, single-sample observer gets null), `TestMetricsSummaryDutyCycleWarnPct` (default 10, config override, `observers` is `[]` not `null`). Full `cmd/server` suite passes locally (skipping `TestSaveGeoFilterPreservesFileMode`, which fails on Windows on clean master too). - Frontend unit: `tests/unit/test-issue-661-observer-airtime.js` (8 assertions, real file in `vm`), added to `test-all.sh`. `test-observers-headings.js` updated for the new column. Full `test-all.sh` passes locally. - E2E: `tests/e2e/test-issue-661-airtime-e2e.js`, registered in `deploy.yml` and `scripts/non-unit-tests.json`. The fixture DB has no `observer_metrics` table, so the summary is served through `page.route` with known values for the fixture's own observer ids. It checks the column header, over/under/unknown cells, sort order and the RF Health tile. 4/4 locally. It caught a real bug during development: `loadRFHealthData` declares a local `const window`, so the tile calls the helper as the global `ObserverAirtime`. ## Validation Deployed to a staging instance with live MQTT data (35 observers with metrics) and checked in the browser: - The observer list shows the column for 42 rows (33 with a value, 9 without enough samples). Sorting puts the highest share (5.3%) first. - RF Health shows `TX 1h` on all 35 tiles. With a fourth stat the line wrapped inside items on narrow tiles; the stats row now wraps between items only. - No observer was over 10% at the time, so the red state was checked by rendering one cell client-side at 12.5% (computed colour `rgb(185, 28, 28)` in light mode), and by the E2E test. ## Not done - The threshold is config-only; exposing it in the customizer is a follow-up. - No airtime comparison across observers (that is #671) and no per-packet airtime (Relay Airtime Share covers it). - Dark mode was not checked in the browser; `--status-red-text` is defined for both themes. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
70 lines
2.7 KiB
JavaScript
70 lines
2.7 KiB
JavaScript
/* test-observers-headings.js — Issue #1039 regression test.
|
|
* Asserts observer table thead column count matches tbody row column count.
|
|
*/
|
|
'use strict';
|
|
const REPO_ROOT = require('path').resolve(__dirname, '..', '..');
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const assert = require('assert');
|
|
|
|
const src = fs.readFileSync(path.join(REPO_ROOT, 'public', 'observers.js'), 'utf8');
|
|
|
|
let passed = 0, failed = 0;
|
|
function test(name, fn) {
|
|
try { fn(); passed++; console.log(` ✓ ${name}`); }
|
|
catch (e) { failed++; console.log(` ✗ ${name}\n ${e.message}`); }
|
|
}
|
|
|
|
function extractBlock(s, openRe, closeRe) {
|
|
const m = s.match(openRe);
|
|
if (!m) throw new Error('open marker not found');
|
|
const start = m.index + m[0].length;
|
|
const rest = s.slice(start);
|
|
const cm = rest.match(closeRe);
|
|
if (!cm) throw new Error('close marker not found');
|
|
return rest.slice(0, cm.index);
|
|
}
|
|
|
|
console.log('── Observers table headings (#1039) ──');
|
|
|
|
test('thead column count equals tbody row column count', () => {
|
|
const thead = extractBlock(src, /<thead><tr>/, /<\/tr><\/thead>/);
|
|
const thCount = (thead.match(/<th\b/g) || []).length;
|
|
|
|
// tbody row template lives inside a backtick-template `<tr ...>...</tr>`.
|
|
// Grab from the first `<tr ` after `tbody>` up to the first `</tr>`.
|
|
const tbodyStart = src.indexOf('<tbody>');
|
|
assert.ok(tbodyStart > 0, '<tbody> not found in observers.js');
|
|
const after = src.slice(tbodyStart);
|
|
const trOpen = after.search(/`<tr\b/);
|
|
assert.ok(trOpen > 0, 'row template `<tr` not found');
|
|
const rowStart = trOpen;
|
|
const rowEnd = after.indexOf('</tr>', rowStart);
|
|
assert.ok(rowEnd > rowStart, '</tr> not found in row template');
|
|
const row = after.slice(rowStart, rowEnd);
|
|
const tdCount = (row.match(/<td\b/g) || []).length;
|
|
|
|
assert.strictEqual(
|
|
tdCount, thCount,
|
|
`Observer table column mismatch: ${thCount} <th> headings vs ${tdCount} <td> cells per row. ` +
|
|
`Headings drift after "Last Packet" — see issue #1039.`
|
|
);
|
|
});
|
|
|
|
test('expected headings present and ordered', () => {
|
|
const thead = extractBlock(src, /<thead><tr>/, /<\/tr><\/thead>/);
|
|
const labels = [];
|
|
const re = /<th[^>]*>([^<]+)<\/th>/g;
|
|
let m;
|
|
while ((m = re.exec(thead)) !== null) labels.push(m[1].trim());
|
|
const expected = ['Status', 'Name', 'Region', 'Last Status', 'Last Packet',
|
|
'Packet Health', 'Total Packets', 'Packets/Hour', 'TX 1h', 'Clock Offset', 'Uptime',
|
|
'Firmware', 'Client'];
|
|
assert.deepStrictEqual(labels, expected,
|
|
`Headings out of sync.\nGot: ${JSON.stringify(labels)}\nExpected: ${JSON.stringify(expected)}`);
|
|
});
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
process.exit(failed === 0 ? 0 : 1);
|