diff --git a/cmd/server/config.go b/cmd/server/config.go index aece9ba3..951f4fce 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -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 { diff --git a/cmd/server/livemap_maxnodes_1574_test.go b/cmd/server/livemap_maxnodes_1574_test.go new file mode 100644 index 00000000..8b336457 --- /dev/null +++ b/cmd/server/livemap_maxnodes_1574_test.go @@ -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) + } + }) + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 3dfc1734..eb17b009 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -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, diff --git a/cmd/server/types.go b/cmd/server/types.go index f457d036..2a6ff9de 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -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 diff --git a/config.example.json b/config.example.json index fd017c8d..55da9ad0 100644 --- a/config.example.json +++ b/config.example.json @@ -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", diff --git a/public/live.js b/public/live.js index 82fbfa54..f7c3c3ca 100644 --- a/public/live.js +++ b/public/live.js @@ -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) { diff --git a/public/roles.js b/public/roles.js index 66e3a904..6f48fbbd 100644 --- a/public/roles.js +++ b/public/roles.js @@ -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 */ }); diff --git a/test-issue-1574-live-map-max-nodes.js b/test-issue-1574-live-map-max-nodes.js new file mode 100644 index 00000000..5cfdd789 --- /dev/null +++ b/test-issue-1574-live-map-max-nodes.js @@ -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);