feat(meshcore): per-source node-age filtering and Node Display settings (closes #4412) - #4433
Conversation
…4412 WP1) Adds src/utils/meshcoreAge.ts as the single place MeshCore timestamps (lastHeard ms, lastSeen ms, lastAdvert s) get normalized to epoch ms, per the Phase 4 spec's D4 decision. Points the three existing hand-rolled copies at it: - meshcoreManager.filterPathfindingContacts - MeshCorePathfindingFilterSection's live preview (plus the D5 cross-reference description under the last-heard-enable checkbox) - MeshCoreContactDetailPanel's lastAdvert display Behavior-preserving: meshcoreManager.pathfindingFilter.test.ts, meshcoreRoutes.pathfindingFilter.test.ts, and MeshCoreContactDetailPanel.test.tsx pass unedited. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
… 4 WP2) MeshCore sources had no Node Display settings UI at all. Add MeshCoreNodeDisplaySection, mounted in MeshCoreSettingsView/MeshCorePage, exposing the four keys that have a MeshCore consumer: maxNodeAgeHours (this epic's list/map age filter, landing in WP3) plus the three inactive-node keys (inactiveNodeThresholdHours/CheckIntervalMinutes/CooldownHours), which inactiveNodeNotificationService already branches on for MeshCore alerts with no way to configure them until now. Persists through the existing per-source /api/settings?sourceId= endpoints (no new route — these are 4 of the 10 rows in the shared settings table, not MeshCore-specific payloads) and reads via useNodeDisplaySettings(sourceId) so the section, Nodes list and map share one TanStack cache entry. Exports nodeDisplaySettingsQueryKey so the section can invalidate that exact entry after a save — without it the filter wouldn't move until a page reload. Per spec docs/internal/dev-notes/PER_SOURCE_NODE_DISPLAY_PHASE4_SPEC.md (D1/D3), added to the worktree for this work package.
… + map (#4412 Phase 4 WP3) Filters the reconciled MergedRow set by the per-source Node Display maxNodeAgeHours setting before sorting/searching, with favorites and the local node exempt from the cutoff (parity with useProcessedNodes). The filtered set is also passed down to MeshCoreMap so the list and map can never disagree. mergeNodesAndContacts now resolves lastHeard via the shared meshcoreLastHeardMs helper instead of reading contact.lastSeen directly, so a contact carrying only lastAdvert (seconds) resolves correctly instead of showing no last-heard. MeshCoreMap gains a doc comment recording that it does not filter by age itself — the caller must.
…ab (#4412 Phase 4 WP4) SettingsTab never mounts under a MeshCore route (main.tsx:86 routes MeshCore sources to MeshCorePage instead), so the Phase 3 isMeshCoreSource branch that hid localStatsIntervalMinutes/nodeHopsCalculation was dead code, and its two tests in SettingsTab.nodeDisplay.perSource.test.tsx passed vacuously. Phase 4 (WP2, MeshCoreNodeDisplaySection.test.tsx, 10 passing tests) is the real replacement coverage on the surface MeshCore users actually reach. Delete the branch, its now-unused useSource import, and the two vacuous tests. Correct the epic doc's Phase 4 "Scope expanded" callout, which wrongly claimed this mount point would make the branch live, and record the Phase 4 deviations-log entry (D1, D2, D3 + latent inactive-node-keys bug, D5, mergeNodesAndContacts behaviour change). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
…ter (#4412 Phase 4 WP3) Rebases the pre-existing lastHeard: 1000/2000/3000 (epoch-1970) fixtures onto Date.now()-relative milliseconds so the suite is neutral to the new maxNodeAgeHours cutoff -- relative order/spacing preserved, assertions unchanged. Adds a new describe covering: in/out of cutoff, favorite bypass, local-node bypass, unknown-timestamp exclusion, lastAdvert-only (seconds) resolution, a unit-confusion regression guard, the map receiving the same filtered contacts, a live maxNodeAgeHours change re-listing an aged-out node, and search narrowing within the already age-filtered set.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
|
Claude finished @Yeraze's task —— View job PR Review: feat(meshcore): per-source node-age filtering and Node Display settingsTasks
This is a well-engineered PR. The self-documentation is thorough, the architecture decisions are sound, and the tests are unusually good. Here's what I found: FindingsBug / Logic Gap
const isAgeExempt = useCallback((r: MergedRow): boolean =>
r.isFavorite || r.name.includes('(local)'), []);The local-node detection in This is an established cross-codebase convention ( Potential Edge Case in
|
…ts, i18n - MeshCoreNodesView: treat a DB row's lastHeard:0 as absent when merging with contacts, matching meshcoreLastHeardMs's own 0-is-unknown rule, so a row doesn't get wrongly excluded by the age filter. - MeshCoreNodeDisplaySection: fall back to the previous draft value when a cleared number input parses to NaN, so it never reaches the save payload. - Document the "(local)" name-matching convention with NOTE comments at both match sites (no behavior change). - Stop borrowing automation.* toast strings in MeshCoreNodeDisplaySection; add dedicated settings.node_display.* keys with identical fallback text. - Add regression tests for the lastHeard:0 merge and the NaN input guard.
|
Thanks — four findings, all applied in The one real bug:
|
|
Claude finished @Yeraze's task —— View job PR Review: feat(meshcore): per-source node-age filtering and Node Display settingsTasks
This is a follow-up review of the updated PR after Yeraze addressed all four findings from the initial review in commit Verification of the Four Fixed Issues✅
|
Closes #4412. Final phase of the per-source Node Display epic. Builds on Phase 1 (#4417), Phase 2 (#4425), Phase 3 (#4431).
Plan:
docs/internal/dev-notes/PER_SOURCE_NODE_DISPLAY_EPIC.mdSpec:
docs/internal/dev-notes/PER_SOURCE_NODE_DISPLAY_PHASE4_SPEC.mdThe bug this fixes first — MeshCore alerts you could not configure
inactiveNodeNotificationService.ts:266already branches onsourceType === 'meshcore'and queriesgetInactiveMeshcoreNodes. So the three inactive-node settings — threshold, check interval, cooldown — have been driving inactive-node alerts on MeshCore sources with no UI anywhere to configure them. A MeshCore source had no Node Display settings surface at all.That is fixed here. It was found by tracing consumers to decide which settings belong on the MeshCore surface, rather than guessing.
What the reporter asked for
Two things were missing, not one:
1. The settings surface.
SettingsTabmounts only atGlobalSettingsPage.tsx:89andApp.tsx:3640(the Meshtastic source route);main.tsx:86routes MeshCore toMeshCoreSourcePage, which never rendered it. NewMeshCoreNodeDisplaySectioncomposes into the existingMeshCoreSettingsView.Mounting
SettingsTabwas considered and rejected: it needs ~50 props (~24 of them no-op callbacks — a seam that type-checks and lies), andSOURCE_SECTIONSwould drag firmware update, packet monitor, telemetry, solar and sorting onto a MeshCore page. MeshCore-shaped UI, shared storage — it persists through the existing/api/settings?sourceId=, not a new route, because forking one setting across two endpoints is the drift #4416 exists to fix.Four fields, not ten: max age + the inactive trio. The other six have Meshtastic-view-only consumers.
2. The filter.
MeshCoreNodesViewhad no age filter whatsoever. Now filters on the reconciledmeshcore_nodes.lastHeard(preferlastSeen, fall back tolastAdvert * 1000) with favorites and the local node exempt, at parity withuseProcessedNodes— otherwise a favorited MeshCore node silently vanishes.Units: one place knows the rule
nodes.lastHeardis seconds,meshcore_nodes.lastHeardis milliseconds, firmwarelastAdvertis seconds. Newsrc/utils/meshcoreAge.tsowns the conversion and three existing hand-rolled copies were collapsed onto it. Grep-verified: no* 1000// 1000arithmetic on these fields remains outside the helper.No
lastAdvertcolumn was added — the interview chose the reconciledlastHeard, which works for rows already in the DB.Retired: an unreachable branch from Phase 3
Phase 3 added an
isMeshCoreSourcebranch toSettingsTabhiding two fields, on my assumption that Phase 4 would mountSettingsTabon MeshCore. It doesn't, so the branch is unreachable by construction. Removed, along with its 2 tests — which built a<SourceProvider sourceType="meshcore">no route constructs and would have passed even if the MeshCore surface showed every Meshtastic knob. Replacement coverage isMeshCoreNodeDisplaySection.test.tsx. The epic doc's now-wrong note is corrected in place rather than deleted, so nobody "restores" the branch believing coverage was lost.Test fixtures that would have hidden the filter
All 17
MeshCoreNodesViewtests usedlastHeard: 1000/2000/3000— epoch 1970. Every fixture falls outside any real cutoff, so the suite would have gone red for the wrong reason and tempted someone into weakening the filter. Rebased toDate.now()-relative ms; no assertion text changed.Browser validation (live MeshCore source, deployed worktree build)
MC-Sandboxat 168 whileMC-BLESandboxstayed 24.locales/en-US.json404.Data-quality note: of 192 stored MeshCore nodes, 185 have plausible ms timestamps; 4 are future-dated and 2 near-epoch — devices with unset RTCs. The filter treats them as reported. Not a defect, but worth knowing before reading a node count as a bug.
Verification
success: truevia JSON reporter, PG + MySQL live, 109 skips unchanged.tsc --noEmitclean;lint:ciratchet clean.Still open
Per-source permission scoping (#4416): a source-A-scoped
settings:writegrant still authorizes source-B writes. This PR does not change that. Also #4419.Closes #4412
🤖 Generated with Claude Code
https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB