Add custom pathing (closes #45)

This commit is contained in:
Jack Kingsman
2026-03-09 10:26:01 -07:00
parent 7e384c12bb
commit 0c5b37c07c
22 changed files with 718 additions and 163 deletions
+3 -2
View File
@@ -137,9 +137,10 @@ export const api = {
fetchJson<TraceResponse>(`/contacts/${publicKey}/trace`, {
method: 'POST',
}),
resetContactPath: (publicKey: string) =>
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/reset-path`, {
setContactRoutingOverride: (publicKey: string, route: string) =>
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/routing-override`, {
method: 'POST',
body: JSON.stringify({ route }),
}),
// Channels
+24 -9
View File
@@ -1,10 +1,13 @@
import { useEffect, useState } from 'react';
import { type ReactNode, useEffect, useState } from 'react';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import {
isValidLocation,
calculateDistance,
formatDistance,
formatRouteLabel,
getEffectiveContactRoute,
hasRoutingOverride,
parsePathHops,
} from '../utils/pathUtils';
import { getMapFocusHash } from '../utils/urlHash';
@@ -106,7 +109,12 @@ export function ContactInfoPane({
isValidLocation(contact.lat, contact.lon)
? calculateDistance(config.lat, config.lon, contact.lat, contact.lon)
: null;
const pathHashModeLabel = contact ? formatPathHashMode(contact.out_path_hash_mode) : null;
const effectiveRoute = contact ? getEffectiveContactRoute(contact) : null;
const pathHashModeLabel =
effectiveRoute && effectiveRoute.pathLen >= 0
? formatPathHashMode(effectiveRoute.pathHashMode)
: null;
const learnedRouteLabel = contact ? formatRouteLabel(contact.last_path_len, true) : null;
return (
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
@@ -220,17 +228,24 @@ export function ContactInfoPane({
{distFromUs !== null && (
<InfoItem label="Distance" value={formatDistance(distFromUs)} />
)}
{contact.last_path_len >= 0 && (
{effectiveRoute && (
<InfoItem
label="Hops"
label="Routing"
value={
contact.last_path_len === 0
? 'Direct'
: `${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`
effectiveRoute.forced ? (
<span>
{formatRouteLabel(effectiveRoute.pathLen, true)}{' '}
<span className="text-destructive">(forced)</span>
</span>
) : (
formatRouteLabel(effectiveRoute.pathLen, true)
)
}
/>
)}
{contact.last_path_len === -1 && <InfoItem label="Routing" value="Flood" />}
{contact && hasRoutingOverride(contact) && learnedRouteLabel && (
<InfoItem label="Learned Route" value={learnedRouteLabel} />
)}
{pathHashModeLabel && <InfoItem label="Hop Width" value={pathHashModeLabel} />}
</div>
</div>
@@ -468,7 +483,7 @@ function ChannelAttributionWarning() {
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
function InfoItem({ label, value }: { label: string; value: ReactNode }) {
return (
<div>
<span className="text-muted-foreground text-xs">{label}</span>
+45 -48
View File
@@ -2,7 +2,14 @@ import type { ReactNode } from 'react';
import { toast } from './ui/sonner';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
import {
isValidLocation,
calculateDistance,
formatDistance,
formatRouteLabel,
formatRoutingOverrideInput,
getEffectiveContactRoute,
} from '../utils/pathUtils';
import { getMapFocusHash } from '../utils/urlHash';
import { handleKeyboardActivate } from '../utils/a11y';
import type { Contact } from '../types';
@@ -19,58 +26,48 @@ interface ContactStatusInfoProps {
*/
export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfoProps) {
const parts: ReactNode[] = [];
const effectiveRoute = getEffectiveContactRoute(contact);
const editRoutingOverride = () => {
const route = window.prompt(
'Enter explicit path as comma-separated 1, 2, or 3 byte hops (for example "ae,f1" or "ae92,f13e").\nEnter 0 to force direct always.\nEnter -1 to force flooding always.\nLeave blank to clear the override and reset to flood until a new path is heard.',
formatRoutingOverrideInput(contact)
);
if (route === null) {
return;
}
api.setContactRoutingOverride(contact.public_key, route).then(
() =>
toast.success(
route.trim() === '' ? 'Routing override cleared' : 'Routing override updated'
),
(err: unknown) =>
toast.error(err instanceof Error ? err.message : 'Failed to update routing override')
);
};
if (contact.last_seen) {
parts.push(`Last heard: ${formatTime(contact.last_seen)}`);
}
if (contact.last_path_len === -1) {
parts.push('flood');
} else if (contact.last_path_len === 0) {
parts.push(
<span
key="path"
className="cursor-pointer hover:text-primary hover:underline"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
if (window.confirm('Reset path to flood?')) {
api.resetContactPath(contact.public_key).then(
() => toast.success('Path reset to flood'),
() => toast.error('Failed to reset path')
);
}
}}
title="Click to reset path to flood"
>
direct
</span>
);
} else if (contact.last_path_len > 0) {
parts.push(
<span
key="path"
className="cursor-pointer hover:text-primary hover:underline"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
if (window.confirm('Reset path to flood?')) {
api.resetContactPath(contact.public_key).then(
() => toast.success('Path reset to flood'),
() => toast.error('Failed to reset path')
);
}
}}
title="Click to reset path to flood"
>
{contact.last_path_len} hop{contact.last_path_len > 1 ? 's' : ''}
</span>
);
}
parts.push(
<span
key="path"
className="cursor-pointer hover:text-primary hover:underline"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
editRoutingOverride();
}}
title="Click to edit routing override"
>
{formatRouteLabel(effectiveRoute.pathLen)}
{effectiveRoute.forced && <span className="text-destructive"> (forced)</span>}
</span>
);
if (isValidLocation(contact.lat, contact.lon)) {
const distFromUs =
@@ -106,4 +106,25 @@ describe('ContactInfoPane', () => {
expect(screen.getByText('Flood')).toBeInTheDocument();
});
});
it('shows forced routing override and learned route separately', async () => {
const contact = createContact({
last_path_len: 1,
out_path_hash_mode: 0,
route_override_path: 'ae92f13e',
route_override_len: 2,
route_override_hash_mode: 1,
});
getContactDetail.mockResolvedValue(createDetail(contact));
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
await screen.findByText('Alice');
await waitFor(() => {
expect(screen.getByText('Routing')).toBeInTheDocument();
expect(screen.getByText('(forced)')).toBeInTheDocument();
expect(screen.getByText('Learned Route')).toBeInTheDocument();
expect(screen.getByText('1 hop')).toBeInTheDocument();
});
});
});
+39
View File
@@ -4,6 +4,9 @@ import {
extractPacketPayloadHex,
findContactsByPrefix,
calculateDistance,
formatRouteLabel,
formatRoutingOverrideInput,
getEffectiveContactRoute,
resolvePath,
formatDistance,
formatHopCounts,
@@ -131,6 +134,42 @@ describe('extractPacketPayloadHex', () => {
});
});
describe('contact routing helpers', () => {
it('prefers routing override over learned route', () => {
const effective = getEffectiveContactRoute(
createContact({
last_path: 'AABB',
last_path_len: 1,
out_path_hash_mode: 0,
route_override_path: 'AE92F13E',
route_override_len: 2,
route_override_hash_mode: 1,
})
);
expect(effective.path).toBe('AE92F13E');
expect(effective.pathLen).toBe(2);
expect(effective.pathHashMode).toBe(1);
expect(effective.forced).toBe(true);
});
it('formats route labels and override input', () => {
expect(formatRouteLabel(-1)).toBe('flood');
expect(formatRouteLabel(0)).toBe('direct');
expect(formatRouteLabel(2, true)).toBe('2 hops');
expect(
formatRoutingOverrideInput(
createContact({
route_override_path: 'AE92F13E',
route_override_len: 2,
route_override_hash_mode: 1,
})
)
).toBe('ae92,f13e');
});
});
describe('findContactsByPrefix', () => {
const contacts: Contact[] = [
createContact({
+47 -32
View File
@@ -307,67 +307,82 @@ describe('RepeaterDashboard', () => {
expect(screen.getByText('1 hop')).toBeInTheDocument();
});
it('direct path is clickable with reset title', () => {
it('direct path is clickable with routing override title', () => {
const directContacts: Contact[] = [
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
];
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
const directEl = screen.getByTitle('Click to reset path to flood');
const directEl = screen.getByTitle('Click to edit routing override');
expect(directEl).toBeInTheDocument();
expect(directEl.textContent).toBe('direct');
});
it('clicking direct path calls resetContactPath on confirm', async () => {
const directContacts: Contact[] = [
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
it('shows forced decorator when a routing override is active', () => {
const forcedContacts: Contact[] = [
{
...contacts[0],
last_path_len: 1,
last_seen: 1700000000,
route_override_path: 'ae92f13e',
route_override_len: 2,
route_override_hash_mode: 1,
},
];
// Mock window.confirm to return true
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<RepeaterDashboard {...defaultProps} contacts={forcedContacts} />);
// Mock the api module
const { api } = await import('../api');
const resetSpy = vi.spyOn(api, 'resetContactPath').mockResolvedValue({
status: 'ok',
public_key: REPEATER_KEY,
});
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
fireEvent.click(screen.getByTitle('Click to reset path to flood'));
expect(confirmSpy).toHaveBeenCalledWith('Reset path to flood?');
expect(resetSpy).toHaveBeenCalledWith(REPEATER_KEY);
confirmSpy.mockRestore();
resetSpy.mockRestore();
expect(screen.getByText('2 hops')).toBeInTheDocument();
expect(screen.getByText('(forced)')).toBeInTheDocument();
});
it('clicking path does not call API when confirm is cancelled', async () => {
it('clicking direct path opens prompt and updates routing override', async () => {
const directContacts: Contact[] = [
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
];
// Mock window.confirm to return false
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('0');
const { api } = await import('../api');
const resetSpy = vi.spyOn(api, 'resetContactPath').mockResolvedValue({
const overrideSpy = vi.spyOn(api, 'setContactRoutingOverride').mockResolvedValue({
status: 'ok',
public_key: REPEATER_KEY,
});
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
fireEvent.click(screen.getByTitle('Click to reset path to flood'));
fireEvent.click(screen.getByTitle('Click to edit routing override'));
expect(confirmSpy).toHaveBeenCalledWith('Reset path to flood?');
expect(resetSpy).not.toHaveBeenCalled();
expect(promptSpy).toHaveBeenCalled();
expect(overrideSpy).toHaveBeenCalledWith(REPEATER_KEY, '0');
confirmSpy.mockRestore();
resetSpy.mockRestore();
promptSpy.mockRestore();
overrideSpy.mockRestore();
});
it('clicking path does not call API when prompt is cancelled', async () => {
const directContacts: Contact[] = [
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
];
const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue(null);
const { api } = await import('../api');
const overrideSpy = vi.spyOn(api, 'setContactRoutingOverride').mockResolvedValue({
status: 'ok',
public_key: REPEATER_KEY,
});
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
fireEvent.click(screen.getByTitle('Click to edit routing override'));
expect(promptSpy).toHaveBeenCalled();
expect(overrideSpy).not.toHaveBeenCalled();
promptSpy.mockRestore();
overrideSpy.mockRestore();
});
});
});
+3
View File
@@ -67,6 +67,9 @@ export interface Contact {
last_path: string | null;
last_path_len: number;
out_path_hash_mode: number;
route_override_path?: string | null;
route_override_len?: number | null;
route_override_hash_mode?: number | null;
last_advert: number | null;
lat: number | null;
lon: number | null;
+59
View File
@@ -32,6 +32,13 @@ export interface SenderInfo {
pathHashMode?: number | null;
}
export interface EffectiveContactRoute {
path: string | null;
pathLen: number;
pathHashMode: number;
forced: boolean;
}
function normalizePathHashMode(mode: number | null | undefined): number | null {
if (mode == null || !Number.isInteger(mode) || mode < 0 || mode > 2) {
return null;
@@ -106,6 +113,58 @@ export function parsePathHops(path: string | null | undefined, hopCount?: number
return hops;
}
export function hasRoutingOverride(contact: Contact): boolean {
return contact.route_override_len !== null && contact.route_override_len !== undefined;
}
export function getEffectiveContactRoute(contact: Contact): EffectiveContactRoute {
const forced = hasRoutingOverride(contact);
const pathLen = forced ? (contact.route_override_len ?? -1) : contact.last_path_len;
const path = forced ? (contact.route_override_path ?? '') : (contact.last_path ?? '');
let pathHashMode = forced
? (contact.route_override_hash_mode ?? null)
: (contact.out_path_hash_mode ?? null);
if (pathLen === -1) {
pathHashMode = -1;
} else if (pathHashMode == null || pathHashMode < 0 || pathHashMode > 2) {
pathHashMode = inferPathHashMode(path, pathLen) ?? 0;
}
return {
path: path || null,
pathLen,
pathHashMode,
forced,
};
}
export function formatRouteLabel(pathLen: number, capitalize: boolean = false): string {
const label =
pathLen === -1
? 'flood'
: pathLen === 0
? 'direct'
: `${pathLen} hop${pathLen === 1 ? '' : 's'}`;
return capitalize ? label.charAt(0).toUpperCase() + label.slice(1) : label;
}
export function formatRoutingOverrideInput(contact: Contact): string {
if (!hasRoutingOverride(contact)) {
return '';
}
if (contact.route_override_len === -1) {
return '-1';
}
if (contact.route_override_len === 0) {
return '0';
}
return parsePathHops(contact.route_override_path, contact.route_override_len)
.map((hop) => hop.toLowerCase())
.join(',');
}
/**
* Extract the payload portion from a raw packet hex string using firmware-equivalent
* path-byte validation. Returns null for malformed or payload-less packets.