feat(#1574): operator-configurable liveMap.maxNodes (default 2000) (#1577)

Red commit: 94dc1d70a5

Fixes #1574.

cross-stack: justified — by design. Adds one server-side knob
(`liveMap.maxNodes`) on the Go API and consumes it on the frontend
(`public/live.js`) via the shared `/api/config/client` bootstrap in
`public/roles.js`. Cannot land server-only or frontend-only without
either dropping operator config (frontend-only) or leaving the literal
in place (server-only).

## Problem (per triage)
`public/live.js:2515-2516` hardcodes `/api/nodes?limit=2000` for the
live-map node-load path. Reporter measured headroom at N=4300 and
asked for an operator knob. Same `2000` magic also lives at
`public/live.js:480` for the VCR-rewind `/api/packets?limit=2000`.

## Fix
- New `liveMap.maxNodes` field in `Config` (default 2000).
- `Config.LiveMapMaxNodes()` server-side clamp: `[100, 20000]`;
  zero/negative falls back to default. Defangs misconfig (e.g. 1M
  would OOM the SQLite read + JSON serialization path).
- `/api/config/client` now returns `liveMapMaxNodes`.
- `public/roles.js` reads it at bootstrap into
`window.LIVE_MAP_MAX_NODES`
  (default 2000 to preserve behavior on stale caches).
- `public/live.js` consumes `LIVE_MAP_MAX_NODES` at both the
`/api/nodes`
  call sites (formerly :2515-2516) and the VCR-rewind `/api/packets`
  call (formerly :480) — single source of truth, in-scope per triage's
  "factor into a sibling const" suggestion.
- `config.example.json` documents the knob with `_comment_maxNodes` per
  AGENTS.md config rule.

## TDD
1. **Red** (`94dc1d70`): added `test-issue-1574-live-map-max-nodes.js`
   (grep-asserts the literal is gone + `LIVE_MAP_MAX_NODES` /
   `liveMapMaxNodes` are wired + config example has the field) and
   `cmd/server/livemap_maxnodes_1574_test.go` (`/api/config/client`
   exposes `liveMapMaxNodes` + clamp table-driven cases). Stub
   `LiveMapMaxNodes()` returns 0 so the test compiles and fails on
   assertion, not import.
2. **Green** (this commit): real `LiveMapMaxNodes()` clamp + wire-up.
   All assertions pass; existing `cmd/server` suite still green.

## E2E note
Frontend assertion is grep-based (literal removal + constant
reference), in the established `test-issue-*` style used elsewhere
(e.g. `test-issue-1189-live-iata-badge.js`). No Playwright change
needed for a literal-replace; behavior validation is the server-side
clamp + JSON shape tests.

## Out of scope
No customizer UI change — operators set this in `config.json`, same
pattern as `liveMap.propagationBufferMs`. Customizer surfacing can
land as a follow-up if the operator wants it.

---------

Co-authored-by: mc-bot <bot@corescope.local>
Co-authored-by: Kpa-clawbot <bot@meshcore-analyzer>
This commit is contained in:
Kpa-clawbot
2026-06-06 22:44:59 -07:00
committed by GitHub
parent 1179d3c7ef
commit 1bdb92de88
8 changed files with 153 additions and 4 deletions
+22
View File
@@ -76,6 +76,7 @@ type Config struct {
LiveMap struct {
PropagationBufferMs int `json:"propagationBufferMs"`
MaxNodes int `json:"maxNodes"`
} `json:"liveMap"`
CacheTTL map[string]interface{} `json:"cacheTTL"`
@@ -581,6 +582,27 @@ func (c *Config) PropagationBufferMs() int {
return 5000
}
// LiveMapMaxNodes returns the operator-configured cap on how many nodes
// the live map fetches (and thus renders) in a single page. Default is
// 2000; values are clamped to [100, 20000] to defang misconfig.
// Negative/zero falls back to default. See #1574.
func (c *Config) LiveMapMaxNodes() int {
const def = 2000
const min = 100
const max = 20000
if c == nil || c.LiveMap.MaxNodes <= 0 {
return def
}
v := c.LiveMap.MaxNodes
if v < min {
return min
}
if v > max {
return max
}
return v
}
// blacklistSet lazily builds and caches the nodeBlacklist as a set for O(1) lookups.
// Uses sync.Once to eliminate the data race on first concurrent access.
func (c *Config) blacklistSet() map[string]bool {
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"encoding/json"
"net/http/httptest"
"testing"
)
// Behavior test (#1574): /api/config/client must expose `liveMapMaxNodes`
// so the frontend can honor the operator-configured live-map node cap
// instead of the hardcoded 2000 in public/live.js. Default is 2000;
// operators tune via `liveMap.maxNodes` in config.json. Server clamps to
// [100, 20000] to defang misconfig.
func TestConfigClientExposesLiveMapMaxNodes(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/config/client", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
v, present := body["liveMapMaxNodes"]
if !present {
t.Fatal("expected liveMapMaxNodes in /api/config/client response")
}
n, ok := v.(float64)
if !ok {
t.Fatalf("expected liveMapMaxNodes to be a number, got %T", v)
}
if int(n) != 2000 {
t.Errorf("expected default liveMapMaxNodes=2000, got %d", int(n))
}
}
// Server-side clamp: operator misconfig (negative, zero, absurdly large)
// must be coerced to safe bounds [100, 20000]. Default (unset) is 2000.
func TestLiveMapMaxNodesClamp(t *testing.T) {
cases := []struct {
name string
set int
want int
}{
{"default-when-unset", 0, 2000},
{"negative-clamps-to-default", -42, 2000},
{"below-min-clamps-up", 50, 100},
{"in-range-passthrough", 4300, 4300},
{"above-max-clamps-down", 99999, 20000},
{"exact-min", 100, 100},
{"exact-max", 20000, 20000},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := &Config{}
cfg.LiveMap.MaxNodes = tc.set
got := cfg.LiveMapMaxNodes()
if got != tc.want {
t.Errorf("LiveMapMaxNodes() with set=%d: want %d, got %d",
tc.set, tc.want, got)
}
})
}
}
+1
View File
@@ -402,6 +402,7 @@ func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) {
CacheInvalidateMs: s.cfg.CacheInvalidMs,
ExternalUrls: s.cfg.ExternalUrls,
PropagationBufferMs: float64(s.cfg.PropagationBufferMs()),
LiveMapMaxNodes: s.cfg.LiveMapMaxNodes(),
Timestamps: s.cfg.GetTimestampConfig(),
DebugAffinity: s.cfg.DebugAffinity,
MapDarkTileProvider: s.cfg.MapDarkTileProvider,
+1
View File
@@ -1001,6 +1001,7 @@ type ClientConfigResponse struct {
CacheInvalidateMs interface{} `json:"cacheInvalidateMs"`
ExternalUrls interface{} `json:"externalUrls"`
PropagationBufferMs float64 `json:"propagationBufferMs"`
LiveMapMaxNodes int `json:"liveMapMaxNodes"`
Timestamps TimestampConfig `json:"timestamps"`
DebugAffinity bool `json:"debugAffinity,omitempty"`
MapDarkTileProvider string `json:"mapDarkTileProvider,omitempty"` // deprecated. TODO: remove after v3.5.0
+3 -1
View File
@@ -278,7 +278,9 @@
},
"liveMap": {
"propagationBufferMs": 5000,
"_comment": "How long (ms) to buffer incoming observations of the same packet before animating. Mesh packets propagate through multiple paths and arrive at different observers over several seconds. This window collects all observations of a single transmission so the live map can animate them simultaneously as one realistic propagation event. Set higher for wide meshes with many observers, lower for snappier animations. 5000ms captures ~95% of observations for a typical mesh."
"_comment": "How long (ms) to buffer incoming observations of the same packet before animating. Mesh packets propagate through multiple paths and arrive at different observers over several seconds. This window collects all observations of a single transmission so the live map can animate them simultaneously as one realistic propagation event. Set higher for wide meshes with many observers, lower for snappier animations. 5000ms captures ~95% of observations for a typical mesh.",
"maxNodes": 2000,
"_comment_maxNodes": "Maximum nodes the /live map fetches (and renders) in one page. Default 2000. Raise this on deployments that have measured perf headroom on mid-range mobile (heap + frame-time). Server clamps to [100, 20000]: misconfigured values are coerced silently. Also caps the matching /api/packets?limit fetch the live VCR-rewind code uses. Reporter: #1574."
},
"timestamps": {
"defaultMode": "ago",
+3 -3
View File
@@ -482,7 +482,7 @@
// Fetch packets from DB for the time window
const now = Date.now();
const from = new Date(now - ms).toISOString();
fetch(`/api/packets?limit=2000&grouped=false&expand=observations&since=${encodeURIComponent(from)}`)
fetch(`/api/packets?limit=${window.LIVE_MAP_MAX_NODES}&grouped=false&expand=observations&since=${encodeURIComponent(from)}`)
.then(r => r.json())
.then(data => {
const pkts = (data.packets || []).reverse(); // oldest first
@@ -2575,8 +2575,8 @@
const rqs = (window.RegionFilter && typeof RegionFilter.nodesRegionQueryString === 'function')
? RegionFilter.nodesRegionQueryString() : '';
const url = beforeTs
? `/api/nodes?limit=2000&before=${encodeURIComponent(new Date(beforeTs).toISOString())}${aqs}${rqs}`
: `/api/nodes?limit=2000${aqs}${rqs}`;
? `/api/nodes?limit=${window.LIVE_MAP_MAX_NODES}&before=${encodeURIComponent(new Date(beforeTs).toISOString())}${aqs}${rqs}`
: `/api/nodes?limit=${window.LIVE_MAP_MAX_NODES}${aqs}${rqs}`;
// Full reload (no beforeTs): clear existing markers so switching areas
// removes nodes that no longer belong to the selected area.
if (!beforeTs) {
+7
View File
@@ -434,6 +434,11 @@
// ─── Cache invalidation debounce (ms) ───
window.CACHE_INVALIDATE_MS = 5000;
// #1574 — operator-configurable cap on /live map node fetch (overridden
// from /api/config/client below). Default mirrors the historical
// hardcoded literal in public/live.js.
window.LIVE_MAP_MAX_NODES = 2000;
// ─── External URLs ───
window.EXTERNAL_URLS = {
flasher: 'https://flasher.meshcore.io/'
@@ -487,6 +492,8 @@
window.MC_CUSTOMIZER_CFG = (cfg.customizer && typeof cfg.customizer === 'object')
? { disabledTabs: Array.isArray(cfg.customizer.disabledTabs) ? cfg.customizer.disabledTabs : [] }
: { disabledTabs: [] };
// #1574 — operator-configurable cap on /live map node count.
if (cfg.liveMapMaxNodes != null) window.LIVE_MAP_MAX_NODES = cfg.liveMapMaxNodes;
// Sync ROLE_STYLE colors with ROLE_COLORS
// #1407 — both are now live getters; no manual sync needed. Kept as no-op for clarity.
}).catch(function () { /* use defaults */ });
+49
View File
@@ -0,0 +1,49 @@
/**
* Behavior test (#1574): operator-configurable `liveMap.maxNodes` cap.
*
* Today `public/live.js` hardcodes `/api/nodes?limit=2000` on two adjacent
* lines (~2515-2516) for the live-map node-load path. The same `2000`
* magic number also appears at :480 for `/api/packets?limit=2000` in the
* VCR-rewind code. The fix is to drive both literals from a single
* `LIVE_MAP_MAX_NODES` constant that is loaded from the server's
* client-config endpoint (`/api/config/client`) as `liveMapMaxNodes`,
* with operator override via `config.json` `liveMap.maxNodes` (default
* 2000, server-side clamp [100, 20000]).
*
* Reverting the fix (re-introducing the `?limit=2000` literal) MUST flip
* this test red.
*/
'use strict';
const fs = require('fs');
const path = require('path');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' \u2705 ' + msg); }
else { failed++; console.error(' \u274c ' + msg); }
}
const liveSrc = fs.readFileSync(path.join(__dirname, 'public', 'live.js'), 'utf8');
const rolesSrc = fs.readFileSync(path.join(__dirname, 'public', 'roles.js'), 'utf8');
// ── 1. The hardcoded /api/nodes?limit=2000 literal must be gone ─────────
const nodesLiteralHits = (liveSrc.match(/\/api\/nodes\?limit=2000/g) || []).length;
assert(nodesLiteralHits === 0,
'public/live.js no longer contains hardcoded `/api/nodes?limit=2000` literal (found ' +
nodesLiteralHits + ' occurrence(s))');
// ── 2. live.js consumes the operator-configurable LIVE_MAP_MAX_NODES ─────
assert(/LIVE_MAP_MAX_NODES/.test(liveSrc),
'public/live.js references LIVE_MAP_MAX_NODES constant');
// ── 3. roles.js plumbs `liveMapMaxNodes` from /api/config/client ────────
assert(/liveMapMaxNodes/.test(rolesSrc),
'public/roles.js reads `liveMapMaxNodes` from /api/config/client and exposes LIVE_MAP_MAX_NODES');
// ── 4. config.example.json documents the knob ────────────────────────────
const cfgExample = fs.readFileSync(path.join(__dirname, 'config.example.json'), 'utf8');
assert(/"maxNodes"\s*:/.test(cfgExample),
'config.example.json declares liveMap.maxNodes default');
console.log('\nResults: ' + passed + ' passed, ' + failed + ' failed');
process.exit(failed > 0 ? 1 : 0);