mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-18 15:26:17 +02:00
Merge branch 'main' of github.com:maplemesh/Remote-Terminal-for-MeshCore into gnomeadrift/repeater_telemetry_history
This commit is contained in:
@@ -274,6 +274,7 @@ export function App() {
|
||||
unreadLastReadAts,
|
||||
recordMessageEvent,
|
||||
renameConversationState,
|
||||
removeConversationState,
|
||||
markAllRead,
|
||||
refreshUnreads,
|
||||
} = useUnreadCounts(channels, contacts, activeConversation);
|
||||
@@ -349,6 +350,7 @@ export function App() {
|
||||
observeMessage,
|
||||
recordMessageEvent,
|
||||
renameConversationState,
|
||||
removeConversationState,
|
||||
checkMention,
|
||||
pendingDeleteFallbackRef,
|
||||
setActiveConversation,
|
||||
@@ -457,6 +459,7 @@ export function App() {
|
||||
loadingNewer,
|
||||
messageInputRef,
|
||||
onTrace: handleTrace,
|
||||
onRunTracePath: api.requestRadioTrace,
|
||||
onPathDiscovery: handlePathDiscovery,
|
||||
onToggleFavorite: handleToggleFavorite,
|
||||
onDeleteContact: handleDeleteContact,
|
||||
|
||||
+11
-2
@@ -20,6 +20,8 @@ import type {
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
RadioDiscoveryResponse,
|
||||
RadioTraceHopRequest,
|
||||
RadioTraceResponse,
|
||||
RadioDiscoveryTarget,
|
||||
PathDiscoveryResponse,
|
||||
ResendChannelMessageResponse,
|
||||
@@ -108,6 +110,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target }),
|
||||
}),
|
||||
requestRadioTrace: (hopHashBytes: 1 | 2 | 4, hops: RadioTraceHopRequest[]) =>
|
||||
fetchJson<RadioTraceResponse>('/radio/trace', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ hop_hash_bytes: hopHashBytes, hops }),
|
||||
}),
|
||||
rebootRadio: () =>
|
||||
fetchJson<{ status: string; message: string }>('/radio/reboot', {
|
||||
method: 'POST',
|
||||
@@ -131,11 +138,13 @@ export const api = {
|
||||
fetchJson<ContactAdvertPathSummary[]>(
|
||||
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
|
||||
),
|
||||
getContactAnalytics: (params: { publicKey?: string; name?: string }) => {
|
||||
getContactAnalytics: (params: { publicKey?: string; name?: string }, signal?: AbortSignal) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.publicKey) searchParams.set('public_key', params.publicKey);
|
||||
if (params.name) searchParams.set('name', params.name);
|
||||
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`);
|
||||
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`, {
|
||||
signal,
|
||||
});
|
||||
},
|
||||
deleteContact: (publicKey: string) =>
|
||||
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
||||
|
||||
@@ -268,7 +268,7 @@ export function ChatHeader({
|
||||
title={
|
||||
activeContactIsPrefixOnly
|
||||
? 'Direct Trace unavailable until the full contact key is known'
|
||||
: 'Direct Trace. Send a zero-hop packet to this contact and display out and back SNR'
|
||||
: 'Direct Trace. Send a direct trace probe to this contact and display out and back SNR'
|
||||
}
|
||||
aria-label="Direct Trace"
|
||||
disabled={activeContactIsPrefixOnly}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { Ban, Search, Star } from 'lucide-react';
|
||||
import { api } from '../api';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { api, isAbortError } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import {
|
||||
getContactDisplayName,
|
||||
@@ -100,29 +110,29 @@ export function ContactInfoPane({
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
setAnalytics(null);
|
||||
setLoading(true);
|
||||
const request =
|
||||
isNameOnly && nameOnlyValue
|
||||
? api.getContactAnalytics({ name: nameOnlyValue })
|
||||
: api.getContactAnalytics({ publicKey: contactKey });
|
||||
? api.getContactAnalytics({ name: nameOnlyValue }, controller.signal)
|
||||
: api.getContactAnalytics({ publicKey: contactKey }, controller.signal);
|
||||
|
||||
request
|
||||
.then((data) => {
|
||||
if (!cancelled) setAnalytics(data);
|
||||
if (!controller.signal.aborted) setAnalytics(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
if (!isAbortError(err)) {
|
||||
console.error('Failed to fetch contact analytics:', err);
|
||||
toast.error('Failed to load contact info');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [contactKey, isNameOnly, nameOnlyValue]);
|
||||
|
||||
@@ -650,20 +660,18 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
||||
{hasHourlyActivity && (
|
||||
<div>
|
||||
<SectionLabel>Messages Per Hour</SectionLabel>
|
||||
<ChartLegend
|
||||
items={[
|
||||
{ label: 'Last 24h', color: '#2563eb' },
|
||||
{ label: '7-day avg', color: '#ea580c' },
|
||||
{ label: 'All-time avg', color: '#64748b' },
|
||||
]}
|
||||
/>
|
||||
<ActivityLineChart
|
||||
ariaLabel="Messages per hour"
|
||||
points={analytics.hourly_activity}
|
||||
series={[
|
||||
{ key: 'last_24h_count', color: '#2563eb' },
|
||||
{ key: 'last_week_average', color: '#ea580c' },
|
||||
{ key: 'all_time_average', color: '#64748b' },
|
||||
{ key: 'last_24h_count', color: '#2563eb', label: 'Last 24h' },
|
||||
{ key: 'last_week_average', color: '#ea580c', label: '7-day avg' },
|
||||
{ key: 'all_time_average', color: '#64748b', label: 'All-time avg' },
|
||||
]}
|
||||
legendItems={[
|
||||
{ label: 'Last 24h', color: '#2563eb' },
|
||||
{ label: '7-day avg', color: '#ea580c' },
|
||||
{ label: 'All-time avg', color: '#64748b' },
|
||||
]}
|
||||
valueFormatter={(value) => value.toFixed(value % 1 === 0 ? 0 : 1)}
|
||||
tickFormatter={(bucket) =>
|
||||
@@ -683,7 +691,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
||||
<ActivityLineChart
|
||||
ariaLabel="Messages per week"
|
||||
points={analytics.weekly_activity}
|
||||
series={[{ key: 'message_count', color: '#16a34a' }]}
|
||||
series={[{ key: 'message_count', color: '#16a34a', label: 'Messages' }]}
|
||||
valueFormatter={(value) => value.toFixed(0)}
|
||||
tickFormatter={(bucket) =>
|
||||
new Date(bucket.bucket_start * 1000).toLocaleDateString([], {
|
||||
@@ -705,133 +713,115 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
||||
);
|
||||
}
|
||||
|
||||
function ChartLegend({ items }: { items: Array<{ label: string; color: string }> }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mb-2 text-[11px] text-muted-foreground">
|
||||
{items.map((item) => (
|
||||
<span key={item.label} className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnalyticsWeeklyBucket>({
|
||||
ariaLabel,
|
||||
points,
|
||||
series,
|
||||
legendItems,
|
||||
tickFormatter,
|
||||
valueFormatter,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
points: T[];
|
||||
series: Array<{ key: keyof T; color: string }>;
|
||||
series: Array<{ key: keyof T; color: string; label?: string }>;
|
||||
legendItems?: Array<{ label: string; color: string }>;
|
||||
tickFormatter: (point: T) => string;
|
||||
valueFormatter: (value: number) => string;
|
||||
}) {
|
||||
const width = 320;
|
||||
const height = 132;
|
||||
const padding = { top: 8, right: 8, bottom: 24, left: 32 };
|
||||
const plotWidth = width - padding.left - padding.right;
|
||||
const plotHeight = height - padding.top - padding.bottom;
|
||||
const allValues = points.flatMap((point) =>
|
||||
series.map((entry) => {
|
||||
const value = point[entry.key];
|
||||
return typeof value === 'number' ? value : 0;
|
||||
})
|
||||
);
|
||||
const maxValue = Math.max(1, ...allValues);
|
||||
const tickIndices = Array.from(
|
||||
new Set([
|
||||
0,
|
||||
Math.floor((points.length - 1) / 3),
|
||||
Math.floor(((points.length - 1) * 2) / 3),
|
||||
points.length - 1,
|
||||
])
|
||||
);
|
||||
const data = points.map((point, i) => {
|
||||
const entry: Record<string, string | number> = { idx: i, tick: tickFormatter(point) };
|
||||
for (const s of series) {
|
||||
const raw = point[s.key];
|
||||
entry[String(s.key)] = typeof raw === 'number' ? raw : 0;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
const buildPolyline = (key: keyof T) =>
|
||||
points
|
||||
.map((point, index) => {
|
||||
const rawValue = point[key];
|
||||
const value = typeof rawValue === 'number' ? rawValue : 0;
|
||||
const x =
|
||||
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
|
||||
const y = padding.top + plotHeight - (value / maxValue) * plotHeight;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(' ');
|
||||
const tickCount = Math.min(5, points.length);
|
||||
const tickIndices: number[] = [];
|
||||
if (points.length > 1) {
|
||||
for (let i = 0; i < tickCount; i++) {
|
||||
tickIndices.push(Math.round((i / (tickCount - 1)) * (points.length - 1)));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="w-full h-auto"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{[0, 0.5, 1].map((ratio) => {
|
||||
const y = padding.top + plotHeight - ratio * plotHeight;
|
||||
const value = maxValue * ratio;
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line
|
||||
x1={padding.left}
|
||||
x2={width - padding.right}
|
||||
y1={y}
|
||||
y2={y}
|
||||
stroke="hsl(var(--border))"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={padding.left - 6}
|
||||
y={y + 4}
|
||||
fontSize="10"
|
||||
textAnchor="end"
|
||||
fill="hsl(var(--muted-foreground))"
|
||||
>
|
||||
{valueFormatter(value)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{series.map((entry) => (
|
||||
<polyline
|
||||
key={String(entry.key)}
|
||||
fill="none"
|
||||
stroke={entry.color}
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
points={buildPolyline(entry.key)}
|
||||
<div role="img" aria-label={ariaLabel}>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<LineChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -16 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="idx"
|
||||
type="number"
|
||||
domain={[0, Math.max(1, points.length - 1)]}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
ticks={tickIndices}
|
||||
tickFormatter={(idx) => String(data[idx]?.tick ?? '')}
|
||||
/>
|
||||
))}
|
||||
|
||||
{tickIndices.map((index) => {
|
||||
const point = points[index];
|
||||
const x =
|
||||
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
|
||||
return (
|
||||
<text
|
||||
key={`${ariaLabel}-${point.bucket_start}`}
|
||||
x={x}
|
||||
y={height - 6}
|
||||
fontSize="10"
|
||||
textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}
|
||||
fill="hsl(var(--muted-foreground))"
|
||||
>
|
||||
{tickFormatter(point)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => valueFormatter(v)}
|
||||
width={40}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{
|
||||
stroke: 'hsl(var(--muted-foreground))',
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: '3 3',
|
||||
}}
|
||||
labelFormatter={(idx) => String(data[Number(idx)]?.tick ?? '')}
|
||||
formatter={(value, name) => {
|
||||
const match = series.find((s) => String(s.key) === name);
|
||||
return [valueFormatter(Number(value)), match?.label ?? String(name)];
|
||||
}}
|
||||
/>
|
||||
{legendItems && (
|
||||
<Legend
|
||||
content={() => (
|
||||
<div className="flex flex-wrap justify-center gap-x-3 gap-y-1 mt-1 text-[11px] text-muted-foreground">
|
||||
{legendItems.map((item) => (
|
||||
<span key={item.label} className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{series.map((entry) => (
|
||||
<Line
|
||||
key={String(entry.key)}
|
||||
type="linear"
|
||||
dataKey={String(entry.key)}
|
||||
stroke={entry.color}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, strokeWidth: 2, stroke: 'hsl(var(--popover))' }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MessageInput, type MessageInputHandle } from './MessageInput';
|
||||
import { MessageList } from './MessageList';
|
||||
import { RawPacketFeedView } from './RawPacketFeedView';
|
||||
import { RoomServerPanel } from './RoomServerPanel';
|
||||
import { TracePane } from './TracePane';
|
||||
import type {
|
||||
Channel,
|
||||
Contact,
|
||||
@@ -15,6 +16,8 @@ import type {
|
||||
PathDiscoveryResponse,
|
||||
RawPacket,
|
||||
RadioConfig,
|
||||
RadioTraceHopRequest,
|
||||
RadioTraceResponse,
|
||||
} from '../types';
|
||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
@@ -50,6 +53,10 @@ interface ConversationPaneProps {
|
||||
loadingNewer: boolean;
|
||||
messageInputRef: Ref<MessageInputHandle>;
|
||||
onTrace: () => Promise<void>;
|
||||
onRunTracePath: (
|
||||
hopHashBytes: 1 | 2 | 4,
|
||||
hops: RadioTraceHopRequest[]
|
||||
) => Promise<RadioTraceResponse>;
|
||||
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
|
||||
onDeleteContact: (publicKey: string) => Promise<void>;
|
||||
@@ -115,6 +122,7 @@ export function ConversationPane({
|
||||
loadingNewer,
|
||||
messageInputRef,
|
||||
onTrace,
|
||||
onRunTracePath,
|
||||
onPathDiscovery,
|
||||
onToggleFavorite,
|
||||
onDeleteContact,
|
||||
@@ -200,6 +208,10 @@ export function ConversationPane({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activeConversation.type === 'trace') {
|
||||
return <TracePane contacts={contacts} config={config} onRunTracePath={onRunTracePath} />;
|
||||
}
|
||||
|
||||
if (activeContactIsRepeater) {
|
||||
return (
|
||||
<Suspense fallback={<LoadingPane label="Loading dashboard..." />}>
|
||||
|
||||
@@ -128,8 +128,9 @@ export function CrackerPanel({
|
||||
}, [existingChannelKeys]);
|
||||
|
||||
// Filter packets to only undecrypted GROUP_TEXT
|
||||
const undecryptedGroupText = packets.filter(
|
||||
(p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted
|
||||
const undecryptedGroupText = useMemo(
|
||||
() => packets.filter((p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted),
|
||||
[packets]
|
||||
);
|
||||
|
||||
// Update queue when packets change (deduplicated by payload)
|
||||
|
||||
@@ -131,9 +131,23 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
|
||||
// Store ref for a marker
|
||||
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
|
||||
if (ref === null) {
|
||||
delete markerRefs.current[key];
|
||||
return;
|
||||
}
|
||||
|
||||
markerRefs.current[key] = ref;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const currentKeys = new Set(mappableContacts.map((contact) => contact.public_key));
|
||||
for (const key of Object.keys(markerRefs.current)) {
|
||||
if (!currentKeys.has(key)) {
|
||||
delete markerRefs.current[key];
|
||||
}
|
||||
}
|
||||
}, [mappableContacts]);
|
||||
|
||||
// Open popup for focused contact after map is ready
|
||||
useEffect(() => {
|
||||
if (focusedContact && markerRefs.current[focusedContact.public_key]) {
|
||||
|
||||
@@ -373,7 +373,22 @@ export function MessageList({
|
||||
}
|
||||
}
|
||||
|
||||
setResendableIds(newResendable);
|
||||
setResendableIds((prev) => {
|
||||
if (prev.size === newResendable.size) {
|
||||
let changed = false;
|
||||
for (const id of newResendable) {
|
||||
if (!prev.has(id)) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
|
||||
return newResendable;
|
||||
});
|
||||
|
||||
return () => {
|
||||
for (const timer of timers.values()) clearTimeout(timer);
|
||||
|
||||
@@ -81,13 +81,14 @@ export function PathModal({
|
||||
) : hasSinglePath ? (
|
||||
<>
|
||||
This shows <em>one route</em> that this message traveled through the mesh network.
|
||||
Repeaters may be incorrectly identified due to prefix collisions between heard and
|
||||
non-heard repeater advertisements.
|
||||
Repeater identities are inferred from locally known advert and path data, so some
|
||||
hops may be missing or misidentified when that data is incomplete.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This message was received via <strong>{paths.length} different routes</strong>.
|
||||
Repeaters may be incorrectly identified due to prefix collisions.
|
||||
Repeater identities are inferred from locally known advert and path data, so some
|
||||
hops may be missing or misidentified when that data is incomplete.
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
|
||||
import { RawPacketList } from './RawPacketList';
|
||||
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
||||
@@ -24,6 +34,18 @@ interface RawPacketFeedViewProps {
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
|
||||
'1m': '1 min',
|
||||
'5m': '5 min',
|
||||
@@ -32,13 +54,7 @@ const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
|
||||
session: 'Session',
|
||||
};
|
||||
|
||||
const TIMELINE_COLORS = [
|
||||
'bg-sky-500/80',
|
||||
'bg-emerald-500/80',
|
||||
'bg-amber-500/80',
|
||||
'bg-rose-500/80',
|
||||
'bg-violet-500/80',
|
||||
];
|
||||
const TIMELINE_FILL_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
|
||||
|
||||
function formatTimestamp(timestampMs: number): string {
|
||||
return new Date(timestampMs).toLocaleString([], {
|
||||
@@ -220,7 +236,13 @@ function RankedBars({
|
||||
emptyLabel: string;
|
||||
formatter?: (item: RankedPacketStat) => string;
|
||||
}) {
|
||||
const maxCount = Math.max(...items.map((item) => item.count), 1);
|
||||
const data = items.map((item) => ({
|
||||
name: item.label,
|
||||
value: item.count,
|
||||
detail: formatter
|
||||
? formatter(item)
|
||||
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
@@ -228,25 +250,36 @@ function RankedBars({
|
||||
{items.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.label}>
|
||||
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
|
||||
<span className="truncate text-foreground">{item.label}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatter
|
||||
? formatter(item)
|
||||
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary/80"
|
||||
style={{ width: `${(item.count / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-2">
|
||||
<ResponsiveContainer width="100%" height={items.length * 28 + 8}>
|
||||
<BarChart
|
||||
data={data}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 4, bottom: 0, left: 0 }}
|
||||
barCategoryGap="20%"
|
||||
>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={80}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={(_v: any, _n: any, props: any) => [props.payload.detail, null]}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]} maxBarSize={16}>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={TIMELINE_FILL_COLORS[i % TIMELINE_FILL_COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -320,53 +353,66 @@ function NeighborList({
|
||||
}
|
||||
|
||||
function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
|
||||
const maxTotal = Math.max(...bins.map((bin) => bin.total), 1);
|
||||
const typeOrder = Array.from(new Set(bins.flatMap((bin) => Object.keys(bin.countsByType)))).slice(
|
||||
0,
|
||||
TIMELINE_COLORS.length
|
||||
TIMELINE_FILL_COLORS.length
|
||||
);
|
||||
|
||||
const data = bins.map((bin) => {
|
||||
const entry: Record<string, string | number> = { label: bin.label };
|
||||
for (const type of typeOrder) {
|
||||
entry[type] = bin.countsByType[type] ?? 0;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
|
||||
<div className="flex flex-wrap justify-end gap-2 text-[11px] text-muted-foreground">
|
||||
{typeOrder.map((type, index) => (
|
||||
{typeOrder.map((type, i) => (
|
||||
<span key={type} className="inline-flex items-center gap-1">
|
||||
<span className={cn('h-2 w-2 rounded-full', TIMELINE_COLORS[index])} />
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: TIMELINE_FILL_COLORS[i] }}
|
||||
/>
|
||||
<span>{type}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-start gap-1">
|
||||
{bins.map((bin, index) => (
|
||||
<div
|
||||
key={`${bin.label}-${index}`}
|
||||
className="flex min-w-0 flex-1 flex-col items-center gap-1"
|
||||
>
|
||||
<div className="flex h-24 w-full items-end overflow-hidden rounded-sm bg-muted/60">
|
||||
<div className="flex h-full w-full flex-col justify-end">
|
||||
{typeOrder.map((type, index) => {
|
||||
const count = bin.countsByType[type] ?? 0;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={type}
|
||||
className={cn('w-full', TIMELINE_COLORS[index])}
|
||||
style={{
|
||||
height: `${(count / maxTotal) * 100}%`,
|
||||
}}
|
||||
title={`${bin.label}: ${type} ${count.toLocaleString()}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">{bin.label}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-2">
|
||||
<ResponsiveContainer width="100%" height={110}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 0, bottom: 0, left: -24 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||
/>
|
||||
{typeOrder.map((type, i) => (
|
||||
<Bar
|
||||
key={type}
|
||||
dataKey={type}
|
||||
stackId="packets"
|
||||
fill={TIMELINE_FILL_COLORS[i]}
|
||||
radius={i === typeOrder.length - 1 ? [2, 2, 0, 0] : undefined}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
Cable,
|
||||
ChartNetwork,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@@ -9,7 +11,6 @@ import {
|
||||
Map,
|
||||
Search as SearchIcon,
|
||||
SquarePen,
|
||||
Waypoints,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
@@ -197,7 +198,7 @@ export function Sidebar({
|
||||
};
|
||||
|
||||
const isActive = (
|
||||
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search',
|
||||
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace',
|
||||
id: string
|
||||
) => activeConversation?.type === type && activeConversation?.id === id;
|
||||
|
||||
@@ -721,7 +722,7 @@ export function Sidebar({
|
||||
renderSidebarActionRow({
|
||||
key: 'tool-visualizer',
|
||||
active: isActive('visualizer', 'visualizer'),
|
||||
icon: <Waypoints className="h-4 w-4" />,
|
||||
icon: <ChartNetwork className="h-4 w-4" />,
|
||||
label: 'Mesh Visualizer',
|
||||
onClick: () =>
|
||||
handleSelectConversation({
|
||||
@@ -730,6 +731,18 @@ export function Sidebar({
|
||||
name: 'Mesh Visualizer',
|
||||
}),
|
||||
}),
|
||||
renderSidebarActionRow({
|
||||
key: 'tool-trace',
|
||||
active: isActive('trace', 'trace'),
|
||||
icon: <Cable className="h-4 w-4" />,
|
||||
label: 'Trace',
|
||||
onClick: () =>
|
||||
handleSelectConversation({
|
||||
type: 'trace',
|
||||
id: 'trace',
|
||||
name: 'Trace',
|
||||
}),
|
||||
}),
|
||||
renderSidebarActionRow({
|
||||
key: 'tool-search',
|
||||
active: isActive('search', 'search'),
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { ArrowDown, ArrowUp, Plus, X } from 'lucide-react';
|
||||
|
||||
import type {
|
||||
Contact,
|
||||
RadioConfig,
|
||||
RadioTraceHopRequest,
|
||||
RadioTraceNode,
|
||||
RadioTraceResponse,
|
||||
} from '../types';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import { calculateDistance, isValidLocation } from '../utils/pathUtils';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type TraceSortMode = 'alpha' | 'recent' | 'distance';
|
||||
type CustomHopBytes = 1 | 2 | 4;
|
||||
|
||||
type TraceDraftHop =
|
||||
| { id: string; kind: 'repeater'; publicKey: string }
|
||||
| { id: string; kind: 'custom'; hopHex: string; hopBytes: CustomHopBytes };
|
||||
|
||||
interface TracePaneProps {
|
||||
contacts: Contact[];
|
||||
config: RadioConfig | null;
|
||||
onRunTracePath: (
|
||||
hopHashBytes: CustomHopBytes,
|
||||
hops: RadioTraceHopRequest[]
|
||||
) => Promise<RadioTraceResponse>;
|
||||
}
|
||||
|
||||
function getHeardTimestamp(contact: Contact): number {
|
||||
return Math.max(contact.last_seen ?? 0, contact.last_advert ?? 0);
|
||||
}
|
||||
|
||||
function getDistanceKm(contact: Contact, config: RadioConfig | null): number | null {
|
||||
if (
|
||||
!config ||
|
||||
!isValidLocation(config.lat, config.lon) ||
|
||||
!isValidLocation(contact.lat, contact.lon)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return calculateDistance(config.lat, config.lon, contact.lat, contact.lon);
|
||||
}
|
||||
|
||||
function getShortKey(publicKey: string | null | undefined): string {
|
||||
if (!publicKey) return 'unknown';
|
||||
return publicKey.slice(0, 12);
|
||||
}
|
||||
|
||||
function formatSNR(snr: number | null | undefined): string {
|
||||
if (typeof snr !== 'number' || Number.isNaN(snr)) {
|
||||
return '—';
|
||||
}
|
||||
return `${snr >= 0 ? '+' : ''}${snr.toFixed(1)} dB`;
|
||||
}
|
||||
|
||||
function moveHop(hops: TraceDraftHop[], index: number, direction: -1 | 1): TraceDraftHop[] {
|
||||
const nextIndex = index + direction;
|
||||
if (nextIndex < 0 || nextIndex >= hops.length) {
|
||||
return hops;
|
||||
}
|
||||
const next = [...hops];
|
||||
const [item] = next.splice(index, 1);
|
||||
next.splice(nextIndex, 0, item);
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeCustomHopHex(value: string): string {
|
||||
return value.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
function nextDraftHopId(prefix: string, currentLength: number): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return `${prefix}-${crypto.randomUUID()}`;
|
||||
}
|
||||
return `${prefix}-${Date.now()}-${currentLength}`;
|
||||
}
|
||||
|
||||
function TraceNodeRow({
|
||||
title,
|
||||
subtitle,
|
||||
meta,
|
||||
note,
|
||||
fixed = false,
|
||||
compact = false,
|
||||
actions,
|
||||
snr,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
meta?: string | null;
|
||||
note?: string | null;
|
||||
fixed?: boolean;
|
||||
compact?: boolean;
|
||||
actions?: ReactNode;
|
||||
snr?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center rounded-md border border-border bg-background',
|
||||
compact ? 'gap-2 px-2.5 py-2' : 'gap-3 px-3 py-3'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-full border text-[11px] font-semibold uppercase tracking-wide',
|
||||
fixed
|
||||
? 'border-primary/30 bg-primary/10 text-primary'
|
||||
: 'border-border bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{fixed ? 'Self' : 'Hop'}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{subtitle}</div>
|
||||
{meta ? <div className="mt-1 text-[11px] text-muted-foreground">{meta}</div> : null}
|
||||
{note ? <div className="mt-1 text-[11px] text-muted-foreground">{note}</div> : null}
|
||||
</div>
|
||||
{snr ? (
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-[11px] text-muted-foreground">SNR</div>
|
||||
<div className="font-mono text-sm">{snr}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{actions ? <div className="ml-1 flex items-center gap-1">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortMode, setSortMode] = useState<TraceSortMode>('alpha');
|
||||
const [draftHops, setDraftHops] = useState<TraceDraftHop[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<RadioTraceResponse | null>(null);
|
||||
const [customDialogOpen, setCustomDialogOpen] = useState(false);
|
||||
const [customHopBytesDraft, setCustomHopBytesDraft] = useState<CustomHopBytes>(1);
|
||||
const [customHopHexDraft, setCustomHopHexDraft] = useState('');
|
||||
const [customHopError, setCustomHopError] = useState<string | null>(null);
|
||||
const activeRunTokenRef = useRef(0);
|
||||
|
||||
const repeaters = useMemo(() => {
|
||||
const deduped = new Map<string, Contact>();
|
||||
for (const contact of contacts) {
|
||||
if (contact.type !== CONTACT_TYPE_REPEATER || contact.public_key.length !== 64) {
|
||||
continue;
|
||||
}
|
||||
if (!deduped.has(contact.public_key)) {
|
||||
deduped.set(contact.public_key, contact);
|
||||
}
|
||||
}
|
||||
return [...deduped.values()];
|
||||
}, [contacts]);
|
||||
|
||||
const repeatersByKey = useMemo(
|
||||
() => new Map(repeaters.map((contact) => [contact.public_key, contact])),
|
||||
[repeaters]
|
||||
);
|
||||
|
||||
const filteredRepeaters = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
const matching = query
|
||||
? repeaters.filter(
|
||||
(contact) =>
|
||||
contact.public_key.toLowerCase().includes(query) ||
|
||||
(contact.name ?? '').toLowerCase().includes(query)
|
||||
)
|
||||
: repeaters;
|
||||
|
||||
return [...matching].sort((left, right) => {
|
||||
if (sortMode === 'recent') {
|
||||
const leftTs = getHeardTimestamp(left);
|
||||
const rightTs = getHeardTimestamp(right);
|
||||
if (leftTs !== rightTs) {
|
||||
return rightTs - leftTs;
|
||||
}
|
||||
}
|
||||
if (sortMode === 'distance') {
|
||||
const leftDistance = getDistanceKm(left, config);
|
||||
const rightDistance = getDistanceKm(right, config);
|
||||
if (leftDistance !== null && rightDistance !== null && leftDistance !== rightDistance) {
|
||||
return leftDistance - rightDistance;
|
||||
}
|
||||
if (leftDistance !== null && rightDistance === null) return -1;
|
||||
if (leftDistance === null && rightDistance !== null) return 1;
|
||||
}
|
||||
return getContactDisplayName(left.name, left.public_key, left.last_advert).localeCompare(
|
||||
getContactDisplayName(right.name, right.public_key, right.last_advert)
|
||||
);
|
||||
});
|
||||
}, [config, repeaters, searchQuery, sortMode]);
|
||||
|
||||
const localRadioName = config?.name || 'Local radio';
|
||||
const localRadioKey = config?.public_key ?? null;
|
||||
const canSortByDistance = !!config && isValidLocation(config.lat, config.lon);
|
||||
const customHopBytesLocked = useMemo(
|
||||
() => draftHops.find((hop) => hop.kind === 'custom')?.hopBytes ?? null,
|
||||
[draftHops]
|
||||
);
|
||||
const effectiveHopHashBytes: CustomHopBytes = customHopBytesLocked ?? 4;
|
||||
|
||||
useEffect(() => {
|
||||
if (!customDialogOpen) return;
|
||||
setCustomHopBytesDraft(customHopBytesLocked ?? 1);
|
||||
setCustomHopHexDraft('');
|
||||
setCustomHopError(null);
|
||||
}, [customDialogOpen, customHopBytesLocked]);
|
||||
|
||||
const clearPendingResult = () => {
|
||||
activeRunTokenRef.current += 1;
|
||||
setLoading(false);
|
||||
if (result) setResult(null);
|
||||
if (error) setError(null);
|
||||
};
|
||||
|
||||
const handleAddRepeater = (publicKey: string) => {
|
||||
setDraftHops((current) => [
|
||||
...current,
|
||||
{
|
||||
id: nextDraftHopId('repeater', current.length),
|
||||
kind: 'repeater',
|
||||
publicKey,
|
||||
},
|
||||
]);
|
||||
clearPendingResult();
|
||||
};
|
||||
|
||||
const handleAddCustomHop = () => {
|
||||
const hopBytes = customHopBytesLocked ?? customHopBytesDraft;
|
||||
const hopHex = normalizeCustomHopHex(customHopHexDraft);
|
||||
if (hopHex.length !== hopBytes * 2) {
|
||||
setCustomHopError(`Custom hop must be exactly ${hopBytes * 2} hex characters.`);
|
||||
return;
|
||||
}
|
||||
setDraftHops((current) => [
|
||||
...current,
|
||||
{
|
||||
id: nextDraftHopId('custom', current.length),
|
||||
kind: 'custom',
|
||||
hopHex,
|
||||
hopBytes,
|
||||
},
|
||||
]);
|
||||
clearPendingResult();
|
||||
setCustomDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleRemoveHop = (id: string) => {
|
||||
setDraftHops((current) => current.filter((hop) => hop.id !== id));
|
||||
clearPendingResult();
|
||||
};
|
||||
|
||||
const handleMoveHop = (index: number, direction: -1 | 1) => {
|
||||
setDraftHops((current) => moveHop(current, index, direction));
|
||||
clearPendingResult();
|
||||
};
|
||||
|
||||
const handleRunTrace = async () => {
|
||||
if (draftHops.length === 0) {
|
||||
return;
|
||||
}
|
||||
const runToken = activeRunTokenRef.current + 1;
|
||||
activeRunTokenRef.current = runToken;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const traceResult = await onRunTracePath(
|
||||
effectiveHopHashBytes,
|
||||
draftHops.map((hop) =>
|
||||
hop.kind === 'repeater' ? { public_key: hop.publicKey } : { hop_hex: hop.hopHex }
|
||||
)
|
||||
);
|
||||
if (activeRunTokenRef.current !== runToken) {
|
||||
return;
|
||||
}
|
||||
setResult(traceResult);
|
||||
} catch (err) {
|
||||
if (activeRunTokenRef.current !== runToken) {
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
if (activeRunTokenRef.current === runToken) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resultNodes: RadioTraceNode[] = result
|
||||
? [
|
||||
{
|
||||
role: 'local',
|
||||
public_key: localRadioKey,
|
||||
name: localRadioName,
|
||||
observed_hash: null,
|
||||
snr: null,
|
||||
},
|
||||
...result.nodes,
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-y-auto">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<h2 className="text-base font-semibold">Trace</h2>
|
||||
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
|
||||
Build a repeater loop and trace it back to the local radio. The selectable hop list only
|
||||
includes known full-key repeaters, but you can also add custom repeater prefixes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 lg:min-h-0 lg:flex-row lg:overflow-hidden">
|
||||
<section className="flex w-full flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:max-w-[24rem]">
|
||||
<div className="border-b border-border p-4">
|
||||
<h3 className="text-sm font-semibold">Repeater Hops</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Search by name or key, then add repeaters in the order you want to traverse them.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-3"
|
||||
onClick={() => setCustomDialogOpen(true)}
|
||||
>
|
||||
Custom path
|
||||
</Button>
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search name or public key"
|
||||
aria-label="Search repeaters"
|
||||
className="mt-3"
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{(
|
||||
[
|
||||
['alpha', 'Alpha'],
|
||||
['recent', 'Recent Heard'],
|
||||
['distance', 'Distance'],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={sortMode === value ? 'default' : 'outline'}
|
||||
onClick={() => setSortMode(value)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{sortMode === 'distance' && !canSortByDistance ? (
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
Distance sorting is using known repeater coordinates, but the local radio does not
|
||||
currently have a valid location.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[40vh] overflow-y-auto p-2 lg:min-h-0 lg:max-h-none lg:flex-1">
|
||||
{filteredRepeaters.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
No repeaters matched this search.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredRepeaters.map((contact) => {
|
||||
const displayName = getContactDisplayName(
|
||||
contact.name,
|
||||
contact.public_key,
|
||||
contact.last_advert
|
||||
);
|
||||
const distanceKm = getDistanceKm(contact, config);
|
||||
const selectedCount = draftHops.filter(
|
||||
(hop) => hop.kind === 'repeater' && hop.publicKey === contact.public_key
|
||||
).length;
|
||||
return (
|
||||
<div
|
||||
key={contact.public_key}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Add repeater ${displayName}`}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-md border px-3 py-3 text-left transition-colors',
|
||||
selectedCount > 0
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-border bg-background hover:bg-accent'
|
||||
)}
|
||||
onClick={() => handleAddRepeater(contact.public_key)}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
>
|
||||
<ContactAvatar
|
||||
name={contact.name}
|
||||
publicKey={contact.public_key}
|
||||
size={28}
|
||||
contactType={contact.type}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{displayName}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{getShortKey(contact.public_key)}
|
||||
</div>
|
||||
{sortMode === 'distance' && distanceKm !== null ? (
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{distanceKm.toFixed(1)} km away
|
||||
</div>
|
||||
) : null}
|
||||
{selectedCount > 0 ? (
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
Added {selectedCount} time{selectedCount === 1 ? '' : 's'}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-input bg-background text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-1 flex-col gap-4 lg:min-h-0 lg:overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<h3 className="text-sm font-semibold">Trace Path</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
The first node is display-only. The terminal node is the local radio.
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-h-[42vh] space-y-2 overflow-y-auto p-4 lg:max-h-none lg:overflow-y-visible">
|
||||
<TraceNodeRow
|
||||
title={localRadioName}
|
||||
subtitle={getShortKey(localRadioKey)}
|
||||
meta="Origin"
|
||||
fixed
|
||||
compact
|
||||
/>
|
||||
{draftHops.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
||||
Add at least one hop to build a trace loop.
|
||||
</div>
|
||||
) : (
|
||||
draftHops.map((hop, index) => {
|
||||
const contact =
|
||||
hop.kind === 'repeater' ? (repeatersByKey.get(hop.publicKey) ?? null) : null;
|
||||
const displayName =
|
||||
hop.kind === 'repeater'
|
||||
? getContactDisplayName(
|
||||
contact?.name,
|
||||
hop.publicKey,
|
||||
contact?.last_advert ?? null
|
||||
)
|
||||
: 'Custom hop';
|
||||
const subtitle =
|
||||
hop.kind === 'repeater'
|
||||
? getShortKey(hop.publicKey)
|
||||
: `${hop.hopHex.toUpperCase()} (${hop.hopBytes}-byte)`;
|
||||
return (
|
||||
<div key={hop.id}>
|
||||
<TraceNodeRow
|
||||
title={displayName}
|
||||
subtitle={subtitle}
|
||||
meta={`Hop ${index + 1}`}
|
||||
note={
|
||||
index === draftHops.length - 1
|
||||
? 'Note: you must be able to hear the final repeater in the trace for trace success.'
|
||||
: null
|
||||
}
|
||||
compact
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-8 w-8"
|
||||
aria-label={`Move ${displayName} up`}
|
||||
onClick={() => handleMoveHop(index, -1)}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-8 w-8"
|
||||
aria-label={`Move ${displayName} down`}
|
||||
onClick={() => handleMoveHop(index, 1)}
|
||||
disabled={index === draftHops.length - 1}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-8 w-8"
|
||||
aria-label={`Remove ${displayName}`}
|
||||
onClick={() => handleRemoveHop(hop.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<TraceNodeRow
|
||||
title={localRadioName}
|
||||
subtitle={getShortKey(localRadioKey)}
|
||||
meta="Terminal"
|
||||
fixed
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{draftHops.length === 0
|
||||
? 'No hops selected'
|
||||
: `${draftHops.length} hop${draftHops.length === 1 ? '' : 's'} selected · ${effectiveHopHashBytes}-byte trace`}
|
||||
</div>
|
||||
<Button onClick={handleRunTrace} disabled={loading || draftHops.length === 0}>
|
||||
{loading ? 'Tracing...' : 'Send trace'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:flex-1">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
Results{result ? ` (${result.timeout_seconds.toFixed(1)}s)` : ''}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="max-h-[42vh] min-h-0 flex-1 space-y-3 overflow-y-auto p-4 lg:max-h-none">
|
||||
{error ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{!error && !result ? (
|
||||
<div className="rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
||||
Send a trace to see the returned hop-by-hop SNR values.
|
||||
</div>
|
||||
) : null}
|
||||
{result
|
||||
? resultNodes.map((node, index) => {
|
||||
const title =
|
||||
node.name ||
|
||||
(node.role === 'custom'
|
||||
? 'Custom hop'
|
||||
: node.role === 'local'
|
||||
? localRadioName
|
||||
: getShortKey(node.public_key));
|
||||
const subtitle =
|
||||
node.role === 'custom'
|
||||
? `Key prefix ${node.observed_hash?.toUpperCase() ?? 'unknown'}`
|
||||
: node.observed_hash &&
|
||||
node.public_key &&
|
||||
node.observed_hash.toLowerCase() !==
|
||||
getShortKey(node.public_key).toLowerCase()
|
||||
? `${getShortKey(node.public_key)} · key prefix ${node.observed_hash.toUpperCase()}`
|
||||
: getShortKey(node.public_key);
|
||||
return (
|
||||
<div
|
||||
key={`${node.role}-${node.public_key ?? node.observed_hash ?? 'local'}-${index}`}
|
||||
>
|
||||
<TraceNodeRow
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
meta={
|
||||
index === 0
|
||||
? 'Origin'
|
||||
: node.role === 'local'
|
||||
? 'Terminal'
|
||||
: `Hop ${index}`
|
||||
}
|
||||
fixed={node.role === 'local'}
|
||||
snr={index === 0 ? null : formatSNR(node.snr)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Dialog open={customDialogOpen} onOpenChange={setCustomDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Custom path hop</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a raw repeater prefix as a 1-byte, 2-byte, or 4-byte hop. Once you add a custom
|
||||
hop, all later custom hops must use the same byte width.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Hop width</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{([1, 2, 4] as const).map((value) => {
|
||||
const locked = customHopBytesLocked !== null && customHopBytesLocked !== value;
|
||||
const active = (customHopBytesLocked ?? customHopBytesDraft) === value;
|
||||
return (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={active ? 'default' : 'outline'}
|
||||
disabled={locked}
|
||||
onClick={() => setCustomHopBytesDraft(value)}
|
||||
>
|
||||
{value}-byte
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{customHopBytesLocked !== null ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Custom hops are locked to {customHopBytesLocked}-byte prefixes for this trace.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="custom-hop-hex">
|
||||
Repeater prefix
|
||||
</label>
|
||||
<Input
|
||||
id="custom-hop-hex"
|
||||
value={customHopHexDraft}
|
||||
onChange={(event) =>
|
||||
setCustomHopHexDraft(normalizeCustomHopHex(event.target.value))
|
||||
}
|
||||
placeholder={`${(customHopBytesLocked ?? customHopBytesDraft) * 2} hex chars`}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter exactly {(customHopBytesLocked ?? customHopBytesDraft) * 2} hex characters.
|
||||
</p>
|
||||
{customHopError ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{customHopError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
<Button type="button" variant="secondary" onClick={() => setCustomDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={handleAddCustomHop}>
|
||||
Add custom hop
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="font-scale-input">Relative Font Size</Label>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_FONT_SCALE}
|
||||
max={MAX_FONT_SCALE}
|
||||
step={FONT_SCALE_SLIDER_STEP}
|
||||
value={fontScaleSlider}
|
||||
onChange={(event) => handleSliderChange(Number(event.target.value))}
|
||||
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
aria-label="Relative font size slider"
|
||||
className="w-full accent-primary sm:flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-2 sm:w-40">
|
||||
<Input
|
||||
id="font-scale-input"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={MIN_FONT_SCALE}
|
||||
max={MAX_FONT_SCALE}
|
||||
step="any"
|
||||
value={fontScaleInput}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setFontScaleInput(nextValue);
|
||||
|
||||
if (nextValue === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
|
||||
commitFontScale(event.target.valueAsNumber);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const parsed = Number.parseFloat(fontScaleInput);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
restoreFontScaleInput();
|
||||
return;
|
||||
}
|
||||
commitFontScale(parsed);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const parsed = Number.parseFloat(fontScaleInput);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
restoreFontScaleInput();
|
||||
return;
|
||||
}
|
||||
commitFontScale(parsed);
|
||||
}}
|
||||
aria-label="Relative font size percentage"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">%</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={fontScale === DEFAULT_FONT_SCALE}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Scales the app's typography for this browser only. The slider moves in 5% steps; the
|
||||
number field accepts any value from 25% to 400%.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="distance-units">Distance Units</Label>
|
||||
<select
|
||||
|
||||
@@ -846,11 +846,16 @@ export function SettingsRadioSection({
|
||||
className="rounded-md border border-input bg-background px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium capitalize">{result.node_type}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{result.name ?? <span className="capitalize">{result.node_type}</span>}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
{result.name && (
|
||||
<p className="text-xs capitalize text-muted-foreground">{result.node_type}</p>
|
||||
)}
|
||||
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
|
||||
{result.public_key}
|
||||
</p>
|
||||
|
||||
@@ -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 (
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="idx"
|
||||
type="number"
|
||||
domain={[0, samples.length - 1]}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
ticks={tickIndices}
|
||||
tickFormatter={(idx) => data[idx]?.time ?? ''}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
domain={['dataMin - 5', 'dataMax + 5']}
|
||||
tickFormatter={(v) => `${v}`}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{
|
||||
stroke: 'hsl(var(--muted-foreground))',
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: '3 3',
|
||||
}}
|
||||
labelFormatter={(idx) => data[Number(idx)]?.time ?? ''}
|
||||
formatter={(value) => [`${value} dBm`, 'Noise Floor']}
|
||||
/>
|
||||
<Area
|
||||
type="linear"
|
||||
dataKey="noise_floor"
|
||||
stroke="#8b5cf6"
|
||||
fill="#8b5cf6"
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: '#8b5cf6', strokeWidth: 2, stroke: 'hsl(var(--popover))' }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsStatisticsSection({ className }: { className?: string }) {
|
||||
const [stats, setStats] = useState<StatisticsResponse | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
@@ -85,60 +185,6 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Packets */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Packets</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Total stored</span>
|
||||
<span className="font-medium">{stats.total_packets}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-success">Decrypted</span>
|
||||
<span className="font-medium text-success">{stats.decrypted_packets}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-warning">Undecrypted</span>
|
||||
<span className="font-medium text-warning">{stats.undecrypted_packets}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Path Hash Width (24h)</h4>
|
||||
<div className="mb-2 text-xs text-muted-foreground">
|
||||
Parsed stored raw packets from the last 24 hours:{' '}
|
||||
{stats.path_hash_width_24h.total_packets}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span>1-byte hops</span>
|
||||
<span className="text-muted-foreground">
|
||||
{stats.path_hash_width_24h.single_byte} (
|
||||
{formatPercent(stats.path_hash_width_24h.single_byte_pct)})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span>2-byte hops</span>
|
||||
<span className="text-muted-foreground">
|
||||
{stats.path_hash_width_24h.double_byte} (
|
||||
{formatPercent(stats.path_hash_width_24h.double_byte_pct)})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span>3-byte hops</span>
|
||||
<span className="text-muted-foreground">
|
||||
{stats.path_hash_width_24h.triple_byte} (
|
||||
{formatPercent(stats.path_hash_width_24h.triple_byte_pct)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Activity */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Activity</h4>
|
||||
@@ -174,23 +220,172 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Packets */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Packets</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Total stored</span>
|
||||
<span className="font-medium">{stats.total_packets}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-success">Decrypted</span>
|
||||
<span className="font-medium text-success">{stats.decrypted_packets}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-warning">Undecrypted</span>
|
||||
<span className="font-medium text-warning">{stats.undecrypted_packets}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Path Hash Width */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Path Hash Width (24h)</h4>
|
||||
<div className="mb-2 text-xs text-muted-foreground">
|
||||
Parsed stored raw packets from the last 24 hours:{' '}
|
||||
{stats.path_hash_width_24h.total_packets}
|
||||
</div>
|
||||
{stats.path_hash_width_24h.total_packets > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
<BarChart
|
||||
data={[
|
||||
{
|
||||
name: '1-byte',
|
||||
count: stats.path_hash_width_24h.single_byte,
|
||||
pct: stats.path_hash_width_24h.single_byte_pct,
|
||||
},
|
||||
{
|
||||
name: '2-byte',
|
||||
count: stats.path_hash_width_24h.double_byte,
|
||||
pct: stats.path_hash_width_24h.double_byte_pct,
|
||||
},
|
||||
{
|
||||
name: '3-byte',
|
||||
count: stats.path_hash_width_24h.triple_byte,
|
||||
pct: stats.path_hash_width_24h.triple_byte_pct,
|
||||
},
|
||||
]}
|
||||
margin={{ top: 4, right: 4, bottom: 0, left: -16 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={(value: any, _: any, props: any) => [
|
||||
`${Number(value).toLocaleString()} (${formatPercent(props.payload.pct)})`,
|
||||
'Packets',
|
||||
]}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]} maxBarSize={40}>
|
||||
<Cell fill="#0ea5e9" />
|
||||
<Cell fill="#10b981" />
|
||||
<Cell fill="#f59e0b" />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No path data in the last 24 hours.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Busiest Channels */}
|
||||
{stats.busiest_channels_24h.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Busiest Channels (24h)</h4>
|
||||
<div className="space-y-1">
|
||||
{stats.busiest_channels_24h.map((ch, i) => (
|
||||
<div key={ch.channel_key} className="flex justify-between items-center text-sm">
|
||||
<span>
|
||||
<span className="text-muted-foreground mr-2">{i + 1}.</span>
|
||||
{ch.channel_name}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{ch.message_count} msgs</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height={stats.busiest_channels_24h.length * 28 + 8}
|
||||
>
|
||||
<BarChart
|
||||
data={stats.busiest_channels_24h.map((ch) => ({
|
||||
name: ch.channel_name,
|
||||
messages: ch.message_count,
|
||||
}))}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 4, bottom: 0, left: 0 }}
|
||||
barCategoryGap="20%"
|
||||
>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={100}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||
formatter={(value) => [`${Number(value).toLocaleString()} messages`, null]}
|
||||
/>
|
||||
<Bar dataKey="messages" radius={[0, 4, 4, 0]} maxBarSize={16}>
|
||||
{stats.busiest_channels_24h.map((_, i) => (
|
||||
<Cell key={i} fill={CHANNEL_BAR_COLORS[i % CHANNEL_BAR_COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Noise Floor */}
|
||||
{stats.noise_floor_24h.supported !== false && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Noise Floor (24h)</h4>
|
||||
{stats.noise_floor_24h.latest_noise_floor_dbm != null && (
|
||||
<div className="mb-2 text-xs text-muted-foreground">
|
||||
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',
|
||||
})}`}
|
||||
</div>
|
||||
)}
|
||||
{stats.noise_floor_24h.samples.length > 1 ? (
|
||||
<NoiseFloorChart samples={stats.noise_floor_24h.samples} />
|
||||
) : stats.noise_floor_24h.samples.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No noise floor samples collected yet. Samples are collected every five minutes,
|
||||
and retained until server restart.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -275,7 +275,9 @@ interface UseConversationMessagesResult {
|
||||
}
|
||||
|
||||
function isMessageConversation(conversation: Conversation | null): conversation is Conversation {
|
||||
return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type);
|
||||
return (
|
||||
!!conversation && !['raw', 'map', 'visualizer', 'search', 'trace'].includes(conversation.type)
|
||||
);
|
||||
}
|
||||
|
||||
function isActiveConversationMessage(
|
||||
|
||||
@@ -62,7 +62,6 @@ export function useConversationRouter({
|
||||
// Only needs channels (fast path) - doesn't wait for contacts
|
||||
useEffect(() => {
|
||||
if (hasSetDefaultConversation.current || activeConversation) return;
|
||||
if (channels.length === 0) return;
|
||||
|
||||
const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
|
||||
|
||||
@@ -92,6 +91,29 @@ export function useConversationRouter({
|
||||
hasSetDefaultConversation.current = true;
|
||||
return;
|
||||
}
|
||||
if (hashConv?.type === 'trace') {
|
||||
setActiveConversationState({ type: 'trace', id: 'trace', name: 'Trace' });
|
||||
hasSetDefaultConversation.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// No hash: optionally restore last-viewed non-data conversation if enabled on this device.
|
||||
if (!hashConv && getReopenLastConversationEnabled()) {
|
||||
const lastViewed = getLastViewedConversation();
|
||||
if (
|
||||
lastViewed &&
|
||||
(lastViewed.type === 'raw' ||
|
||||
lastViewed.type === 'map' ||
|
||||
lastViewed.type === 'visualizer' ||
|
||||
lastViewed.type === 'trace')
|
||||
) {
|
||||
setActiveConversationState(lastViewed);
|
||||
hasSetDefaultConversation.current = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (channels.length === 0) return;
|
||||
|
||||
// Handle channel hash (ID-first with legacy-name fallback)
|
||||
if (hashConv?.type === 'channel') {
|
||||
@@ -109,14 +131,6 @@ export function useConversationRouter({
|
||||
// No hash: optionally restore last-viewed conversation if enabled on this device.
|
||||
if (!hashConv && getReopenLastConversationEnabled()) {
|
||||
const lastViewed = getLastViewedConversation();
|
||||
if (
|
||||
lastViewed &&
|
||||
(lastViewed.type === 'raw' || lastViewed.type === 'map' || lastViewed.type === 'visualizer')
|
||||
) {
|
||||
setActiveConversationState(lastViewed);
|
||||
hasSetDefaultConversation.current = true;
|
||||
return;
|
||||
}
|
||||
if (lastViewed?.type === 'channel') {
|
||||
const channel =
|
||||
channels.find((c) => c.key.toLowerCase() === lastViewed.id.toLowerCase()) ||
|
||||
|
||||
@@ -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<boolean>;
|
||||
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,
|
||||
|
||||
@@ -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<string, number>;
|
||||
/** 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<void>;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
|
||||
@@ -195,6 +195,53 @@ describe('App startup hash resolution', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the trace tool from the URL hash', async () => {
|
||||
window.location.hash = '#trace';
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
for (const node of screen.getAllByTestId('active-conversation')) {
|
||||
expect(node).toHaveTextContent('trace:trace:Trace');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the trace tool from the URL hash even when channels are unavailable', async () => {
|
||||
window.location.hash = '#trace';
|
||||
mocks.api.getChannels.mockResolvedValue([]);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
for (const node of screen.getAllByTestId('active-conversation')) {
|
||||
expect(node).toHaveTextContent('trace:trace:Trace');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('reopens the last viewed trace tool even when channels are unavailable', async () => {
|
||||
window.location.hash = '';
|
||||
localStorage.setItem(REOPEN_LAST_CONVERSATION_KEY, '1');
|
||||
localStorage.setItem(
|
||||
LAST_VIEWED_CONVERSATION_KEY,
|
||||
JSON.stringify({
|
||||
type: 'trace',
|
||||
id: 'trace',
|
||||
name: 'Trace',
|
||||
})
|
||||
);
|
||||
mocks.api.getChannels.mockResolvedValue([]);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
for (const node of screen.getAllByTestId('active-conversation')) {
|
||||
expect(node).toHaveTextContent('trace:trace:Trace');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('restores last viewed channel when hash is empty and reopen preference is enabled', async () => {
|
||||
const chatChannel = {
|
||||
key: '11111111111111111111111111111111',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -64,6 +64,10 @@ vi.mock('../components/VisualizerView', () => ({
|
||||
VisualizerView: () => <div data-testid="visualizer-view" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/TracePane', () => ({
|
||||
TracePane: () => <div data-testid="trace-pane" />,
|
||||
}));
|
||||
|
||||
const config: RadioConfig = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: 'Radio',
|
||||
@@ -141,6 +145,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
||||
loadingNewer: false,
|
||||
messageInputRef: { current: null },
|
||||
onTrace: vi.fn(async () => {}),
|
||||
onRunTracePath: vi.fn(async () => ({ path_len: 0, timeout_seconds: 5, nodes: [] })),
|
||||
onPathDiscovery: vi.fn(async () => {
|
||||
throw new Error('unused');
|
||||
}),
|
||||
@@ -231,6 +236,23 @@ describe('ConversationPane', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the trace tool pane for trace conversations', () => {
|
||||
render(
|
||||
<ConversationPane
|
||||
{...createProps({
|
||||
activeConversation: {
|
||||
type: 'trace',
|
||||
id: 'trace',
|
||||
name: 'Trace',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('trace-pane')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('message-list')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gates room chat behind room login controls until authenticated', async () => {
|
||||
render(
|
||||
<ConversationPane
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
applyFontScale,
|
||||
DEFAULT_FONT_SCALE,
|
||||
FONT_SCALE_KEY,
|
||||
MAX_FONT_SCALE,
|
||||
MIN_FONT_SCALE,
|
||||
getSavedFontScale,
|
||||
setSavedFontScale,
|
||||
} from '../utils/fontScale';
|
||||
|
||||
describe('fontScale utilities', () => {
|
||||
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}%`);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -75,13 +75,14 @@ function renderSidebar(overrides?: {
|
||||
|
||||
const favorites = overrides?.favorites ?? [{ type: 'channel', id: flightChannel.key }];
|
||||
const channels = overrides?.channels ?? [publicChannel, flightChannel, opsChannel];
|
||||
const onSelectConversation = vi.fn();
|
||||
|
||||
const view = render(
|
||||
<Sidebar
|
||||
contacts={[alice, board, relay]}
|
||||
channels={channels}
|
||||
activeConversation={null}
|
||||
onSelectConversation={vi.fn()}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onNewMessage={vi.fn()}
|
||||
lastMessageTimes={overrides?.lastMessageTimes ?? {}}
|
||||
unreadCounts={unreadCounts}
|
||||
@@ -96,7 +97,7 @@ function renderSidebar(overrides?: {
|
||||
/>
|
||||
);
|
||||
|
||||
return { ...view, flightChannel, opsChannel, aliceName, roomName };
|
||||
return { ...view, flightChannel, opsChannel, aliceName, roomName, onSelectConversation };
|
||||
}
|
||||
|
||||
function getSectionHeaderContainer(title: string): HTMLElement {
|
||||
@@ -306,6 +307,18 @@ describe('Sidebar section summaries', () => {
|
||||
expect(bell.compareDocumentPosition(unread) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows the trace tool row and selects it', () => {
|
||||
const { onSelectConversation } = renderSidebar();
|
||||
|
||||
fireEvent.click(screen.getByText('Trace'));
|
||||
|
||||
expect(onSelectConversation).toHaveBeenCalledWith({
|
||||
type: 'trace',
|
||||
id: 'trace',
|
||||
name: 'Trace',
|
||||
});
|
||||
});
|
||||
|
||||
it('sorts each section independently and persists per-section sort preferences', () => {
|
||||
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
|
||||
const zebraChannel = makeChannel('BB'.repeat(16), '#zebra');
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TracePane } from '../components/TracePane';
|
||||
import type { Contact, RadioConfig, RadioTraceResponse } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
|
||||
function makeContact(
|
||||
publicKey: string,
|
||||
name: string | null,
|
||||
type = CONTACT_TYPE_REPEATER,
|
||||
overrides: Partial<Contact> = {}
|
||||
): Contact {
|
||||
return {
|
||||
public_key: publicKey,
|
||||
name,
|
||||
type,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const config: RadioConfig = {
|
||||
public_key: 'ff'.repeat(32),
|
||||
name: 'Base Radio',
|
||||
lat: 10,
|
||||
lon: 20,
|
||||
tx_power: 17,
|
||||
max_tx_power: 22,
|
||||
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
|
||||
path_hash_mode: 0,
|
||||
path_hash_mode_supported: true,
|
||||
};
|
||||
|
||||
describe('TracePane', () => {
|
||||
it('shows only full-key repeaters and filters by name or key', () => {
|
||||
render(
|
||||
<TracePane
|
||||
config={config}
|
||||
onRunTracePath={vi.fn()}
|
||||
contacts={[
|
||||
makeContact('11'.repeat(32), 'Relay Alpha'),
|
||||
makeContact('22'.repeat(6), 'Prefix Relay'),
|
||||
makeContact('33'.repeat(32), 'Client Node', 1),
|
||||
makeContact('44'.repeat(32), 'Relay Beta'),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Relay Alpha')).toBeInTheDocument();
|
||||
expect(screen.getByText('Relay Beta')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Prefix Relay')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Client Node')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: 'beta' } });
|
||||
expect(screen.queryByText('Relay Alpha')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Relay Beta')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: '111111' } });
|
||||
expect(screen.getByText('Relay Alpha')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds, reorders, removes, and sends a trace path with known repeaters', async () => {
|
||||
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
|
||||
const relayB = makeContact('22'.repeat(32), 'Relay Beta');
|
||||
const onRunTracePath = vi.fn(
|
||||
async (): Promise<RadioTraceResponse> => ({
|
||||
path_len: 2,
|
||||
timeout_seconds: 6,
|
||||
nodes: [
|
||||
{
|
||||
role: 'repeater',
|
||||
public_key: relayB.public_key,
|
||||
name: relayB.name,
|
||||
observed_hash: relayB.public_key.slice(0, 8),
|
||||
snr: 7.5,
|
||||
},
|
||||
{
|
||||
role: 'repeater',
|
||||
public_key: relayA.public_key,
|
||||
name: relayA.name,
|
||||
observed_hash: relayA.public_key.slice(0, 8),
|
||||
snr: 3.25,
|
||||
},
|
||||
{
|
||||
role: 'local',
|
||||
public_key: config.public_key,
|
||||
name: config.name,
|
||||
observed_hash: null,
|
||||
snr: 5.0,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
render(
|
||||
<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA, relayB]} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i }));
|
||||
|
||||
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /move relay beta up/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRunTracePath).toHaveBeenCalledWith(4, [
|
||||
{ public_key: relayB.public_key },
|
||||
{ public_key: relayA.public_key },
|
||||
]);
|
||||
});
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Results (6.0s)' })).toBeInTheDocument();
|
||||
expect(screen.getByText('+7.5 dB')).toBeInTheDocument();
|
||||
expect(screen.getByText('+5.0 dB')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /remove relay alpha/i }));
|
||||
expect(screen.getByText('1 hop selected · 4-byte trace')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /remove relay beta/i }));
|
||||
expect(screen.getByText('No hops selected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows adding the same repeater multiple times from the picker row', () => {
|
||||
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
|
||||
|
||||
render(<TracePane config={config} onRunTracePath={vi.fn()} contacts={[relayA]} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
|
||||
|
||||
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
|
||||
expect(screen.getByText('Added 2 times')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds custom hops from the modal and locks later custom hops to the same byte width', async () => {
|
||||
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
|
||||
const onRunTracePath = vi.fn(
|
||||
async (): Promise<RadioTraceResponse> => ({
|
||||
path_len: 2,
|
||||
timeout_seconds: 4.5,
|
||||
nodes: [
|
||||
{
|
||||
role: 'custom',
|
||||
public_key: null,
|
||||
name: null,
|
||||
observed_hash: 'ae',
|
||||
snr: 4.0,
|
||||
},
|
||||
{
|
||||
role: 'repeater',
|
||||
public_key: relayA.public_key,
|
||||
name: relayA.name,
|
||||
observed_hash: '11',
|
||||
snr: 2.0,
|
||||
},
|
||||
{
|
||||
role: 'local',
|
||||
public_key: config.public_key,
|
||||
name: config.name,
|
||||
observed_hash: null,
|
||||
snr: 3.0,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
render(<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA]} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Custom path' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '1-byte' }));
|
||||
fireEvent.change(screen.getByLabelText('Repeater prefix'), { target: { value: 'ae' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add custom hop' }));
|
||||
|
||||
expect(screen.getByText('1 hop selected · 1-byte trace')).toBeInTheDocument();
|
||||
expect(screen.getByText('AE (1-byte)')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRunTracePath).toHaveBeenCalledWith(1, [
|
||||
{ hop_hex: 'ae' },
|
||||
{ public_key: relayA.public_key },
|
||||
]);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Custom path' }));
|
||||
expect(screen.getByRole('button', { name: '2-byte' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: '4-byte' })).toBeDisabled();
|
||||
expect(screen.getByText(/custom hops are locked to 1-byte prefixes/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drops an in-flight result after the draft path changes', async () => {
|
||||
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
|
||||
const relayB = makeContact('22'.repeat(32), 'Relay Beta');
|
||||
let resolveTrace: ((value: RadioTraceResponse) => void) | null = null;
|
||||
const onRunTracePath = vi.fn(
|
||||
() =>
|
||||
new Promise<RadioTraceResponse>((resolve) => {
|
||||
resolveTrace = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
render(
|
||||
<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA, relayB]} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRunTracePath).toHaveBeenCalledWith(4, [{ public_key: relayA.public_key }]);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i }));
|
||||
|
||||
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /send trace/i })).toBeEnabled();
|
||||
|
||||
await act(async () => {
|
||||
resolveTrace?.({
|
||||
path_len: 1,
|
||||
timeout_seconds: 6,
|
||||
nodes: [
|
||||
{
|
||||
role: 'repeater',
|
||||
public_key: relayA.public_key,
|
||||
name: relayA.name,
|
||||
observed_hash: relayA.public_key.slice(0, 8),
|
||||
snr: 7.5,
|
||||
},
|
||||
{
|
||||
role: 'local',
|
||||
public_key: config.public_key,
|
||||
name: config.name,
|
||||
observed_hash: null,
|
||||
snr: 5.0,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('heading', { name: 'Results (6.0s)' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('+7.5 dB')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Send a trace to see the returned hop-by-hop SNR values.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,14 @@ describe('parseHashConversation', () => {
|
||||
expect(result).toEqual({ type: 'map', name: 'map' });
|
||||
});
|
||||
|
||||
it('parses #trace as trace type', () => {
|
||||
window.location.hash = '#trace';
|
||||
|
||||
const result = parseHashConversation();
|
||||
|
||||
expect(result).toEqual({ type: 'trace', name: 'trace' });
|
||||
});
|
||||
|
||||
it('parses #map/focus/PUBKEY with focus key', () => {
|
||||
window.location.hash = '#map/focus/ABCD1234';
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
|
||||
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
|
||||
recordMessageEvent: vi.fn(),
|
||||
renameConversationState: vi.fn(),
|
||||
removeConversationState: vi.fn(),
|
||||
checkMention: vi.fn(() => false),
|
||||
pendingDeleteFallbackRef: { current: false },
|
||||
setActiveConversation: vi.fn(),
|
||||
|
||||
@@ -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')];
|
||||
|
||||
+36
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,7 +4,14 @@ import { parseHashConversation } from './urlHash';
|
||||
export const REOPEN_LAST_CONVERSATION_KEY = 'remoteterm-reopen-last-conversation';
|
||||
export const LAST_VIEWED_CONVERSATION_KEY = 'remoteterm-last-viewed-conversation';
|
||||
|
||||
const SUPPORTED_TYPES: Conversation['type'][] = ['contact', 'channel', 'raw', 'map', 'visualizer'];
|
||||
const SUPPORTED_TYPES: Conversation['type'][] = [
|
||||
'contact',
|
||||
'channel',
|
||||
'raw',
|
||||
'map',
|
||||
'visualizer',
|
||||
'trace',
|
||||
];
|
||||
|
||||
function isSupportedType(value: unknown): value is Conversation['type'] {
|
||||
return typeof value === 'string' && SUPPORTED_TYPES.includes(value as Conversation['type']);
|
||||
@@ -94,6 +101,10 @@ export function captureLastViewedConversationFromHash(): void {
|
||||
saveLastViewedConversation({ type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer' });
|
||||
return;
|
||||
}
|
||||
if (hashConversation.type === 'trace') {
|
||||
saveLastViewedConversation({ type: 'trace', id: 'trace', name: 'Trace' });
|
||||
return;
|
||||
}
|
||||
|
||||
saveLastViewedConversation({
|
||||
type: hashConversation.type,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getContactDisplayName } from './pubkey';
|
||||
import type { SettingsSection } from '../components/settings/settingsConstants';
|
||||
|
||||
interface ParsedHashConversation {
|
||||
type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search';
|
||||
type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace';
|
||||
/** Conversation identity token (channel key or contact public key, or legacy name token) */
|
||||
name: string;
|
||||
/** Optional human-readable label segment (ignored for identity resolution) */
|
||||
@@ -44,6 +44,10 @@ export function parseHashConversation(): ParsedHashConversation | null {
|
||||
return { type: 'search', name: 'search' };
|
||||
}
|
||||
|
||||
if (hash === 'trace') {
|
||||
return { type: 'trace', name: 'trace' };
|
||||
}
|
||||
|
||||
// Check for map with focus: #map/focus/{pubkey_prefix}
|
||||
if (hash.startsWith('map/focus/')) {
|
||||
const focusKey = hash.slice('map/focus/'.length);
|
||||
@@ -149,6 +153,7 @@ function getConversationHash(conv: Conversation | null): string {
|
||||
if (conv.type === 'map') return '#map';
|
||||
if (conv.type === 'visualizer') return '#visualizer';
|
||||
if (conv.type === 'search') return '#search';
|
||||
if (conv.type === 'trace') return '#trace';
|
||||
|
||||
// Use immutable IDs for identity, append readable label for UX.
|
||||
if (conv.type === 'channel') {
|
||||
|
||||
Reference in New Issue
Block a user