Add trace tool. Closes #130.

This commit is contained in:
Jack Kingsman
2026-03-30 19:26:12 -07:00
parent eb1f7ae638
commit 134e8d0d29
19 changed files with 1625 additions and 21 deletions

View File

@@ -628,6 +628,59 @@ class TraceResponse(BaseModel):
path_len: int = Field(description="Number of hops in the trace path")
class RadioTraceHopRequest(BaseModel):
"""One requested hop in a radio trace path."""
public_key: str | None = Field(
default=None,
description="Full repeater public key when this hop maps to a known repeater",
)
hop_hex: str | None = Field(
default=None,
description="Raw hop hash hex when using a custom repeater prefix",
)
class RadioTraceRequest(BaseModel):
"""Ordered trace path for a radio trace loop."""
hop_hash_bytes: Literal[1, 2, 4] = Field(
default=4,
description="Hash width in bytes for every hop in this trace path",
)
hops: list[RadioTraceHopRequest] = Field(
min_length=1,
description="Ordered repeater hops, using either known repeater keys or custom hop hex",
)
class RadioTraceNode(BaseModel):
"""One resolved node in a radio trace result."""
role: Literal["repeater", "custom", "local"] = Field(description="Node role in the trace")
public_key: str | None = Field(
default=None,
description="Resolved full public key for this node when known",
)
name: str | None = Field(default=None, description="Display name for this node when known")
observed_hash: str | None = Field(
default=None,
description="Observed 4-byte trace hash for this node as hex",
)
snr: float | None = Field(default=None, description="Reported SNR for this node in dB")
class RadioTraceResponse(BaseModel):
"""Resolved multi-hop radio trace result."""
path_len: int = Field(description="Number of hashed nodes returned by the trace response")
timeout_seconds: float = Field(description="Timeout window used while waiting for the trace")
nodes: list[RadioTraceNode] = Field(
default_factory=list,
description="Ordered trace nodes: repeater hops followed by the terminal local radio",
)
class PathDiscoveryRoute(BaseModel):
"""One resolved route returned by contact path discovery."""

View File

@@ -2,6 +2,7 @@ import asyncio
import logging
import random
import time
from contextlib import suppress
from typing import Literal, TypeAlias
from fastapi import APIRouter, HTTPException
@@ -10,10 +11,15 @@ from pydantic import BaseModel, Field
from app.dependencies import require_connected
from app.models import (
CONTACT_TYPE_REPEATER,
ContactUpsert,
RadioDiscoveryRequest,
RadioDiscoveryResponse,
RadioDiscoveryResult,
RadioTraceHopRequest,
RadioTraceNode,
RadioTraceRequest,
RadioTraceResponse,
)
from app.radio_sync import send_advertisement as do_send_advertisement
from app.radio_sync import sync_radio_time
@@ -45,6 +51,12 @@ _DISCOVERY_NODE_TYPES: dict[int, DiscoveryNodeType] = {
2: "repeater",
4: "sensor",
}
TRACE_WAIT_TIMEOUT_SECONDS = 45.0
TRACE_DEFAULT_TIMEOUT_SECONDS = 15.0
TRACE_TIMEOUT_MIN_SECONDS = 5.0
TRACE_TIMEOUT_MAX_SECONDS = 30.0
TRACE_TIMEOUT_MARGIN = 1.2
TRACE_HASH_FLAGS = {1: 0, 2: 1, 4: 2}
async def _prepare_connected(*, broadcast_on_success: bool) -> bool:
@@ -217,6 +229,101 @@ async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None:
result.name = contact.name
def _trace_hash_for_key(public_key: str, hop_hash_bytes: int) -> str:
return public_key[: hop_hash_bytes * 2].lower()
def _trace_timeout_seconds(send_result: object) -> float:
payload = getattr(send_result, "payload", None) or {}
suggested_timeout = payload.get("suggested_timeout")
try:
if suggested_timeout is None:
raise TypeError
timeout_seconds = float(suggested_timeout) / 1000.0 * TRACE_TIMEOUT_MARGIN
except (TypeError, ValueError):
timeout_seconds = TRACE_DEFAULT_TIMEOUT_SECONDS
return max(TRACE_TIMEOUT_MIN_SECONDS, min(TRACE_TIMEOUT_MAX_SECONDS, timeout_seconds))
async def _resolve_trace_hops(
hops: list[RadioTraceHopRequest], hop_hash_bytes: int
) -> tuple[list[RadioTraceNode], list[str]]:
trace_nodes: list[RadioTraceNode] = []
requested_hashes: list[str] = []
expected_hex_len = hop_hash_bytes * 2
for hop in hops:
public_key = hop.public_key.strip().lower() if isinstance(hop.public_key, str) else None
hop_hex = hop.hop_hex.strip().lower() if isinstance(hop.hop_hex, str) else None
if bool(public_key) == bool(hop_hex):
raise HTTPException(
status_code=400,
detail="Each trace hop must provide exactly one of public_key or hop_hex",
)
if public_key:
if len(public_key) != 64:
raise HTTPException(
status_code=400,
detail="Trace repeater keys must be full 64-character public keys",
)
try:
bytes.fromhex(public_key)
except ValueError as exc:
raise HTTPException(
status_code=400,
detail="Trace repeater keys must be valid hex public keys",
) from exc
contact = await ContactRepository.get_by_key(public_key)
if contact is None:
raise HTTPException(
status_code=404, detail=f"Trace repeater not found: {public_key}"
)
if contact.type != CONTACT_TYPE_REPEATER:
raise HTTPException(
status_code=400,
detail=f"Trace node is not a repeater: {public_key[:12]}",
)
requested_hashes.append(_trace_hash_for_key(contact.public_key, hop_hash_bytes))
trace_nodes.append(
RadioTraceNode(
role="repeater",
public_key=contact.public_key,
name=contact.name,
observed_hash=None,
snr=None,
)
)
continue
assert hop_hex is not None
if len(hop_hex) != expected_hex_len:
raise HTTPException(
status_code=400,
detail=f"Custom trace hops must be exactly {expected_hex_len} hex characters",
)
try:
bytes.fromhex(hop_hex)
except ValueError as exc:
raise HTTPException(
status_code=400,
detail="Custom trace hops must be valid hex",
) from exc
requested_hashes.append(hop_hex)
trace_nodes.append(
RadioTraceNode(
role="custom",
public_key=None,
name=None,
observed_hash=hop_hex,
snr=None,
)
)
return trace_nodes, requested_hashes
@router.get("/config", response_model=RadioConfigResponse)
async def get_radio_config() -> RadioConfigResponse:
"""Get the current radio configuration."""
@@ -388,6 +495,105 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
)
@router.post("/trace", response_model=RadioTraceResponse)
async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
"""Send a multi-hop trace loop through known repeaters and back to the local radio."""
require_connected()
trace_nodes, requested_hashes = await _resolve_trace_hops(request.hops, request.hop_hash_bytes)
tag = random.randint(1, 0xFFFFFFFF)
trace_flags = TRACE_HASH_FLAGS[request.hop_hash_bytes]
async with radio_manager.radio_operation("radio_trace", pause_polling=True) as mc:
local_public_key = str((mc.self_info or {}).get("public_key") or "").lower()
if len(local_public_key) != 64:
raise HTTPException(status_code=503, detail="Local radio public key is unavailable")
local_name = (mc.self_info or {}).get("name")
response_task = asyncio.create_task(
mc.wait_for_event(
EventType.TRACE_DATA,
attribute_filters={"tag": tag},
timeout=TRACE_WAIT_TIMEOUT_SECONDS,
)
)
try:
send_result = await mc.commands.send_trace(
path=",".join(requested_hashes),
tag=tag,
flags=trace_flags,
)
if send_result is None or send_result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail="Failed to send trace")
timeout_seconds = _trace_timeout_seconds(send_result)
try:
event = await asyncio.wait_for(response_task, timeout=timeout_seconds)
except asyncio.TimeoutError as exc:
raise HTTPException(status_code=504, detail="No trace response heard") from exc
finally:
if not response_task.done():
response_task.cancel()
with suppress(asyncio.CancelledError):
await response_task
if event is None:
raise HTTPException(status_code=504, detail="No trace response heard")
payload = event.payload if isinstance(event.payload, dict) else {}
path_len = payload.get("path_len")
if not isinstance(path_len, int):
raise HTTPException(status_code=500, detail="Trace response was malformed")
raw_path = payload.get("path")
path_nodes = raw_path if isinstance(raw_path, list) else []
final_local_node = (
path_nodes[-1]
if path_nodes
and isinstance(path_nodes[-1], dict)
and not isinstance(path_nodes[-1].get("hash"), str)
else None
)
hashed_nodes = path_nodes[:-1] if final_local_node is not None else path_nodes
if len(hashed_nodes) < len(trace_nodes):
raise HTTPException(status_code=500, detail="Trace response was incomplete")
nodes: list[RadioTraceNode] = []
for index, trace_node in enumerate(trace_nodes):
observed = hashed_nodes[index] if index < len(hashed_nodes) else {}
observed_hash = observed.get("hash") if isinstance(observed, dict) else None
observed_snr = observed.get("snr") if isinstance(observed, dict) else None
nodes.append(
RadioTraceNode(
role=trace_node.role,
public_key=trace_node.public_key,
name=trace_node.name,
observed_hash=(
observed_hash if isinstance(observed_hash, str) else trace_node.observed_hash
),
snr=float(observed_snr) if isinstance(observed_snr, (int, float)) else None,
)
)
terminal_snr_value = final_local_node.get("snr") if isinstance(final_local_node, dict) else None
nodes.append(
RadioTraceNode(
role="local",
public_key=local_public_key,
name=local_name if isinstance(local_name, str) and local_name else None,
observed_hash=None,
snr=float(terminal_snr_value) if isinstance(terminal_snr_value, (int, float)) else None,
)
)
return RadioTraceResponse(
path_len=path_len,
timeout_seconds=timeout_seconds,
nodes=nodes,
)
async def _attempt_reconnect() -> dict:
"""Shared reconnection logic for reboot and reconnect endpoints."""
radio_manager.resume_connection()

View File

@@ -457,6 +457,7 @@ export function App() {
loadingNewer,
messageInputRef,
onTrace: handleTrace,
onRunTracePath: api.requestRadioTrace,
onPathDiscovery: handlePathDiscovery,
onToggleFavorite: handleToggleFavorite,
onDeleteContact: handleDeleteContact,

View File

@@ -20,6 +20,8 @@ import type {
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
RadioTraceHopRequest,
RadioTraceResponse,
RadioDiscoveryTarget,
PathDiscoveryResponse,
ResendChannelMessageResponse,
@@ -107,6 +109,11 @@ export const api = {
method: 'POST',
body: JSON.stringify({ target }),
}),
requestRadioTrace: (hopHashBytes: 1 | 2 | 4, hops: RadioTraceHopRequest[]) =>
fetchJson<RadioTraceResponse>('/radio/trace', {
method: 'POST',
body: JSON.stringify({ hop_hash_bytes: hopHashBytes, hops }),
}),
rebootRadio: () =>
fetchJson<{ status: string; message: string }>('/radio/reboot', {
method: 'POST',

View File

@@ -5,6 +5,7 @@ import { MessageInput, type MessageInputHandle } from './MessageInput';
import { MessageList } from './MessageList';
import { RawPacketFeedView } from './RawPacketFeedView';
import { RoomServerPanel } from './RoomServerPanel';
import { TracePane } from './TracePane';
import type {
Channel,
Contact,
@@ -15,6 +16,8 @@ import type {
PathDiscoveryResponse,
RawPacket,
RadioConfig,
RadioTraceHopRequest,
RadioTraceResponse,
} from '../types';
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
@@ -50,6 +53,10 @@ interface ConversationPaneProps {
loadingNewer: boolean;
messageInputRef: Ref<MessageInputHandle>;
onTrace: () => Promise<void>;
onRunTracePath: (
hopHashBytes: 1 | 2 | 4,
hops: RadioTraceHopRequest[]
) => Promise<RadioTraceResponse>;
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
onDeleteContact: (publicKey: string) => Promise<void>;
@@ -115,6 +122,7 @@ export function ConversationPane({
loadingNewer,
messageInputRef,
onTrace,
onRunTracePath,
onPathDiscovery,
onToggleFavorite,
onDeleteContact,
@@ -200,6 +208,10 @@ export function ConversationPane({
return null;
}
if (activeConversation.type === 'trace') {
return <TracePane contacts={contacts} config={config} onRunTracePath={onRunTracePath} />;
}
if (activeContactIsRepeater) {
return (
<Suspense fallback={<LoadingPane label="Loading dashboard..." />}>

View File

@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Bell,
Cable,
ChartNetwork,
CheckCheck,
ChevronDown,
ChevronRight,
@@ -9,7 +11,6 @@ import {
Map,
Search as SearchIcon,
SquarePen,
Waypoints,
X,
} from 'lucide-react';
import {
@@ -197,7 +198,7 @@ export function Sidebar({
};
const isActive = (
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search',
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace',
id: string
) => activeConversation?.type === type && activeConversation?.id === id;
@@ -721,7 +722,7 @@ export function Sidebar({
renderSidebarActionRow({
key: 'tool-visualizer',
active: isActive('visualizer', 'visualizer'),
icon: <Waypoints className="h-4 w-4" />,
icon: <ChartNetwork className="h-4 w-4" />,
label: 'Mesh Visualizer',
onClick: () =>
handleSelectConversation({
@@ -730,6 +731,18 @@ export function Sidebar({
name: 'Mesh Visualizer',
}),
}),
renderSidebarActionRow({
key: 'tool-trace',
active: isActive('trace', 'trace'),
icon: <Cable className="h-4 w-4" />,
label: 'Trace',
onClick: () =>
handleSelectConversation({
type: 'trace',
id: 'trace',
name: 'Trace',
}),
}),
renderSidebarActionRow({
key: 'tool-search',
active: isActive('search', 'search'),

View File

@@ -0,0 +1,691 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { ArrowDown, ArrowUp, Plus, X } from 'lucide-react';
import type {
Contact,
RadioConfig,
RadioTraceHopRequest,
RadioTraceNode,
RadioTraceResponse,
} from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { calculateDistance, isValidLocation } from '../utils/pathUtils';
import { getContactDisplayName } from '../utils/pubkey';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import { Button } from './ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Input } from './ui/input';
import { cn } from '@/lib/utils';
type TraceSortMode = 'alpha' | 'recent' | 'distance';
type CustomHopBytes = 1 | 2 | 4;
type TraceDraftHop =
| { id: string; kind: 'repeater'; publicKey: string }
| { id: string; kind: 'custom'; hopHex: string; hopBytes: CustomHopBytes };
interface TracePaneProps {
contacts: Contact[];
config: RadioConfig | null;
onRunTracePath: (
hopHashBytes: CustomHopBytes,
hops: RadioTraceHopRequest[]
) => Promise<RadioTraceResponse>;
}
function getHeardTimestamp(contact: Contact): number {
return Math.max(contact.last_seen ?? 0, contact.last_advert ?? 0);
}
function getDistanceKm(contact: Contact, config: RadioConfig | null): number | null {
if (
!config ||
!isValidLocation(config.lat, config.lon) ||
!isValidLocation(contact.lat, contact.lon)
) {
return null;
}
return calculateDistance(config.lat, config.lon, contact.lat, contact.lon);
}
function getShortKey(publicKey: string | null | undefined): string {
if (!publicKey) return 'unknown';
return publicKey.slice(0, 12);
}
function formatSNR(snr: number | null | undefined): string {
if (typeof snr !== 'number' || Number.isNaN(snr)) {
return '—';
}
return `${snr >= 0 ? '+' : ''}${snr.toFixed(1)} dB`;
}
function moveHop(hops: TraceDraftHop[], index: number, direction: -1 | 1): TraceDraftHop[] {
const nextIndex = index + direction;
if (nextIndex < 0 || nextIndex >= hops.length) {
return hops;
}
const next = [...hops];
const [item] = next.splice(index, 1);
next.splice(nextIndex, 0, item);
return next;
}
function normalizeCustomHopHex(value: string): string {
return value.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
}
function nextDraftHopId(prefix: string, currentLength: number): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `${prefix}-${crypto.randomUUID()}`;
}
return `${prefix}-${Date.now()}-${currentLength}`;
}
function TraceNodeRow({
title,
subtitle,
meta,
note,
fixed = false,
compact = false,
actions,
snr,
}: {
title: string;
subtitle: string;
meta?: string | null;
note?: string | null;
fixed?: boolean;
compact?: boolean;
actions?: ReactNode;
snr?: string | null;
}) {
return (
<div
className={cn(
'flex items-center rounded-md border border-border bg-background',
compact ? 'gap-2 px-2.5 py-2' : 'gap-3 px-3 py-3'
)}
>
<div
className={cn(
'flex h-9 w-9 items-center justify-center rounded-full border text-[11px] font-semibold uppercase tracking-wide',
fixed
? 'border-primary/30 bg-primary/10 text-primary'
: 'border-border bg-muted text-muted-foreground'
)}
>
{fixed ? 'Self' : 'Hop'}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{title}</div>
<div className="truncate text-xs text-muted-foreground">{subtitle}</div>
{meta ? <div className="mt-1 text-[11px] text-muted-foreground">{meta}</div> : null}
{note ? <div className="mt-1 text-[11px] text-muted-foreground">{note}</div> : null}
</div>
{snr ? (
<div className="shrink-0 text-right">
<div className="text-[11px] text-muted-foreground">SNR</div>
<div className="font-mono text-sm">{snr}</div>
</div>
) : null}
{actions ? <div className="ml-1 flex items-center gap-1">{actions}</div> : null}
</div>
);
}
export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) {
const [searchQuery, setSearchQuery] = useState('');
const [sortMode, setSortMode] = useState<TraceSortMode>('alpha');
const [draftHops, setDraftHops] = useState<TraceDraftHop[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<RadioTraceResponse | null>(null);
const [customDialogOpen, setCustomDialogOpen] = useState(false);
const [customHopBytesDraft, setCustomHopBytesDraft] = useState<CustomHopBytes>(1);
const [customHopHexDraft, setCustomHopHexDraft] = useState('');
const [customHopError, setCustomHopError] = useState<string | null>(null);
const activeRunTokenRef = useRef(0);
const repeaters = useMemo(() => {
const deduped = new Map<string, Contact>();
for (const contact of contacts) {
if (contact.type !== CONTACT_TYPE_REPEATER || contact.public_key.length !== 64) {
continue;
}
if (!deduped.has(contact.public_key)) {
deduped.set(contact.public_key, contact);
}
}
return [...deduped.values()];
}, [contacts]);
const repeatersByKey = useMemo(
() => new Map(repeaters.map((contact) => [contact.public_key, contact])),
[repeaters]
);
const filteredRepeaters = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
const matching = query
? repeaters.filter(
(contact) =>
contact.public_key.toLowerCase().includes(query) ||
(contact.name ?? '').toLowerCase().includes(query)
)
: repeaters;
return [...matching].sort((left, right) => {
if (sortMode === 'recent') {
const leftTs = getHeardTimestamp(left);
const rightTs = getHeardTimestamp(right);
if (leftTs !== rightTs) {
return rightTs - leftTs;
}
}
if (sortMode === 'distance') {
const leftDistance = getDistanceKm(left, config);
const rightDistance = getDistanceKm(right, config);
if (leftDistance !== null && rightDistance !== null && leftDistance !== rightDistance) {
return leftDistance - rightDistance;
}
if (leftDistance !== null && rightDistance === null) return -1;
if (leftDistance === null && rightDistance !== null) return 1;
}
return getContactDisplayName(left.name, left.public_key, left.last_advert).localeCompare(
getContactDisplayName(right.name, right.public_key, right.last_advert)
);
});
}, [config, repeaters, searchQuery, sortMode]);
const localRadioName = config?.name || 'Local radio';
const localRadioKey = config?.public_key ?? null;
const canSortByDistance = !!config && isValidLocation(config.lat, config.lon);
const customHopBytesLocked = useMemo(
() => draftHops.find((hop) => hop.kind === 'custom')?.hopBytes ?? null,
[draftHops]
);
const effectiveHopHashBytes: CustomHopBytes = customHopBytesLocked ?? 4;
useEffect(() => {
if (!customDialogOpen) return;
setCustomHopBytesDraft(customHopBytesLocked ?? 1);
setCustomHopHexDraft('');
setCustomHopError(null);
}, [customDialogOpen, customHopBytesLocked]);
const clearPendingResult = () => {
activeRunTokenRef.current += 1;
setLoading(false);
if (result) setResult(null);
if (error) setError(null);
};
const handleAddRepeater = (publicKey: string) => {
setDraftHops((current) => [
...current,
{
id: nextDraftHopId('repeater', current.length),
kind: 'repeater',
publicKey,
},
]);
clearPendingResult();
};
const handleAddCustomHop = () => {
const hopBytes = customHopBytesLocked ?? customHopBytesDraft;
const hopHex = normalizeCustomHopHex(customHopHexDraft);
if (hopHex.length !== hopBytes * 2) {
setCustomHopError(`Custom hop must be exactly ${hopBytes * 2} hex characters.`);
return;
}
setDraftHops((current) => [
...current,
{
id: nextDraftHopId('custom', current.length),
kind: 'custom',
hopHex,
hopBytes,
},
]);
clearPendingResult();
setCustomDialogOpen(false);
};
const handleRemoveHop = (id: string) => {
setDraftHops((current) => current.filter((hop) => hop.id !== id));
clearPendingResult();
};
const handleMoveHop = (index: number, direction: -1 | 1) => {
setDraftHops((current) => moveHop(current, index, direction));
clearPendingResult();
};
const handleRunTrace = async () => {
if (draftHops.length === 0) {
return;
}
const runToken = activeRunTokenRef.current + 1;
activeRunTokenRef.current = runToken;
setLoading(true);
setError(null);
setResult(null);
try {
const traceResult = await onRunTracePath(
effectiveHopHashBytes,
draftHops.map((hop) =>
hop.kind === 'repeater' ? { public_key: hop.publicKey } : { hop_hex: hop.hopHex }
)
);
if (activeRunTokenRef.current !== runToken) {
return;
}
setResult(traceResult);
} catch (err) {
if (activeRunTokenRef.current !== runToken) {
return;
}
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
if (activeRunTokenRef.current === runToken) {
setLoading(false);
}
}
};
const resultNodes: RadioTraceNode[] = result
? [
{
role: 'local',
public_key: localRadioKey,
name: localRadioName,
observed_hash: null,
snr: null,
},
...result.nodes,
]
: [];
return (
<div className="flex h-full min-h-0 flex-col overflow-y-auto">
<div className="border-b border-border px-4 py-3">
<h2 className="text-base font-semibold">Trace</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
Build a repeater loop and trace it back to the local radio. The selectable hop list only
includes known full-key repeaters, but you can also add custom repeater prefixes.
</p>
</div>
<div className="flex flex-1 flex-col gap-4 p-4 lg:min-h-0 lg:flex-row lg:overflow-hidden">
<section className="flex w-full flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:max-w-[24rem]">
<div className="border-b border-border p-4">
<h3 className="text-sm font-semibold">Repeater Hops</h3>
<p className="mt-1 text-xs text-muted-foreground">
Search by name or key, then add repeaters in the order you want to traverse them.
</p>
<Button
type="button"
size="sm"
variant="outline"
className="mt-3"
onClick={() => setCustomDialogOpen(true)}
>
Custom path
</Button>
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search name or public key"
aria-label="Search repeaters"
className="mt-3"
/>
<div className="mt-3 flex flex-wrap gap-2">
{(
[
['alpha', 'Alpha'],
['recent', 'Recent Heard'],
['distance', 'Distance'],
] as const
).map(([value, label]) => (
<Button
key={value}
type="button"
size="sm"
variant={sortMode === value ? 'default' : 'outline'}
onClick={() => setSortMode(value)}
>
{label}
</Button>
))}
</div>
{sortMode === 'distance' && !canSortByDistance ? (
<p className="mt-2 text-[11px] text-muted-foreground">
Distance sorting is using known repeater coordinates, but the local radio does not
currently have a valid location.
</p>
) : null}
</div>
<div className="max-h-[40vh] overflow-y-auto p-2 lg:min-h-0 lg:max-h-none lg:flex-1">
{filteredRepeaters.length === 0 ? (
<div className="rounded-md border border-dashed border-border px-3 py-6 text-center text-sm text-muted-foreground">
No repeaters matched this search.
</div>
) : (
<div className="space-y-2">
{filteredRepeaters.map((contact) => {
const displayName = getContactDisplayName(
contact.name,
contact.public_key,
contact.last_advert
);
const distanceKm = getDistanceKm(contact, config);
const selectedCount = draftHops.filter(
(hop) => hop.kind === 'repeater' && hop.publicKey === contact.public_key
).length;
return (
<div
key={contact.public_key}
role="button"
tabIndex={0}
aria-label={`Add repeater ${displayName}`}
className={cn(
'flex w-full items-center gap-3 rounded-md border px-3 py-3 text-left transition-colors',
selectedCount > 0
? 'border-primary/30 bg-primary/5'
: 'border-border bg-background hover:bg-accent'
)}
onClick={() => handleAddRepeater(contact.public_key)}
onKeyDown={handleKeyboardActivate}
>
<ContactAvatar
name={contact.name}
publicKey={contact.public_key}
size={28}
contactType={contact.type}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{displayName}</div>
<div className="truncate text-xs text-muted-foreground">
{getShortKey(contact.public_key)}
</div>
{sortMode === 'distance' && distanceKm !== null ? (
<div className="mt-1 text-[11px] text-muted-foreground">
{distanceKm.toFixed(1)} km away
</div>
) : null}
{selectedCount > 0 ? (
<div className="mt-1 text-[11px] text-muted-foreground">
Added {selectedCount} time{selectedCount === 1 ? '' : 's'}
</div>
) : null}
</div>
<span
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-input bg-background text-muted-foreground"
aria-hidden="true"
>
<Plus className="h-4 w-4" />
</span>
</div>
);
})}
</div>
)}
</div>
</section>
<section className="flex flex-1 flex-col gap-4 lg:min-h-0 lg:overflow-hidden">
<div className="rounded-lg border border-border bg-card">
<div className="border-b border-border px-4 py-3">
<h3 className="text-sm font-semibold">Trace Path</h3>
<p className="mt-1 text-xs text-muted-foreground">
The first node is display-only. The terminal node is the local radio.
</p>
</div>
<div className="max-h-[42vh] space-y-2 overflow-y-auto p-4 lg:max-h-none lg:overflow-y-visible">
<TraceNodeRow
title={localRadioName}
subtitle={getShortKey(localRadioKey)}
meta="Origin"
fixed
compact
/>
{draftHops.length === 0 ? (
<div className="rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
Add at least one hop to build a trace loop.
</div>
) : (
draftHops.map((hop, index) => {
const contact =
hop.kind === 'repeater' ? (repeatersByKey.get(hop.publicKey) ?? null) : null;
const displayName =
hop.kind === 'repeater'
? getContactDisplayName(
contact?.name,
hop.publicKey,
contact?.last_advert ?? null
)
: 'Custom hop';
const subtitle =
hop.kind === 'repeater'
? getShortKey(hop.publicKey)
: `${hop.hopHex.toUpperCase()} (${hop.hopBytes}-byte)`;
return (
<div key={hop.id}>
<TraceNodeRow
title={displayName}
subtitle={subtitle}
meta={`Hop ${index + 1}`}
note={
index === draftHops.length - 1
? 'Note: you must be able to hear the final repeater in the trace for trace success.'
: null
}
compact
actions={
<>
<Button
type="button"
size="icon"
variant="outline"
className="h-8 w-8"
aria-label={`Move ${displayName} up`}
onClick={() => handleMoveHop(index, -1)}
disabled={index === 0}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
size="icon"
variant="outline"
className="h-8 w-8"
aria-label={`Move ${displayName} down`}
onClick={() => handleMoveHop(index, 1)}
disabled={index === draftHops.length - 1}
>
<ArrowDown className="h-4 w-4" />
</Button>
<Button
type="button"
size="icon"
variant="outline"
className="h-8 w-8"
aria-label={`Remove ${displayName}`}
onClick={() => handleRemoveHop(hop.id)}
>
<X className="h-4 w-4" />
</Button>
</>
}
/>
</div>
);
})
)}
<TraceNodeRow
title={localRadioName}
subtitle={getShortKey(localRadioKey)}
meta="Terminal"
fixed
compact
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-3">
<div className="text-xs text-muted-foreground">
{draftHops.length === 0
? 'No hops selected'
: `${draftHops.length} hop${draftHops.length === 1 ? '' : 's'} selected · ${effectiveHopHashBytes}-byte trace`}
</div>
<Button onClick={handleRunTrace} disabled={loading || draftHops.length === 0}>
{loading ? 'Tracing...' : 'Send trace'}
</Button>
</div>
</div>
<div className="flex flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:flex-1">
<div className="border-b border-border px-4 py-3">
<h3 className="text-sm font-semibold">
Results{result ? ` (${result.timeout_seconds.toFixed(1)}s)` : ''}
</h3>
</div>
<div className="max-h-[42vh] min-h-0 flex-1 space-y-3 overflow-y-auto p-4 lg:max-h-none">
{error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
{!error && !result ? (
<div className="rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
Send a trace to see the returned hop-by-hop SNR values.
</div>
) : null}
{result
? resultNodes.map((node, index) => {
const title =
node.name ||
(node.role === 'custom'
? 'Custom hop'
: node.role === 'local'
? localRadioName
: getShortKey(node.public_key));
const subtitle =
node.role === 'custom'
? `Key prefix ${node.observed_hash?.toUpperCase() ?? 'unknown'}`
: node.observed_hash &&
node.public_key &&
node.observed_hash.toLowerCase() !==
getShortKey(node.public_key).toLowerCase()
? `${getShortKey(node.public_key)} · key prefix ${node.observed_hash.toUpperCase()}`
: getShortKey(node.public_key);
return (
<div
key={`${node.role}-${node.public_key ?? node.observed_hash ?? 'local'}-${index}`}
>
<TraceNodeRow
title={title}
subtitle={subtitle}
meta={
index === 0
? 'Origin'
: node.role === 'local'
? 'Terminal'
: `Hop ${index}`
}
fixed={node.role === 'local'}
snr={index === 0 ? null : formatSNR(node.snr)}
/>
</div>
);
})
: null}
</div>
</div>
</section>
</div>
<Dialog open={customDialogOpen} onOpenChange={setCustomDialogOpen}>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>Custom path hop</DialogTitle>
<DialogDescription>
Add a raw repeater prefix as a 1-byte, 2-byte, or 4-byte hop. Once you add a custom
hop, all later custom hops must use the same byte width.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Hop width</div>
<div className="flex flex-wrap gap-2">
{([1, 2, 4] as const).map((value) => {
const locked = customHopBytesLocked !== null && customHopBytesLocked !== value;
const active = (customHopBytesLocked ?? customHopBytesDraft) === value;
return (
<Button
key={value}
type="button"
size="sm"
variant={active ? 'default' : 'outline'}
disabled={locked}
onClick={() => setCustomHopBytesDraft(value)}
>
{value}-byte
</Button>
);
})}
</div>
{customHopBytesLocked !== null ? (
<p className="text-xs text-muted-foreground">
Custom hops are locked to {customHopBytesLocked}-byte prefixes for this trace.
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="custom-hop-hex">
Repeater prefix
</label>
<Input
id="custom-hop-hex"
value={customHopHexDraft}
onChange={(event) =>
setCustomHopHexDraft(normalizeCustomHopHex(event.target.value))
}
placeholder={`${(customHopBytesLocked ?? customHopBytesDraft) * 2} hex chars`}
/>
<p className="text-xs text-muted-foreground">
Enter exactly {(customHopBytesLocked ?? customHopBytesDraft) * 2} hex characters.
</p>
{customHopError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{customHopError}
</div>
) : null}
</div>
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button type="button" variant="secondary" onClick={() => setCustomDialogOpen(false)}>
Cancel
</Button>
<Button type="button" onClick={handleAddCustomHop}>
Add custom hop
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -192,8 +192,8 @@ export function SettingsLocalSection({
</button>
</div>
<p className="text-xs text-muted-foreground">
Scales the app&apos;s typography for this browser only. The slider moves in 5% steps;
the number field accepts any value from 25% to 400%.
Scales the app&apos;s typography for this browser only. The slider moves in 5% steps; the
number field accepts any value from 25% to 400%.
</p>
</div>

View File

@@ -275,7 +275,9 @@ interface UseConversationMessagesResult {
}
function isMessageConversation(conversation: Conversation | null): conversation is Conversation {
return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type);
return (
!!conversation && !['raw', 'map', 'visualizer', 'search', 'trace'].includes(conversation.type)
);
}
function isActiveConversationMessage(

View File

@@ -62,7 +62,6 @@ export function useConversationRouter({
// Only needs channels (fast path) - doesn't wait for contacts
useEffect(() => {
if (hasSetDefaultConversation.current || activeConversation) return;
if (channels.length === 0) return;
const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
@@ -92,6 +91,29 @@ export function useConversationRouter({
hasSetDefaultConversation.current = true;
return;
}
if (hashConv?.type === 'trace') {
setActiveConversationState({ type: 'trace', id: 'trace', name: 'Trace' });
hasSetDefaultConversation.current = true;
return;
}
// No hash: optionally restore last-viewed non-data conversation if enabled on this device.
if (!hashConv && getReopenLastConversationEnabled()) {
const lastViewed = getLastViewedConversation();
if (
lastViewed &&
(lastViewed.type === 'raw' ||
lastViewed.type === 'map' ||
lastViewed.type === 'visualizer' ||
lastViewed.type === 'trace')
) {
setActiveConversationState(lastViewed);
hasSetDefaultConversation.current = true;
return;
}
}
if (channels.length === 0) return;
// Handle channel hash (ID-first with legacy-name fallback)
if (hashConv?.type === 'channel') {
@@ -109,14 +131,6 @@ export function useConversationRouter({
// No hash: optionally restore last-viewed conversation if enabled on this device.
if (!hashConv && getReopenLastConversationEnabled()) {
const lastViewed = getLastViewedConversation();
if (
lastViewed &&
(lastViewed.type === 'raw' || lastViewed.type === 'map' || lastViewed.type === 'visualizer')
) {
setActiveConversationState(lastViewed);
hasSetDefaultConversation.current = true;
return;
}
if (lastViewed?.type === 'channel') {
const channel =
channels.find((c) => c.key.toLowerCase() === lastViewed.id.toLowerCase()) ||

View File

@@ -195,6 +195,53 @@ describe('App startup hash resolution', () => {
});
});
it('restores the trace tool from the URL hash', async () => {
window.location.hash = '#trace';
render(<App />);
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent('trace:trace:Trace');
}
});
});
it('restores the trace tool from the URL hash even when channels are unavailable', async () => {
window.location.hash = '#trace';
mocks.api.getChannels.mockResolvedValue([]);
render(<App />);
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent('trace:trace:Trace');
}
});
});
it('reopens the last viewed trace tool even when channels are unavailable', async () => {
window.location.hash = '';
localStorage.setItem(REOPEN_LAST_CONVERSATION_KEY, '1');
localStorage.setItem(
LAST_VIEWED_CONVERSATION_KEY,
JSON.stringify({
type: 'trace',
id: 'trace',
name: 'Trace',
})
);
mocks.api.getChannels.mockResolvedValue([]);
render(<App />);
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent('trace:trace:Trace');
}
});
});
it('restores last viewed channel when hash is empty and reopen preference is enabled', async () => {
const chatChannel = {
key: '11111111111111111111111111111111',

View File

@@ -64,6 +64,10 @@ vi.mock('../components/VisualizerView', () => ({
VisualizerView: () => <div data-testid="visualizer-view" />,
}));
vi.mock('../components/TracePane', () => ({
TracePane: () => <div data-testid="trace-pane" />,
}));
const config: RadioConfig = {
public_key: 'aa'.repeat(32),
name: 'Radio',
@@ -141,6 +145,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
loadingNewer: false,
messageInputRef: { current: null },
onTrace: vi.fn(async () => {}),
onRunTracePath: vi.fn(async () => ({ path_len: 0, timeout_seconds: 5, nodes: [] })),
onPathDiscovery: vi.fn(async () => {
throw new Error('unused');
}),
@@ -231,6 +236,23 @@ describe('ConversationPane', () => {
});
});
it('renders the trace tool pane for trace conversations', () => {
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'trace',
id: 'trace',
name: 'Trace',
},
})}
/>
);
expect(screen.getByTestId('trace-pane')).toBeInTheDocument();
expect(screen.queryByTestId('message-list')).not.toBeInTheDocument();
});
it('gates room chat behind room login controls until authenticated', async () => {
render(
<ConversationPane

View File

@@ -75,13 +75,14 @@ function renderSidebar(overrides?: {
const favorites = overrides?.favorites ?? [{ type: 'channel', id: flightChannel.key }];
const channels = overrides?.channels ?? [publicChannel, flightChannel, opsChannel];
const onSelectConversation = vi.fn();
const view = render(
<Sidebar
contacts={[alice, board, relay]}
channels={channels}
activeConversation={null}
onSelectConversation={vi.fn()}
onSelectConversation={onSelectConversation}
onNewMessage={vi.fn()}
lastMessageTimes={overrides?.lastMessageTimes ?? {}}
unreadCounts={unreadCounts}
@@ -96,7 +97,7 @@ function renderSidebar(overrides?: {
/>
);
return { ...view, flightChannel, opsChannel, aliceName, roomName };
return { ...view, flightChannel, opsChannel, aliceName, roomName, onSelectConversation };
}
function getSectionHeaderContainer(title: string): HTMLElement {
@@ -306,6 +307,18 @@ describe('Sidebar section summaries', () => {
expect(bell.compareDocumentPosition(unread) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('shows the trace tool row and selects it', () => {
const { onSelectConversation } = renderSidebar();
fireEvent.click(screen.getByText('Trace'));
expect(onSelectConversation).toHaveBeenCalledWith({
type: 'trace',
id: 'trace',
name: 'Trace',
});
});
it('sorts each section independently and persists per-section sort preferences', () => {
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
const zebraChannel = makeChannel('BB'.repeat(16), '#zebra');

View File

@@ -0,0 +1,262 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { TracePane } from '../components/TracePane';
import type { Contact, RadioConfig, RadioTraceResponse } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
function makeContact(
publicKey: string,
name: string | null,
type = CONTACT_TYPE_REPEATER,
overrides: Partial<Contact> = {}
): Contact {
return {
public_key: publicKey,
name,
type,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
...overrides,
};
}
const config: RadioConfig = {
public_key: 'ff'.repeat(32),
name: 'Base Radio',
lat: 10,
lon: 20,
tx_power: 17,
max_tx_power: 22,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
path_hash_mode: 0,
path_hash_mode_supported: true,
};
describe('TracePane', () => {
it('shows only full-key repeaters and filters by name or key', () => {
render(
<TracePane
config={config}
onRunTracePath={vi.fn()}
contacts={[
makeContact('11'.repeat(32), 'Relay Alpha'),
makeContact('22'.repeat(6), 'Prefix Relay'),
makeContact('33'.repeat(32), 'Client Node', 1),
makeContact('44'.repeat(32), 'Relay Beta'),
]}
/>
);
expect(screen.getByText('Relay Alpha')).toBeInTheDocument();
expect(screen.getByText('Relay Beta')).toBeInTheDocument();
expect(screen.queryByText('Prefix Relay')).not.toBeInTheDocument();
expect(screen.queryByText('Client Node')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: 'beta' } });
expect(screen.queryByText('Relay Alpha')).not.toBeInTheDocument();
expect(screen.getByText('Relay Beta')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: '111111' } });
expect(screen.getByText('Relay Alpha')).toBeInTheDocument();
});
it('adds, reorders, removes, and sends a trace path with known repeaters', async () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const relayB = makeContact('22'.repeat(32), 'Relay Beta');
const onRunTracePath = vi.fn(
async (): Promise<RadioTraceResponse> => ({
path_len: 2,
timeout_seconds: 6,
nodes: [
{
role: 'repeater',
public_key: relayB.public_key,
name: relayB.name,
observed_hash: relayB.public_key.slice(0, 8),
snr: 7.5,
},
{
role: 'repeater',
public_key: relayA.public_key,
name: relayA.name,
observed_hash: relayA.public_key.slice(0, 8),
snr: 3.25,
},
{
role: 'local',
public_key: config.public_key,
name: config.name,
observed_hash: null,
snr: 5.0,
},
],
})
);
render(
<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA, relayB]} />
);
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i }));
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /move relay beta up/i }));
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
await waitFor(() => {
expect(onRunTracePath).toHaveBeenCalledWith(4, [
{ public_key: relayB.public_key },
{ public_key: relayA.public_key },
]);
});
expect(screen.getByRole('heading', { name: 'Results (6.0s)' })).toBeInTheDocument();
expect(screen.getByText('+7.5 dB')).toBeInTheDocument();
expect(screen.getByText('+5.0 dB')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /remove relay alpha/i }));
expect(screen.getByText('1 hop selected · 4-byte trace')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /remove relay beta/i }));
expect(screen.getByText('No hops selected')).toBeInTheDocument();
});
it('allows adding the same repeater multiple times from the picker row', () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
render(<TracePane config={config} onRunTracePath={vi.fn()} contacts={[relayA]} />);
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
expect(screen.getByText('Added 2 times')).toBeInTheDocument();
});
it('adds custom hops from the modal and locks later custom hops to the same byte width', async () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const onRunTracePath = vi.fn(
async (): Promise<RadioTraceResponse> => ({
path_len: 2,
timeout_seconds: 4.5,
nodes: [
{
role: 'custom',
public_key: null,
name: null,
observed_hash: 'ae',
snr: 4.0,
},
{
role: 'repeater',
public_key: relayA.public_key,
name: relayA.name,
observed_hash: '11',
snr: 2.0,
},
{
role: 'local',
public_key: config.public_key,
name: config.name,
observed_hash: null,
snr: 3.0,
},
],
})
);
render(<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA]} />);
fireEvent.click(screen.getByRole('button', { name: 'Custom path' }));
fireEvent.click(screen.getByRole('button', { name: '1-byte' }));
fireEvent.change(screen.getByLabelText('Repeater prefix'), { target: { value: 'ae' } });
fireEvent.click(screen.getByRole('button', { name: 'Add custom hop' }));
expect(screen.getByText('1 hop selected · 1-byte trace')).toBeInTheDocument();
expect(screen.getByText('AE (1-byte)')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
await waitFor(() => {
expect(onRunTracePath).toHaveBeenCalledWith(1, [
{ hop_hex: 'ae' },
{ public_key: relayA.public_key },
]);
});
fireEvent.click(screen.getByRole('button', { name: 'Custom path' }));
expect(screen.getByRole('button', { name: '2-byte' })).toBeDisabled();
expect(screen.getByRole('button', { name: '4-byte' })).toBeDisabled();
expect(screen.getByText(/custom hops are locked to 1-byte prefixes/i)).toBeInTheDocument();
});
it('drops an in-flight result after the draft path changes', async () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const relayB = makeContact('22'.repeat(32), 'Relay Beta');
let resolveTrace: ((value: RadioTraceResponse) => void) | null = null;
const onRunTracePath = vi.fn(
() =>
new Promise<RadioTraceResponse>((resolve) => {
resolveTrace = resolve;
})
);
render(
<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA, relayB]} />
);
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
await waitFor(() => {
expect(onRunTracePath).toHaveBeenCalledWith(4, [{ public_key: relayA.public_key }]);
});
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i }));
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /send trace/i })).toBeEnabled();
await act(async () => {
resolveTrace?.({
path_len: 1,
timeout_seconds: 6,
nodes: [
{
role: 'repeater',
public_key: relayA.public_key,
name: relayA.name,
observed_hash: relayA.public_key.slice(0, 8),
snr: 7.5,
},
{
role: 'local',
public_key: config.public_key,
name: config.name,
observed_hash: null,
snr: 5.0,
},
],
});
});
expect(screen.queryByRole('heading', { name: 'Results (6.0s)' })).not.toBeInTheDocument();
expect(screen.queryByText('+7.5 dB')).not.toBeInTheDocument();
expect(
screen.getByText('Send a trace to see the returned hop-by-hop SNR values.')
).toBeInTheDocument();
});
});

View File

@@ -52,6 +52,14 @@ describe('parseHashConversation', () => {
expect(result).toEqual({ type: 'map', name: 'map' });
});
it('parses #trace as trace type', () => {
window.location.hash = '#trace';
const result = parseHashConversation();
expect(result).toEqual({ type: 'trace', name: 'trace' });
});
it('parses #map/focus/PUBKEY with focus key', () => {
window.location.hash = '#map/focus/ABCD1234';

View File

@@ -286,7 +286,7 @@ export interface ResendChannelMessageResponse {
message?: Message;
}
type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search';
type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace';
export interface Conversation {
type: ConversationType;
@@ -474,6 +474,25 @@ export interface TraceResponse {
path_len: number;
}
export interface RadioTraceNode {
role: 'repeater' | 'custom' | 'local';
public_key: string | null;
name: string | null;
observed_hash: string | null;
snr: number | null;
}
export interface RadioTraceHopRequest {
public_key?: string | null;
hop_hex?: string | null;
}
export interface RadioTraceResponse {
path_len: number;
timeout_seconds: number;
nodes: RadioTraceNode[];
}
export interface PathDiscoveryRoute {
path: string;
path_len: number;

View File

@@ -4,7 +4,14 @@ import { parseHashConversation } from './urlHash';
export const REOPEN_LAST_CONVERSATION_KEY = 'remoteterm-reopen-last-conversation';
export const LAST_VIEWED_CONVERSATION_KEY = 'remoteterm-last-viewed-conversation';
const SUPPORTED_TYPES: Conversation['type'][] = ['contact', 'channel', 'raw', 'map', 'visualizer'];
const SUPPORTED_TYPES: Conversation['type'][] = [
'contact',
'channel',
'raw',
'map',
'visualizer',
'trace',
];
function isSupportedType(value: unknown): value is Conversation['type'] {
return typeof value === 'string' && SUPPORTED_TYPES.includes(value as Conversation['type']);
@@ -94,6 +101,10 @@ export function captureLastViewedConversationFromHash(): void {
saveLastViewedConversation({ type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer' });
return;
}
if (hashConversation.type === 'trace') {
saveLastViewedConversation({ type: 'trace', id: 'trace', name: 'Trace' });
return;
}
saveLastViewedConversation({
type: hashConversation.type,

View File

@@ -4,7 +4,7 @@ import { getContactDisplayName } from './pubkey';
import type { SettingsSection } from '../components/settings/settingsConstants';
interface ParsedHashConversation {
type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search';
type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace';
/** Conversation identity token (channel key or contact public key, or legacy name token) */
name: string;
/** Optional human-readable label segment (ignored for identity resolution) */
@@ -44,6 +44,10 @@ export function parseHashConversation(): ParsedHashConversation | null {
return { type: 'search', name: 'search' };
}
if (hash === 'trace') {
return { type: 'trace', name: 'trace' };
}
// Check for map with focus: #map/focus/{pubkey_prefix}
if (hash.startsWith('map/focus/')) {
const focusKey = hash.slice('map/focus/'.length);
@@ -149,6 +153,7 @@ function getConversationHash(conv: Conversation | null): string {
if (conv.type === 'map') return '#map';
if (conv.type === 'visualizer') return '#visualizer';
if (conv.type === 'search') return '#search';
if (conv.type === 'trace') return '#trace';
// Use immutable IDs for identity, append readable label for UX.
if (conv.type === 'channel') {

View File

@@ -9,7 +9,7 @@ from fastapi import HTTPException
from meshcore import EventType
from pydantic import ValidationError
from app.models import Contact
from app.models import CONTACT_TYPE_REPEATER, Contact, RadioTraceHopRequest, RadioTraceRequest
from app.radio import RadioManager, radio_manager
from app.routers.radio import (
PrivateKeyUpdate,
@@ -25,6 +25,7 @@ from app.routers.radio import (
reconnect_radio,
send_advertisement,
set_private_key,
trace_path,
update_radio_config,
)
from app.services.radio_runtime import RadioRuntime
@@ -524,6 +525,223 @@ class TestDiscoverMesh:
mock_upsert.assert_not_awaited()
mock_broadcast.assert_not_called()
class TestTracePath:
@pytest.mark.asyncio
async def test_returns_resolved_nodes_for_multi_hop_trace(self):
mc = _mock_meshcore_with_info()
repeater_a = Contact(
public_key="11" * 32,
name="Relay Alpha",
type=CONTACT_TYPE_REPEATER,
flags=0,
direct_path=None,
direct_path_len=-1,
direct_path_hash_mode=-1,
last_advert=None,
lat=None,
lon=None,
last_seen=None,
on_radio=False,
last_contacted=None,
last_read_at=None,
first_seen=None,
)
repeater_b = Contact(
public_key="22" * 32,
name="Relay Beta",
type=CONTACT_TYPE_REPEATER,
flags=0,
direct_path=None,
direct_path_len=-1,
direct_path_hash_mode=-1,
last_advert=None,
lat=None,
lon=None,
last_seen=None,
on_radio=False,
last_contacted=None,
last_read_at=None,
first_seen=None,
)
mc.commands.send_trace = AsyncMock(
return_value=_radio_result(EventType.MSG_SENT, {"suggested_timeout": 4000})
)
mc.wait_for_event = AsyncMock(
return_value=MagicMock(
payload={
"path_len": 2,
"path": [
{"hash": "11111111", "snr": 7.5},
{"hash": "22222222", "snr": 3.25},
{"snr": 5.0},
],
}
)
)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(
"app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock
) as mock_get,
patch("app.routers.radio.radio_manager") as mock_rm,
):
mock_get.side_effect = [repeater_a, repeater_b]
mock_rm.radio_operation = _noop_radio_operation(mc)
response = await trace_path(
RadioTraceRequest(
hop_hash_bytes=4,
hops=[
RadioTraceHopRequest(public_key=repeater_a.public_key),
RadioTraceHopRequest(public_key=repeater_b.public_key),
],
)
)
mc.commands.send_trace.assert_awaited_once_with(
path="11111111,22222222",
tag=ANY,
flags=2,
)
mc.wait_for_event.assert_awaited_once()
assert response.path_len == 2
assert response.nodes[0].name == "Relay Alpha"
assert response.nodes[0].snr == 7.5
assert response.nodes[1].name == "Relay Beta"
assert response.nodes[1].observed_hash == "22222222"
assert response.nodes[2].role == "local"
assert response.nodes[2].public_key == "aa" * 32
assert response.nodes[2].observed_hash is None
assert response.nodes[2].snr == 5.0
@pytest.mark.asyncio
async def test_rejects_non_repeater_nodes(self):
mc = _mock_meshcore_with_info()
non_repeater = Contact(
public_key="33" * 32,
name="Client",
type=1,
flags=0,
direct_path=None,
direct_path_len=-1,
direct_path_hash_mode=-1,
last_advert=None,
lat=None,
lon=None,
last_seen=None,
on_radio=False,
last_contacted=None,
last_read_at=None,
first_seen=None,
)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch(
"app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock
) as mock_get,
):
mock_get.return_value = non_repeater
with pytest.raises(HTTPException) as exc:
await trace_path(
RadioTraceRequest(
hop_hash_bytes=4,
hops=[RadioTraceHopRequest(public_key=non_repeater.public_key)],
)
)
assert exc.value.status_code == 400
assert "not a repeater" in exc.value.detail
@pytest.mark.asyncio
async def test_returns_504_when_no_trace_response_is_heard(self):
mc = _mock_meshcore_with_info()
repeater = Contact(
public_key="44" * 32,
name="Relay",
type=CONTACT_TYPE_REPEATER,
flags=0,
direct_path=None,
direct_path_len=-1,
direct_path_hash_mode=-1,
last_advert=None,
lat=None,
lon=None,
last_seen=None,
on_radio=False,
last_contacted=None,
last_read_at=None,
first_seen=None,
)
mc.commands.send_trace = AsyncMock(
return_value=_radio_result(EventType.MSG_SENT, {"suggested_timeout": 1000})
)
mc.wait_for_event = AsyncMock(return_value=None)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(
"app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock
) as mock_get,
patch("app.routers.radio.radio_manager") as mock_rm,
):
mock_get.return_value = repeater
mock_rm.radio_operation = _noop_radio_operation(mc)
with pytest.raises(HTTPException) as exc:
await trace_path(
RadioTraceRequest(
hop_hash_bytes=4,
hops=[RadioTraceHopRequest(public_key=repeater.public_key)],
)
)
assert exc.value.status_code == 504
assert "No trace response heard" in exc.value.detail
@pytest.mark.asyncio
async def test_supports_custom_hops_with_shorter_hash_width(self):
mc = _mock_meshcore_with_info()
mc.commands.send_trace = AsyncMock(
return_value=_radio_result(EventType.MSG_SENT, {"suggested_timeout": 2500})
)
mc.wait_for_event = AsyncMock(
return_value=MagicMock(
payload={
"path_len": 2,
"path": [
{"hash": "ae", "snr": 4.0},
{"hash": "bf", "snr": 2.5},
{"snr": 3.0},
],
}
)
)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.radio.radio_manager") as mock_rm,
):
mock_rm.radio_operation = _noop_radio_operation(mc)
response = await trace_path(
RadioTraceRequest(
hop_hash_bytes=1,
hops=[
RadioTraceHopRequest(hop_hex="ae"),
RadioTraceHopRequest(hop_hex="bf"),
],
)
)
mc.commands.send_trace.assert_awaited_once_with(path="ae,bf", tag=ANY, flags=0)
assert response.nodes[0].role == "custom"
assert response.nodes[0].observed_hash == "ae"
assert response.nodes[1].role == "custom"
assert response.nodes[1].observed_hash == "bf"
@pytest.mark.asyncio
async def test_discovers_all_supported_types(self):
mc = _mock_meshcore_with_info()