diff --git a/docs/meshcore/passive-path-preview.md b/docs/meshcore/passive-path-preview.md new file mode 100644 index 0000000..c22f599 --- /dev/null +++ b/docs/meshcore/passive-path-preview.md @@ -0,0 +1,12 @@ +## Passive path tracing (preview) + +Route: `/meshcore/path-tracing` + +This page is a **diagnostic MVP** for MeshCore passive packet path tracing (API milestone M1). It lists: + +- **Segments** — hash segments from ingest rollups, with `hash_size`, `hash_mode`, and resolution `status`. Unknown hashes use dashed styling consistent with heard-path maps. +- **Edges** — hourly hash→hash buckets derived from list-order `path_hashes` chains. The direction column shows the API value `list_order` (packet list order, not RF forwarding direction). + +Staff users can manually annotate a segment (link hash to an observed node) via `PATCH /api/meshcore/path-tracing/segments/{id}/`. + +This preview supports **M2 decision-making** (mode/size distribution, chain sanity). The full map, realtime buffer, and centrality UI are tracked separately as [meshflow-ui#309](https://github.com/pskillen/meshflow-ui/issues/309) (M7), building on API work in [meshflow-api#372](https://github.com/pskillen/meshflow-api/issues/372). diff --git a/src/App.tsx b/src/App.tsx index b7236dc..c70678d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,6 +32,7 @@ import DxMonitoringPage from '@/pages/nodes/DxMonitoringPage'; import { MeshCoreNodesList } from '@/pages/meshcore/MeshCoreNodesList'; import { MeshCoreMessages } from '@/pages/meshcore/MeshCoreMessages'; import { MeshCoreDashboard } from '@/pages/meshcore/MeshCoreDashboard'; +import { MeshCorePassivePath } from '@/pages/meshcore/MeshCorePassivePath'; import { MeshtasticDashboard } from '@/pages/meshtastic/MeshtasticDashboard'; const ManagedNodesStatus = lazy(() => import('@/pages/nodes/ManagedNodesStatus')); @@ -62,6 +63,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/nav-main.tsx b/src/components/nav-main.tsx index db6e55d..057fdfb 100644 --- a/src/components/nav-main.tsx +++ b/src/components/nav-main.tsx @@ -142,6 +142,11 @@ function buildNavSections(showDxMonitoring: boolean): NavSection[] { label: 'MeshCore', items: [ { title: 'Dashboard', url: '/meshcore/dashboard', icon: BarChartIcon }, + { + title: 'Passive path (preview)', + url: '/meshcore/path-tracing', + icon: RouteIcon, + }, { title: 'Messages', url: '/meshcore/messages', icon: MessageSquareIcon }, { title: 'Nodes', diff --git a/src/hooks/api/usePathTracing.ts b/src/hooks/api/usePathTracing.ts new file mode 100644 index 0000000..365e600 --- /dev/null +++ b/src/hooks/api/usePathTracing.ts @@ -0,0 +1,40 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { MeshCorePathSegmentAnnotateBody } from '@/lib/models'; +import type { PathTracingQueryParams } from '@/lib/types'; +import { useMeshflowApi } from './useApi'; + +export function pathTracingEdgesKey(params?: PathTracingQueryParams) { + return ['pathTracing', 'edges', params] as const; +} + +export function pathTracingSegmentsKey(params?: PathTracingQueryParams) { + return ['pathTracing', 'segments', params] as const; +} + +export function usePathTracingEdges(params?: PathTracingQueryParams) { + const api = useMeshflowApi(); + return useQuery({ + queryKey: pathTracingEdgesKey(params), + queryFn: () => api.getPathTracingEdges(params), + }); +} + +export function usePathTracingSegments(params?: PathTracingQueryParams) { + const api = useMeshflowApi(); + return useQuery({ + queryKey: pathTracingSegmentsKey(params), + queryFn: () => api.getPathTracingSegments(params), + }); +} + +export function useAnnotatePathSegment() { + const api = useMeshflowApi(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ segmentId, body }: { segmentId: string; body: MeshCorePathSegmentAnnotateBody }) => + api.annotatePathSegment(segmentId, body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['pathTracing'] }); + }, + }); +} diff --git a/src/lib/api/meshflow-api.ts b/src/lib/api/meshflow-api.ts index 6a80175..61ef8be 100644 --- a/src/lib/api/meshflow-api.ts +++ b/src/lib/api/meshflow-api.ts @@ -34,6 +34,9 @@ import { DxNotificationSettings, DxNotificationSettingsWrite, MeshCorePacketListItem, + MeshCorePathEdgeBucket, + MeshCorePathSegment, + MeshCorePathSegmentAnnotateBody, } from '../models'; import { ApiConfig, @@ -42,6 +45,7 @@ import { PaginationParams, StatsSnapshotsParams, DxEventsQueryParams, + PathTracingQueryParams, } from '@/lib/types'; import { parseNodeWatchFromAPI, parseObservedNodeFromAPI } from './api-utils'; import type { RfProfile, RfProfileUpdateBody, RfPropagationPollResult, RfPropagationRenderRow } from '@/lib/models'; @@ -513,6 +517,38 @@ export class MeshflowApi extends BaseApi { return this.get('/meshcore/packets/', searchParams); } + private appendPathTracingParams(searchParams: URLSearchParams, params?: PathTracingQueryParams) { + if (params?.page) searchParams.append('page', params.page.toString()); + if (params?.page_size) searchParams.append('page_size', params.page_size.toString()); + if (params?.bucket_start_after) searchParams.append('bucket_start_after', params.bucket_start_after); + if (params?.bucket_start_before) searchParams.append('bucket_start_before', params.bucket_start_before); + if (params?.observer) searchParams.append('observer', params.observer); + if (params?.constellation != null) searchParams.append('constellation', String(params.constellation)); + if (params?.from_hash) searchParams.append('from_hash', params.from_hash); + if (params?.to_hash) searchParams.append('to_hash', params.to_hash); + if (params?.resolved != null) searchParams.append('resolved', params.resolved ? 'true' : 'false'); + if (params?.status) searchParams.append('status', params.status); + if (params?.hash_mode != null) searchParams.append('hash_mode', String(params.hash_mode)); + if (params?.hash_size != null) searchParams.append('hash_size', String(params.hash_size)); + if (params?.segment_hash) searchParams.append('segment_hash', params.segment_hash); + } + + async getPathTracingEdges(params?: PathTracingQueryParams): Promise> { + const searchParams = new URLSearchParams(); + this.appendPathTracingParams(searchParams, params); + return this.get('/meshcore/path-tracing/edges/', searchParams); + } + + async getPathTracingSegments(params?: PathTracingQueryParams): Promise> { + const searchParams = new URLSearchParams(); + this.appendPathTracingParams(searchParams, params); + return this.get('/meshcore/path-tracing/segments/', searchParams); + } + + async annotatePathSegment(segmentId: string, body: MeshCorePathSegmentAnnotateBody): Promise { + return this.patch(`/meshcore/path-tracing/segments/${segmentId}/`, body); + } + async getManagedNodes( params?: PaginationParams & { includeStatus?: boolean; diff --git a/src/lib/models.ts b/src/lib/models.ts index 8ca12e3..5c4a514 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -154,6 +154,55 @@ export interface GeoClassification { }; } +export interface MeshCorePathObservedNodeMinimal { + internal_id: string; + node_id_str: string | null; + long_name: string | null; +} + +export interface MeshCorePathEdgeBucket { + id: string; + bucket_start: string; + bucket_size: string; + from_kind: string; + to_kind: string; + from_hash: string; + to_hash: string; + observer: string | null; + observer_name: string | null; + constellation: number | null; + constellation_name: string | null; + packet_count: number; + observation_count: number; + first_seen_at: string | null; + last_seen_at: string | null; + avg_snr: number | null; + min_snr: number | null; + max_snr: number | null; + direction: string; + resolved: boolean; +} + +export interface MeshCorePathSegment { + id: string; + segment_hash: string; + hash_size: number | null; + hash_mode: number | null; + status: string; + source: string; + resolver_version: number; + confidence: number | null; + observed_node: MeshCorePathObservedNodeMinimal | null; + first_seen_at: string; + last_seen_at: string; +} + +export interface MeshCorePathSegmentAnnotateBody { + observed_node_id?: string | null; + node_id_str?: string | null; + status?: string; +} + export interface MeshCorePacketListItem { id: string; payload_type: number; diff --git a/src/lib/types.ts b/src/lib/types.ts index 03212b3..875e619 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -44,6 +44,21 @@ export interface DxEventsQueryParams extends PaginationParams { first_observed_before?: string; } +/** Query params for MeshCore passive path tracing list endpoints. */ +export interface PathTracingQueryParams extends PaginationParams { + bucket_start_after?: string; + bucket_start_before?: string; + observer?: string; + constellation?: number; + from_hash?: string; + to_hash?: string; + resolved?: boolean; + status?: string; + hash_mode?: number; + hash_size?: number; + segment_hash?: string; +} + export interface ApiAuthConfig { type: 'none' | 'token' | 'basic' | 'oauth' | 'apiKey'; token?: string; diff --git a/src/pages/meshcore/MeshCorePassivePath.test.tsx b/src/pages/meshcore/MeshCorePassivePath.test.tsx new file mode 100644 index 0000000..1626cef --- /dev/null +++ b/src/pages/meshcore/MeshCorePassivePath.test.tsx @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { MeshCorePathEdgeBucket, MeshCorePathSegment } from '@/lib/models'; +import { + useAnnotatePathSegment, + usePathTracingEdges, + usePathTracingSegments, +} from '@/hooks/api/usePathTracing'; +import { MeshCorePassivePath } from './MeshCorePassivePath'; + +const getCurrentUser = vi.fn(); + +vi.mock('@/lib/auth/authService', () => ({ + authService: { + getCurrentUser: () => getCurrentUser(), + }, +})); + +vi.mock('@/hooks/api/usePathTracing', () => ({ + usePathTracingSegments: vi.fn(), + usePathTracingEdges: vi.fn(), + useAnnotatePathSegment: vi.fn(), +})); + +const usePathTracingSegmentsMock = vi.mocked(usePathTracingSegments); +const usePathTracingEdgesMock = vi.mocked(usePathTracingEdges); +const useAnnotatePathSegmentMock = vi.mocked(useAnnotatePathSegment); + +const emptyPage = { count: 0, next: null, previous: null, results: [] as MeshCorePathSegment[] }; +const emptyEdges = { count: 0, next: null, previous: null, results: [] as MeshCorePathEdgeBucket[] }; + +const sampleSegment: MeshCorePathSegment = { + id: 'seg-1', + segment_hash: 'aabb', + hash_size: 2, + hash_mode: 0, + status: 'unknown', + source: 'rollup', + resolver_version: 0, + confidence: null, + observed_node: null, + first_seen_at: '2026-06-01T10:00:00Z', + last_seen_at: '2026-06-01T12:00:00Z', +}; + +const sampleEdge: MeshCorePathEdgeBucket = { + id: 'edge-1', + bucket_start: '2026-06-01T09:00:00Z', + bucket_size: '1h', + from_kind: 'hash', + to_kind: 'hash', + from_hash: 'aa', + to_hash: 'bb', + observer: 'obs-uuid', + observer_name: 'Feeder A', + constellation: null, + constellation_name: null, + packet_count: 3, + observation_count: 5, + first_seen_at: '2026-06-01T09:05:00Z', + last_seen_at: '2026-06-01T09:55:00Z', + avg_snr: 4.5, + min_snr: 2, + max_snr: 7, + direction: 'list_order', + resolved: false, +}; + +function renderPage() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + + ); +} + +describe('MeshCorePassivePath', () => { + const mutateAsync = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + getCurrentUser.mockReturnValue({ id: 1, username: 'staff', is_staff: true }); + usePathTracingSegmentsMock.mockReturnValue({ + isLoading: false, + isSuccess: true, + isError: false, + error: null, + data: { ...emptyPage, count: 1, results: [sampleSegment] }, + } as ReturnType); + usePathTracingEdgesMock.mockReturnValue({ + isLoading: false, + isSuccess: true, + isError: false, + error: null, + data: { ...emptyEdges, count: 1, results: [sampleEdge] }, + } as ReturnType); + useAnnotatePathSegmentMock.mockReturnValue({ + mutateAsync, + isPending: false, + } as unknown as ReturnType); + }); + + it('renders preview title and segment/edge rows', () => { + renderPage(); + expect(screen.getByText(/Passive path tracing \(preview\)/i)).toBeInTheDocument(); + expect(screen.getByText('aabb')).toBeInTheDocument(); + expect(screen.getByText('list_order')).toBeInTheDocument(); + expect(screen.getByText(/aa → bb/)).toBeInTheDocument(); + }); + + it('passes segment filters into the segments query hook', async () => { + const user = userEvent.setup(); + renderPage(); + await user.type(screen.getByTestId('segment-filter-hash-mode'), '1'); + await waitFor(() => { + const lastCall = usePathTracingSegmentsMock.mock.calls.at(-1)?.[0]; + expect(lastCall?.hash_mode).toBe(1); + }); + }); + + it('shows staff annotate panel for staff', () => { + renderPage(); + expect(screen.getByTestId('staff-annotate-panel')).toBeInTheDocument(); + }); + + it('hides staff annotate panel for non-staff', () => { + getCurrentUser.mockReturnValue({ id: 2, username: 'user', is_staff: false }); + renderPage(); + expect(screen.queryByTestId('staff-annotate-panel')).not.toBeInTheDocument(); + }); + + it('submits staff annotation via mutation', async () => { + const user = userEvent.setup(); + mutateAsync.mockResolvedValue(sampleSegment); + renderPage(); + await user.type(screen.getByTestId('annotate-segment-id'), 'seg-1'); + await user.type(screen.getByTestId('annotate-node-id-str'), 'mc:abc'); + await user.click(screen.getByTestId('annotate-submit')); + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalledWith({ + segmentId: 'seg-1', + body: { node_id_str: 'mc:abc', status: 'resolved' }, + }); + }); + }); +}); diff --git a/src/pages/meshcore/MeshCorePassivePath.tsx b/src/pages/meshcore/MeshCorePassivePath.tsx new file mode 100644 index 0000000..977f1d3 --- /dev/null +++ b/src/pages/meshcore/MeshCorePassivePath.tsx @@ -0,0 +1,419 @@ +import { useMemo, useState } from 'react'; +import { format } from 'date-fns'; +import { enGB } from 'date-fns/locale'; +import { toast } from 'sonner'; +import { authService } from '@/lib/auth/authService'; +import type { MeshCorePathEdgeBucket, MeshCorePathSegment } from '@/lib/models'; +import type { PathTracingQueryParams } from '@/lib/types'; +import { useAnnotatePathSegment, usePathTracingEdges, usePathTracingSegments } from '@/hooks/api/usePathTracing'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { cn } from '@/lib/utils'; + +const SEGMENT_STATUSES = ['unknown', 'resolved', 'ambiguous', 'stale'] as const; +const PAGE_SIZE = 100; + +function formatWhen(iso: string | null | undefined): string { + if (!iso) return '—'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '—'; + return format(d, 'PPp', { locale: enGB }); +} + +function segmentNodeLabel(seg: MeshCorePathSegment): string { + const node = seg.observed_node; + if (!node) return '—'; + if (node.long_name?.trim()) { + return node.node_id_str ? `${node.long_name} (${node.node_id_str})` : node.long_name; + } + return node.node_id_str ?? node.internal_id; +} + +function snrSummary(edge: MeshCorePathEdgeBucket): string { + if (edge.avg_snr == null) return '—'; + const parts = [`avg ${edge.avg_snr.toFixed(1)}`]; + if (edge.min_snr != null && edge.max_snr != null) { + parts.push(`[${edge.min_snr.toFixed(1)}, ${edge.max_snr.toFixed(1)}]`); + } + return parts.join(' '); +} + +function SegmentStatusBadge({ status }: { status: string }) { + const isUnknown = status === 'unknown'; + return ( + + {status} + + ); +} + +function SegmentHashCell({ hash, status }: { hash: string; status: string }) { + const isUnknown = status === 'unknown'; + return ( + + {hash} + + ); +} + +export function MeshCorePassivePath() { + const isStaff = Boolean(authService.getCurrentUser()?.is_staff); + + const [segmentStatus, setSegmentStatus] = useState(''); + const [segmentHashMode, setSegmentHashMode] = useState(''); + const [segmentHashSize, setSegmentHashSize] = useState(''); + const [segmentHash, setSegmentHash] = useState(''); + + const [edgeBucketAfter, setEdgeBucketAfter] = useState(''); + const [edgeBucketBefore, setEdgeBucketBefore] = useState(''); + const [edgeObserver, setEdgeObserver] = useState(''); + const [edgeFromHash, setEdgeFromHash] = useState(''); + const [edgeToHash, setEdgeToHash] = useState(''); + + const [annotateSegmentId, setAnnotateSegmentId] = useState(''); + const [annotateNodeIdStr, setAnnotateNodeIdStr] = useState(''); + const [annotateStatus, setAnnotateStatus] = useState('resolved'); + + const segmentParams = useMemo((): PathTracingQueryParams => { + const p: PathTracingQueryParams = { page: 1, page_size: PAGE_SIZE }; + if (segmentStatus) p.status = segmentStatus; + if (segmentHashMode.trim()) p.hash_mode = Number(segmentHashMode); + if (segmentHashSize.trim()) p.hash_size = Number(segmentHashSize); + if (segmentHash.trim()) p.segment_hash = segmentHash.trim(); + return p; + }, [segmentStatus, segmentHashMode, segmentHashSize, segmentHash]); + + const edgeParams = useMemo((): PathTracingQueryParams => { + const p: PathTracingQueryParams = { page: 1, page_size: PAGE_SIZE }; + if (edgeBucketAfter.trim()) p.bucket_start_after = edgeBucketAfter.trim(); + if (edgeBucketBefore.trim()) p.bucket_start_before = edgeBucketBefore.trim(); + if (edgeObserver.trim()) p.observer = edgeObserver.trim(); + if (edgeFromHash.trim()) p.from_hash = edgeFromHash.trim(); + if (edgeToHash.trim()) p.to_hash = edgeToHash.trim(); + return p; + }, [edgeBucketAfter, edgeBucketBefore, edgeObserver, edgeFromHash, edgeToHash]); + + const segmentsQuery = usePathTracingSegments(segmentParams); + const edgesQuery = usePathTracingEdges(edgeParams); + const annotateMutation = useAnnotatePathSegment(); + + const handleAnnotate = async () => { + const id = annotateSegmentId.trim(); + if (!id) { + toast.error('Segment id is required'); + return; + } + try { + await annotateMutation.mutateAsync({ + segmentId: id, + body: { + node_id_str: annotateNodeIdStr.trim() || undefined, + status: annotateStatus || undefined, + }, + }); + toast.success('Segment annotated'); + setAnnotateSegmentId(''); + setAnnotateNodeIdStr(''); + } catch { + toast.error('Annotation failed'); + } + }; + + return ( +
+
+

Passive path tracing (preview)

+

+ Diagnostic tables for MeshCore hash-chain edges and segment resolution. Use this view to compare{' '} + path_hash_mode / path_hash_size{' '} + distributions before M2 decisions. Full map and realtime UI are planned separately (M7). +

+
+ + + + Path segments + + Segment hashes seen on ingest; unknown rows use dashed styling (same convention as heard-path maps). + + + +
+
+ + +
+
+ + setSegmentHashMode(e.target.value)} + data-testid="segment-filter-hash-mode" + /> +
+
+ + setSegmentHashSize(e.target.value)} + data-testid="segment-filter-hash-size" + /> +
+
+ + setSegmentHash(e.target.value)} + data-testid="segment-filter-hash" + /> +
+
+ + {segmentsQuery.isLoading &&

Loading segments…

} + {segmentsQuery.isError &&

Failed to load segments.

} + {segmentsQuery.isSuccess && ( + <> +

+ {segmentsQuery.data.count} segment(s) (showing up to {PAGE_SIZE}) +

+ + + + Hash + Size + Mode + Status + Node + Source + First seen + Last seen + + + + {segmentsQuery.data.results.length === 0 ? ( + + + No segments + + + ) : ( + segmentsQuery.data.results.map((seg) => ( + + + + + {seg.hash_size ?? '—'} + {seg.hash_mode ?? '—'} + + + + {segmentNodeLabel(seg)} + {seg.source} + {formatWhen(seg.first_seen_at)} + {formatWhen(seg.last_seen_at)} + + )) + )} + +
+ + )} + + {isStaff && ( +
+

Staff: manual segment annotation

+
+
+ + setAnnotateSegmentId(e.target.value)} + data-testid="annotate-segment-id" + /> +
+
+ + setAnnotateNodeIdStr(e.target.value)} + data-testid="annotate-node-id-str" + /> +
+
+ + +
+
+ +
+ )} +
+
+ + + + Path edges (hourly buckets) + + Consecutive hash pairs from list-order path_hashes. Direction is list order + only, not RF forwarding direction. + + + +
+
+ + setEdgeBucketAfter(e.target.value)} + data-testid="edge-filter-bucket-after" + /> +
+
+ + setEdgeBucketBefore(e.target.value)} + data-testid="edge-filter-bucket-before" + /> +
+
+ + setEdgeObserver(e.target.value)} + data-testid="edge-filter-observer" + /> +
+
+ + setEdgeFromHash(e.target.value)} + data-testid="edge-filter-from-hash" + /> +
+
+ + setEdgeToHash(e.target.value)} + data-testid="edge-filter-to-hash" + /> +
+
+ + {edgesQuery.isLoading &&

Loading edges…

} + {edgesQuery.isError &&

Failed to load edges.

} + {edgesQuery.isSuccess && ( + <> +

+ {edgesQuery.data.count} edge bucket(s) (showing up to {PAGE_SIZE}) +

+ + + + Bucket + From → To + Direction + Observer + Packets + Observations + SNR + Resolved + + + + {edgesQuery.data.results.length === 0 ? ( + + + No edges + + + ) : ( + edgesQuery.data.results.map((edge) => ( + + {formatWhen(edge.bucket_start)} + + {edge.from_hash} → {edge.to_hash} + + {edge.direction} + {edge.observer_name ?? edge.observer ?? '—'} + {edge.packet_count} + {edge.observation_count} + {snrSummary(edge)} + {edge.resolved ? 'yes' : 'no'} + + )) + )} + +
+ + )} +
+
+
+ ); +} + +export default MeshCorePassivePath;