- {bins.map((bin, index) => (
-
-
-
- {typeOrder.map((type, index) => {
- const count = bin.countsByType[type] ?? 0;
- if (count === 0) return null;
- return (
-
- );
- })}
-
-
-
{bin.label}
-
- ))}
+
+
+
+
+
+
+
+ {typeOrder.map((type, i) => (
+
+ ))}
+
+
);
diff --git a/frontend/src/components/SearchView.tsx b/frontend/src/components/SearchView.tsx
index e297a51..7932996 100644
--- a/frontend/src/components/SearchView.tsx
+++ b/frontend/src/components/SearchView.tsx
@@ -174,7 +174,11 @@ export function SearchView({
api
.getMessages({ q: debouncedQuery, limit: SEARCH_PAGE_SIZE, offset }, controller.signal)
.then((data) => {
- setResults((prev) => [...prev, ...(data as SearchResult[])]);
+ setResults((prev) => {
+ const existingIds = new Set(prev.map((r) => r.id));
+ const unique = (data as SearchResult[]).filter((r) => !existingIds.has(r.id));
+ return [...prev, ...unique];
+ });
setHasMore(data.length >= SEARCH_PAGE_SIZE);
setOffset((prev) => prev + data.length);
})
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 8a716de..088031b 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -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:
,
+ icon:
,
label: 'Mesh Visualizer',
onClick: () =>
handleSelectConversation({
@@ -730,6 +731,18 @@ export function Sidebar({
name: 'Mesh Visualizer',
}),
}),
+ renderSidebarActionRow({
+ key: 'tool-trace',
+ active: isActive('trace', 'trace'),
+ icon:
,
+ label: 'Trace',
+ onClick: () =>
+ handleSelectConversation({
+ type: 'trace',
+ id: 'trace',
+ name: 'Trace',
+ }),
+ }),
renderSidebarActionRow({
key: 'tool-search',
active: isActive('search', 'search'),
diff --git a/frontend/src/components/TracePane.tsx b/frontend/src/components/TracePane.tsx
new file mode 100644
index 0000000..54c2da1
--- /dev/null
+++ b/frontend/src/components/TracePane.tsx
@@ -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
;
+}
+
+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 (
+
+
+ {fixed ? 'Self' : 'Hop'}
+
+
+
{title}
+
{subtitle}
+ {meta ?
{meta}
: null}
+ {note ?
{note}
: null}
+
+ {snr ? (
+
+ ) : null}
+ {actions ?
{actions}
: null}
+
+ );
+}
+
+export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [sortMode, setSortMode] = useState('alpha');
+ const [draftHops, setDraftHops] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [result, setResult] = useState(null);
+ const [customDialogOpen, setCustomDialogOpen] = useState(false);
+ const [customHopBytesDraft, setCustomHopBytesDraft] = useState(1);
+ const [customHopHexDraft, setCustomHopHexDraft] = useState('');
+ const [customHopError, setCustomHopError] = useState(null);
+ const activeRunTokenRef = useRef(0);
+
+ const repeaters = useMemo(() => {
+ const deduped = new Map();
+ 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 (
+
+
+
Trace
+
+ 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.
+
+
+
+
+
+
+
Repeater Hops
+
+ Search by name or key, then add repeaters in the order you want to traverse them.
+
+
setCustomDialogOpen(true)}
+ >
+ Custom path
+
+
setSearchQuery(event.target.value)}
+ placeholder="Search name or public key"
+ aria-label="Search repeaters"
+ className="mt-3"
+ />
+
+ {(
+ [
+ ['alpha', 'Alpha'],
+ ['recent', 'Recent Heard'],
+ ['distance', 'Distance'],
+ ] as const
+ ).map(([value, label]) => (
+ setSortMode(value)}
+ >
+ {label}
+
+ ))}
+
+ {sortMode === 'distance' && !canSortByDistance ? (
+
+ Distance sorting is using known repeater coordinates, but the local radio does not
+ currently have a valid location.
+
+ ) : null}
+
+
+
+ {filteredRepeaters.length === 0 ? (
+
+ No repeaters matched this search.
+
+ ) : (
+
+ {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 (
+
0
+ ? 'border-primary/30 bg-primary/5'
+ : 'border-border bg-background hover:bg-accent'
+ )}
+ onClick={() => handleAddRepeater(contact.public_key)}
+ onKeyDown={handleKeyboardActivate}
+ >
+
+
+
{displayName}
+
+ {getShortKey(contact.public_key)}
+
+ {sortMode === 'distance' && distanceKm !== null ? (
+
+ {distanceKm.toFixed(1)} km away
+
+ ) : null}
+ {selectedCount > 0 ? (
+
+ Added {selectedCount} time{selectedCount === 1 ? '' : 's'}
+
+ ) : null}
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+
+
Trace Path
+
+ The first node is display-only. The terminal node is the local radio.
+
+
+
+
+ {draftHops.length === 0 ? (
+
+ Add at least one hop to build a trace loop.
+
+ ) : (
+ 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 (
+
+
+ handleMoveHop(index, -1)}
+ disabled={index === 0}
+ >
+
+
+ handleMoveHop(index, 1)}
+ disabled={index === draftHops.length - 1}
+ >
+
+
+ handleRemoveHop(hop.id)}
+ >
+
+
+ >
+ }
+ />
+
+ );
+ })
+ )}
+
+
+
+
+ {draftHops.length === 0
+ ? 'No hops selected'
+ : `${draftHops.length} hop${draftHops.length === 1 ? '' : 's'} selected · ${effectiveHopHashBytes}-byte trace`}
+
+
+ {loading ? 'Tracing...' : 'Send trace'}
+
+
+
+
+
+
+
+ Results{result ? ` (${result.timeout_seconds.toFixed(1)}s)` : ''}
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {!error && !result ? (
+
+ Send a trace to see the returned hop-by-hop SNR values.
+
+ ) : 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 (
+
+
+
+ );
+ })
+ : null}
+
+
+
+
+
+
+
+
+ Custom path hop
+
+ 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.
+
+
+
+
+
+
Hop width
+
+ {([1, 2, 4] as const).map((value) => {
+ const locked = customHopBytesLocked !== null && customHopBytesLocked !== value;
+ const active = (customHopBytesLocked ?? customHopBytesDraft) === value;
+ return (
+ setCustomHopBytesDraft(value)}
+ >
+ {value}-byte
+
+ );
+ })}
+
+ {customHopBytesLocked !== null ? (
+
+ Custom hops are locked to {customHopBytesLocked}-byte prefixes for this trace.
+
+ ) : null}
+
+
+
+
+ Repeater prefix
+
+
+ setCustomHopHexDraft(normalizeCustomHopHex(event.target.value))
+ }
+ placeholder={`${(customHopBytesLocked ?? customHopBytesDraft) * 2} hex chars`}
+ />
+
+ Enter exactly {(customHopBytesLocked ?? customHopBytesDraft) * 2} hex characters.
+
+ {customHopError ? (
+
+ {customHopError}
+
+ ) : null}
+
+
+
+
+ setCustomDialogOpen(false)}>
+ Cancel
+
+
+ Add custom hop
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/settings/SettingsLocalSection.tsx b/frontend/src/components/settings/SettingsLocalSection.tsx
index 5b08952..9c886dd 100644
--- a/frontend/src/components/settings/SettingsLocalSection.tsx
+++ b/frontend/src/components/settings/SettingsLocalSection.tsx
@@ -17,6 +17,14 @@ import {
setSavedDistanceUnit,
} from '../../utils/distanceUnits';
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
+import {
+ DEFAULT_FONT_SCALE,
+ FONT_SCALE_SLIDER_STEP,
+ MAX_FONT_SCALE,
+ MIN_FONT_SCALE,
+ getSavedFontScale,
+ setSavedFontScale,
+} from '../../utils/fontScale';
export function SettingsLocalSection({
onLocalLabelChange,
@@ -31,6 +39,29 @@ export function SettingsLocalSection({
);
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
+ const [fontScale, setFontScale] = useState(getSavedFontScale);
+ const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
+ const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
+
+ const commitFontScale = (nextScale: number) => {
+ const normalized = setSavedFontScale(nextScale);
+ setFontScale(normalized);
+ setFontScaleSlider(normalized);
+ setFontScaleInput(String(normalized));
+ };
+
+ const restoreFontScaleInput = () => {
+ setFontScaleInput(String(fontScale));
+ };
+
+ const handleSliderChange = (nextScale: number) => {
+ setFontScaleSlider(nextScale);
+ setFontScaleInput(String(nextScale));
+ };
+
+ const handleSliderCommit = (nextScale: number) => {
+ commitFontScale(nextScale);
+ };
const handleToggleReopenLastConversation = (enabled: boolean) => {
setReopenLastConversation(enabled);
@@ -89,6 +120,85 @@ export function SettingsLocalSection({
+
+
Relative Font Size
+
+
+ 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%.
+
+
+
+
+
Distance Units
- {result.node_type}
+
+ {result.name ?? {result.node_type} }
+
heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
+ {result.name && (
+ {result.node_type}
+ )}
{result.public_key}
diff --git a/frontend/src/components/settings/SettingsStatisticsSection.tsx b/frontend/src/components/settings/SettingsStatisticsSection.tsx
index 626088a..6605a03 100644
--- a/frontend/src/components/settings/SettingsStatisticsSection.tsx
+++ b/frontend/src/components/settings/SettingsStatisticsSection.tsx
@@ -1,4 +1,16 @@
import { useState, useEffect } from 'react';
+import {
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip as RechartsTooltip,
+ ResponsiveContainer,
+ AreaChart,
+ Area,
+ Cell,
+} from 'recharts';
import { Separator } from '../ui/separator';
import { api } from '../../api';
import type { StatisticsResponse } from '../../types';
@@ -7,6 +19,94 @@ function formatPercent(value: number): string {
return `${value.toFixed(1)}%`;
}
+const CHANNEL_BAR_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
+
+const TOOLTIP_STYLE = {
+ contentStyle: {
+ backgroundColor: 'hsl(var(--popover))',
+ border: '1px solid hsl(var(--border))',
+ borderRadius: '6px',
+ fontSize: '11px',
+ color: 'hsl(var(--popover-foreground))',
+ },
+ itemStyle: { color: 'hsl(var(--popover-foreground))' },
+ labelStyle: { color: 'hsl(var(--muted-foreground))' },
+} as const;
+
+function formatTime(ts: number): string {
+ return new Date(ts * 1000).toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false,
+ });
+}
+
+function NoiseFloorChart({
+ samples,
+}: {
+ samples: { timestamp: number; noise_floor_dbm: number }[];
+}) {
+ const data = samples.map((s, i) => ({
+ idx: i,
+ time: formatTime(s.timestamp),
+ noise_floor: s.noise_floor_dbm,
+ }));
+
+ const tickCount = Math.min(6, samples.length);
+ const tickIndices: number[] = [];
+ if (samples.length > 1) {
+ for (let i = 0; i < tickCount; i++) {
+ tickIndices.push(Math.round((i / (tickCount - 1)) * (samples.length - 1)));
+ }
+ }
+
+ return (
+
+
+
+ data[idx]?.time ?? ''}
+ />
+ `${v}`}
+ />
+ data[Number(idx)]?.time ?? ''}
+ formatter={(value) => [`${value} dBm`, 'Noise Floor']}
+ />
+
+
+
+ );
+}
+
export function SettingsStatisticsSection({ className }: { className?: string }) {
const [stats, setStats] = useState(null);
const [statsLoading, setStatsLoading] = useState(false);
@@ -85,60 +185,6 @@ export function SettingsStatisticsSection({ className }: { className?: string })
- {/* Packets */}
-
-
Packets
-
-
- Total stored
- {stats.total_packets}
-
-
- Decrypted
- {stats.decrypted_packets}
-
-
- Undecrypted
- {stats.undecrypted_packets}
-
-
-
-
-
-
-
-
Path Hash Width (24h)
-
- Parsed stored raw packets from the last 24 hours:{' '}
- {stats.path_hash_width_24h.total_packets}
-
-
-
- 1-byte hops
-
- {stats.path_hash_width_24h.single_byte} (
- {formatPercent(stats.path_hash_width_24h.single_byte_pct)})
-
-
-
- 2-byte hops
-
- {stats.path_hash_width_24h.double_byte} (
- {formatPercent(stats.path_hash_width_24h.double_byte_pct)})
-
-
-
- 3-byte hops
-
- {stats.path_hash_width_24h.triple_byte} (
- {formatPercent(stats.path_hash_width_24h.triple_byte_pct)})
-
-
-
-
-
-
-
{/* Activity */}
Activity
@@ -174,23 +220,172 @@ export function SettingsStatisticsSection({ className }: { className?: string })
+
+
+ {/* Packets */}
+
+
Packets
+
+
+ Total stored
+ {stats.total_packets}
+
+
+ Decrypted
+ {stats.decrypted_packets}
+
+
+ Undecrypted
+ {stats.undecrypted_packets}
+
+
+
+
+
+
+ {/* Path Hash Width */}
+
+
Path Hash Width (24h)
+
+ Parsed stored raw packets from the last 24 hours:{' '}
+ {stats.path_hash_width_24h.total_packets}
+
+ {stats.path_hash_width_24h.total_packets > 0 ? (
+
+
+
+
+
+ [
+ `${Number(value).toLocaleString()} (${formatPercent(props.payload.pct)})`,
+ 'Packets',
+ ]}
+ />
+
+ |
+ |
+ |
+
+
+
+ ) : (
+
No path data in the last 24 hours.
+ )}
+
+
{/* Busiest Channels */}
{stats.busiest_channels_24h.length > 0 && (
<>
Busiest Channels (24h)
-
- {stats.busiest_channels_24h.map((ch, i) => (
-
-
- {i + 1}.
- {ch.channel_name}
-
- {ch.message_count} msgs
-
- ))}
-
+
+ ({
+ name: ch.channel_name,
+ messages: ch.message_count,
+ }))}
+ layout="vertical"
+ margin={{ top: 0, right: 4, bottom: 0, left: 0 }}
+ barCategoryGap="20%"
+ >
+
+
+ [`${Number(value).toLocaleString()} messages`, null]}
+ />
+
+ {stats.busiest_channels_24h.map((_, i) => (
+ |
+ ))}
+
+
+
+
+ >
+ )}
+
+ {/* Noise Floor */}
+ {stats.noise_floor_24h.supported !== false && (
+ <>
+
+
+
Noise Floor (24h)
+ {stats.noise_floor_24h.latest_noise_floor_dbm != null && (
+
+ Latest reading: {stats.noise_floor_24h.latest_noise_floor_dbm} dBm
+ {stats.noise_floor_24h.latest_timestamp != null &&
+ ` at ${new Date(
+ stats.noise_floor_24h.latest_timestamp * 1000
+ ).toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ })}`}
+
+ )}
+ {stats.noise_floor_24h.samples.length > 1 ? (
+
+ ) : stats.noise_floor_24h.samples.length === 0 ? (
+
+ No noise floor samples collected yet. Samples are collected every five minutes,
+ and retained until server restart.
+
+ ) : (
+
+ Only one sample so far ({stats.noise_floor_24h.samples[0].noise_floor_dbm} dBm).
+ More data needed for a chart. Samples are collected every five minutes, and
+ retained until server restart.
+
+ )}
>
)}
diff --git a/frontend/src/hooks/useConversationMessages.ts b/frontend/src/hooks/useConversationMessages.ts
index 167fd0f..5c11b80 100644
--- a/frontend/src/hooks/useConversationMessages.ts
+++ b/frontend/src/hooks/useConversationMessages.ts
@@ -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(
diff --git a/frontend/src/hooks/useConversationRouter.ts b/frontend/src/hooks/useConversationRouter.ts
index 5abd6fe..d0869a4 100644
--- a/frontend/src/hooks/useConversationRouter.ts
+++ b/frontend/src/hooks/useConversationRouter.ts
@@ -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()) ||
diff --git a/frontend/src/hooks/useRealtimeAppState.ts b/frontend/src/hooks/useRealtimeAppState.ts
index cd1c843..724abb3 100644
--- a/frontend/src/hooks/useRealtimeAppState.ts
+++ b/frontend/src/hooks/useRealtimeAppState.ts
@@ -43,6 +43,7 @@ interface UseRealtimeAppStateArgs {
hasMention?: boolean;
}) => void;
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
+ removeConversationState: (stateKey: string) => void;
checkMention: (text: string) => boolean;
pendingDeleteFallbackRef: MutableRefObject;
setActiveConversation: (conv: Conversation | null) => void;
@@ -96,6 +97,7 @@ export function useRealtimeAppState({
observeMessage,
recordMessageEvent,
renameConversationState,
+ removeConversationState,
checkMention,
pendingDeleteFallbackRef,
setActiveConversation,
@@ -232,6 +234,7 @@ export function useRealtimeAppState({
onContactDeleted: (publicKey: string) => {
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
removeConversationMessages(publicKey);
+ removeConversationState(getStateKey('contact', publicKey));
const active = activeConversationRef.current;
if (active?.type === 'contact' && active.id === publicKey) {
pendingDeleteFallbackRef.current = true;
@@ -241,6 +244,7 @@ export function useRealtimeAppState({
onChannelDeleted: (key: string) => {
setChannels((prev) => prev.filter((c) => c.key !== key));
removeConversationMessages(key);
+ removeConversationState(getStateKey('channel', key));
const active = activeConversationRef.current;
if (active?.type === 'channel' && active.id === key) {
pendingDeleteFallbackRef.current = true;
@@ -267,6 +271,7 @@ export function useRealtimeAppState({
checkMention,
fetchAllContacts,
fetchConfig,
+ removeConversationState,
renameConversationState,
renameConversationMessages,
maxRawPackets,
diff --git a/frontend/src/hooks/useUnreadCounts.ts b/frontend/src/hooks/useUnreadCounts.ts
index 623deb4..be438c5 100644
--- a/frontend/src/hooks/useUnreadCounts.ts
+++ b/frontend/src/hooks/useUnreadCounts.ts
@@ -10,6 +10,14 @@ import {
import type { Channel, Contact, Conversation, Message, UnreadCounts } from '../types';
import { takePrefetchOrFetch } from '../prefetch';
+type UnreadTrackedConversation = Conversation & { type: 'channel' | 'contact' };
+
+function isUnreadTrackedConversation(
+ conversation: Conversation | null
+): conversation is UnreadTrackedConversation {
+ return conversation?.type === 'channel' || conversation?.type === 'contact';
+}
+
interface UseUnreadCountsResult {
unreadCounts: Record;
/** Tracks which conversations have unread messages that mention the user */
@@ -23,6 +31,7 @@ interface UseUnreadCountsResult {
hasMention?: boolean;
}) => void;
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
+ removeConversationState: (stateKey: string) => void;
markAllRead: () => void;
refreshUnreads: () => Promise;
}
@@ -47,14 +56,7 @@ export function useUnreadCounts(
// (the user is already viewing it, so its count should stay at 0).
const applyUnreads = useCallback((data: UnreadCounts) => {
const ac = activeConvRef.current;
- const activeKey =
- ac &&
- ac.type !== 'raw' &&
- ac.type !== 'map' &&
- ac.type !== 'visualizer' &&
- ac.type !== 'search'
- ? getStateKey(ac.type as 'channel' | 'contact', ac.id)
- : null;
+ const activeKey = isUnreadTrackedConversation(ac) ? getStateKey(ac.type, ac.id) : null;
if (activeKey) {
const counts = { ...data.counts };
@@ -122,16 +124,8 @@ export function useUnreadCounts(
// Mark conversation as read when user views it
// Calls server API to persist read state across devices
useEffect(() => {
- if (
- activeConversation &&
- activeConversation.type !== 'raw' &&
- activeConversation.type !== 'map' &&
- activeConversation.type !== 'visualizer'
- ) {
- const key = getStateKey(
- activeConversation.type as 'channel' | 'contact',
- activeConversation.id
- );
+ if (isUnreadTrackedConversation(activeConversation)) {
+ const key = getStateKey(activeConversation.type, activeConversation.id);
// Update local state immediately for responsive UI
setUnreadCounts((prev) => {
@@ -235,6 +229,27 @@ export function useUnreadCounts(
setLastMessageTimes(renameConversationTimeKey(oldStateKey, newStateKey));
}, []);
+ const removeConversationState = useCallback((stateKey: string) => {
+ setUnreadCounts((prev) => {
+ if (!(stateKey in prev)) return prev;
+ const next = { ...prev };
+ delete next[stateKey];
+ return next;
+ });
+ setMentions((prev) => {
+ if (!(stateKey in prev)) return prev;
+ const next = { ...prev };
+ delete next[stateKey];
+ return next;
+ });
+ setUnreadLastReadAts((prev) => {
+ if (!(stateKey in prev)) return prev;
+ const next = { ...prev };
+ delete next[stateKey];
+ return next;
+ });
+ }, []);
+
// Mark all conversations as read
// Calls single bulk API endpoint to persist read state
const markAllRead = useCallback(() => {
@@ -256,6 +271,7 @@ export function useUnreadCounts(
unreadLastReadAts,
recordMessageEvent,
renameConversationState,
+ removeConversationState,
markAllRead,
refreshUnreads: fetchUnreads,
};
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 4a1ad44..b28e91e 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -85,7 +85,7 @@
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
- font-size: 14px;
+ font-size: 0.875rem;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 0d45952..2bafefd 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -5,9 +5,11 @@ import './index.css';
import './themes.css';
import './styles.css';
import { getSavedTheme, applyTheme } from './utils/theme';
+import { applyFontScale, getSavedFontScale } from './utils/fontScale';
// Apply saved theme before first render
applyTheme(getSavedTheme());
+applyFontScale(getSavedFontScale());
createRoot(document.getElementById('root')!).render(
diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx
index 8b9f7cd..d0170af 100644
--- a/frontend/src/test/appStartupHash.test.tsx
+++ b/frontend/src/test/appStartupHash.test.tsx
@@ -195,6 +195,53 @@ describe('App startup hash resolution', () => {
});
});
+ it('restores the trace tool from the URL hash', async () => {
+ window.location.hash = '#trace';
+
+ render( );
+
+ 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( );
+
+ 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( );
+
+ 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',
diff --git a/frontend/src/test/contactInfoPane.test.tsx b/frontend/src/test/contactInfoPane.test.tsx
index e518694..58df85c 100644
--- a/frontend/src/test/contactInfoPane.test.tsx
+++ b/frontend/src/test/contactInfoPane.test.tsx
@@ -181,7 +181,10 @@ describe('ContactInfoPane', () => {
await screen.findByText('Mystery');
await waitFor(() => {
- expect(getContactAnalytics).toHaveBeenCalledWith({ name: 'Mystery' });
+ expect(getContactAnalytics).toHaveBeenCalledWith(
+ { name: 'Mystery' },
+ expect.any(AbortSignal)
+ );
expect(screen.getByText('Messages')).toBeInTheDocument();
expect(screen.getByText('Channel Messages')).toBeInTheDocument();
expect(screen.getByText('4', { selector: 'p' })).toBeInTheDocument();
diff --git a/frontend/src/test/conversationPane.test.tsx b/frontend/src/test/conversationPane.test.tsx
index 1fcc9f8..7e81944 100644
--- a/frontend/src/test/conversationPane.test.tsx
+++ b/frontend/src/test/conversationPane.test.tsx
@@ -64,6 +64,10 @@ vi.mock('../components/VisualizerView', () => ({
VisualizerView: () =>
,
}));
+vi.mock('../components/TracePane', () => ({
+ TracePane: () =>
,
+}));
+
const config: RadioConfig = {
public_key: 'aa'.repeat(32),
name: 'Radio',
@@ -141,6 +145,7 @@ function createProps(overrides: Partial {}),
+ 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(
+
+ );
+
+ 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(
{
+ beforeEach(() => {
+ localStorage.clear();
+ document.documentElement.style.fontSize = '';
+ });
+
+ afterEach(() => {
+ document.documentElement.style.fontSize = '';
+ });
+
+ it('defaults to 100% when nothing is saved', () => {
+ expect(getSavedFontScale()).toBe(DEFAULT_FONT_SCALE);
+ });
+
+ it('reads a saved scale from localStorage', () => {
+ localStorage.setItem(FONT_SCALE_KEY, '135');
+
+ expect(getSavedFontScale()).toBe(135);
+ });
+
+ it('falls back to the default when the saved value is invalid', () => {
+ localStorage.setItem(FONT_SCALE_KEY, 'giant');
+
+ expect(getSavedFontScale()).toBe(DEFAULT_FONT_SCALE);
+ });
+
+ it('applies the scale to the document root', () => {
+ expect(applyFontScale(150)).toBe(150);
+ expect(document.documentElement.style.fontSize).toBe('150%');
+ });
+
+ it('stores non-default values and applies them immediately', () => {
+ expect(setSavedFontScale(137.5)).toBe(137.5);
+ expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('137.5');
+ expect(document.documentElement.style.fontSize).toBe('137.5%');
+ });
+
+ it('removes the saved value when returning to the default scale', () => {
+ localStorage.setItem(FONT_SCALE_KEY, '150');
+
+ expect(setSavedFontScale(DEFAULT_FONT_SCALE)).toBe(DEFAULT_FONT_SCALE);
+ expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
+ expect(document.documentElement.style.fontSize).toBe('100%');
+ });
+
+ it('clamps saved and applied values to the supported range', () => {
+ localStorage.setItem(FONT_SCALE_KEY, '900');
+ expect(getSavedFontScale()).toBe(MAX_FONT_SCALE);
+
+ expect(setSavedFontScale(5)).toBe(MIN_FONT_SCALE);
+ expect(localStorage.getItem(FONT_SCALE_KEY)).toBe(String(MIN_FONT_SCALE));
+ expect(document.documentElement.style.fontSize).toBe(`${MIN_FONT_SCALE}%`);
+ });
+});
diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx
index e8b229b..ef44e87 100644
--- a/frontend/src/test/settingsModal.test.tsx
+++ b/frontend/src/test/settingsModal.test.tsx
@@ -20,6 +20,12 @@ import {
} from '../utils/lastViewedConversation';
import { api } from '../api';
import { DISTANCE_UNIT_KEY } from '../utils/distanceUnits';
+import {
+ DEFAULT_FONT_SCALE,
+ FONT_SCALE_KEY,
+ MAX_FONT_SCALE,
+ MIN_FONT_SCALE,
+} from '../utils/fontScale';
const baseConfig: RadioConfig = {
public_key: 'aa'.repeat(32),
@@ -187,6 +193,7 @@ describe('SettingsModal', () => {
vi.restoreAllMocks();
localStorage.clear();
window.location.hash = '';
+ document.documentElement.style.fontSize = '';
});
it('refreshes app settings when opened', async () => {
@@ -301,6 +308,7 @@ describe('SettingsModal', () => {
results: [
{
public_key: '11'.repeat(32),
+ name: null,
node_type: 'repeater',
heard_count: 2,
local_snr: 7.5,
@@ -549,6 +557,55 @@ describe('SettingsModal', () => {
expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots');
});
+ it('defaults relative font size to 100% and exposes the expected input bounds', () => {
+ renderModal();
+ openLocalSection();
+
+ const slider = screen.getByLabelText('Relative font size slider');
+ const input = screen.getByLabelText('Relative font size percentage');
+
+ expect(slider).toHaveValue(String(DEFAULT_FONT_SCALE));
+ expect(slider).toHaveAttribute('step', '5');
+ expect(input).toHaveValue(DEFAULT_FONT_SCALE);
+ expect(input).toHaveAttribute('min', String(MIN_FONT_SCALE));
+ expect(input).toHaveAttribute('max', String(MAX_FONT_SCALE));
+ });
+
+ it('stores and applies relative font size changes locally', async () => {
+ renderModal();
+ openLocalSection();
+
+ const slider = screen.getByLabelText('Relative font size slider');
+
+ fireEvent.change(slider, { target: { value: '135' } });
+
+ expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
+ expect(document.documentElement.style.fontSize).toBe('');
+
+ fireEvent.mouseUp(slider);
+
+ await waitFor(() => {
+ expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('135');
+ expect(document.documentElement.style.fontSize).toBe('135%');
+ });
+
+ fireEvent.change(screen.getByLabelText('Relative font size percentage'), {
+ target: { value: '137.5' },
+ });
+
+ await waitFor(() => {
+ expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('137.5');
+ expect(document.documentElement.style.fontSize).toBe('137.5%');
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
+
+ await waitFor(() => {
+ expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
+ expect(document.documentElement.style.fontSize).toBe('100%');
+ });
+ });
+
it('purges decrypted raw packets via maintenance endpoint action', async () => {
const runMaintenanceSpy = vi.spyOn(api, 'runMaintenance').mockResolvedValue({
packets_deleted: 12,
@@ -595,6 +652,14 @@ describe('SettingsModal', () => {
double_byte_pct: 30,
triple_byte_pct: 20,
},
+ noise_floor_24h: {
+ sample_interval_seconds: 300,
+ coverage_seconds: 3600,
+ latest_noise_floor_dbm: -105,
+ latest_timestamp: 1711800000,
+ supported: true,
+ samples: [],
+ },
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
@@ -626,17 +691,11 @@ describe('SettingsModal', () => {
expect(
screen.getByText(/Parsed stored raw packets from the last 24 hours: 120/)
).toBeInTheDocument();
- expect(screen.getByText('1-byte hops')).toBeInTheDocument();
- expect(screen.getByText('60 (50.0%)')).toBeInTheDocument();
- expect(screen.getByText('36 (30.0%)')).toBeInTheDocument();
- expect(screen.getByText('24 (20.0%)')).toBeInTheDocument();
expect(screen.getByText('Contacts heard')).toBeInTheDocument();
expect(screen.getByText('Repeaters heard')).toBeInTheDocument();
expect(screen.getByText('Known-channels active')).toBeInTheDocument();
-
- // Busiest channels
- expect(screen.getByText('general')).toBeInTheDocument();
- expect(screen.getByText('42 msgs')).toBeInTheDocument();
+ expect(screen.getByText('Busiest Channels (24h)')).toBeInTheDocument();
+ expect(screen.getByText('Noise Floor (24h)')).toBeInTheDocument();
});
it('fetches statistics when expanded in mobile external-nav mode', async () => {
@@ -663,6 +722,14 @@ describe('SettingsModal', () => {
double_byte_pct: 30,
triple_byte_pct: 20,
},
+ noise_floor_24h: {
+ sample_interval_seconds: 300,
+ coverage_seconds: 0,
+ latest_noise_floor_dbm: null,
+ latest_timestamp: null,
+ supported: null,
+ samples: [],
+ },
};
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
diff --git a/frontend/src/test/sidebar.test.tsx b/frontend/src/test/sidebar.test.tsx
index ddbc442..167ab1d 100644
--- a/frontend/src/test/sidebar.test.tsx
+++ b/frontend/src/test/sidebar.test.tsx
@@ -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(
);
- 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');
diff --git a/frontend/src/test/tracePane.test.tsx b/frontend/src/test/tracePane.test.tsx
new file mode 100644
index 0000000..83d9cca
--- /dev/null
+++ b/frontend/src/test/tracePane.test.tsx
@@ -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 {
+ 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(
+
+ );
+
+ 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 => ({
+ 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(
+
+ );
+
+ 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( );
+
+ 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 => ({
+ 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( );
+
+ 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((resolve) => {
+ resolveTrace = resolve;
+ })
+ );
+
+ render(
+
+ );
+
+ 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();
+ });
+});
diff --git a/frontend/src/test/urlHash.test.ts b/frontend/src/test/urlHash.test.ts
index 8e3cd1f..57cb8f9 100644
--- a/frontend/src/test/urlHash.test.ts
+++ b/frontend/src/test/urlHash.test.ts
@@ -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';
diff --git a/frontend/src/test/useRealtimeAppState.test.ts b/frontend/src/test/useRealtimeAppState.test.ts
index 877fa89..05267f1 100644
--- a/frontend/src/test/useRealtimeAppState.test.ts
+++ b/frontend/src/test/useRealtimeAppState.test.ts
@@ -69,6 +69,7 @@ function createRealtimeArgs(overrides: Partial ({ added: false, activeConversation: false })),
recordMessageEvent: vi.fn(),
renameConversationState: vi.fn(),
+ removeConversationState: vi.fn(),
checkMention: vi.fn(() => false),
pendingDeleteFallbackRef: { current: false },
setActiveConversation: vi.fn(),
diff --git a/frontend/src/test/useUnreadCounts.test.ts b/frontend/src/test/useUnreadCounts.test.ts
index 82bfb87..cf90124 100644
--- a/frontend/src/test/useUnreadCounts.test.ts
+++ b/frontend/src/test/useUnreadCounts.test.ts
@@ -221,6 +221,49 @@ describe('useUnreadCounts', () => {
});
});
+ it('does not treat search or trace views as readable conversations', async () => {
+ const mocks = await getMockedApi();
+ mocks.getUnreads.mockResolvedValue({
+ counts: {
+ [getStateKey('channel', CHANNEL_KEY)]: 4,
+ [getStateKey('contact', CONTACT_KEY)]: 2,
+ },
+ mentions: {
+ [getStateKey('channel', CHANNEL_KEY)]: true,
+ },
+ last_message_times: {},
+ last_read_ats: {},
+ });
+
+ const { result, rerender } = renderWith({
+ channels: [makeChannel(CHANNEL_KEY, 'Test')],
+ contacts: [makeContact(CONTACT_KEY)],
+ activeConversation: { type: 'search', id: 'search', name: 'Message Search' },
+ });
+
+ await act(async () => {
+ await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
+ });
+
+ expect(result.current.unreadCounts[getStateKey('channel', CHANNEL_KEY)]).toBe(4);
+ expect(result.current.unreadCounts[getStateKey('contact', CONTACT_KEY)]).toBe(2);
+ expect(mocks.markChannelRead).not.toHaveBeenCalled();
+ expect(mocks.markContactRead).not.toHaveBeenCalled();
+
+ rerender({
+ channels: [makeChannel(CHANNEL_KEY, 'Test')],
+ contacts: [makeContact(CONTACT_KEY)],
+ activeConversation: { type: 'trace', id: 'trace', name: 'Trace' },
+ });
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(mocks.markChannelRead).not.toHaveBeenCalled();
+ expect(mocks.markContactRead).not.toHaveBeenCalled();
+ });
+
it('re-fetches and filters when refreshUnreads is called (simulating WS reconnect)', async () => {
const mocks = await getMockedApi();
const channels = [makeChannel(CHANNEL_KEY, 'Test')];
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index b7946ab..8395fd4 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -34,6 +34,7 @@ export type RadioDiscoveryTarget = 'repeaters' | 'sensors' | 'all';
export interface RadioDiscoveryResult {
public_key: string;
+ name: string | null;
node_type: 'repeater' | 'sensor';
heard_count: number;
local_snr: number | null;
@@ -285,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;
@@ -485,6 +486,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;
@@ -516,6 +536,20 @@ interface ContactActivityCounts {
last_week: number;
}
+export interface NoiseFloorSample {
+ timestamp: number;
+ noise_floor_dbm: number;
+}
+
+export interface NoiseFloorHistoryStats {
+ sample_interval_seconds: number;
+ coverage_seconds: number;
+ latest_noise_floor_dbm: number | null;
+ latest_timestamp: number | null;
+ supported: boolean | null;
+ samples: NoiseFloorSample[];
+}
+
export interface StatisticsResponse {
busiest_channels_24h: BusyChannel[];
contact_count: number;
@@ -539,4 +573,5 @@ export interface StatisticsResponse {
double_byte_pct: number;
triple_byte_pct: number;
};
+ noise_floor_24h: NoiseFloorHistoryStats;
}
diff --git a/frontend/src/utils/fontScale.ts b/frontend/src/utils/fontScale.ts
new file mode 100644
index 0000000..5f0cc98
--- /dev/null
+++ b/frontend/src/utils/fontScale.ts
@@ -0,0 +1,53 @@
+export const FONT_SCALE_KEY = 'remoteterm-font-scale';
+export const DEFAULT_FONT_SCALE = 100;
+export const MIN_FONT_SCALE = 25;
+export const MAX_FONT_SCALE = 400;
+export const FONT_SCALE_SLIDER_STEP = 5;
+
+function normalizeFontScale(scale: number): number {
+ if (!Number.isFinite(scale)) {
+ return DEFAULT_FONT_SCALE;
+ }
+
+ const clamped = Math.min(MAX_FONT_SCALE, Math.max(MIN_FONT_SCALE, scale));
+ return Number.parseFloat(clamped.toFixed(2));
+}
+
+export function getSavedFontScale(): number {
+ try {
+ const raw = localStorage.getItem(FONT_SCALE_KEY);
+ if (raw === null) {
+ return DEFAULT_FONT_SCALE;
+ }
+
+ return normalizeFontScale(Number.parseFloat(raw));
+ } catch {
+ return DEFAULT_FONT_SCALE;
+ }
+}
+
+export function applyFontScale(scale: number): number {
+ const normalized = normalizeFontScale(scale);
+
+ if (typeof document !== 'undefined') {
+ document.documentElement.style.fontSize = `${normalized}%`;
+ }
+
+ return normalized;
+}
+
+export function setSavedFontScale(scale: number): number {
+ const normalized = applyFontScale(scale);
+
+ try {
+ if (normalized === DEFAULT_FONT_SCALE) {
+ localStorage.removeItem(FONT_SCALE_KEY);
+ } else {
+ localStorage.setItem(FONT_SCALE_KEY, String(normalized));
+ }
+ } catch {
+ // localStorage may be unavailable
+ }
+
+ return normalized;
+}
diff --git a/frontend/src/utils/lastViewedConversation.ts b/frontend/src/utils/lastViewedConversation.ts
index 1bd7967..cd661ee 100644
--- a/frontend/src/utils/lastViewedConversation.ts
+++ b/frontend/src/utils/lastViewedConversation.ts
@@ -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,
diff --git a/frontend/src/utils/urlHash.ts b/frontend/src/utils/urlHash.ts
index 929fe5a..0e82243 100644
--- a/frontend/src/utils/urlHash.ts
+++ b/frontend/src/utils/urlHash.ts
@@ -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') {
diff --git a/pyproject.toml b/pyproject.toml
index c4e8850..de26eb0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "remoteterm-meshcore"
-version = "3.6.2"
+version = "3.6.3"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md"
requires-python = ">=3.10"
diff --git a/scripts/all_quality.sh b/scripts/all_quality.sh
deleted file mode 100755
index 1cd2f5c..0000000
--- a/scripts/all_quality.sh
+++ /dev/null
@@ -1,64 +0,0 @@
-#!/usr/bin/env bash
-set -e
-
-# developer perogative ;D
-if command -v enablenvm >/dev/null 2>&1; then
- enablenvm >/dev/null 2>&1 || true
-fi
-
-
-# Colors for output
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-BLUE='\033[0;34m'
-NC='\033[0m' # No Color
-
-SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
-
-echo -e "${YELLOW}=== RemoteTerm Quality Checks ===${NC}"
-echo
-
-# --- Phase 1: Lint & Format ---
-
-echo -e "${YELLOW}=== Phase 1: Lint & Format ===${NC}"
-
-echo -e "${BLUE}[backend lint]${NC} Running ruff check + format..."
-cd "$SCRIPT_DIR"
-uv run ruff check app/ tests/ --fix
-uv run ruff format app/ tests/
-echo -e "${GREEN}[backend lint]${NC} Passed!"
-
-echo -e "${BLUE}[frontend lint]${NC} Running eslint + prettier..."
-cd "$SCRIPT_DIR/frontend"
-npm run lint:fix
-npm run format
-echo -e "${GREEN}[frontend lint]${NC} Passed!"
-
-echo -e "${GREEN}=== Phase 1 complete ===${NC}"
-echo
-
-# --- Phase 2: Typecheck, Tests & Build ---
-
-echo -e "${YELLOW}=== Phase 2: Typecheck, Tests & Build ===${NC}"
-
-echo -e "${BLUE}[pyright]${NC} Running type check..."
-cd "$SCRIPT_DIR"
-uv run pyright app/
-echo -e "${GREEN}[pyright]${NC} Passed!"
-
-echo -e "${BLUE}[pytest]${NC} Running backend tests..."
-cd "$SCRIPT_DIR"
-PYTHONPATH=. uv run pytest tests/ -v
-echo -e "${GREEN}[pytest]${NC} Passed!"
-
-echo -e "${BLUE}[frontend]${NC} Running tests + build..."
-cd "$SCRIPT_DIR/frontend"
-npm run test:run
-npm run build
-echo -e "${GREEN}[frontend]${NC} Passed!"
-
-echo -e "${GREEN}=== Phase 2 complete ===${NC}"
-echo
-
-echo -e "${GREEN}=== All quality checks passed! ===${NC}"
diff --git a/scripts/collect_licenses.sh b/scripts/build/collect_licenses.sh
old mode 100755
new mode 100644
similarity index 89%
rename from scripts/collect_licenses.sh
rename to scripts/build/collect_licenses.sh
index 0dfa602..9311c1c
--- a/scripts/collect_licenses.sh
+++ b/scripts/build/collect_licenses.sh
@@ -2,10 +2,10 @@
set -euo pipefail
# Collect third-party license texts into LICENSES.md
-# Usage: scripts/collect_licenses.sh [output-path]
+# Usage: scripts/build/collect_licenses.sh [output-path]
# output-path defaults to LICENSES.md at the repo root
-REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
OUT="${1:-$REPO_ROOT/LICENSES.md}"
FRONTEND_LICENSE_IMAGE="${FRONTEND_LICENSE_IMAGE:-node:20-slim}"
FRONTEND_LICENSE_NPM="${FRONTEND_LICENSE_NPM:-10.9.5}"
@@ -59,7 +59,7 @@ for d in data:
# ── Frontend (npm) ───────────────────────────────────────────────────
frontend_licenses_local() {
cd "$REPO_ROOT/frontend"
- node "$REPO_ROOT/scripts/print_frontend_licenses.cjs"
+ node "$REPO_ROOT/scripts/build/print_frontend_licenses.cjs"
}
frontend_licenses_docker() {
@@ -73,7 +73,7 @@ frontend_licenses_docker() {
cd frontend
npm i -g npm@$FRONTEND_LICENSE_NPM >/dev/null
npm ci --ignore-scripts >/dev/null
- node /src/scripts/print_frontend_licenses.cjs
+ node /src/scripts/build/print_frontend_licenses.cjs
"
}
@@ -85,7 +85,7 @@ frontend_licenses() {
{
echo "# Third-Party Licenses"
echo
- echo "Auto-generated by \`scripts/collect_licenses.sh\` — do not edit by hand."
+ echo "Auto-generated by \`scripts/build/collect_licenses.sh\` — do not edit by hand."
echo
echo "## Backend (Python) Dependencies"
echo
diff --git a/scripts/print_frontend_licenses.cjs b/scripts/build/print_frontend_licenses.cjs
old mode 100755
new mode 100644
similarity index 100%
rename from scripts/print_frontend_licenses.cjs
rename to scripts/build/print_frontend_licenses.cjs
diff --git a/scripts/publish.sh b/scripts/build/publish.sh
old mode 100755
new mode 100644
similarity index 92%
rename from scripts/publish.sh
rename to scripts/build/publish.sh
index 1ad6f37..e886751
--- a/scripts/publish.sh
+++ b/scripts/build/publish.sh
@@ -7,8 +7,8 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
-SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
-cd "$SCRIPT_DIR"
+REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+cd "$REPO_ROOT"
RELEASE_WORK_DIR=""
RELEASE_BUNDLE_DIR_NAME="Remote-Terminal-for-MeshCore"
@@ -17,14 +17,14 @@ DOCKER_IMAGE="jkingsman/remoteterm-meshcore"
DOCKER_PLATFORMS="linux/amd64,linux/arm64"
cleanup_release_build_artifacts() {
- if [ -d "$SCRIPT_DIR/frontend/prebuilt" ]; then
- rm -rf "$SCRIPT_DIR/frontend/prebuilt"
+ if [ -d "$REPO_ROOT/frontend/prebuilt" ]; then
+ rm -rf "$REPO_ROOT/frontend/prebuilt"
fi
if [ -n "$RELEASE_WORK_DIR" ] && [ -d "$RELEASE_WORK_DIR" ]; then
rm -rf "$RELEASE_WORK_DIR"
fi
- if [ -n "$RELEASE_ASSET" ] && [ -f "$SCRIPT_DIR/$RELEASE_ASSET" ]; then
- rm -f "$SCRIPT_DIR/$RELEASE_ASSET"
+ if [ -n "$RELEASE_ASSET" ] && [ -f "$REPO_ROOT/$RELEASE_ASSET" ]; then
+ rm -f "$REPO_ROOT/$RELEASE_ASSET"
fi
}
@@ -78,7 +78,7 @@ echo
# Run frontend linting and formatting check
echo -e "${YELLOW}Running frontend lint (ESLint)...${NC}"
-cd "$SCRIPT_DIR/frontend"
+cd "$REPO_ROOT/frontend"
npm run lint
echo -e "${GREEN}Frontend lint passed!${NC}"
echo
@@ -97,11 +97,11 @@ echo
echo -e "${YELLOW}Building frontend...${NC}"
npm run build
echo -e "${GREEN}Frontend build complete!${NC}"
-cd "$SCRIPT_DIR"
+cd "$REPO_ROOT"
echo
echo -e "${YELLOW}Regenerating LICENSES.md...${NC}"
-bash scripts/collect_licenses.sh LICENSES.md
+bash scripts/build/collect_licenses.sh LICENSES.md
echo -e "${GREEN}LICENSES.md updated!${NC}"
echo
@@ -202,16 +202,16 @@ FULL_GIT_HASH=$(git rev-parse HEAD)
RELEASE_ASSET="remoteterm-prebuilt-frontend-v${VERSION}-${GIT_HASH}.zip"
echo -e "${YELLOW}Building packaged frontend artifact...${NC}"
-cd "$SCRIPT_DIR/frontend"
+cd "$REPO_ROOT/frontend"
npm run packaged-build
-cd "$SCRIPT_DIR"
+cd "$REPO_ROOT"
RELEASE_WORK_DIR=$(mktemp -d)
RELEASE_BUNDLE_DIR="$RELEASE_WORK_DIR/$RELEASE_BUNDLE_DIR_NAME"
mkdir -p "$RELEASE_BUNDLE_DIR"
git archive "$FULL_GIT_HASH" | tar -x -C "$RELEASE_BUNDLE_DIR"
mkdir -p "$RELEASE_BUNDLE_DIR/frontend"
-cp -R "$SCRIPT_DIR/frontend/prebuilt" "$RELEASE_BUNDLE_DIR/frontend/prebuilt"
+cp -R "$REPO_ROOT/frontend/prebuilt" "$RELEASE_BUNDLE_DIR/frontend/prebuilt"
cat > "$RELEASE_BUNDLE_DIR/build_info.json" < "$RELEASE_BUNDLE_DIR/build_info.json" </dev/null 2>&1; then
+ enablenvm >/dev/null 2>&1 || true
+fi
+
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+
+echo -e "${YELLOW}=== RemoteTerm Quality Checks ===${NC}"
+echo
+
+# --- Phase 1: Lint & Format ---
+
+echo -e "${YELLOW}=== Phase 1: Lint & Format ===${NC}"
+
+echo -ne "${BLUE}[backend lint]${NC} "
+cd "$REPO_ROOT"
+uv run ruff check app/ tests/ --fix --quiet
+uv run ruff format app/ tests/ --check --quiet
+echo -e "${GREEN}Passed!${NC}"
+
+echo -ne "${BLUE}[frontend lint]${NC} "
+cd "$REPO_ROOT/frontend"
+npx --quiet eslint src/ --fix --cache --quiet
+npx --quiet prettier --write --list-different src/ --log-level warn
+echo -e "${GREEN}Passed!${NC}"
+
+echo -e "${GREEN}=== Phase 1 complete ===${NC}"
+echo
+
+# --- Phase 2: Typecheck, Tests & Build ---
+
+echo -e "${YELLOW}=== Phase 2: Typecheck, Tests & Build ===${NC}"
+
+echo -ne "${BLUE}[pyright]${NC} "
+cd "$REPO_ROOT"
+uv run pyright app/ --outputjson 2>/dev/null | python3 -c "
+import sys, json
+d = json.load(sys.stdin)
+s = d.get('summary', {})
+print(f\"{s.get('filesAnalyzed',0)} files, 0 errors\")
+" 2>/dev/null || { uv run pyright app/; exit 1; }
+echo -e "${GREEN}Passed!${NC}"
+
+echo -ne "${BLUE}[pytest]${NC} "
+cd "$REPO_ROOT"
+PYTHONPATH=. uv run pytest tests/ -q --no-header --tb=short
+echo -e "${GREEN}Passed!${NC}"
+
+echo -ne "${BLUE}[vitest]${NC} "
+cd "$REPO_ROOT/frontend"
+npx --quiet vitest run --reporter=dot 2>&1 | tail -5
+echo -e "${GREEN}Passed!${NC}"
+
+echo -ne "${BLUE}[build]${NC} "
+cd "$REPO_ROOT/frontend"
+npx --quiet tsc 2>&1 && npx --quiet vite build --logLevel error 2>&1
+echo -e "${GREEN}Passed!${NC}"
+
+echo -e "${GREEN}=== Phase 2 complete ===${NC}"
+echo
+
+echo -e "${GREEN}=== All quality checks passed! ===${NC}"
diff --git a/scripts/docker_ci.sh b/scripts/quality/docker_ci.sh
old mode 100755
new mode 100644
similarity index 95%
rename from scripts/docker_ci.sh
rename to scripts/quality/docker_ci.sh
index 5feeea8..877ff3e
--- a/scripts/docker_ci.sh
+++ b/scripts/quality/docker_ci.sh
@@ -7,7 +7,7 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
-SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
NODE_VERSIONS=("20" "22" "24")
# Use explicit npm patch versions so resolver regressions are caught.
@@ -27,7 +27,7 @@ run_combo() {
local image="node:${node_version}-slim"
docker run --rm \
- -v "$SCRIPT_DIR:/src:ro" \
+ -v "$REPO_ROOT:/src:ro" \
-w /tmp \
"$image" \
bash -lc "
@@ -79,7 +79,7 @@ cleanup() {
trap cleanup EXIT
echo -e "${YELLOW}=== Frontend Docker CI Matrix ===${NC}"
-echo -e "${BLUE}Repo:${NC} $SCRIPT_DIR"
+echo -e "${BLUE}Repo:${NC} $REPO_ROOT"
echo
for case_spec in "${TEST_CASES[@]}"; do
diff --git a/scripts/e2e.sh b/scripts/quality/e2e.sh
old mode 100755
new mode 100644
similarity index 52%
rename from scripts/e2e.sh
rename to scripts/quality/e2e.sh
index 10a8c95..d48639c
--- a/scripts/e2e.sh
+++ b/scripts/quality/e2e.sh
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
set -e
-SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
echo "Starting E2E tests..."
-cd "$SCRIPT_DIR/tests/e2e"
+cd "$REPO_ROOT/tests/e2e"
npx playwright test "$@"
diff --git a/scripts/extended_quality.sh b/scripts/quality/extended_quality.sh
old mode 100755
new mode 100644
similarity index 78%
rename from scripts/extended_quality.sh
rename to scripts/quality/extended_quality.sh
index e2ea11d..93515e4
--- a/scripts/extended_quality.sh
+++ b/scripts/quality/extended_quality.sh
@@ -6,23 +6,23 @@ GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m'
-SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
echo -e "${YELLOW}=== Extended Quality Checks ===${NC}"
echo
echo -e "${BLUE}[all_quality]${NC} Running full lint, typecheck, unit tests, and the standard frontend build..."
-"$SCRIPT_DIR/scripts/all_quality.sh"
+"$REPO_ROOT/scripts/quality/all_quality.sh"
echo -e "${GREEN}[all_quality]${NC} Passed!"
echo
echo -e "${BLUE}[e2e]${NC} Running end-to-end tests..."
-"$SCRIPT_DIR/scripts/e2e.sh" "$@"
+"$REPO_ROOT/scripts/quality/e2e.sh" "$@"
echo -e "${GREEN}[e2e]${NC} Passed!"
echo
echo -e "${BLUE}[docker_ci]${NC} Running Docker frontend install/build matrix..."
-"$SCRIPT_DIR/scripts/docker_ci.sh"
+"$REPO_ROOT/scripts/quality/docker_ci.sh"
echo -e "${GREEN}[docker_ci]${NC} Passed!"
echo
diff --git a/scripts/fetch_prebuilt_frontend.py b/scripts/setup/fetch_prebuilt_frontend.py
old mode 100755
new mode 100644
similarity index 97%
rename from scripts/fetch_prebuilt_frontend.py
rename to scripts/setup/fetch_prebuilt_frontend.py
index e775949..7cbf567
--- a/scripts/fetch_prebuilt_frontend.py
+++ b/scripts/setup/fetch_prebuilt_frontend.py
@@ -21,7 +21,8 @@ API_URL = f"https://api.github.com/repos/{REPO}/releases/latest"
PREBUILT_PREFIX = "Remote-Terminal-for-MeshCore/frontend/prebuilt/"
SCRIPT_DIR = Path(__file__).resolve().parent
-PREBUILT_DIR = SCRIPT_DIR.parent / "frontend" / "prebuilt"
+REPO_ROOT = SCRIPT_DIR.parent.parent
+PREBUILT_DIR = REPO_ROOT / "frontend" / "prebuilt"
def fetch_json(url: str) -> dict:
diff --git a/scripts/setup/install_docker.sh b/scripts/setup/install_docker.sh
new file mode 100644
index 0000000..a6951f4
--- /dev/null
+++ b/scripts/setup/install_docker.sh
@@ -0,0 +1,494 @@
+#!/usr/bin/env bash
+# install_docker.sh
+#
+# Generates a local docker-compose.yml for RemoteTerm from a guided prompt flow.
+# The generated compose file is intentionally gitignored so local customization
+# does not create merge churn on future pulls.
+#
+# Run from anywhere inside the repo:
+# bash scripts/setup/install_docker.sh
+
+set -euo pipefail
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+CYAN='\033[0;36m'
+PURPLE='\033[0;35m'
+BOLD='\033[1m'
+NC='\033[0m'
+
+REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
+COMPOSE_FILE="$REPO_DIR/docker-compose.yml"
+EXAMPLE_FILE="$REPO_DIR/docker-compose.example.yml"
+SNAKEOIL_CERT_DIR="$REPO_DIR/.docker-certs"
+SNAKEOIL_CERT_BASENAME="remoteterm-snakeoil.crt"
+SNAKEOIL_KEY_BASENAME="remoteterm-snakeoil.key"
+SNAKEOIL_CERT_HOST_PATH="$SNAKEOIL_CERT_DIR/$SNAKEOIL_CERT_BASENAME"
+SNAKEOIL_KEY_HOST_PATH="$SNAKEOIL_CERT_DIR/$SNAKEOIL_KEY_BASENAME"
+SNAKEOIL_CERT_CONTAINER_PATH="/app/certs/$SNAKEOIL_CERT_BASENAME"
+SNAKEOIL_KEY_CONTAINER_PATH="/app/certs/$SNAKEOIL_KEY_BASENAME"
+
+IMAGE_MODE="image"
+TRANSPORT_MODE="serial"
+SERIAL_HOST_PATH="/dev/ttyACM0"
+SERIAL_CONTAINER_PATH="/dev/meshcore-radio"
+TCP_HOST=""
+TCP_PORT="4000"
+BLE_ADDRESS=""
+BLE_PIN=""
+ENABLE_BOTS="N"
+ENABLE_AUTH="N"
+AUTH_USERNAME=""
+AUTH_PASSWORD=""
+RUN_AS_HOST_USER="N"
+ENABLE_SNAKEOIL_TLS="Y"
+BLE_MANUAL_WARNING=false
+LOCAL_ACCESS_IP=""
+SERIAL_FOUND_HOST_PATHS=()
+SERIAL_FOUND_LABELS=()
+SERIAL_FOUND_DISPLAYS=()
+
+find_serial_devices() {
+ local path
+ local resolved
+ local label
+ local existing
+
+ SERIAL_FOUND_HOST_PATHS=()
+ SERIAL_FOUND_LABELS=()
+ SERIAL_FOUND_DISPLAYS=()
+
+ if [ -d /dev/serial/by-id ]; then
+ while IFS= read -r path; do
+ [ -n "$path" ] || continue
+ resolved="$(readlink -f "$path" 2>/dev/null || true)"
+ [ -n "$resolved" ] || resolved="$path"
+ label="$(basename "$path")"
+ SERIAL_FOUND_HOST_PATHS+=("$path")
+ SERIAL_FOUND_LABELS+=("$label")
+ SERIAL_FOUND_DISPLAYS+=("$path -> $resolved")
+ done < <(find /dev/serial/by-id -maxdepth 1 -type l | sort)
+ fi
+
+ for path in /dev/ttyACM* /dev/ttyUSB* /dev/cu.usbmodem* /dev/cu.usbserial*; do
+ [ -e "$path" ] || continue
+ resolved="$(readlink -f "$path" 2>/dev/null || true)"
+ [ -n "$resolved" ] || resolved="$path"
+
+ if ((${#SERIAL_FOUND_HOST_PATHS[@]} > 0)); then
+ for existing in "${SERIAL_FOUND_DISPLAYS[@]}"; do
+ if [[ "$existing" = *"-> $resolved" ]]; then
+ resolved=""
+ break
+ fi
+ done
+ [ -n "$resolved" ] || continue
+ fi
+
+ SERIAL_FOUND_HOST_PATHS+=("$path")
+ SERIAL_FOUND_LABELS+=("$(basename "$path")")
+ SERIAL_FOUND_DISPLAYS+=("$path")
+ done
+}
+
+yaml_quote() {
+ local value="$1"
+ value=${value//\'/\'\'}
+ printf "'%s'" "$value"
+}
+
+detect_primary_local_ip() {
+ local ip=""
+ local iface=""
+
+ if command -v hostname &>/dev/null; then
+ ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
+ fi
+
+ if [ -z "$ip" ] && command -v ip &>/dev/null; then
+ ip="$(ip route get 1.1.1.1 2>/dev/null | awk '/src/ {for (i = 1; i <= NF; i++) if ($i == "src") {print $(i + 1); exit}}')"
+ fi
+
+ if [ -z "$ip" ] && command -v route &>/dev/null && command -v ipconfig &>/dev/null; then
+ iface="$(route -n get default 2>/dev/null | awk '/interface:/{print $2; exit}')"
+ if [ -n "$iface" ]; then
+ ip="$(ipconfig getifaddr "$iface" 2>/dev/null || true)"
+ fi
+ fi
+
+ if [ -z "$ip" ]; then
+ ip="127.0.0.1"
+ fi
+
+ printf '%s' "$ip"
+}
+
+ensure_snakeoil_requirements() {
+ local dep
+
+ for dep in openssl mktemp; do
+ if ! command -v "$dep" &>/dev/null; then
+ echo -e "${RED}Error: ${dep} is required to generate the snakeoil TLS certificate.${NC}"
+ exit 1
+ fi
+ done
+}
+
+generate_snakeoil_certificate() {
+ local san_ip="$1"
+ local tmp_config=""
+
+ mkdir -p "$SNAKEOIL_CERT_DIR"
+ tmp_config="$(mktemp)"
+
+ cat >"$tmp_config" <>"$tmp_config"
+ fi
+
+ openssl req \
+ -x509 \
+ -nodes \
+ -newkey rsa:2048 \
+ -days 3650 \
+ -keyout "$SNAKEOIL_KEY_HOST_PATH" \
+ -out "$SNAKEOIL_CERT_HOST_PATH" \
+ -config "$tmp_config" \
+ -extensions v3_req >/dev/null 2>&1
+
+ rm -f "$tmp_config"
+
+ chmod 600 "$SNAKEOIL_KEY_HOST_PATH"
+ chmod 644 "$SNAKEOIL_CERT_HOST_PATH"
+}
+
+echo -e "${BOLD}=== RemoteTerm for MeshCore — Docker Setup ===${NC}"
+echo
+echo -e " Repo directory : ${CYAN}${REPO_DIR}${NC}"
+echo -e " Example compose : ${CYAN}${EXAMPLE_FILE}${NC}"
+echo -e " Output compose : ${CYAN}${COMPOSE_FILE}${NC}"
+echo
+
+if ! command -v docker &>/dev/null; then
+ echo -e "${RED}Error: docker was not found in PATH.${NC}"
+ exit 1
+fi
+
+if ! docker compose version &>/dev/null; then
+ echo -e "${RED}Error: docker compose is required but was not available.${NC}"
+ exit 1
+fi
+
+if [ -f "$COMPOSE_FILE" ]; then
+ echo -e "${YELLOW}A local docker-compose.yml already exists.${NC}"
+ read -r -p "Overwrite it? [y/N]: " OVERWRITE
+ OVERWRITE="${OVERWRITE:-N}"
+ if ! [[ "$OVERWRITE" =~ ^[Yy]$ ]]; then
+ echo -e "${YELLOW}Leaving the existing compose file untouched.${NC}"
+ exit 0
+ fi
+fi
+
+echo -e "${BOLD}─── Image Source ────────────────────────────────────────────────────${NC}"
+echo "How should Docker run RemoteTerm?"
+echo " 1) Use the published Docker Hub image (default)"
+echo " 2) Build locally from this checkout"
+echo
+read -r -p "Select image mode [1-2] (default: 1): " IMAGE_CHOICE
+IMAGE_CHOICE="${IMAGE_CHOICE:-1}"
+echo
+
+case "$IMAGE_CHOICE" in
+ 1)
+ IMAGE_MODE="image"
+ echo -e "${GREEN}Using published Docker image.${NC}"
+ ;;
+ 2)
+ IMAGE_MODE="build"
+ echo -e "${GREEN}Using local Docker build.${NC}"
+ ;;
+ *)
+ IMAGE_MODE="image"
+ echo -e "${YELLOW}Invalid selection; defaulting to published Docker image.${NC}"
+ ;;
+esac
+echo
+
+echo -e "${BOLD}─── Transport ───────────────────────────────────────────────────────${NC}"
+echo "How will the container reach your MeshCore radio?"
+echo " 1) Serial device passthrough (default)"
+echo " 2) TCP"
+echo " 3) BLE"
+echo
+echo "BLE can be configured here, but Docker Bluetooth access still requires manual compose customization."
+echo
+read -r -p "Select transport [1-3] (default: 1): " TRANSPORT_CHOICE
+TRANSPORT_CHOICE="${TRANSPORT_CHOICE:-1}"
+echo
+
+case "$TRANSPORT_CHOICE" in
+ 1)
+ TRANSPORT_MODE="serial"
+ find_serial_devices
+
+ if ((${#SERIAL_FOUND_HOST_PATHS[@]} == 0)); then
+ echo -e "${YELLOW}No serial devices were auto-detected.${NC}"
+ read -r -p "Serial device path on the host (default: /dev/ttyACM0): " SERIAL_HOST_PATH
+ SERIAL_HOST_PATH="${SERIAL_HOST_PATH:-/dev/ttyACM0}"
+ else
+ echo "Detected serial devices:"
+ for i in "${!SERIAL_FOUND_HOST_PATHS[@]}"; do
+ printf ' %d) %s (%s)\n' "$((i + 1))" "${SERIAL_FOUND_LABELS[$i]}" "${SERIAL_FOUND_DISPLAYS[$i]}"
+ done
+ echo " m) Enter a path manually"
+ echo
+ read -r -p "Select serial device [1-${#SERIAL_FOUND_HOST_PATHS[@]} or m] (default: 1): " SERIAL_CHOICE
+ SERIAL_CHOICE="${SERIAL_CHOICE:-1}"
+
+ if [[ "$SERIAL_CHOICE" =~ ^[Mm]$ ]]; then
+ read -r -p "Serial device path on the host (default: ${SERIAL_FOUND_HOST_PATHS[0]}): " SERIAL_HOST_PATH
+ SERIAL_HOST_PATH="${SERIAL_HOST_PATH:-${SERIAL_FOUND_HOST_PATHS[0]}}"
+ elif [[ "$SERIAL_CHOICE" =~ ^[0-9]+$ ]] && [ "$SERIAL_CHOICE" -ge 1 ] && [ "$SERIAL_CHOICE" -le "${#SERIAL_FOUND_HOST_PATHS[@]}" ]; then
+ SERIAL_HOST_PATH="${SERIAL_FOUND_HOST_PATHS[$((SERIAL_CHOICE - 1))]}"
+ else
+ SERIAL_HOST_PATH="${SERIAL_FOUND_HOST_PATHS[0]}"
+ echo -e "${YELLOW}Invalid selection; defaulting to ${SERIAL_HOST_PATH}.${NC}"
+ fi
+ fi
+
+ echo -e "${GREEN}Serial passthrough: ${SERIAL_HOST_PATH} -> ${SERIAL_CONTAINER_PATH}${NC}"
+ ;;
+ 2)
+ TRANSPORT_MODE="tcp"
+ read -r -p "TCP host (IP address or hostname): " TCP_HOST
+ while [ -z "$TCP_HOST" ]; do
+ echo -e "${RED}TCP host is required.${NC}"
+ read -r -p "TCP host: " TCP_HOST
+ done
+ read -r -p "TCP port (default: 4000): " TCP_PORT
+ TCP_PORT="${TCP_PORT:-4000}"
+ echo -e "${GREEN}TCP: ${TCP_HOST}:${TCP_PORT}${NC}"
+ ;;
+ 3)
+ TRANSPORT_MODE="ble"
+ read -r -p "BLE device address (e.g. AA:BB:CC:DD:EE:FF): " BLE_ADDRESS
+ while [ -z "$BLE_ADDRESS" ]; do
+ echo -e "${RED}BLE address is required.${NC}"
+ read -r -p "BLE device address: " BLE_ADDRESS
+ done
+ read -r -s -p "BLE PIN: " BLE_PIN
+ echo
+ while [ -z "$BLE_PIN" ]; do
+ echo -e "${RED}BLE PIN is required.${NC}"
+ read -r -s -p "BLE PIN: " BLE_PIN
+ echo
+ done
+ echo -e "${GREEN}BLE: ${BLE_ADDRESS}${NC}"
+ echo
+ echo -e "${RED}BLE Docker warning:${NC} Bluetooth access is not fully automated here."
+ echo -e "${RED}You will need to customize docker-compose.yml manually before BLE works.${NC}"
+ echo "That may include passing through Bluetooth devices, enabling privileged mode,"
+ echo "using host networking, or other host-specific Docker changes."
+ echo "If you want the easier path, use the regular Python launch flow for BLE instead."
+ BLE_MANUAL_WARNING=true
+ ;;
+ *)
+ TRANSPORT_MODE="serial"
+ SERIAL_HOST_PATH="/dev/ttyACM0"
+ echo -e "${YELLOW}Invalid selection; defaulting to serial passthrough at ${SERIAL_HOST_PATH}.${NC}"
+ ;;
+esac
+echo
+
+echo -e "${BOLD}─── Bot System ──────────────────────────────────────────────────────${NC}"
+echo -e "${YELLOW}Warning:${NC} The bot system executes arbitrary Python code on the server."
+echo "It is not recommended on untrusted networks."
+echo
+read -r -p "Enable bots? [y/N]: " ENABLE_BOTS
+ENABLE_BOTS="${ENABLE_BOTS:-N}"
+echo
+
+if [[ "$ENABLE_BOTS" =~ ^[Yy]$ ]]; then
+ echo -e "${GREEN}Bots enabled.${NC}"
+ echo
+ echo -e "${BOLD}─── HTTP Basic Auth ─────────────────────────────────────────────────${NC}"
+ echo "With bots enabled, HTTP Basic Auth is strongly recommended if this"
+ echo "service will be reachable beyond your local machine."
+ echo
+ read -r -p "Set up HTTP Basic Auth? [Y/n]: " ENABLE_AUTH
+ ENABLE_AUTH="${ENABLE_AUTH:-Y}"
+ echo
+
+ if [[ "$ENABLE_AUTH" =~ ^[Yy]$ ]]; then
+ read -r -p "Username: " AUTH_USERNAME
+ while [ -z "$AUTH_USERNAME" ]; do
+ echo -e "${RED}Username cannot be empty.${NC}"
+ read -r -p "Username: " AUTH_USERNAME
+ done
+ read -r -s -p "Password: " AUTH_PASSWORD
+ echo
+ while [ -z "$AUTH_PASSWORD" ]; do
+ echo -e "${RED}Password cannot be empty.${NC}"
+ read -r -s -p "Password: " AUTH_PASSWORD
+ echo
+ done
+ echo -e "${GREEN}Basic Auth configured for user '${AUTH_USERNAME}'.${NC}"
+ fi
+else
+ echo -e "${GREEN}Bots disabled.${NC}"
+fi
+echo
+
+echo -e "${BOLD}─── HTTPS / Snakeoil TLS ────────────────────────────────────────────${NC}"
+echo "Generating a local self-signed certificate enables HTTPS-only browser features"
+echo "such as the channel key finder and, in some browsers, notifications."
+echo "Browsers will still warn that the certificate is untrusted."
+echo
+read -r -p "Generate and enable a snakeoil TLS certificate? [Y/n]: " ENABLE_SNAKEOIL_TLS
+ENABLE_SNAKEOIL_TLS="${ENABLE_SNAKEOIL_TLS:-Y}"
+LOCAL_ACCESS_IP="$(detect_primary_local_ip)"
+if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
+ ensure_snakeoil_requirements
+ generate_snakeoil_certificate "$LOCAL_ACCESS_IP"
+ echo -e "${GREEN}Generated snakeoil TLS certificate in ${SNAKEOIL_CERT_DIR}.${NC}"
+ echo -e "${YELLOW}Browsers will show an untrusted/self-signed certificate warning.${NC}"
+else
+ echo -e "${GREEN}Skipping snakeoil TLS generation. The container will serve plain HTTP.${NC}"
+fi
+echo
+
+if [ "$(uname -s)" = "Linux" ]; then
+ echo -e "${BOLD}─── Container User ──────────────────────────────────────────────────${NC}"
+ echo "The container runs as root by default for maximum serial compatibility."
+ echo "You can override that and run as your host UID/GID instead to avoid"
+ echo "root-owned files in ./data."
+ echo
+ read -r -p "Run as your current UID/GID instead of the default root user? [y/N]: " RUN_AS_HOST_USER
+ RUN_AS_HOST_USER="${RUN_AS_HOST_USER:-N}"
+ if [[ "$RUN_AS_HOST_USER" =~ ^[Yy]$ ]] && [ "$TRANSPORT_MODE" = "serial" ]; then
+ echo
+ echo -e "${YELLOW}Note:${NC} host-user mode can be less reliable for serial device access than running as root."
+ echo "It may require extra group setup such as dialout, or other manual"
+ echo "container customization, depending on your host."
+ echo "If serial access becomes unreliable, rerun this setup and keep the"
+ echo "default root user instead."
+ fi
+ echo
+fi
+
+mkdir -p "$REPO_DIR/data"
+
+{
+ echo "# Generated by scripts/setup/install_docker.sh"
+ echo "# This file is gitignored. Re-run the setup script to regenerate it."
+ echo "services:"
+ echo " remoteterm:"
+ if [ "$IMAGE_MODE" = "build" ]; then
+ echo " build: ."
+ else
+ echo " image: docker.io/jkingsman/remoteterm-meshcore:latest"
+ fi
+ if [[ "$RUN_AS_HOST_USER" =~ ^[Yy]$ ]]; then
+ echo " user: \"$(id -u):$(id -g)\""
+ fi
+ echo " ports:"
+ echo " - \"8000:8000\""
+ echo " volumes:"
+ echo " - ./data:/app/data"
+ if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
+ echo " - ./.docker-certs:/app/certs:ro"
+ fi
+ if [ "$TRANSPORT_MODE" = "serial" ]; then
+ echo " devices:"
+ echo " - ${SERIAL_HOST_PATH}:${SERIAL_CONTAINER_PATH}"
+ fi
+ if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
+ echo " command:"
+ echo " - uv"
+ echo " - run"
+ echo " - uvicorn"
+ echo " - app.main:app"
+ echo " - --host"
+ echo " - 0.0.0.0"
+ echo " - --port"
+ echo " - \"8000\""
+ echo " - --ssl-keyfile"
+ echo " - $SNAKEOIL_KEY_CONTAINER_PATH"
+ echo " - --ssl-certfile"
+ echo " - $SNAKEOIL_CERT_CONTAINER_PATH"
+ fi
+ echo " environment:"
+ echo " MESHCORE_DATABASE_PATH: $(yaml_quote "data/meshcore.db")"
+ if [ "$TRANSPORT_MODE" = "serial" ]; then
+ echo " MESHCORE_SERIAL_PORT: $(yaml_quote "$SERIAL_CONTAINER_PATH")"
+ elif [ "$TRANSPORT_MODE" = "tcp" ]; then
+ echo " MESHCORE_TCP_HOST: $(yaml_quote "$TCP_HOST")"
+ echo " MESHCORE_TCP_PORT: $(yaml_quote "$TCP_PORT")"
+ else
+ echo " MESHCORE_BLE_ADDRESS: $(yaml_quote "$BLE_ADDRESS")"
+ echo " MESHCORE_BLE_PIN: $(yaml_quote "$BLE_PIN")"
+ fi
+ if ! [[ "$ENABLE_BOTS" =~ ^[Yy]$ ]]; then
+ echo " MESHCORE_DISABLE_BOTS: $(yaml_quote "true")"
+ fi
+ if [[ "$ENABLE_AUTH" =~ ^[Yy]$ ]]; then
+ echo " MESHCORE_BASIC_AUTH_USERNAME: $(yaml_quote "$AUTH_USERNAME")"
+ echo " MESHCORE_BASIC_AUTH_PASSWORD: $(yaml_quote "$AUTH_PASSWORD")"
+ fi
+ echo " restart: unless-stopped"
+} >"$COMPOSE_FILE"
+
+echo -e "${GREEN}Generated ${COMPOSE_FILE}.${NC}"
+echo
+echo -e "${BOLD}Docker commands${NC}"
+if [ "$IMAGE_MODE" = "build" ]; then
+ echo " docker compose up -d --build # build the local image and start RemoteTerm in the background"
+else
+ echo " docker compose up -d # start RemoteTerm in the background"
+fi
+echo " docker compose logs -f # follow the container logs live"
+echo
+echo " docker compose down # stop and remove the running container"
+echo " docker compose restart # restart the container without changing the image"
+echo " docker compose pull && docker compose up -d # upgrade to the latest published image and restart"
+if [ "$TRANSPORT_MODE" = "ble" ] || [ "$BLE_MANUAL_WARNING" = true ]; then
+ echo
+ echo -e "${RED}BLE requires more than the generated env vars.${NC}"
+ echo -e "${RED}Before starting, edit docker-compose.yml for Bluetooth passthrough and any privileged/network settings your host requires.${NC}"
+fi
+echo
+echo -e "${GREEN}Your new docker file is ready at ${COMPOSE_FILE}.${NC}"
+echo -e "${GREEN}Feel free to edit it by hand as desired, or:${NC}"
+echo
+echo -e "${PURPLE}┌──────────────────────────────────────────────┐${NC}"
+echo -e "${PURPLE}│ Run ${GREEN}${BOLD}docker compose up -d${NC}${PURPLE} to get started. │${NC}"
+echo -e "${PURPLE}└──────────────────────────────────────────────┘${NC}"
+if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
+ echo
+ echo -e "After the container starts, open ${CYAN}https://${LOCAL_ACCESS_IP}:8000${NC}."
+ echo -e "${YELLOW}Expect an untrusted/self-signed certificate warning the first time you connect.${NC}"
+else
+ echo
+ echo -e "After the container starts, open ${CYAN}http://${LOCAL_ACCESS_IP}:8000${NC}."
+fi
+echo "If the interface does not appear, follow the logs with:"
+echo " docker compose logs -f"
diff --git a/scripts/install_service.sh b/scripts/setup/install_service.sh
old mode 100755
new mode 100644
similarity index 98%
rename from scripts/install_service.sh
rename to scripts/setup/install_service.sh
index f51a091..88fca29
--- a/scripts/install_service.sh
+++ b/scripts/setup/install_service.sh
@@ -7,7 +7,7 @@
# gymnastics.
#
# Run from anywhere inside the repo:
-# bash scripts/install_service.sh
+# bash scripts/setup/install_service.sh
set -e
@@ -19,7 +19,7 @@ BOLD='\033[1m'
NC='\033[0m'
SERVICE_NAME="remoteterm"
-REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
CURRENT_USER="$(id -un)"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
FRONTEND_MODE="build"
@@ -252,7 +252,7 @@ if [ "$FRONTEND_MODE" = "build" ]; then
)
else
echo -e "${YELLOW}Fetching prebuilt frontend...${NC}"
- python3 "$REPO_DIR/scripts/fetch_prebuilt_frontend.py"
+ python3 "$REPO_DIR/scripts/setup/fetch_prebuilt_frontend.py"
fi
echo
@@ -402,7 +402,7 @@ echo -e " cd frontend && npm install && npm run build && cd .."
echo -e " sudo systemctl restart ${SERVICE_NAME}"
echo
echo -e "${YELLOW}Refresh prebuilt frontend only (skips local build):${NC}"
-echo -e " python3 ${REPO_DIR}/scripts/fetch_prebuilt_frontend.py"
+echo -e " python3 ${REPO_DIR}/scripts/setup/fetch_prebuilt_frontend.py"
echo -e " sudo systemctl restart ${SERVICE_NAME}"
echo
echo -e "${YELLOW}View live logs (useful for troubleshooting):${NC}"
diff --git a/tests/test_api.py b/tests/test_api.py
index 574b968..4cf90f8 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -190,6 +190,29 @@ class TestDebugEndpoint:
assert payload["database"]["total_channel_messages"] == 1
assert payload["database"]["total_outgoing"] == 1
+ @pytest.mark.asyncio
+ async def test_support_snapshot_uses_lightweight_message_totals(self, test_db, client):
+ """Debug snapshot should not call the full statistics aggregation."""
+ with (
+ patch(
+ "app.routers.debug.StatisticsRepository.get_all",
+ new=AsyncMock(side_effect=AssertionError("get_all should not be called")),
+ ),
+ patch(
+ "app.routers.debug.StatisticsRepository.get_database_message_totals",
+ new=AsyncMock(
+ return_value={
+ "total_dms": 0,
+ "total_channel_messages": 0,
+ "total_outgoing": 0,
+ }
+ ),
+ ),
+ ):
+ response = await client.get("/api/debug")
+
+ assert response.status_code == 200
+
class TestRadioDisconnectedHandler:
"""Test that RadioDisconnectedError maps to 503."""
diff --git a/tests/test_event_handlers.py b/tests/test_event_handlers.py
index 7360bd6..5de10b0 100644
--- a/tests/test_event_handlers.py
+++ b/tests/test_event_handlers.py
@@ -11,8 +11,6 @@ import pytest
from app.event_handlers import (
_active_subscriptions,
- _buffered_acks,
- _pending_acks,
cleanup_expired_acks,
register_event_handlers,
track_pending_ack,
@@ -23,6 +21,7 @@ from app.repository import (
ContactRepository,
MessageRepository,
)
+from app.services.dm_ack_tracker import _buffered_acks, _pending_acks
@pytest.fixture(autouse=True)
diff --git a/tests/test_migrations.py b/tests/test_migrations.py
index 7d21c92..7608015 100644
--- a/tests/test_migrations.py
+++ b/tests/test_migrations.py
@@ -1247,8 +1247,8 @@ class TestMigration039:
applied = await run_migrations(conn)
- assert applied == 8
- assert await get_version(conn) == 46
+ assert applied == 9
+ assert await get_version(conn) == 47
cursor = await conn.execute(
"""
@@ -1319,8 +1319,8 @@ class TestMigration039:
applied = await run_migrations(conn)
- assert applied == 8
- assert await get_version(conn) == 46
+ assert applied == 9
+ assert await get_version(conn) == 47
cursor = await conn.execute(
"""
@@ -1386,8 +1386,8 @@ class TestMigration039:
applied = await run_migrations(conn)
- assert applied == 2
- assert await get_version(conn) == 46
+ assert applied == 3
+ assert await get_version(conn) == 47
cursor = await conn.execute(
"""
@@ -1439,8 +1439,8 @@ class TestMigration040:
applied = await run_migrations(conn)
- assert applied == 7
- assert await get_version(conn) == 46
+ assert applied == 8
+ assert await get_version(conn) == 47
await conn.execute(
"""
@@ -1501,8 +1501,8 @@ class TestMigration041:
applied = await run_migrations(conn)
- assert applied == 6
- assert await get_version(conn) == 46
+ assert applied == 7
+ assert await get_version(conn) == 47
await conn.execute(
"""
@@ -1554,8 +1554,8 @@ class TestMigration042:
applied = await run_migrations(conn)
- assert applied == 5
- assert await get_version(conn) == 46
+ assert applied == 6
+ assert await get_version(conn) == 47
await conn.execute(
"""
@@ -1694,8 +1694,8 @@ class TestMigration046:
applied = await run_migrations(conn)
- assert applied == 1
- assert await get_version(conn) == 46
+ assert applied == 2
+ assert await get_version(conn) == 47
cursor = await conn.execute(
"""
@@ -1750,6 +1750,70 @@ class TestMigration046:
await conn.close()
+class TestMigration047:
+ """Test migration 047: add statistics indexes."""
+
+ @pytest.mark.asyncio
+ async def test_adds_statistics_indexes(self):
+ conn = await aiosqlite.connect(":memory:")
+ conn.row_factory = aiosqlite.Row
+ try:
+ await set_version(conn, 46)
+ await conn.execute("""
+ CREATE TABLE contacts (
+ public_key TEXT PRIMARY KEY,
+ name TEXT,
+ type INTEGER DEFAULT 0,
+ last_seen INTEGER
+ )
+ """)
+ await conn.execute("""
+ CREATE TABLE messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ type TEXT NOT NULL,
+ conversation_key TEXT NOT NULL,
+ received_at INTEGER NOT NULL
+ )
+ """)
+ await conn.execute("""
+ CREATE TABLE raw_packets (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ timestamp INTEGER NOT NULL,
+ data BLOB NOT NULL,
+ message_id INTEGER,
+ payload_hash BLOB
+ )
+ """)
+ await conn.commit()
+
+ applied = await run_migrations(conn)
+
+ assert applied == 1
+ assert await get_version(conn) == 47
+
+ cursor = await conn.execute(
+ """
+ SELECT name
+ FROM sqlite_master
+ WHERE type = 'index'
+ AND name IN (
+ 'idx_raw_packets_timestamp',
+ 'idx_contacts_type_last_seen',
+ 'idx_messages_type_received_conversation'
+ )
+ ORDER BY name
+ """
+ )
+ rows = await cursor.fetchall()
+ assert [row["name"] for row in rows] == [
+ "idx_contacts_type_last_seen",
+ "idx_messages_type_received_conversation",
+ "idx_raw_packets_timestamp",
+ ]
+ finally:
+ await conn.close()
+
+
class TestMigrationPacketHelpers:
"""Test migration-local packet helpers against canonical path validation."""
diff --git a/tests/test_packets_router.py b/tests/test_packets_router.py
index 339b5a5..d9d1243 100644
--- a/tests/test_packets_router.py
+++ b/tests/test_packets_router.py
@@ -5,7 +5,7 @@ undecrypted count endpoint, and the maintenance endpoint.
"""
import time
-from unittest.mock import patch
+from unittest.mock import AsyncMock, patch
import pytest
@@ -305,6 +305,43 @@ class TestDecryptHistoricalPackets:
assert "key_type" in data["detail"].lower()
+class TestUndecryptedTextPacketStreaming:
+ @pytest.mark.asyncio
+ async def test_count_undecrypted_text_messages_uses_batched_streaming(self, test_db):
+ """Counting undecrypted DM packets should stream batches and filter by payload type."""
+
+ class FakeCursor:
+ def __init__(self):
+ self._batches = [
+ [
+ {"id": 1, "data": b"\x09\x00dm", "timestamp": 1000},
+ {"id": 2, "data": b"\x15\x00chan", "timestamp": 1001},
+ ],
+ [{"id": 3, "data": b"\x09\x00dm2", "timestamp": 1002}],
+ [],
+ ]
+ self.fetchall_called = False
+
+ async def fetchmany(self, size):
+ assert size > 0
+ return self._batches.pop(0)
+
+ async def close(self):
+ return None
+
+ async def fetchall(self):
+ self.fetchall_called = True
+ raise AssertionError("fetchall() should not be used")
+
+ fake_cursor = FakeCursor()
+
+ with patch.object(test_db.conn, "execute", new=AsyncMock(return_value=fake_cursor)):
+ count = await RawPacketRepository.count_undecrypted_text_messages(batch_size=2)
+
+ assert fake_cursor.fetchall_called is False
+ assert count == 2
+
+
class TestRunHistoricalChannelDecryption:
"""Test the _run_historical_channel_decryption background task."""
diff --git a/tests/test_radio_router.py b/tests/test_radio_router.py
index c4acde8..a3a9ac0 100644
--- a/tests/test_radio_router.py
+++ b/tests/test_radio_router.py
@@ -2,14 +2,14 @@
import asyncio
from contextlib import asynccontextmanager
-from unittest.mock import AsyncMock, MagicMock, patch
+from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest
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
@@ -375,6 +376,11 @@ class TestDiscoverMesh:
return_value=None,
),
patch("app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock),
+ patch(
+ "app.routers.radio.promote_prefix_contacts_for_contact",
+ new_callable=AsyncMock,
+ return_value=[],
+ ),
patch("app.routers.radio.broadcast_event"),
):
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
@@ -436,18 +442,27 @@ class TestDiscoverMesh:
patch(
"app.routers.radio.ContactRepository.get_by_key",
new_callable=AsyncMock,
- side_effect=[None, created_contact],
+ # 1st: _persist check (not found), 2nd: _persist re-fetch (created),
+ # 3rd: _attach_known_names lookup
+ side_effect=[None, created_contact, created_contact],
) as mock_get_by_key,
patch(
"app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock
) as mock_upsert,
+ patch(
+ "app.routers.radio.promote_prefix_contacts_for_contact",
+ new_callable=AsyncMock,
+ return_value=[],
+ ) as mock_promote,
patch("app.routers.radio.broadcast_event") as mock_broadcast,
):
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
assert len(response.results) == 1
+ assert response.results[0].name is None # created_contact has no name
mock_get_by_key.assert_awaited()
mock_upsert.assert_awaited_once()
+ mock_promote.assert_awaited_once_with(public_key="44" * 32, log=ANY)
upsert_arg = mock_upsert.await_args.args[0]
assert upsert_arg.public_key == "44" * 32
assert upsert_arg.type == 2
@@ -510,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()
@@ -542,6 +774,11 @@ class TestDiscoverMesh:
return_value=None,
),
patch("app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock),
+ patch(
+ "app.routers.radio.promote_prefix_contacts_for_contact",
+ new_callable=AsyncMock,
+ return_value=[],
+ ),
patch("app.routers.radio.broadcast_event"),
):
response = await discover_mesh(RadioDiscoveryRequest(target="all"))
diff --git a/tests/test_repeater_routes.py b/tests/test_repeater_routes.py
index 7f113fb..cb633b1 100644
--- a/tests/test_repeater_routes.py
+++ b/tests/test_repeater_routes.py
@@ -12,7 +12,6 @@ from app.repository import ContactRepository
from app.routers.contacts import request_trace
from app.routers.repeaters import (
_batch_cli_fetch,
- _fetch_repeater_response,
prepare_repeater_connection,
repeater_acl,
repeater_advert_intervals,
@@ -25,12 +24,17 @@ from app.routers.repeaters import (
repeater_status,
send_repeater_command,
)
+from app.routers.server_control import fetch_contact_cli_response
KEY_A = "aa" * 32
-# Patch target for the wall-clock wrapper used by _fetch_repeater_response.
+# Patch target for the wall-clock wrapper used by fetch_contact_cli_response.
# We patch _monotonic (not time.monotonic) to avoid breaking the asyncio event loop.
-_MONOTONIC = "app.routers.repeaters._monotonic"
+_MONOTONIC = "app.routers.server_control._monotonic"
+
+# Patch targets for the store helpers called on consumed non-target messages.
+_STORE_DM = "app.routers.server_control._store_pending_direct_message"
+_STORE_CHAN = "app.routers.server_control._store_pending_channel_message"
@pytest.fixture(autouse=True)
@@ -104,8 +108,8 @@ def _advancing_clock(start=0.0, step=0.1):
return _tick
-class TestFetchRepeaterResponse:
- """Tests for the _fetch_repeater_response helper."""
+class TestFetchContactCliResponse:
+ """Tests for the fetch_contact_cli_response helper."""
@pytest.mark.asyncio
async def test_returns_matching_cli_response(self):
@@ -118,7 +122,7 @@ class TestFetchRepeaterResponse:
)
with patch(_MONOTONIC, side_effect=_advancing_clock()):
- result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
+ result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ok"
@@ -138,16 +142,20 @@ class TestFetchRepeaterResponse:
)
mc.commands.get_msg = AsyncMock(side_effect=[non_cli, cli_response])
- with patch(_MONOTONIC, side_effect=_advancing_clock()):
- result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
+ with (
+ patch(_MONOTONIC, side_effect=_advancing_clock()),
+ patch(_STORE_DM, new_callable=AsyncMock) as store_dm,
+ ):
+ result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ver 1.0"
assert mc.commands.get_msg.await_count == 2
+ store_dm.assert_awaited_once_with(non_cli)
@pytest.mark.asyncio
- async def test_unrelated_dm_is_skipped(self):
- """Unrelated DMs are skipped (dispatcher already handled them)."""
+ async def test_unrelated_dm_is_stored(self):
+ """Unrelated DMs consumed during CLI fetch are stored, not discarded."""
mc = _mock_mc()
unrelated = _radio_result(
EventType.CONTACT_MSG_RECV,
@@ -159,14 +167,18 @@ class TestFetchRepeaterResponse:
)
mc.commands.get_msg = AsyncMock(side_effect=[unrelated, expected])
- with patch(_MONOTONIC, side_effect=_advancing_clock()):
- result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
+ with (
+ patch(_MONOTONIC, side_effect=_advancing_clock()),
+ patch(_STORE_DM, new_callable=AsyncMock) as store_dm,
+ ):
+ result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ver 1.0"
+ store_dm.assert_awaited_once_with(unrelated)
@pytest.mark.asyncio
- async def test_channel_message_is_skipped(self):
+ async def test_channel_message_is_stored(self):
mc = _mock_mc()
channel_msg = _radio_result(
EventType.CHANNEL_MSG_RECV,
@@ -178,11 +190,15 @@ class TestFetchRepeaterResponse:
)
mc.commands.get_msg = AsyncMock(side_effect=[channel_msg, expected])
- with patch(_MONOTONIC, side_effect=_advancing_clock()):
- result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
+ with (
+ patch(_MONOTONIC, side_effect=_advancing_clock()),
+ patch(_STORE_CHAN, new_callable=AsyncMock) as store_chan,
+ ):
+ result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ok"
+ store_chan.assert_awaited_once_with(mc, channel_msg.payload)
@pytest.mark.asyncio
async def test_no_more_msgs_retries_then_succeeds(self):
@@ -196,9 +212,9 @@ class TestFetchRepeaterResponse:
with (
patch(_MONOTONIC, side_effect=_advancing_clock()),
- patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
+ patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
- result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
+ result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ok"
@@ -215,9 +231,9 @@ class TestFetchRepeaterResponse:
with (
patch(_MONOTONIC, side_effect=times),
- patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
+ patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
- result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=2.0)
+ result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=2.0)
assert result is None
@@ -233,16 +249,16 @@ class TestFetchRepeaterResponse:
with (
patch(_MONOTONIC, side_effect=_advancing_clock()),
- patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
+ patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
- result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
+ result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ok"
@pytest.mark.asyncio
- async def test_high_traffic_does_not_exhaust_budget(self):
- """Many unrelated messages don't prevent eventual success (wall-clock deadline)."""
+ async def test_high_traffic_stores_all_consumed_messages(self):
+ """Many unrelated messages are stored and don't prevent eventual success."""
mc = _mock_mc()
# 20 unrelated DMs followed by the expected CLI response
unrelated = [
@@ -258,12 +274,16 @@ class TestFetchRepeaterResponse:
)
mc.commands.get_msg = AsyncMock(side_effect=[*unrelated, expected])
- with patch(_MONOTONIC, side_effect=_advancing_clock()):
- result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=30.0)
+ with (
+ patch(_MONOTONIC, side_effect=_advancing_clock()),
+ patch(_STORE_DM, new_callable=AsyncMock) as store_dm,
+ ):
+ result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=30.0)
assert result is not None
assert result.payload["text"] == "ver 1.0"
assert mc.commands.get_msg.await_count == 21
+ assert store_dm.await_count == 20
class TestRepeaterCommandRoute:
@@ -297,7 +317,7 @@ class TestRepeaterCommandRoute:
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=[0.0, 5.0, 25.0]),
- patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
+ patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
response = await send_repeater_command(KEY_A, CommandRequest(command="ver"))
@@ -457,7 +477,7 @@ class TestRepeaterCommandRoute:
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=_advancing_clock()),
- patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
+ patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
response = await send_repeater_command(KEY_A, CommandRequest(command="ver"))
@@ -483,6 +503,11 @@ class TestTraceRoute:
await request_trace(KEY_A)
assert exc.value.status_code == 500
+ mc.commands.send_trace.assert_awaited_once_with(
+ path=KEY_A[:8],
+ tag=1234,
+ flags=2,
+ )
@pytest.mark.asyncio
async def test_wait_timeout_returns_504(self, test_db):
@@ -500,6 +525,11 @@ class TestTraceRoute:
await request_trace(KEY_A)
assert exc.value.status_code == 504
+ mc.commands.send_trace.assert_awaited_once_with(
+ path=KEY_A[:8],
+ tag=1234,
+ flags=2,
+ )
@pytest.mark.asyncio
async def test_success_returns_remote_and_local_snr(self, test_db):
@@ -520,6 +550,11 @@ class TestTraceRoute:
assert response.remote_snr == 5.5
assert response.local_snr == 3.2
assert response.path_len == 2
+ mc.commands.send_trace.assert_awaited_once_with(
+ path=KEY_A[:8],
+ tag=1234,
+ flags=2,
+ )
# ---------------------------------------------------------------------------
@@ -983,7 +1018,7 @@ class TestRepeaterRadioSettings:
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=clock_ticks),
- patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
+ patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
response = await repeater_radio_settings(KEY_A)
@@ -1058,7 +1093,7 @@ class TestRepeaterNodeInfo:
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=clock_ticks),
- patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
+ patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
response = await repeater_node_info(KEY_A)
@@ -1111,7 +1146,7 @@ class TestRepeaterAdvertIntervals:
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=clock_ticks),
- patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
+ patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
response = await repeater_advert_intervals(KEY_A)
@@ -1166,7 +1201,7 @@ class TestRepeaterOwnerInfo:
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=clock_ticks),
- patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
+ patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
response = await repeater_owner_info(KEY_A)
@@ -1224,7 +1259,7 @@ class TestBatchCliFetch:
with (
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=_advancing_clock()),
- patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
+ patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
results = await _batch_cli_fetch(
contact, "test_op", [("bad_cmd", "field_a"), ("good_cmd", "field_b")]
@@ -1245,7 +1280,7 @@ class TestBatchCliFetch:
with (
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=[0.0, 5.0, 11.0]),
- patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
+ patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
results = await _batch_cli_fetch(contact, "test_op", [("clock", "clock_output")])
diff --git a/tests/test_statistics.py b/tests/test_statistics.py
index 66b8748..79d5ff2 100644
--- a/tests/test_statistics.py
+++ b/tests/test_statistics.py
@@ -1,6 +1,8 @@
"""Tests for the statistics repository and endpoint."""
import time
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
import pytest
@@ -347,3 +349,75 @@ class TestPathHashWidthStats:
assert breakdown["single_byte_pct"] == pytest.approx(100 / 3, rel=1e-3)
assert breakdown["double_byte_pct"] == pytest.approx(100 / 3, rel=1e-3)
assert breakdown["triple_byte_pct"] == pytest.approx(100 / 3, rel=1e-3)
+
+ @pytest.mark.asyncio
+ async def test_path_hash_width_scan_uses_batched_fetchmany(self, test_db):
+ """Hash-width stats should stream batches instead of calling fetchall()."""
+
+ class FakeCursor:
+ def __init__(self):
+ self._batches = [
+ [{"data": b"a"}, {"data": b"b"}],
+ [{"data": b"c"}],
+ [],
+ ]
+ self.fetchall_called = False
+
+ async def fetchmany(self, size):
+ assert size > 0
+ return self._batches.pop(0)
+
+ async def fetchall(self):
+ self.fetchall_called = True
+ raise AssertionError("fetchall() should not be used")
+
+ fake_cursor = FakeCursor()
+
+ def fake_parse(raw_packet: bytes):
+ hash_sizes = {
+ b"a": 1,
+ b"b": 2,
+ b"c": 3,
+ }
+ hash_size = hash_sizes.get(raw_packet)
+ if hash_size is None:
+ return None
+ return SimpleNamespace(hash_size=hash_size)
+
+ with (
+ patch.object(test_db.conn, "execute", new=AsyncMock(return_value=fake_cursor)),
+ patch("app.repository.settings.parse_packet_envelope", side_effect=fake_parse),
+ ):
+ breakdown = await StatisticsRepository._path_hash_width_24h()
+
+ assert fake_cursor.fetchall_called is False
+ assert breakdown["total_packets"] == 3
+ assert breakdown["single_byte"] == 1
+ assert breakdown["double_byte"] == 1
+ assert breakdown["triple_byte"] == 1
+
+
+class TestStatisticsEndpoint:
+ @pytest.mark.asyncio
+ async def test_statistics_endpoint_includes_noise_floor_history(self, test_db, client):
+ noise_floor_history = {
+ "sample_interval_seconds": 300,
+ "coverage_seconds": 1800,
+ "latest_noise_floor_dbm": -119,
+ "latest_timestamp": 1_700_000_000,
+ "supported": True,
+ "samples": [
+ {"timestamp": 1_699_998_200, "noise_floor_dbm": -121},
+ {"timestamp": 1_700_000_000, "noise_floor_dbm": -119},
+ ],
+ }
+
+ with patch(
+ "app.routers.statistics.get_noise_floor_history",
+ new=AsyncMock(return_value=noise_floor_history),
+ ):
+ response = await client.get("/api/statistics")
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload["noise_floor_24h"] == noise_floor_history
diff --git a/uv.lock b/uv.lock
index f95cb93..ff2edd1 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1098,7 +1098,7 @@ wheels = [
[[package]]
name = "remoteterm-meshcore"
-version = "3.6.2"
+version = "3.6.3"
source = { virtual = "." }
dependencies = [
{ name = "aiomqtt" },