Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/meshcore/passive-path-preview.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -62,6 +63,7 @@ function App() {
<Route path="/messages" element={<MessageHistory />} />
<Route path="/meshtastic/dashboard" element={<MeshtasticDashboard />} />
<Route path="/meshcore/dashboard" element={<MeshCoreDashboard />} />
<Route path="/meshcore/path-tracing" element={<MeshCorePassivePath />} />
<Route path="/meshcore/nodes" element={<MeshCoreNodesList />} />
<Route path="/meshcore/messages" element={<MeshCoreMessages />} />
<Route path="/meshcore/map" element={<Navigate to="/meshcore/nodes" replace />} />
Expand Down
5 changes: 5 additions & 0 deletions src/components/nav-main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
40 changes: 40 additions & 0 deletions src/hooks/api/usePathTracing.ts
Original file line number Diff line number Diff line change
@@ -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'] });
},
});
}
36 changes: 36 additions & 0 deletions src/lib/api/meshflow-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ import {
DxNotificationSettings,
DxNotificationSettingsWrite,
MeshCorePacketListItem,
MeshCorePathEdgeBucket,
MeshCorePathSegment,
MeshCorePathSegmentAnnotateBody,
} from '../models';
import {
ApiConfig,
Expand All @@ -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';
Expand Down Expand Up @@ -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<PaginatedResponse<MeshCorePathEdgeBucket>> {
const searchParams = new URLSearchParams();
this.appendPathTracingParams(searchParams, params);
return this.get('/meshcore/path-tracing/edges/', searchParams);
}

async getPathTracingSegments(params?: PathTracingQueryParams): Promise<PaginatedResponse<MeshCorePathSegment>> {
const searchParams = new URLSearchParams();
this.appendPathTracingParams(searchParams, params);
return this.get('/meshcore/path-tracing/segments/', searchParams);
}

async annotatePathSegment(segmentId: string, body: MeshCorePathSegmentAnnotateBody): Promise<MeshCorePathSegment> {
return this.patch(`/meshcore/path-tracing/segments/${segmentId}/`, body);
}

async getManagedNodes(
params?: PaginationParams & {
includeStatus?: boolean;
Expand Down
49 changes: 49 additions & 0 deletions src/lib/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
152 changes: 152 additions & 0 deletions src/pages/meshcore/MeshCorePassivePath.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<QueryClientProvider client={client}>
<MemoryRouter>
<MeshCorePassivePath />
</MemoryRouter>
</QueryClientProvider>
);
}

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<typeof usePathTracingSegments>);
usePathTracingEdgesMock.mockReturnValue({
isLoading: false,
isSuccess: true,
isError: false,
error: null,
data: { ...emptyEdges, count: 1, results: [sampleEdge] },
} as ReturnType<typeof usePathTracingEdges>);
useAnnotatePathSegmentMock.mockReturnValue({
mutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useAnnotatePathSegment>);
});

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' },
});
});
});
});
Loading
Loading