perf(dashboard): batch per-source reads to fix source-toggle 429s without exempting GETs (#3735)#3748
Conversation
…ut request volume (#3735) Source-heavy dashboards fired one GET per dataset per source on every 15s poll (nodes+traceroutes+neighbor-info+channels = 4×N, plus status = N). At 5 sources that's ~25 req/15s — already over the 1000-per-15-min API limit before any user action, and toggling a source spiked it enough to trip 429s that broke the UI for the full window (#3735). The proposed fix (exempting all GETs from the limiter, #3741) removes back-pressure protection for low-powered / busy servers; this instead cuts the volume at the source. Backend: - New shared builders (src/server/services/sourceDashboardData.ts) extracted verbatim from the per-dataset GET handlers, so individual routes and the new aggregates compute identical, identically-masked results. - GET /api/sources/:id/dashboard — bundles a source's 4 datasets (5→1 for the single-source view). - GET /api/unified/dashboard?sources= — one request for the whole unified view (4N→1). Per-dataset permission gating mirrors the individual routes (a denied dataset returns [] rather than 403-ing the bundle). Frontend: - useDashboardSourceData / useDashboardUnifiedData consume the aggregates. - Debounce the source-toggle refresh so a flurry of toggles coalesces into one refetch cycle. Net: unified view ~4N+N → ~1+N requests/poll; the existing rate limiter stays fully in place. The four individual endpoints are unchanged in behavior (validated by the existing sourceRoutes suites). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJPe6J5vKrcbwzt6vCdtrt
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
|
Claude finished @Yeraze's task —— View job PR Review: perf(dashboard): batch per-source reads to fix source-toggle 429sTasks
Overall this is a well-designed, well-motivated change. The core approach — extract shared builder functions, add two aggregate endpoints, swap the frontend to consume them — is exactly right. A few issues worth addressing before merge: Security
CSV injection via
const requested = typeof req.query.sources === 'string' && req.query.sources.length > 0
? new Set(req.query.sources.split(',').map((s) => s.trim()).filter(Boolean))
: null;The values are only used as keys into Bugs / Correctness
const localNode = await databaseService.nodes.getNode(localNodeInfo.nodeNum);The individual
Code Quality
const mcNodes: any[] = [];This was probably copied from the existing route handler (the PR claims verbatim extraction). A typed intermediate would be better than Magic constants inline ( const TX_MQTT = 5;
const TX_UDP = 6;These are duplicated from the equivalent code in the pre-existing neighbor-info handler. This PR copies them instead of referencing the canonical
All four builder functions return Debounce on source toggle ( The 400ms debounce coalesces rapid toggle refetches, which is good. However, the useEffect(() => () => {
if (refreshSourcesTimer.current) clearTimeout(refreshSourcesTimer.current);
}, []);Test CoverageTests are good overall. A few gaps:
Minor Nits
SummaryThe approach is sound and the request reduction math checks out. The main items I'd want addressed before merge:
|
…rs once, guard ?sources= length - buildSourceNeighborInfo: replace inline TX_MQTT=5/TX_UDP=6 with the canonical TransportMechanism.MQTT / .MULTICAST_UDP constants (CLAUDE.md). - maxNodeAgeHours is global: fetch it once in the /unified/dashboard route and thread it through buildSourceDashboard → buildSourceNeighborInfo, so a multi-source poll no longer re-queries the same setting once per source. - Cap the ?sources= query string (4096) before split; document that anonymous access yields empty bundles via the existing per-dataset permission gates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJPe6J5vKrcbwzt6vCdtrt
|
Thanks for the review — addressed in Fixed
Declined (with reasoning)
Full suite green (7544 passed) after the changes. |
|
Claude finished @Yeraze's task —— View job PR Review: perf(dashboard): batch per-source reads to fix source-toggle 429sTasks
This is a well-structured, high-impact change and the fixup commit addressed all the critical issues from the first review round. The code is in good shape. A few remaining items worth calling out: Bug:
|
| Issue | Severity | |
|---|---|---|
| 🐛 | getNode without sourceId in buildSourceNodes — can return a node from a different source |
Medium |
| 🔍 | Anonymous users may silently lose public channel visibility vs. old /channels route |
Low |
| 🧹 | Timer ref not cleaned up on unmount | Low |
| 📊 | No concurrency cap on Promise.all over all sources |
Low (future scale) |
| 🧪 | Missing test: anonymous access to /unified/dashboard |
Low |
The getNode source-isolation bug is the only one I'd push on before merge — the rest are quality improvements. Everything else from the first review round was correctly addressed.
|
Closing the loop on the re-review before merge:
Merging — CI green, full suite 7544 passing. |
buildSourceNodes injected the manager's local node by calling getNode(localNodeInfo.nodeNum) with no sourceId — a cross-source first-match that, on a multi-source deployment where two sources have heard the same nodeNum, could inject another source's row (and its position) into this source's node list. Pass source.id so the lookup is per-source; if this source hasn't stored the local node yet, it falls through to the synthesized minimal record as before. Pre-existing behavior carried over verbatim during the #3748 extraction (origin/main sourceRoutes.ts:990, 'regardless of sourceId'); surfaced in the #3748 review. The /:id/status route already scoped this lookup correctly. Claude-Session: https://claude.ai/code/session_01SJPe6J5vKrcbwzt6vCdtrt Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Fixes #3735 (source toggling trips the API rate limiter → 429s break the UI for 15 min) by cutting request volume, rather than exempting all GETs from the limiter as proposed in #3741.
Why not just exempt GETs (#3741)?
skip: ... || req.method === 'GET'removes back-pressure entirely for reads — exactly what hammers low-powered or heavily-utilized servers. The actual problem is that the dashboard fires far too many requests:Fix — batch the reads
Backend
src/server/services/sourceDashboardData.ts—buildSourceNodes/Channels/Traceroutes/NeighborInfoextracted verbatim from the existing per-dataset handlers (same masking/gating), plusbuildSourceDashboardwhich applies the same per-dataset permission checks the routes did via middleware.GET /api/sources/:id/dashboard— bundles a source's four datasets (single-source view: 5→1).GET /api/unified/dashboard?sources=— one request for the entire unified view (4N→1). A dataset the caller can't read returns[]rather than 403-ing the whole bundle./nodes,/traceroutes,/neighbor-info,/channels) are now thin wrappers over the shared builders — behavior unchanged, proven by the existingsourceRoutes.*suites staying green.Frontend
useDashboardSourceDataanduseDashboardUnifiedDataconsume the aggregates.Result
Unified view drops from ~
4N+Nto ~1+Nrequests per poll, and the existing rate limiter stays fully in place — no protection removed. This supersedes #3741.Tests
sourceRoutes.dashboard.test.ts— bundle shape + per-dataset permission gating (denied dataset →[], no wasted query).unifiedRoutes.test.ts—/unified/dashboardreturns one bundle per source and honors?sources=.useDashboardData.test.ts— updated to assert the batched request shape.Closes #3735
🤖 Generated with Claude Code