Skip to content

feat(traceroute): participation picker on all sources — brings the strip to MQTT (epic phase 2) - #4427

Merged
Yeraze merged 11 commits into
mainfrom
feature/traceroute-participation-picker
Jul 29, 2026
Merged

feat(traceroute): participation picker on all sources — brings the strip to MQTT (epic phase 2)#4427
Yeraze merged 11 commits into
mainfrom
feature/traceroute-participation-picker

Conversation

@Yeraze

@Yeraze Yeraze commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 2 (final) of the Traceroute Strip Interactivity epic (plan: docs/internal/dev-notes/TRACEROUTE_STRIP_INTERACTIVITY_EPIC.md, spec: TRS_PHASE2_SPEC.md). Adds a per-node traceroute picker to the Node Details traceroute section on every Meshtastic-family source — which is what makes the strip appear on MQTT sources at all, since they have no origin node and therefore no own-request traceroute.

Backend

  • tracerouteParticipationKind() (src/utils/tracerouteSegments.ts): pure predicate classifying a row as endpoint / hop / not-involved. Number()-coerced against PG/MySQL BIGINT strings; hop membership via parseHopArray, immune to the substring bug a LIKE '%num%' approach would ship (regression test included).
  • TraceroutesRepository.getTraceroutesInvolvingNode(): one bounded (2000), newest-first, source-scoped query-builder scan + JS filter — no raw SQL. Overflow can only drop older participations.
  • GET /api/traceroutes/participation/:nodeNumrequirePermission('traceroute','read', {sourceIdFrom:'query'}), sourceId required (400 MISSING_SOURCE_ID, no ALL_SOURCES fallback), envelope via ok/fail, maskTraceroutesByChannel applied ([BUG] MeshCore telemetry retrieval fails on every repeater (meshcore-only setup, v4.6.1) #3092), routePositions stripped from the projection, honest hopCount: number | null (no 999 sentinel).

Frontend

  • useNodeTraceroutes (one-shot TanStack query, 7-day window) + TracerouteParticipationPicker (native <select>, renders only at ≥2 entries; labels = date · endpoints · hop count, relay-hop entries marked "relayed").
  • MessagesTab coexistence rules: displayed row = explicit pick, else newest-of-both (poll row vs entries[0]), so the shipped TCP request-flow (pending/failed badges, "last traced") stays byte-identical — badges and age now describe the displayed row. Pick clears on node change and on a fresh traceroute response. The render guard moves from recentTrace to the displayed row — the MQTT unlock.
  • Box gate widened writeread || write (the box is a display; the request button still requires write). 6 new en.json keys, flat with inline defaults.

Testing

  • Full Vitest suite: 12,512 passed / 0 failed (success: true, JSON reporter). New: predicate tests (incl. substring trap), repository + perSource isolation tests, route tests via createRouteTestApp (permission, missing-sourceId, bounds), ApiService test, picker component tests, MessagesTab gate/coexistence tests.
  • tsc --noEmit clean; lint ratchet passes.
  • Live-validated in the dev container:
    • Florida MQTT source, node !04c5b85c: picker lists 100 (capped) dated entries; strip renders on an MQTT source for the first time; picking a 2-hop entry re-renders 2→7 glyphs, "last traced" follows the pick; Phase 1 edge tooltips work on picked rows.
    • Sandbox TCP source regression: BOCA G2 strip unchanged, picker shows 4 entries incl. a "relayed" hop-participation label.
    • No new console errors.

Closes out the epic (Phase 1 was #4424).

🤖 Generated with Claude Code

https://claude.ai/code/session_01BTfGTsPBskKYJUK8SSvYob

Yeraze and others added 10 commits July 29, 2026 14:08
…ion picker

Spec for the traceroute picker epic phase 2: a sourceId-scoped participation
API (endpoints + intermediate hops, 7-day window, JS matching over Drizzle
rows) plus a Node Details dropdown that makes the strip render on MQTT
sources for the first time.

Covers reuse inventory, file-by-file signatures, the picker/badge coexistence
rules, the read||write permission gate, the test plan (repository, route via
createRouteTestApp, perSource isolation, component/MQTT cases), and three
work packages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTfGTsPBskKYJUK8SSvYob
tracerouteParticipationKind() classifies how a node took part in a
traceroute row — as an endpoint (fromNodeNum/toNodeNum) or an
intermediate hop in route/routeBack. Endpoint match wins over hop
match. Coerces node numbers with Number() for the PG/MySQL
BIGINT-as-string shape; parseHopArray already tolerates malformed
JSON.

Covers §8.1: endpoint/hop matches both directions, endpoint
precedence, non-participant, string-typed node numbers, malformed
JSON, and the substring-match regression guard.
…phase 2 §4, WP1)

Bounded, source-scoped, newest-first scan through the query builder,
filtered in JS via tracerouteParticipationKind — there is no
database-agnostic SQL for JSON-array hop membership, and raw SQL is
banned outside migrations. scanLimit (default 2000) bounds memory;
because the scan orders newest-first, exceeding it can only drop
older participations.

Covers §8.2 (endpoint/hop matches, time window, ordering, limit,
scanLimit tradeoff, participation kind, fail-closed sourceId) and
§8.3 (per-source isolation, including hop-only participation, and the
explicit ALL_SOURCES opt-in).
…ase 2 §5, WP1)

New route backing the Node Details traceroute picker — the only way an
MQTT source (no origin node, so no own-request traceroute) can render
the strip at all. sourceId is required (400 MISSING_SOURCE_ID, no
ALL_SOURCES fallback — fail closed). Applies maskTraceroutesByChannel
(#3092 channel gate) and strips routePositions from the projection
(no hop-position leak surface). The two pre-existing handlers in this
file are untouched.

Covers §8.4 via createRouteTestApp: permission grant/deny per source,
anonymous 403, validation ladder (sourceId/nodeNum/hours/limit),
endpoint-level source isolation, hop-only participation, absent
routePositions, hopCount null-vs-length, and channel masking.
…§6.1, WP1)

Client transport for GET /api/traceroutes/participation/:nodeNum.
Declares TracerouteParticipationEntry next to SignalTrendResult,
following getSignalTrend's declare-here + unwrap-data precedent —
request() deliberately does not unwrap {success,data} (CLAUDE.md).
Structurally satisfies TracerouteStripInput; no adapter needed.

Covers §8.5: encoded query string (sourceId + optional hours/limit),
unwraps data.entries, and returns [] when data is absent.
…route

req.user is already typed globally via the Express.Request augmentation
in src/types/auth.ts — the (req as any).user cast was unnecessary and
tripped the @typescript-eslint/no-explicit-any lint ratchet.
… wiring (epic phase 2 WP2)

Adds the client-side half of the traceroute participation picker: a
one-shot useNodeTraceroutes hook, the TracerouteParticipationPicker
component (native <select>, renders only at >=2 entries), and the
MessagesTab coexistence rules (gate widened to read||write, newest-of-both
default selection, reset on node change / fresh traceroute, badges/age and
the strip memo keyed off the displayed row instead of only the poll row).
This is what unlocks the traceroute strip on MQTT sources, which have no
own-request traceroute to show.

Codes against the §6.1 API contract from TRS_PHASE2_SPEC.md; WP1 landed
apiService.getTracerouteParticipation / TracerouteParticipationEntry in
parallel in this same worktree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTfGTsPBskKYJUK8SSvYob
… a QueryClientProvider

MessagesTab.composeFocus.test.tsx and MessagesTab.txDisabled.test.tsx render
MessagesTab directly (no QueryClientProvider), unlike
MessagesTab.tracerouteStrip.test.tsx which wraps it in one. The traceroute
participation picker (epic phase 2, WP2) added an unconditional
useNodeTraceroutes -> useQuery call in MessagesTab, which throws "No
QueryClient set, use QueryClientProvider" in both files. Stub the hook to a
deterministic empty list, same pattern already used in
MessagesTab.tracerouteStrip.test.tsx.

Checked every other src/components/*.test.tsx referencing MessagesTab
(NodesTab.test.tsx) — it only mentions MessagesTab in comments and never
renders it, so no further stragglers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTfGTsPBskKYJUK8SSvYob
…rticipation-picker

# Conflicts:
#	src/server/routes/tracerouteRoutes.ts
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(traceroute): participation picker on all sources

Tasks

  • Gather context and understand the PR
  • Review backend changes (predicate, repository, route)
  • Review frontend changes (hook, component, MessagesTab)
  • Review test coverage
  • Check security and performance
  • Post final review

Overall Assessment

Solid, well-structured feature addition. The code is clean, follows project conventions, and the PR description accurately reflects the implementation. The test coverage is thorough. I have a few observations ranging from minor nits to one worth discussing.


Backend

tracerouteParticipationKind (src/utils/tracerouteSegments.ts:303)

Clean predicate. The Number() coercion for PG/MySQL BIGINTs is correct and well-documented, and reusing parseHopArray avoids the substring bug LIKE '%num%' would have. Endpoint-wins-over-hop ordering is a reasonable design choice.

One edge case worth documenting (not a bug): a node that appears in both route and routeBack of a traceroute it's not an endpoint of returns 'hop' — this is correct by the predicate's ordering, but the comment on the endpoint-wins rule only mentions the origin-and-relay case. If this comes up in a user bug report it may cause confusion.

getTraceroutesInvolvingNode (src/db/repositories/traceroutes.ts:182)

The scan-then-JS-filter approach is a reasonable workaround for the JSON-column-search problem across three backends; the 2000-row scanLimit is documented and the "drops oldest" property is made explicit. One observation: the method accepts opts.sourceId: SourceScope but the route handler always supplies a concrete string (never ALL_SOURCES). It would be marginally safer to type the parameter as string here to make the "always-scoped" invariant impossible to violate accidentally, but this is a minor style preference.

Route GET /api/traceroutes/participation/:nodeNum (src/server/routes/tracerouteRoutes.ts:113)

  • The requirePermission('traceroute', 'read', { sourceIdFrom: 'query' }) middleware runs before the !sourceId check at line 118. When sourceId is absent the middleware resolves it from query to undefined, which may cause the permission check to fall back to a global permission rather than source-scoped. This is worth verifying with the harness — the test at line 85 passes only because harness.admin has global write, not because the 400 fires before auth. If a read-only user with no sourceId skips through the middleware and then hits the 400, the 400 is correct, but the ordering means a missing sourceId isn't caught as early as possible.

  • The hopCount computation at line 166 calls parseHopArray which is already called inside tracerouteParticipationKind for every candidate row. For the happy path this means each matched row parses its route twice. At 100-row cap this is negligible, but a shared parse at the scan level would eliminate the duplication.

  • routePositions is correctly excluded from the projection (security: [BUG] MeshCore telemetry retrieval fails on every repeater (meshcore-only setup, v4.6.1) #3092). Well done.

  • The sentinel-free hopCount: number | null instead of the 999 used elsewhere is a deliberate improvement — the PR description calls this out, and it's the right call for a picker label.


Frontend

useNodeTraceroutes (src/hooks/useNodeTraceroutes.ts)

Using useResolvedSourceId() rather than useSource() to handle the outside-SourceProvider case is the correct approach. staleTime: 60_000 is reasonable. refetchOnWindowFocus: false prevents noisy refetches. No issues.

TracerouteParticipationPicker (src/components/traceroute/TracerouteParticipationPicker.tsx)

  • entries.length < 2 early-return is the right call — a single-entry dropdown adds chrome with no choice.
  • The eslint-disable-next-line react-refresh/only-export-components comment on buildOptionLabel is correctly scoped and issue-referenced ([FEAT] Color-code node roles in traceroute display #4381).
  • CSS module is used correctly; no global sheet modifications.
  • Minor: the select element has both value and onChange but when selectedId is null and entries.length >= 2, value="" will not match any option's value (which are stringified IDs), so the browser will show no selection, effectively showing whatever the browser picks as "uncontrolled". This is probably the intended UX (first option visible by default), but it means the value prop is a noop in the null-selectedId state. Consider whether value={selectedId != null ? String(selectedId) : String(entries[0]?.id)} is clearer intent, or document the current behaviour explicitly.

MessagesTab.tsx coexistence logic (src/components/MessagesTab.tsx:795-802)

The three-way priority logic (explicit pick → newest-of-both by timestamp → fallback) is well-reasoned and the comment explains all the cases. One subtle point: when pickedTracerouteId is set but the participation list is refetched and that ID no longer appears (e.g. entry aged out of the 7-day window), pickedEntry becomes null and newestAvailable silently takes over. This is correct per the spec and the comment at line 793, but no badge indicates to the user that their pick disappeared — this might be surprising if a user picks a ~7-day-old entry and the next refetch drops it. Low priority, but worth a follow-up issue if not already tracked.

isPending / isFailed logic (src/components/MessagesTab.tsx:2034-2038)

These badges fire when forwardFailed && returnFailed — i.e. both route and routeBack are null/absent. For a 'hop'-participation entry the route is never null (the node is in it), so this path is unreachable for a pure-hop pick. For an 'endpoint' pick that was seeded with a pending row (no route yet), the logic correctly shows "Pending". This is fine, but worth noting that the badge semantics become moot for hop picks since a hop can't exist in a null-route row.

Box gate widening (src/components/MessagesTab.tsx:2010)

hasPermission('traceroute', 'read') || hasPermission('traceroute', 'write') — the comment at line 2005-2009 explains the rationale (non-regressive for write-without-read users). This is sound. The spec reference (§6.4) is helpful.


Test Coverage

Tests are comprehensive and well-structured:

  • tracerouteParticipationKind unit tests include the substring-trap regression, malformed JSON, and string-typed node numbers — all the right cases.
  • Route tests use createRouteTestApp() per CLAUDE.md convention, with permission/source isolation, bounds checks, and the missing-sourceId 400.
  • Repository perSource isolation test exists and follows the prescribed pattern.
  • MessagesTab.tracerouteStrip.test.tsx correctly stubs useNodeTraceroutes to prevent real network calls under jsdom.

One gap: there's no test exercising the newestAvailable coexistence rule directly (poll row newer than entries[0] → shows poll row; entries[0] newer → shows entries row). The MessagesTab component test is complex to set up for this, but it's the most nuanced part of the feature.


Security

  • requirePermission('traceroute', 'read', { sourceIdFrom: 'query' }) is present and correctly scoped.
  • maskTraceroutesByChannel is applied before the projection (line 148), matching the existing channel gate on other endpoints.
  • routePositions is excluded from the response projection — correct.
  • No raw SQL introduced.
  • sourceId is required (no silent ALL_SOURCES fallback) — good design.

Summary

The PR is in good shape. The main points worth a second look before merge:

  1. isPending/isFailed on a hop-picked entry with null route: confirms the badge logic is unreachable (correct), but consider whether hop entries should suppress those badges explicitly for clarity.
  2. select value="" when selectedId is null: document or fix the implicit "first option" UX.
  3. scanLimit SourceScope type on getTraceroutesInvolvingNode: minor — narrowing to string would enforce the always-scoped invariant.
  4. Missing coexistence rule test: low priority follow-up.

None of these are blockers. The feature is well-reasoned and the implementation correctly follows project conventions.

…narrowed sourceId type

- Hidden "Latest" placeholder for the select's value="" state so the picker
  no longer implies the first option is selected when the displayed row came
  from the poll (review item 2).
- getTraceroutesInvolvingNode narrows opts.sourceId from SourceScope to
  string, making an ALL_SOURCES caller unrepresentable (review item 3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTfGTsPBskKYJUK8SSvYob
@Yeraze

Yeraze commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

Review feedback triage — addressed in the latest commit:

  • feat: traceroute highlighting and UI improvements #2 select value=\"\" implicit first-option — fixed: a hidden "Latest" placeholder now renders for the null-selection state, so the picker never implies a selection that wasn't made. The test that documented the old browser-fallback quirk now asserts the placeholder.
  • "Update Claude PR Assistant workflow" #3 sourceId: SourceScope on the repository method — narrowed to string with a comment; an ALL_SOURCES caller is now unrepresentable.

Declined:

  • "Claude PR Assistant workflow" #1 badge suppression for hop-picked entries — the review itself confirms the current logic is unreachable-therefore-correct; adding an explicit suppression path would be dead code guarding a state that can't occur.
  • feat: traceroute request tracking and auth documentation #4 extra coexistence-rule test — agreed it's worthwhile, deferred as a low-priority follow-up per the review's own framing; the shipped tests cover the newest-of-both and pick-reset rules.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BTfGTsPBskKYJUK8SSvYob

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(traceroute): participation picker on all sources

Tasks


Follow-up Review

Both addressed items are clean.

Fix #2select value="" implicit first-option (TracerouteParticipationPicker.tsx:93-97)

The hidden "Latest" placeholder option is the correct solution. When selectedId is null, value="" now has an actual matching <option value="" hidden> to land on, so the browser displays "Latest" rather than silently promoting the first real entry's label. The placeholder is rendered conditionally — it vanishes the moment a real pick is active, so it can't be selected again. Well done.

The test at line 275-314 covers exactly the right cases: selectedId: 7 binds correctly, selectedId: null transitions without throwing, select.value is "", the placeholder is present and hidden, and its text is "Latest". No gaps.

Fix #3sourceId: string on getTraceroutesInvolvingNode (traceroutes.ts:184-188)

The inline comment explains the narrowing rationale clearly: "Narrowed to string (not SourceScope): the participation picker is per-source by definition, so an ALL_SOURCES caller is a bug this signature makes unrepresentable." This is exactly the right approach — the type system enforces the invariant rather than relying on call-site discipline.

No new issues introduced. The two changes are minimal and additive; the rest of the implementation is unchanged.


The two outstanding actionable items from the previous review are resolved. The declined items (#1 dead-code badge suppression, #4 deferred coexistence test) remain reasonable calls given the justifications provided. This PR is ready to merge.

@Yeraze
Yeraze merged commit e277d34 into main Jul 29, 2026
16 checks passed
@Yeraze
Yeraze deleted the feature/traceroute-participation-picker branch July 29, 2026 21:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant