mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 11:02:56 +02:00
Add trace tool. Closes #130.
This commit is contained in:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -457,6 +457,7 @@ export function App() {
|
||||
loadingNewer,
|
||||
messageInputRef,
|
||||
onTrace: handleTrace,
|
||||
onRunTracePath: api.requestRadioTrace,
|
||||
onPathDiscovery: handlePathDiscovery,
|
||||
onToggleFavorite: handleToggleFavorite,
|
||||
onDeleteContact: handleDeleteContact,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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..." />}>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
691
frontend/src/components/TracePane.tsx
Normal file
691
frontend/src/components/TracePane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -192,8 +192,8 @@ export function SettingsLocalSection({
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Scales the app'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's typography for this browser only. The slider moves in 5% steps; the
|
||||
number field accepts any value from 25% to 400%.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()) ||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
262
frontend/src/test/tracePane.test.tsx
Normal file
262
frontend/src/test/tracePane.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user