Repeater UI overhaul

This commit is contained in:
Jack Kingsman
2026-02-25 21:02:38 -08:00
parent f4a383082e
commit 26fbfcd015
26 changed files with 3622 additions and 1305 deletions
+17 -15
View File
@@ -33,8 +33,7 @@ frontend/src/
│ ├── index.ts # Central re-export of all hooks
│ ├── useConversationMessages.ts # Fetch, pagination, dedup, ACK buffering
│ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps
│ ├── useRepeaterMode.ts # Repeater login/command workflow
│ ├── useAirtimeTracking.ts # Repeater airtime stats polling
│ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries)
│ ├── useRadioControl.ts # Radio health/config state, reconnection
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
│ ├── useConversationRouter.ts # URL hash → active conversation routing
@@ -68,6 +67,8 @@ frontend/src/
│ ├── BotCodeEditor.tsx
│ ├── ContactAvatar.tsx
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
│ ├── RepeaterDashboard.tsx # Repeater pane-based dashboard (telemetry, neighbors, ACL, etc.)
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
│ └── ui/ # shadcn/ui primitives
├── types/
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
@@ -75,7 +76,6 @@ frontend/src/
├── setup.ts
├── fixtures/websocket_events.json
├── api.test.ts
├── useAirtimeTracking.test.ts
├── appFavorites.test.tsx
├── appStartupHash.test.tsx
├── contactAvatar.test.ts
@@ -85,6 +85,7 @@ frontend/src/
├── pathUtils.test.ts
├── radioPresets.test.ts
├── rawPacketIdentity.test.ts
├── repeaterDashboard.test.tsx
├── repeaterMode.test.ts
├── settingsModal.test.tsx
├── sidebar.test.tsx
@@ -92,7 +93,7 @@ frontend/src/
├── urlHash.test.ts
├── useConversationMessages.test.ts
├── useConversationMessages.race.test.ts
├── useRepeaterMode.test.ts
├── useRepeaterDashboard.test.ts
├── useWebSocket.dispatch.test.ts
└── useWebSocket.lifecycle.test.ts
```
@@ -108,12 +109,7 @@ frontend/src/
- `useConversationRouter`: URL hash → active conversation routing
- `useConversationMessages`: fetch, pagination, dedup/update helpers
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps
- `useRepeaterMode`: repeater login/command workflow
- `useAirtimeTracking`: repeater airtime stats polling
### Local message IDs
`useRepeaterMode` and `useAirtimeTracking` each have a `createLocalMessage` that generates ephemeral (non-persisted) message IDs via `-Date.now()`. Both hooks write to the same `setMessages`, so a same-millisecond call would produce duplicate IDs. In practice this requires a repeater command response and an airtime poll to land in the exact same ms — staggeringly unlikely and cosmetic-only (React duplicate key warning). Not worth fixing.
- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions)
### Initial load + realtime
@@ -204,13 +200,19 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
- `last_advert_time`
- `bots`
## Repeater Mode
## Repeater Dashboard
For repeater contacts (`type=2`):
1. Telemetry/login phase (`POST /api/contacts/{key}/telemetry`)
2. Command phase (`POST /api/contacts/{key}/command`)
For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
CLI responses are rendered as local-only messages (not persisted to DB).
**Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`.
**Dashboard panes** (after login): Telemetry, Neighbors, ACL, Radio Settings, Advert Intervals, Owner Info, Clock — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. Panes retry up to 3 times client-side. "Load All" fetches all panes serially (parallel would queue behind the radio lock).
**Actions pane**: Send Advert, Sync Clock, Reboot — all send CLI commands via `POST /api/contacts/{key}/command`.
**Console pane**: Full CLI access via the same command endpoint. History is ephemeral (not persisted to DB).
All state is managed by `useRepeaterDashboard` hook. State resets on conversation change.
## Styling
+33 -21
View File
@@ -12,7 +12,6 @@ import { api } from './api';
import { takePrefetch } from './prefetch';
import { useWebSocket } from './useWebSocket';
import {
useRepeaterMode,
useUnreadCounts,
useConversationMessages,
getMessageContentKey,
@@ -35,8 +34,12 @@ import {
} from './components/settingsConstants';
import { RawPacketList } from './components/RawPacketList';
import { ContactInfoPane } from './components/ContactInfoPane';
import { CONTACT_TYPE_REPEATER } from './types';
// Lazy-load heavy components to reduce initial bundle
const RepeaterDashboard = lazy(() =>
import('./components/RepeaterDashboard').then((m) => ({ default: m.RepeaterDashboard }))
);
const MapView = lazy(() => import('./components/MapView').then((m) => ({ default: m.MapView })));
const VisualizerView = lazy(() =>
import('./components/VisualizerView').then((m) => ({ default: m.VisualizerView }))
@@ -167,7 +170,6 @@ export function App() {
messagesLoading,
loadingOlder,
hasOlderMessages,
setMessages,
fetchOlderMessages,
addMessageIfNew,
updateMessageAck,
@@ -182,12 +184,12 @@ export function App() {
trackNewMessage,
} = useUnreadCounts(channels, contacts, activeConversation);
const {
repeaterLoggedIn,
activeContactIsRepeater,
handleTelemetryRequest,
handleRepeaterCommand,
} = useRepeaterMode(activeConversation, contacts, setMessages, activeConversationRef);
// Determine if active contact is a repeater (used for routing to dashboard)
const activeContactIsRepeater = useMemo(() => {
if (!activeConversation || activeConversation.type !== 'contact') return false;
const contact = contacts.find((c) => c.public_key === activeConversation.id);
return contact?.type === CONTACT_TYPE_REPEATER;
}, [activeConversation, contacts]);
// WebSocket handlers - memoized to prevent reconnection loops
const wsHandlers = useMemo(
@@ -562,6 +564,27 @@ export function App() {
<RawPacketList packets={rawPackets} />
</div>
</>
) : activeContactIsRepeater ? (
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Loading dashboard...
</div>
}
>
<RepeaterDashboard
key={activeConversation.id}
conversation={activeConversation}
contacts={contacts}
favorites={favorites}
radioLat={config?.lat ?? null}
radioLon={config?.lon ?? null}
radioName={config?.name ?? null}
onTrace={handleTrace}
onToggleFavorite={handleToggleFavorite}
onDeleteContact={handleDeleteContact}
/>
</Suspense>
) : (
<>
<ChatHeader
@@ -595,25 +618,14 @@ export function App() {
/>
<MessageInput
ref={messageInputRef}
onSend={
activeContactIsRepeater
? repeaterLoggedIn
? handleRepeaterCommand
: handleTelemetryRequest
: handleSendMessage
}
onSend={handleSendMessage}
disabled={!health?.radio_connected}
isRepeaterMode={activeContactIsRepeater && !repeaterLoggedIn}
conversationType={activeConversation.type}
senderName={config?.name}
placeholder={
!health?.radio_connected
? 'Radio not connected'
: activeContactIsRepeater
? repeaterLoggedIn
? 'Send CLI command (requires admin login)...'
: `Enter password for ${activeConversation.name} (or . for none)...`
: `Message ${activeConversation.name}...`
: `Message ${activeConversation.name}...`
}
/>
</>
+43 -6
View File
@@ -15,8 +15,15 @@ import type {
MigratePreferencesResponse,
RadioConfig,
RadioConfigUpdate,
RepeaterAclResponse,
RepeaterAdvertIntervalsResponse,
RepeaterLoginResponse,
RepeaterLppTelemetryResponse,
RepeaterNeighborsResponse,
RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse,
RepeaterStatusResponse,
StatisticsResponse,
TelemetryResponse,
TraceResponse,
UnreadCounts,
} from './types';
@@ -118,11 +125,6 @@ export const api = {
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/mark-read`, {
method: 'POST',
}),
requestTelemetry: (publicKey: string, password: string) =>
fetchJson<TelemetryResponse>(`/contacts/${publicKey}/telemetry`, {
method: 'POST',
body: JSON.stringify({ password }),
}),
sendRepeaterCommand: (publicKey: string, command: string) =>
fetchJson<CommandResponse>(`/contacts/${publicKey}/command`, {
method: 'POST',
@@ -240,4 +242,39 @@ export const api = {
// Statistics
getStatistics: () => fetchJson<StatisticsResponse>('/statistics'),
// Granular repeater endpoints
repeaterLogin: (publicKey: string, password: string) =>
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/repeater/login`, {
method: 'POST',
body: JSON.stringify({ password }),
}),
repeaterStatus: (publicKey: string) =>
fetchJson<RepeaterStatusResponse>(`/contacts/${publicKey}/repeater/status`, {
method: 'POST',
}),
repeaterNeighbors: (publicKey: string) =>
fetchJson<RepeaterNeighborsResponse>(`/contacts/${publicKey}/repeater/neighbors`, {
method: 'POST',
}),
repeaterAcl: (publicKey: string) =>
fetchJson<RepeaterAclResponse>(`/contacts/${publicKey}/repeater/acl`, {
method: 'POST',
}),
repeaterRadioSettings: (publicKey: string) =>
fetchJson<RepeaterRadioSettingsResponse>(`/contacts/${publicKey}/repeater/radio-settings`, {
method: 'POST',
}),
repeaterAdvertIntervals: (publicKey: string) =>
fetchJson<RepeaterAdvertIntervalsResponse>(`/contacts/${publicKey}/repeater/advert-intervals`, {
method: 'POST',
}),
repeaterOwnerInfo: (publicKey: string) =>
fetchJson<RepeaterOwnerInfoResponse>(`/contacts/${publicKey}/repeater/owner-info`, {
method: 'POST',
}),
repeaterLppTelemetry: (publicKey: string) =>
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
method: 'POST',
}),
};
+23 -57
View File
@@ -33,8 +33,6 @@ interface MessageInputProps {
onSend: (text: string) => Promise<void>;
disabled: boolean;
placeholder?: string;
/** When true, input becomes password field for repeater telemetry */
isRepeaterMode?: boolean;
/** Conversation type for character limit calculation */
conversationType?: 'contact' | 'channel' | 'raw';
/** Sender name (radio name) for channel message limit calculation */
@@ -48,7 +46,7 @@ export interface MessageInputHandle {
}
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
{ onSend, disabled, placeholder, isRepeaterMode, conversationType, senderName },
{ onSend, disabled, placeholder, conversationType, senderName },
ref
) {
const [text, setText] = useState('');
@@ -112,45 +110,25 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
async (e: FormEvent) => {
e.preventDefault();
const trimmed = text.trim();
if (!trimmed || sending || disabled) return;
// For repeater mode, empty password means guest login
if (isRepeaterMode) {
if (sending || disabled) return;
setSending(true);
try {
await onSend(trimmed);
setText('');
} catch (err) {
console.error('Failed to request telemetry:', err);
toast.error('Failed to request telemetry', {
description: err instanceof Error ? err.message : 'Check radio connection',
});
return;
} finally {
setSending(false);
}
// Refocus after React re-enables the input (now in CLI command mode)
setTimeout(() => inputRef.current?.focus(), 0);
} else {
if (!trimmed || sending || disabled) return;
setSending(true);
try {
await onSend(trimmed);
setText('');
} catch (err) {
console.error('Failed to send message:', err);
toast.error('Failed to send message', {
description: err instanceof Error ? err.message : 'Check radio connection',
});
return;
} finally {
setSending(false);
}
// Refocus after React re-enables the input
setTimeout(() => inputRef.current?.focus(), 0);
setSending(true);
try {
await onSend(trimmed);
setText('');
} catch (err) {
console.error('Failed to send message:', err);
toast.error('Failed to send message', {
description: err instanceof Error ? err.message : 'Check radio connection',
});
return;
} finally {
setSending(false);
}
// Refocus after React re-enables the input
setTimeout(() => inputRef.current?.focus(), 0);
},
[text, sending, disabled, onSend, isRepeaterMode]
[text, sending, disabled, onSend]
);
const handleKeyDown = useCallback(
@@ -163,12 +141,11 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
[handleSubmit]
);
// For repeater mode, always allow submit (empty = guest login)
const canSubmit = isRepeaterMode ? true : text.trim().length > 0;
const canSubmit = text.trim().length > 0;
// Show counter for messages (not repeater mode or raw).
// Show counter for messages (not raw).
// Desktop: always visible. Mobile: only show count after 100 characters.
const showCharCounter = !isRepeaterMode && limits !== null;
const showCharCounter = limits !== null;
const showMobileCounterValue = text.length > 100;
return (
@@ -180,7 +157,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
<div className="flex gap-2">
<Input
ref={inputRef}
type={isRepeaterMode ? 'password' : 'text'}
type="text"
autoComplete="off"
name="chat-message-input"
data-lpignore="true"
@@ -189,10 +166,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
placeholder ||
(isRepeaterMode ? 'Enter password for admin login...' : 'Type a message...')
}
placeholder={placeholder || 'Type a message...'}
disabled={disabled || sending}
className="flex-1 min-w-0"
/>
@@ -201,15 +175,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
disabled={disabled || sending || !canSubmit}
className="flex-shrink-0"
>
{sending
? isRepeaterMode
? 'Logging in...'
: 'Sending...'
: isRepeaterMode
? text.trim()
? 'Log in with password'
: 'Log in as guest/use repeater ACLs'
: 'Send'}
{sending ? 'Sending...' : 'Send'}
</Button>
</div>
{showCharCounter && (
@@ -0,0 +1,98 @@
import { MapContainer, TileLayer, CircleMarker, Popup, Polyline } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
interface Neighbor {
lat: number | null;
lon: number | null;
name: string | null;
pubkey_prefix: string;
snr: number;
}
interface Props {
neighbors: Neighbor[];
radioLat?: number | null;
radioLon?: number | null;
radioName?: string | null;
}
export function NeighborsMiniMap({ neighbors, radioLat, radioLon, radioName }: Props) {
const valid = neighbors.filter(
(n): n is Neighbor & { lat: number; lon: number } => n.lat != null && n.lon != null
);
const hasRadio = radioLat != null && radioLon != null && !(radioLat === 0 && radioLon === 0);
if (valid.length === 0 && !hasRadio) return null;
// Center on radio if available, otherwise first neighbor
const center: [number, number] = hasRadio ? [radioLat!, radioLon!] : [valid[0].lat, valid[0].lon];
return (
<div className="min-h-48 flex-1 rounded border border-border overflow-hidden">
<MapContainer
center={center}
zoom={10}
className="h-full w-full"
style={{ background: '#1a1a2e' }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Dotted lines from radio to each neighbor */}
{hasRadio &&
valid.map((n, i) => (
<Polyline
key={`line-${i}`}
positions={[
[radioLat!, radioLon!],
[n.lat, n.lon],
]}
pathOptions={{
color: '#3b82f6',
weight: 1.5,
opacity: 0.5,
dashArray: '6 4',
}}
/>
))}
{/* Radio node (bright blue) */}
{hasRadio && (
<CircleMarker
center={[radioLat!, radioLon!]}
radius={8}
pathOptions={{
color: '#1d4ed8',
fillColor: '#3b82f6',
fillOpacity: 1,
weight: 2,
}}
>
<Popup>
<span className="text-sm font-medium">{radioName || 'Our Radio'}</span>
</Popup>
</CircleMarker>
)}
{/* Neighbor nodes (SNR-colored) */}
{valid.map((n, i) => (
<CircleMarker
key={i}
center={[n.lat, n.lon]}
radius={6}
pathOptions={{
color: '#000',
fillColor: n.snr >= 6 ? '#22c55e' : n.snr >= 0 ? '#eab308' : '#ef4444',
fillOpacity: 0.8,
weight: 1,
}}
>
<Popup>
<span className="text-sm">{n.name || n.pubkey_prefix}</span>
</Popup>
</CircleMarker>
))}
</MapContainer>
</div>
);
}
@@ -0,0 +1,971 @@
import {
useState,
useCallback,
useRef,
useEffect,
useMemo,
type FormEvent,
lazy,
Suspense,
} from 'react';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Separator } from './ui/separator';
import { RepeaterLogin } from './RepeaterLogin';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import { formatTime } from '../utils/messageParser';
import { isFavorite } from '../utils/favorites';
import { cn } from '@/lib/utils';
import type {
Contact,
Conversation,
Favorite,
LppSensor,
PaneState,
RepeaterStatusResponse,
RepeaterNeighborsResponse,
RepeaterAclResponse,
RepeaterRadioSettingsResponse,
RepeaterAdvertIntervalsResponse,
RepeaterOwnerInfoResponse,
RepeaterLppTelemetryResponse,
NeighborInfo,
} from '../types';
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
// Lazy-load the entire mini-map file so react-leaflet imports are bundled together
// and MapContainer only mounts once (avoids "already initialized" crash).
const NeighborsMiniMap = lazy(() =>
import('./NeighborsMiniMap').then((m) => ({ default: m.NeighborsMiniMap }))
);
// --- Shared Icons ---
function RefreshIcon({ className }: { className?: string }) {
return (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
);
}
// --- Utility ---
export function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (days > 0) {
if (hours > 0 && mins > 0) return `${days}d${hours}h${mins}m`;
if (hours > 0) return `${days}d${hours}h`;
if (mins > 0) return `${days}d${mins}m`;
return `${days}d`;
}
if (hours > 0) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
return `${mins}m`;
}
// --- Generic Pane Wrapper ---
function RepeaterPane({
title,
state,
onRefresh,
disabled,
children,
className,
contentClassName,
}: {
title: string;
state: PaneState;
onRefresh?: () => void;
disabled?: boolean;
children: React.ReactNode;
className?: string;
contentClassName?: string;
}) {
return (
<div className={cn('border border-border rounded-lg overflow-hidden', className)}>
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium">{title}</h3>
{onRefresh && (
<button
type="button"
onClick={onRefresh}
disabled={disabled || state.loading}
className={cn(
'p-1 rounded transition-colors disabled:opacity-50',
disabled || state.loading
? 'text-muted-foreground'
: 'text-green-500 hover:bg-accent hover:text-green-400'
)}
title="Refresh"
>
<RefreshIcon
className={cn(
'w-3.5 h-3.5',
state.loading && 'animate-spin [animation-direction:reverse]'
)}
/>
</button>
)}
</div>
{state.error && (
<div className="px-3 py-1.5 text-xs text-destructive bg-destructive/5 border-b border-border">
{state.error}
</div>
)}
<div className={cn('p-3', contentClassName)}>
{state.loading ? (
<p className="text-sm text-muted-foreground italic">
Fetching{state.attempt > 1 ? ` (attempt ${state.attempt}/${3})` : ''}...
</p>
) : (
children
)}
</div>
</div>
);
}
function NotFetched() {
return <p className="text-sm text-muted-foreground italic">&lt;not fetched&gt;</p>;
}
function KvRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between items-center text-sm py-0.5">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium text-right">{value}</span>
</div>
);
}
// --- Individual Panes ---
function TelemetryPane({
data,
state,
onRefresh,
disabled,
}: {
data: RepeaterStatusResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
}) {
return (
<RepeaterPane title="Telemetry" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : (
<div className="space-y-2">
<KvRow label="Battery" value={`${data.battery_volts.toFixed(3)}V`} />
<KvRow label="Uptime" value={formatDuration(data.uptime_seconds)} />
<KvRow label="TX Airtime" value={formatDuration(data.airtime_seconds)} />
<KvRow label="RX Airtime" value={formatDuration(data.rx_airtime_seconds)} />
<Separator className="my-1" />
<KvRow label="Noise Floor" value={`${data.noise_floor_dbm} dBm`} />
<KvRow label="Last RSSI" value={`${data.last_rssi_dbm} dBm`} />
<KvRow label="Last SNR" value={`${data.last_snr_db.toFixed(1)} dB`} />
<Separator className="my-1" />
<KvRow
label="Packets"
value={`${data.packets_received.toLocaleString()} rx / ${data.packets_sent.toLocaleString()} tx`}
/>
<KvRow
label="Flood"
value={`${data.recv_flood.toLocaleString()} rx / ${data.sent_flood.toLocaleString()} tx`}
/>
<KvRow
label="Direct"
value={`${data.recv_direct.toLocaleString()} rx / ${data.sent_direct.toLocaleString()} tx`}
/>
<KvRow
label="Duplicates"
value={`${data.flood_dups.toLocaleString()} flood / ${data.direct_dups.toLocaleString()} direct`}
/>
<Separator className="my-1" />
<KvRow label="TX Queue" value={data.tx_queue_len} />
<KvRow label="Debug Flags" value={data.full_events} />
</div>
)}
</RepeaterPane>
);
}
function NeighborsPane({
data,
state,
onRefresh,
disabled,
contacts,
radioLat,
radioLon,
radioName,
}: {
data: RepeaterNeighborsResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
contacts: Contact[];
radioLat: number | null;
radioLon: number | null;
radioName: string | null;
}) {
// Resolve contact data for each neighbor in a single pass — used for
// coords (mini-map), distances (table column), and sorted display order.
const { neighborsWithCoords, sorted, hasDistances } = useMemo(() => {
if (!data) {
return {
neighborsWithCoords: [] as Array<NeighborInfo & { lat: number | null; lon: number | null }>,
sorted: [] as Array<NeighborInfo & { distance: string | null }>,
hasDistances: false,
};
}
const withCoords: Array<NeighborInfo & { lat: number | null; lon: number | null }> = [];
const enriched: Array<NeighborInfo & { distance: string | null }> = [];
let anyDist = false;
for (const n of data.neighbors) {
const contact = contacts.find((c) => c.public_key.startsWith(n.pubkey_prefix));
const nLat = contact?.lat ?? null;
const nLon = contact?.lon ?? null;
let dist: string | null = null;
if (isValidLocation(radioLat, radioLon) && isValidLocation(nLat, nLon)) {
const distKm = calculateDistance(radioLat, radioLon, nLat, nLon);
if (distKm != null) {
dist = formatDistance(distKm);
anyDist = true;
}
}
enriched.push({ ...n, distance: dist });
if (isValidLocation(nLat, nLon)) {
withCoords.push({ ...n, lat: nLat, lon: nLon });
}
}
enriched.sort((a, b) => b.snr - a.snr);
return {
neighborsWithCoords: withCoords,
sorted: enriched,
hasDistances: anyDist,
};
}, [data, contacts, radioLat, radioLon]);
return (
<RepeaterPane
title="Neighbors"
state={state}
onRefresh={onRefresh}
disabled={disabled}
className="flex flex-col"
contentClassName="flex-1 flex flex-col"
>
{!data ? (
<NotFetched />
) : sorted.length === 0 ? (
<p className="text-sm text-muted-foreground">No neighbors reported</p>
) : (
<div className="flex-1 flex flex-col gap-2">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-muted-foreground text-xs">
<th className="pb-1 font-medium">Name</th>
<th className="pb-1 font-medium text-right">SNR</th>
{hasDistances && <th className="pb-1 font-medium text-right">Dist</th>}
<th className="pb-1 font-medium text-right">Last Heard</th>
</tr>
</thead>
<tbody>
{sorted.map((n, i) => {
const dist = n.distance;
const snrStr = n.snr >= 0 ? `+${n.snr.toFixed(1)}` : n.snr.toFixed(1);
const snrColor =
n.snr >= 6 ? 'text-green-500' : n.snr >= 0 ? 'text-yellow-500' : 'text-red-500';
return (
<tr key={i} className="border-t border-border/50">
<td className="py-1">{n.name || n.pubkey_prefix}</td>
<td className={cn('py-1 text-right font-mono', snrColor)}>{snrStr} dB</td>
{hasDistances && (
<td className="py-1 text-right text-muted-foreground font-mono">
{dist ?? '—'}
</td>
)}
<td className="py-1 text-right text-muted-foreground">
{formatDuration(n.last_heard_seconds)} ago
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{(neighborsWithCoords.length > 0 || isValidLocation(radioLat, radioLon)) && (
<Suspense
fallback={
<div className="h-48 flex items-center justify-center text-xs text-muted-foreground">
Loading map...
</div>
}
>
<NeighborsMiniMap
key={neighborsWithCoords.map((n) => n.pubkey_prefix).join(',')}
neighbors={neighborsWithCoords}
radioLat={radioLat}
radioLon={radioLon}
radioName={radioName}
/>
</Suspense>
)}
</div>
)}
</RepeaterPane>
);
}
function AclPane({
data,
state,
onRefresh,
disabled,
}: {
data: RepeaterAclResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
}) {
const permColor: Record<number, string> = {
0: 'bg-muted text-muted-foreground',
1: 'bg-blue-500/10 text-blue-500',
2: 'bg-green-500/10 text-green-500',
3: 'bg-amber-500/10 text-amber-500',
};
return (
<RepeaterPane title="ACL" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : data.acl.length === 0 ? (
<p className="text-sm text-muted-foreground">No ACL entries</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-muted-foreground text-xs">
<th className="pb-1 font-medium">Name</th>
<th className="pb-1 font-medium text-right">Permission</th>
</tr>
</thead>
<tbody>
{data.acl.map((entry, i) => (
<tr key={i} className="border-t border-border/50">
<td className="py-1">{entry.name || entry.pubkey_prefix}</td>
<td className="py-1 text-right">
<span
className={cn(
'text-xs px-1.5 py-0.5 rounded',
permColor[entry.permission] ?? 'bg-muted text-muted-foreground'
)}
>
{entry.permission_name}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</RepeaterPane>
);
}
export function formatClockDrift(clockUtc: string): { text: string; isLarge: boolean } {
// Firmware format: "HH:MM - D/M/YYYY UTC" or "HH:MM:SS - D/M/YYYY UTC"
// Also handle ISO-like: "YYYY-MM-DD HH:MM:SS"
let parsed: Date;
const fwMatch = clockUtc.match(
/^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*-\s*(\d{1,2})\/(\d{1,2})\/(\d{4})/
);
if (fwMatch) {
const [, hh, mm, ss, dd, mo, yyyy] = fwMatch;
parsed = new Date(Date.UTC(+yyyy, +mo - 1, +dd, +hh, +mm, +(ss ?? 0)));
} else {
parsed = new Date(
clockUtc.replace(' ', 'T') + (clockUtc.includes('Z') || clockUtc.includes('UTC') ? '' : 'Z')
);
}
if (isNaN(parsed.getTime())) return { text: '(invalid)', isLarge: false };
const driftMs = Math.abs(Date.now() - parsed.getTime());
const driftSec = Math.floor(driftMs / 1000);
if (driftSec >= 86400) return { text: '>24 hours!', isLarge: true };
const h = Math.floor(driftSec / 3600);
const m = Math.floor((driftSec % 3600) / 60);
const s = driftSec % 60;
const parts: string[] = [];
if (h > 0) parts.push(`${h}h`);
if (m > 0) parts.push(`${m}m`);
parts.push(`${s}s`);
return { text: parts.join(''), isLarge: false };
}
function RadioSettingsPane({
data,
state,
onRefresh,
disabled,
advertData,
advertState,
onRefreshAdvert,
}: {
data: RepeaterRadioSettingsResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
advertData: RepeaterAdvertIntervalsResponse | null;
advertState: PaneState;
onRefreshAdvert: () => void;
}) {
const clockDrift = useMemo(() => {
if (!data?.clock_utc) return null;
return formatClockDrift(data.clock_utc);
}, [data?.clock_utc]);
return (
<RepeaterPane title="Radio Settings" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : (
<div>
<KvRow label="Firmware" value={data.firmware_version ?? '—'} />
<KvRow label="Radio" value={data.radio ?? '—'} />
<KvRow label="TX Power" value={data.tx_power != null ? `${data.tx_power} dBm` : '—'} />
<KvRow label="Airtime Factor" value={data.airtime_factor ?? '—'} />
<KvRow label="Repeat Mode" value={data.repeat_enabled ?? '—'} />
<KvRow label="Max Flood Hops" value={data.flood_max ?? '—'} />
<Separator className="my-1" />
<KvRow label="Name" value={data.name ?? '—'} />
<KvRow
label="Lat / Lon"
value={
data.lat != null || data.lon != null ? `${data.lat ?? '—'}, ${data.lon ?? '—'}` : '—'
}
/>
<Separator className="my-1" />
<div className="flex justify-between text-sm py-0.5">
<span className="text-muted-foreground">Clock (UTC)</span>
<span>
{data.clock_utc ?? '—'}
{clockDrift && (
<span
className={cn(
'ml-2 text-xs',
clockDrift.isLarge ? 'text-red-500' : 'text-muted-foreground'
)}
>
(drift: {clockDrift.text})
</span>
)}
</span>
</div>
</div>
)}
{/* Advert Intervals sub-section */}
<Separator className="my-2" />
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-muted-foreground">Advert Intervals</span>
<button
type="button"
onClick={onRefreshAdvert}
disabled={disabled || advertState.loading}
className={cn(
'p-1 rounded transition-colors disabled:opacity-50',
disabled || advertState.loading
? 'text-muted-foreground'
: 'text-green-500 hover:bg-accent hover:text-green-400'
)}
title="Refresh Advert Intervals"
>
<RefreshIcon
className={cn(
'w-3 h-3',
advertState.loading && 'animate-spin [animation-direction:reverse]'
)}
/>
</button>
</div>
{advertState.error && <p className="text-xs text-destructive mb-1">{advertState.error}</p>}
{advertState.loading ? (
<p className="text-sm text-muted-foreground italic">
Fetching{advertState.attempt > 1 ? ` (attempt ${advertState.attempt}/3)` : ''}...
</p>
) : !advertData ? (
<NotFetched />
) : (
<div>
<KvRow label="Local Advert" value={formatAdvertInterval(advertData.advert_interval)} />
<KvRow
label="Flood Advert"
value={formatAdvertInterval(advertData.flood_advert_interval)}
/>
</div>
)}
</RepeaterPane>
);
}
function formatAdvertInterval(val: string | null): string {
if (val == null) return '—';
const trimmed = val.trim();
if (trimmed === '0') return '<disabled>';
return `${trimmed}h`;
}
const LPP_UNIT_MAP: Record<string, string> = {
temperature: '°C',
humidity: '%',
barometer: 'hPa',
voltage: 'V',
current: 'mA',
luminosity: 'lux',
altitude: 'm',
power: 'W',
distance: 'mm',
energy: 'kWh',
direction: '°',
concentration: 'ppm',
colour: '',
};
function formatLppLabel(typeName: string): string {
return typeName.charAt(0).toUpperCase() + typeName.slice(1).replace(/_/g, ' ');
}
function LppSensorRow({ sensor }: { sensor: LppSensor }) {
const label = formatLppLabel(sensor.type_name);
if (typeof sensor.value === 'object' && sensor.value !== null) {
// Multi-value sensor (GPS, accelerometer, etc.)
return (
<div className="py-0.5">
<span className="text-sm text-muted-foreground">{label}</span>
<div className="pl-3">
{Object.entries(sensor.value).map(([k, v]) => (
<KvRow
key={k}
label={k.charAt(0).toUpperCase() + k.slice(1)}
value={typeof v === 'number' ? v.toFixed(2) : String(v)}
/>
))}
</div>
</div>
);
}
const unit = LPP_UNIT_MAP[sensor.type_name] ?? '';
const formatted =
typeof sensor.value === 'number'
? `${sensor.value % 1 === 0 ? sensor.value : sensor.value.toFixed(2)}${unit ? ` ${unit}` : ''}`
: String(sensor.value);
return <KvRow label={label} value={formatted} />;
}
function LppTelemetryPane({
data,
state,
onRefresh,
disabled,
}: {
data: RepeaterLppTelemetryResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
}) {
return (
<RepeaterPane title="LPP Sensors" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : data.sensors.length === 0 ? (
<p className="text-sm text-muted-foreground">No sensor data available</p>
) : (
<div className="space-y-0.5">
{data.sensors.map((sensor, i) => (
<LppSensorRow key={i} sensor={sensor} />
))}
</div>
)}
</RepeaterPane>
);
}
function OwnerInfoPane({
data,
state,
onRefresh,
disabled,
}: {
data: RepeaterOwnerInfoResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
}) {
return (
<RepeaterPane title="Owner Info" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : (
<div className="break-all">
<KvRow label="Owner Info" value={data.owner_info ?? '—'} />
<KvRow label="Guest Password" value={data.guest_password ?? '—'} />
</div>
)}
</RepeaterPane>
);
}
function ActionsPane({
onSendAdvert,
onSyncClock,
onReboot,
consoleLoading,
}: {
onSendAdvert: () => void;
onSyncClock: () => void;
onReboot: () => void;
consoleLoading: boolean;
}) {
const [confirmReboot, setConfirmReboot] = useState(false);
const handleReboot = useCallback(() => {
if (!confirmReboot) {
setConfirmReboot(true);
return;
}
setConfirmReboot(false);
onReboot();
}, [confirmReboot, onReboot]);
// Reset confirmation after 3 seconds
useEffect(() => {
if (!confirmReboot) return;
const timer = setTimeout(() => setConfirmReboot(false), 3000);
return () => clearTimeout(timer);
}, [confirmReboot]);
return (
<div className="border border-border rounded-lg overflow-hidden">
<div className="px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium">Actions</h3>
</div>
<div className="p-3 flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={onSendAdvert} disabled={consoleLoading}>
Send Advert
</Button>
<Button variant="outline" size="sm" onClick={onSyncClock} disabled={consoleLoading}>
Sync Clock
</Button>
<Button
variant={confirmReboot ? 'destructive' : 'outline'}
size="sm"
onClick={handleReboot}
disabled={consoleLoading}
>
{confirmReboot ? 'Confirm Reboot' : 'Reboot'}
</Button>
</div>
</div>
);
}
function ConsolePane({
history,
loading,
onSend,
}: {
history: Array<{ command: string; response: string; timestamp: number; outgoing: boolean }>;
loading: boolean;
onSend: (command: string) => Promise<void>;
}) {
const [input, setInput] = useState('');
const outputRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom on new entries
useEffect(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [history]);
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
const trimmed = input.trim();
if (!trimmed || loading) return;
setInput('');
await onSend(trimmed);
},
[input, loading, onSend]
);
return (
<div className="border border-border rounded-lg overflow-hidden col-span-full">
<div className="px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium">Console</h3>
</div>
<div
ref={outputRef}
className="h-48 overflow-y-auto p-3 font-mono text-xs bg-black/50 text-green-400 space-y-1"
>
{history.length === 0 && (
<p className="text-muted-foreground italic">Type a CLI command below...</p>
)}
{history.map((entry, i) =>
entry.outgoing ? (
<div key={i} className="text-green-300">
&gt; {entry.command}
</div>
) : (
<div key={i} className="text-green-400/80 whitespace-pre-wrap">
{entry.response}
</div>
)
)}
{loading && <div className="text-muted-foreground animate-pulse">...</div>}
</div>
<form onSubmit={handleSubmit} className="flex gap-2 p-2 border-t border-border">
<Input
type="text"
autoComplete="off"
name="console-input"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="CLI command..."
disabled={loading}
className="flex-1 font-mono text-sm"
/>
<Button type="submit" size="sm" disabled={loading || !input.trim()}>
Send
</Button>
</form>
</div>
);
}
// --- Main Dashboard ---
interface RepeaterDashboardProps {
conversation: Conversation;
contacts: Contact[];
favorites: Favorite[];
radioLat: number | null;
radioLon: number | null;
radioName: string | null;
onTrace: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onDeleteContact: (publicKey: string) => void;
}
export function RepeaterDashboard({
conversation,
contacts,
favorites,
radioLat,
radioLon,
radioName,
onTrace,
onToggleFavorite,
onDeleteContact,
}: RepeaterDashboardProps) {
const {
loggedIn,
loginLoading,
loginError,
paneData,
paneStates,
consoleHistory,
consoleLoading,
login,
loginAsGuest,
refreshPane,
loadAll,
sendConsoleCommand,
sendAdvert,
rebootRepeater,
syncClock,
} = useRepeaterDashboard(conversation);
const contact = contacts.find((c) => c.public_key === conversation.id);
const isFav = isFavorite(favorites, 'contact', conversation.id);
// Loading all panes indicator
const anyLoading = Object.values(paneStates).some((s) => s.loading);
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Header */}
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
<span className="flex-shrink-0 font-semibold text-base">{conversation.name}</span>
<span
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
onClick={() => {
navigator.clipboard.writeText(conversation.id);
toast.success('Contact key copied!');
}}
title="Click to copy"
>
{conversation.id}
</span>
{contact?.last_seen && (
<span className="font-normal text-sm text-muted-foreground flex-shrink-0">
(Last heard: {formatTime(contact.last_seen)})
</span>
)}
</span>
<div className="flex items-center gap-0.5 flex-shrink-0">
{loggedIn && (
<Button
variant="outline"
size="sm"
onClick={loadAll}
disabled={anyLoading}
className="text-xs border-green-600 text-green-600 hover:bg-green-600/10 hover:text-green-600"
>
{anyLoading ? 'Loading...' : 'Load All'}
</Button>
)}
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
onClick={onTrace}
title="Direct Trace"
>
&#x1F6CE;
</button>
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
onClick={() => onToggleFavorite('contact', conversation.id)}
title={isFav ? 'Remove from favorites' : 'Add to favorites'}
>
{isFav ? (
<span className="text-amber-400">&#9733;</span>
) : (
<span className="text-muted-foreground">&#9734;</span>
)}
</button>
<button
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors"
onClick={() => onDeleteContact(conversation.id)}
title="Delete"
>
&#128465;
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4">
{!loggedIn ? (
<RepeaterLogin
repeaterName={conversation.name}
loading={loginLoading}
error={loginError}
onLogin={login}
onLoginAsGuest={loginAsGuest}
/>
) : (
<div className="space-y-4">
{/* Top row: Telemetry + Radio Settings | Neighbors (with expanding map) */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-4">
<TelemetryPane
data={paneData.status}
state={paneStates.status}
onRefresh={() => refreshPane('status')}
disabled={anyLoading}
/>
<RadioSettingsPane
data={paneData.radioSettings}
state={paneStates.radioSettings}
onRefresh={() => refreshPane('radioSettings')}
disabled={anyLoading}
advertData={paneData.advertIntervals}
advertState={paneStates.advertIntervals}
onRefreshAdvert={() => refreshPane('advertIntervals')}
/>
<LppTelemetryPane
data={paneData.lppTelemetry}
state={paneStates.lppTelemetry}
onRefresh={() => refreshPane('lppTelemetry')}
disabled={anyLoading}
/>
</div>
<NeighborsPane
data={paneData.neighbors}
state={paneStates.neighbors}
onRefresh={() => refreshPane('neighbors')}
disabled={anyLoading}
contacts={contacts}
radioLat={radioLat}
radioLon={radioLon}
radioName={radioName}
/>
</div>
{/* Remaining panes: ACL | Owner Info + Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<AclPane
data={paneData.acl}
state={paneStates.acl}
onRefresh={() => refreshPane('acl')}
disabled={anyLoading}
/>
<div className="flex flex-col gap-4">
<OwnerInfoPane
data={paneData.ownerInfo}
state={paneStates.ownerInfo}
onRefresh={() => refreshPane('ownerInfo')}
disabled={anyLoading}
/>
<ActionsPane
onSendAdvert={sendAdvert}
onSyncClock={syncClock}
onReboot={rebootRepeater}
consoleLoading={consoleLoading}
/>
</div>
</div>
{/* Console — full width */}
<ConsolePane
history={consoleHistory}
loading={consoleLoading}
onSend={sendConsoleCommand}
/>
</div>
)}
</div>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { useState, useCallback, type FormEvent } from 'react';
import { Input } from './ui/input';
import { Button } from './ui/button';
interface RepeaterLoginProps {
repeaterName: string;
loading: boolean;
error: string | null;
onLogin: (password: string) => Promise<void>;
onLoginAsGuest: () => Promise<void>;
}
export function RepeaterLogin({
repeaterName,
loading,
error,
onLogin,
onLoginAsGuest,
}: RepeaterLoginProps) {
const [password, setPassword] = useState('');
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
if (loading) return;
await onLogin(password.trim());
},
[password, loading, onLogin]
);
return (
<div className="flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-sm space-y-6">
<div className="text-center space-y-1">
<h2 className="text-lg font-semibold">{repeaterName}</h2>
<p className="text-sm text-muted-foreground">Log in to access repeater dashboard</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
<Input
type="password"
autoComplete="off"
name="repeater-password"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Repeater password..."
disabled={loading}
autoFocus
/>
{error && <p className="text-sm text-destructive text-center">{error}</p>}
<div className="flex flex-col gap-2">
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Logging in...' : 'Login with Password'}
</Button>
<Button
type="button"
variant="outline"
disabled={loading}
className="w-full"
onClick={onLoginAsGuest}
>
Login as Guest / ACLs
</Button>
</div>
</form>
</div>
</div>
);
}
-1
View File
@@ -1,4 +1,3 @@
export { useRepeaterMode } from './useRepeaterMode';
export { useUnreadCounts } from './useUnreadCounts';
export { useConversationMessages, getMessageContentKey } from './useConversationMessages';
export { useRadioControl } from './useRadioControl';
-281
View File
@@ -1,281 +0,0 @@
/**
* Airtime/duty cycle tracking for repeaters.
*
* When "dutycycle_start" command is issued, this captures baseline telemetry
* and polls every 5 minutes to display rolling airtime/duty cycle statistics.
*/
import { useRef, useCallback, useEffect } from 'react';
import { api } from '../api';
import type { Message, TelemetryResponse } from '../types';
// Baseline telemetry snapshot for airtime tracking
interface AirtimeBaseline {
startTime: number; // epoch seconds
uptime: number;
txAirtime: number;
rxAirtime: number;
sentFlood: number;
sentDirect: number;
recvFlood: number;
recvDirect: number;
conversationId: string;
}
// Polling interval: 5 minutes
const AIRTIME_POLL_INTERVAL_MS = 5 * 60 * 1000;
// Format duration in XhXmXs format
function formatAirtimeDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return `${hours}h${mins}m${secs}s`;
}
// Get emoji indicator for TX duty cycle percentage
function getTxDutyCycleEmoji(pct: number): string {
if (pct <= 5) return '✅';
if (pct <= 10) return '🟢';
if (pct <= 25) return '🟡';
if (pct <= 50) return '🔴';
return '🚨';
}
// Format airtime statistics comparing current telemetry to baseline
function formatAirtimeStats(baseline: AirtimeBaseline, current: TelemetryResponse): string {
const now = Math.floor(Date.now() / 1000);
const wallDuration = now - baseline.startTime;
// Compute deltas
const deltaUptime = current.uptime_seconds - baseline.uptime;
const deltaTxAirtime = current.airtime_seconds - baseline.txAirtime;
const deltaRxAirtime = current.rx_airtime_seconds - baseline.rxAirtime;
const deltaSentFlood = current.sent_flood - baseline.sentFlood;
const deltaSentDirect = current.sent_direct - baseline.sentDirect;
const deltaRecvFlood = current.recv_flood - baseline.recvFlood;
const deltaRecvDirect = current.recv_direct - baseline.recvDirect;
// Calculate airtime percentages
const txPct = deltaUptime > 0 ? (deltaTxAirtime / deltaUptime) * 100 : 0;
const rxPct = deltaUptime > 0 ? (deltaRxAirtime / deltaUptime) * 100 : 0;
// Estimate flood/direct airtime breakdown based on packet proportions
const totalSent = deltaSentFlood + deltaSentDirect;
const totalRecv = deltaRecvFlood + deltaRecvDirect;
const txFloodPct = totalSent > 0 ? txPct * (deltaSentFlood / totalSent) : 0;
const txDirectPct = totalSent > 0 ? txPct * (deltaSentDirect / totalSent) : 0;
const rxFloodPct = totalRecv > 0 ? rxPct * (deltaRecvFlood / totalRecv) : 0;
const rxDirectPct = totalRecv > 0 ? rxPct * (deltaRecvDirect / totalRecv) : 0;
const txEmoji = getTxDutyCycleEmoji(txPct);
const idlePct = Math.max(0, 100 - txPct - rxPct);
const lines = [
`Airtime/Duty Cycle Statistics`,
`Duration: ${formatAirtimeDuration(wallDuration)} (uptime delta: ${formatAirtimeDuration(deltaUptime)})`,
``,
`${txEmoji} TX Airtime: ${txPct.toFixed(3)}% (${totalSent.toLocaleString()} pkts)`,
` Flood: ${txFloodPct.toFixed(3)}% (${deltaSentFlood.toLocaleString()} pkts)`,
` Direct: ${txDirectPct.toFixed(3)}% (${deltaSentDirect.toLocaleString()} pkts)`,
``,
`RX Airtime: ${rxPct.toFixed(3)}% (${totalRecv.toLocaleString()} pkts)`,
` Flood: ${rxFloodPct.toFixed(3)}% (${deltaRecvFlood.toLocaleString()} pkts)`,
` Direct: ${rxDirectPct.toFixed(3)}% (${deltaRecvDirect.toLocaleString()} pkts)`,
``,
`Idle: ${idlePct.toFixed(3)}%`,
];
return lines.join('\n');
}
// Create a local message object (not persisted to database)
function createLocalMessage(conversationKey: string, text: string, outgoing: boolean): Message {
const now = Math.floor(Date.now() / 1000);
return {
id: -Date.now(),
type: 'PRIV',
conversation_key: conversationKey,
text,
sender_timestamp: now,
received_at: now,
paths: null,
txt_type: 0,
signature: null,
outgoing,
acked: 1,
};
}
interface UseAirtimeTrackingResult {
/** Returns true if this was an airtime command that was handled */
handleAirtimeCommand: (command: string, conversationId: string) => Promise<boolean>;
/** Stop any active airtime tracking */
stopTracking: () => void;
}
export function useAirtimeTracking(
setMessages: React.Dispatch<React.SetStateAction<Message[]>>
): UseAirtimeTrackingResult {
const baselineRef = useRef<AirtimeBaseline | null>(null);
const intervalRef = useRef<number | null>(null);
// Stop tracking and clear interval
const stopTracking = useCallback(() => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
baselineRef.current = null;
}, []);
// Poll for airtime stats with one retry on failure
const pollAirtimeStats = useCallback(async () => {
const baseline = baselineRef.current;
if (!baseline) return;
let telemetry: TelemetryResponse | null = null;
let lastError: Error | null = null;
// Try up to 2 times (initial + 1 retry)
for (let attempt = 0; attempt < 2; attempt++) {
try {
telemetry = await api.requestTelemetry(baseline.conversationId, '');
break; // Success, exit loop
} catch (err) {
lastError = err instanceof Error ? err : new Error('Unknown error');
// Wait a moment before retry
if (attempt === 0) {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
}
// If tracking was stopped while the request was in-flight (e.g. conversation
// switch called stopTracking), discard the stale response.
if (!baselineRef.current) return;
if (telemetry) {
const statsMessage = createLocalMessage(
baseline.conversationId,
formatAirtimeStats(baseline, telemetry),
false
);
setMessages((prev) => [...prev, statsMessage]);
} else {
const errorMessage = createLocalMessage(
baseline.conversationId,
`Duty cycle poll failed after retry: ${lastError?.message ?? 'Unknown error'}`,
false
);
setMessages((prev) => [...prev, errorMessage]);
}
}, [setMessages]);
// Handle airtime commands
const handleAirtimeCommand = useCallback(
async (command: string, conversationId: string): Promise<boolean> => {
const cmd = command.trim().toLowerCase();
if (cmd === 'dutycycle_start') {
// Stop any existing tracking
stopTracking();
// Fetch initial telemetry with one retry
let telemetry: TelemetryResponse | null = null;
let lastError: Error | null = null;
for (let attempt = 0; attempt < 2; attempt++) {
try {
telemetry = await api.requestTelemetry(conversationId, '');
break;
} catch (err) {
lastError = err instanceof Error ? err : new Error('Unknown error');
if (attempt === 0) {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
}
if (!telemetry) {
const errorMessage = createLocalMessage(
conversationId,
`Failed to start duty cycle tracking after retry: ${lastError?.message ?? 'Unknown error'}`,
false
);
setMessages((prev) => [...prev, errorMessage]);
return true;
}
// Store baseline
const now = Math.floor(Date.now() / 1000);
baselineRef.current = {
startTime: now,
uptime: telemetry.uptime_seconds,
txAirtime: telemetry.airtime_seconds,
rxAirtime: telemetry.rx_airtime_seconds,
sentFlood: telemetry.sent_flood,
sentDirect: telemetry.sent_direct,
recvFlood: telemetry.recv_flood,
recvDirect: telemetry.recv_direct,
conversationId,
};
// Add start message
const startMessage = createLocalMessage(
conversationId,
`Airtime/duty cycle statistics gathering begins at ${now}. Logs will follow every 5 minutes. To stop, run dutycycle_stop or navigate away from this conversation.`,
false
);
setMessages((prev) => [...prev, startMessage]);
// Start polling interval
intervalRef.current = window.setInterval(pollAirtimeStats, AIRTIME_POLL_INTERVAL_MS);
return true;
}
if (cmd === 'dutycycle_stop') {
if (baselineRef.current && baselineRef.current.conversationId === conversationId) {
// Do one final poll before stopping
await pollAirtimeStats();
stopTracking();
const stopMessage = createLocalMessage(
conversationId,
'Airtime/duty cycle statistics gathering stopped.',
false
);
setMessages((prev) => [...prev, stopMessage]);
} else {
const notRunningMessage = createLocalMessage(
conversationId,
'Duty cycle tracking is not active.',
false
);
setMessages((prev) => [...prev, notRunningMessage]);
}
return true;
}
return false; // Not an airtime command
},
[setMessages, stopTracking, pollAirtimeStats]
);
// Cleanup on unmount
useEffect(() => {
return () => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
}
};
}, []);
return {
handleAirtimeCommand,
stopTracking,
};
}
+298
View File
@@ -0,0 +1,298 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { api } from '../api';
import { toast } from '../components/ui/sonner';
import type {
Conversation,
PaneName,
PaneState,
RepeaterStatusResponse,
RepeaterNeighborsResponse,
RepeaterAclResponse,
RepeaterRadioSettingsResponse,
RepeaterAdvertIntervalsResponse,
RepeaterOwnerInfoResponse,
RepeaterLppTelemetryResponse,
CommandResponse,
} from '../types';
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 2000;
interface ConsoleEntry {
command: string;
response: string;
timestamp: number;
outgoing: boolean;
}
interface PaneData {
status: RepeaterStatusResponse | null;
neighbors: RepeaterNeighborsResponse | null;
acl: RepeaterAclResponse | null;
radioSettings: RepeaterRadioSettingsResponse | null;
advertIntervals: RepeaterAdvertIntervalsResponse | null;
ownerInfo: RepeaterOwnerInfoResponse | null;
lppTelemetry: RepeaterLppTelemetryResponse | null;
}
const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null };
function createInitialPaneStates(): Record<PaneName, PaneState> {
return {
status: { ...INITIAL_PANE_STATE },
neighbors: { ...INITIAL_PANE_STATE },
acl: { ...INITIAL_PANE_STATE },
radioSettings: { ...INITIAL_PANE_STATE },
advertIntervals: { ...INITIAL_PANE_STATE },
ownerInfo: { ...INITIAL_PANE_STATE },
lppTelemetry: { ...INITIAL_PANE_STATE },
};
}
function createInitialPaneData(): PaneData {
return {
status: null,
neighbors: null,
acl: null,
radioSettings: null,
advertIntervals: null,
ownerInfo: null,
lppTelemetry: null,
};
}
// Maps pane name to the API call
function fetchPaneData(publicKey: string, pane: PaneName) {
switch (pane) {
case 'status':
return api.repeaterStatus(publicKey);
case 'neighbors':
return api.repeaterNeighbors(publicKey);
case 'acl':
return api.repeaterAcl(publicKey);
case 'radioSettings':
return api.repeaterRadioSettings(publicKey);
case 'advertIntervals':
return api.repeaterAdvertIntervals(publicKey);
case 'ownerInfo':
return api.repeaterOwnerInfo(publicKey);
case 'lppTelemetry':
return api.repeaterLppTelemetry(publicKey);
}
}
export interface UseRepeaterDashboardResult {
loggedIn: boolean;
loginLoading: boolean;
loginError: string | null;
paneData: PaneData;
paneStates: Record<PaneName, PaneState>;
consoleHistory: ConsoleEntry[];
consoleLoading: boolean;
login: (password: string) => Promise<void>;
loginAsGuest: () => Promise<void>;
refreshPane: (pane: PaneName) => Promise<void>;
loadAll: () => Promise<void>;
sendConsoleCommand: (command: string) => Promise<void>;
sendAdvert: () => Promise<void>;
rebootRepeater: () => Promise<void>;
syncClock: () => Promise<void>;
}
export function useRepeaterDashboard(
activeConversation: Conversation | null
): UseRepeaterDashboardResult {
const [loggedIn, setLoggedIn] = useState(false);
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [paneData, setPaneData] = useState<PaneData>(createInitialPaneData);
const [paneStates, setPaneStates] =
useState<Record<PaneName, PaneState>>(createInitialPaneStates);
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>([]);
const [consoleLoading, setConsoleLoading] = useState(false);
// Track which conversation we're operating on to avoid stale updates after
// unmount. Initialised from activeConversation because the parent renders
// <RepeaterDashboard key={id}>, so this hook only ever sees one conversation.
const activeIdRef = useRef(activeConversation?.id ?? null);
// Guard against setting state after unmount (retry timers firing late)
const mountedRef = useRef(true);
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
const getPublicKey = useCallback((): string | null => {
if (!activeConversation || activeConversation.type !== 'contact') return null;
return activeConversation.id;
}, [activeConversation]);
const login = useCallback(
async (password: string) => {
const publicKey = getPublicKey();
if (!publicKey) return;
const conversationId = publicKey;
setLoginLoading(true);
setLoginError(null);
try {
await api.repeaterLogin(publicKey, password);
if (activeIdRef.current !== conversationId) return;
setLoggedIn(true);
} catch (err) {
if (activeIdRef.current !== conversationId) return;
const msg = err instanceof Error ? err.message : 'Login failed';
setLoginError(msg);
} finally {
if (activeIdRef.current === conversationId) {
setLoginLoading(false);
}
}
},
[getPublicKey]
);
const loginAsGuest = useCallback(async () => {
await login('');
}, [login]);
const refreshPane = useCallback(
async (pane: PaneName) => {
const publicKey = getPublicKey();
if (!publicKey) return;
const conversationId = publicKey;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
setPaneStates((prev) => ({
...prev,
[pane]: { loading: true, attempt, error: null },
}));
try {
const data = await fetchPaneData(publicKey, pane);
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
setPaneData((prev) => ({ ...prev, [pane]: data }));
setPaneStates((prev) => ({
...prev,
[pane]: { loading: false, attempt, error: null },
}));
return; // Success
} catch (err) {
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
const msg = err instanceof Error ? err.message : 'Request failed';
if (attempt === MAX_RETRIES) {
setPaneStates((prev) => ({
...prev,
[pane]: { loading: false, attempt, error: msg },
}));
toast.error(`Failed to fetch ${pane}`, { description: msg });
} else {
// Wait before retrying
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
}
}
}
},
[getPublicKey]
);
const loadAll = useCallback(async () => {
const panes: PaneName[] = [
'status',
'neighbors',
'acl',
'radioSettings',
'advertIntervals',
'ownerInfo',
'lppTelemetry',
];
// Serial execution — parallel calls just queue behind the radio lock anyway
for (const pane of panes) {
await refreshPane(pane);
}
}, [refreshPane]);
const sendConsoleCommand = useCallback(
async (command: string) => {
const publicKey = getPublicKey();
if (!publicKey) return;
const conversationId = publicKey;
const now = Math.floor(Date.now() / 1000);
// Add outgoing command entry
setConsoleHistory((prev) => [
...prev,
{ command, response: '', timestamp: now, outgoing: true },
]);
setConsoleLoading(true);
try {
const result: CommandResponse = await api.sendRepeaterCommand(publicKey, command);
if (activeIdRef.current !== conversationId) return;
setConsoleHistory((prev) => [
...prev,
{
command,
response: result.response,
timestamp: result.sender_timestamp ?? now,
outgoing: false,
},
]);
} catch (err) {
if (activeIdRef.current !== conversationId) return;
const msg = err instanceof Error ? err.message : 'Command failed';
setConsoleHistory((prev) => [
...prev,
{ command, response: `Error: ${msg}`, timestamp: now, outgoing: false },
]);
} finally {
if (activeIdRef.current === conversationId) {
setConsoleLoading(false);
}
}
},
[getPublicKey]
);
const sendAdvert = useCallback(async () => {
await sendConsoleCommand('advert');
}, [sendConsoleCommand]);
const rebootRepeater = useCallback(async () => {
await sendConsoleCommand('reboot');
}, [sendConsoleCommand]);
const syncClock = useCallback(async () => {
const epoch = Math.floor(Date.now() / 1000);
await sendConsoleCommand(`clock ${epoch}`);
}, [sendConsoleCommand]);
return {
loggedIn,
loginLoading,
loginError,
paneData,
paneStates,
consoleHistory,
consoleLoading,
login,
loginAsGuest,
refreshPane,
loadAll,
sendConsoleCommand,
sendAdvert,
rebootRepeater,
syncClock,
};
}
-246
View File
@@ -1,246 +0,0 @@
import { useState, useCallback, useMemo, useEffect, type RefObject } from 'react';
import { api } from '../api';
import type {
Contact,
Conversation,
Message,
TelemetryResponse,
NeighborInfo,
AclEntry,
} from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { useAirtimeTracking } from './useAirtimeTracking';
// Format seconds into human-readable duration (e.g., 1d17h2m, 1h5m, 3m)
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (days > 0) {
if (hours > 0 && mins > 0) return `${days}d${hours}h${mins}m`;
if (hours > 0) return `${days}d${hours}h`;
if (mins > 0) return `${days}d${mins}m`;
return `${days}d`;
}
if (hours > 0) {
return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
}
return `${mins}m`;
}
// Format telemetry response as human-readable text
function formatTelemetry(telemetry: TelemetryResponse): string {
const lines = [
`Telemetry`,
`Battery Voltage: ${telemetry.battery_volts.toFixed(3)}V`,
`Uptime: ${formatDuration(telemetry.uptime_seconds)}`,
...(telemetry.clock_output ? [`Clock: ${telemetry.clock_output}`] : []),
`TX Airtime: ${formatDuration(telemetry.airtime_seconds)}`,
`RX Airtime: ${formatDuration(telemetry.rx_airtime_seconds)}`,
'',
`Noise Floor: ${telemetry.noise_floor_dbm} dBm`,
`Last RSSI: ${telemetry.last_rssi_dbm} dBm`,
`Last SNR: ${telemetry.last_snr_db.toFixed(1)} dB`,
'',
`Packets: ${telemetry.packets_received.toLocaleString()} rx / ${telemetry.packets_sent.toLocaleString()} tx`,
`Flood: ${telemetry.recv_flood.toLocaleString()} rx / ${telemetry.sent_flood.toLocaleString()} tx`,
`Direct: ${telemetry.recv_direct.toLocaleString()} rx / ${telemetry.sent_direct.toLocaleString()} tx`,
`Duplicates: ${telemetry.flood_dups.toLocaleString()} flood / ${telemetry.direct_dups.toLocaleString()} direct`,
'',
`TX Queue: ${telemetry.tx_queue_len}`,
`Debug Flags: ${telemetry.full_events}`,
];
return lines.join('\n');
}
// Format neighbors list as human-readable text
function formatNeighbors(neighbors: NeighborInfo[]): string {
if (neighbors.length === 0) {
return 'Neighbors\nNo neighbors reported';
}
// Sort by SNR descending (highest first)
const sorted = [...neighbors].sort((a, b) => b.snr - a.snr);
const lines = [`Neighbors (${sorted.length})`];
for (const n of sorted) {
const name = n.name || n.pubkey_prefix;
const snr = n.snr >= 0 ? `+${n.snr.toFixed(1)}` : n.snr.toFixed(1);
lines.push(`${name}, ${snr} dB [${formatDuration(n.last_heard_seconds)} ago]`);
}
return lines.join('\n');
}
// Format ACL list as human-readable text
function formatAcl(acl: AclEntry[]): string {
if (acl.length === 0) {
return 'ACL\nNo ACL entries';
}
const lines = [`ACL (${acl.length})`];
for (const entry of acl) {
const name = entry.name || entry.pubkey_prefix;
lines.push(`${name}: ${entry.permission_name}`);
}
return lines.join('\n');
}
// Create a local message object (not persisted to database)
function createLocalMessage(
conversationKey: string,
text: string,
outgoing: boolean,
idOffset = 0
): Message {
const now = Math.floor(Date.now() / 1000);
return {
id: -Date.now() - idOffset,
type: 'PRIV',
conversation_key: conversationKey,
text,
sender_timestamp: now,
received_at: now,
paths: null,
txt_type: 0,
signature: null,
outgoing,
acked: 1,
};
}
interface UseRepeaterModeResult {
repeaterLoggedIn: boolean;
activeContactIsRepeater: boolean;
handleTelemetryRequest: (password: string) => Promise<void>;
handleRepeaterCommand: (command: string) => Promise<void>;
}
export function useRepeaterMode(
activeConversation: Conversation | null,
contacts: Contact[],
setMessages: React.Dispatch<React.SetStateAction<Message[]>>,
activeConversationRef: RefObject<Conversation | null>
): UseRepeaterModeResult {
const [repeaterLoggedIn, setRepeaterLoggedIn] = useState(false);
const { handleAirtimeCommand, stopTracking } = useAirtimeTracking(setMessages);
// Reset login state and stop airtime tracking when conversation changes
useEffect(() => {
setRepeaterLoggedIn(false);
stopTracking();
}, [activeConversation?.id, stopTracking]);
// Check if active conversation is a repeater
const activeContactIsRepeater = useMemo(() => {
if (!activeConversation || activeConversation.type !== 'contact') return false;
const contact = contacts.find((c) => c.public_key === activeConversation.id);
return contact?.type === CONTACT_TYPE_REPEATER;
}, [activeConversation, contacts]);
// Request telemetry from a repeater
const handleTelemetryRequest = useCallback(
async (password: string) => {
if (!activeConversation || activeConversation.type !== 'contact') return;
if (!activeContactIsRepeater) return;
const conversationId = activeConversation.id;
try {
const telemetry = await api.requestTelemetry(conversationId, password);
// User may have switched conversations during the await
if (activeConversationRef.current?.id !== conversationId) return;
// Create local messages to display the telemetry (not persisted to database)
const telemetryMessage = createLocalMessage(
conversationId,
formatTelemetry(telemetry),
false,
0
);
const neighborsMessage = createLocalMessage(
conversationId,
formatNeighbors(telemetry.neighbors),
false,
1
);
const aclMessage = createLocalMessage(conversationId, formatAcl(telemetry.acl), false, 2);
// Add all messages to the list
setMessages((prev) => [...prev, telemetryMessage, neighborsMessage, aclMessage]);
// Mark as logged in for CLI command mode
setRepeaterLoggedIn(true);
} catch (err) {
if (activeConversationRef.current?.id !== conversationId) return;
const errorMessage = createLocalMessage(
conversationId,
`Telemetry request failed: ${err instanceof Error ? err.message : 'Unknown error'}`,
false,
0
);
setMessages((prev) => [...prev, errorMessage]);
}
},
[activeConversation, activeContactIsRepeater, setMessages, activeConversationRef]
);
// Send CLI command to a repeater (after logged in)
const handleRepeaterCommand = useCallback(
async (command: string) => {
if (!activeConversation || activeConversation.type !== 'contact') return;
if (!activeContactIsRepeater || !repeaterLoggedIn) return;
const conversationId = activeConversation.id;
// Check for special airtime commands first (handled locally)
const handled = await handleAirtimeCommand(command, conversationId);
if (handled) return;
// Show the command as an outgoing message
const commandMessage = createLocalMessage(conversationId, `> ${command}`, true, 0);
setMessages((prev) => [...prev, commandMessage]);
try {
const response = await api.sendRepeaterCommand(conversationId, command);
// User may have switched conversations during the await
if (activeConversationRef.current?.id !== conversationId) return;
// Use the actual timestamp from the repeater if available
const responseMessage = createLocalMessage(conversationId, response.response, false, 1);
if (response.sender_timestamp) {
responseMessage.sender_timestamp = response.sender_timestamp;
}
setMessages((prev) => [...prev, responseMessage]);
} catch (err) {
if (activeConversationRef.current?.id !== conversationId) return;
const errorMessage = createLocalMessage(
conversationId,
`Command failed: ${err instanceof Error ? err.message : 'Unknown error'}`,
false,
1
);
setMessages((prev) => [...prev, errorMessage]);
}
},
[
activeConversation,
activeContactIsRepeater,
repeaterLoggedIn,
setMessages,
handleAirtimeCommand,
activeConversationRef,
]
);
return {
repeaterLoggedIn,
activeContactIsRepeater,
handleTelemetryRequest,
handleRepeaterCommand,
};
}
-8
View File
@@ -39,8 +39,6 @@ const mocks = vi.hoisted(() => ({
incrementUnread: vi.fn(),
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
handleTelemetryRequest: vi.fn(),
handleRepeaterCommand: vi.fn(),
},
}));
@@ -75,12 +73,6 @@ vi.mock('../hooks', async (importOriginal) => {
markAllRead: mocks.hookFns.markAllRead,
trackNewMessage: mocks.hookFns.trackNewMessage,
}),
useRepeaterMode: () => ({
repeaterLoggedIn: false,
activeContactIsRepeater: false,
handleTelemetryRequest: mocks.hookFns.handleTelemetryRequest,
handleRepeaterCommand: mocks.hookFns.handleRepeaterCommand,
}),
getMessageContentKey: () => 'content-key',
};
});
@@ -44,12 +44,6 @@ vi.mock('../hooks', async (importOriginal) => {
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
}),
useRepeaterMode: () => ({
repeaterLoggedIn: false,
activeContactIsRepeater: false,
handleTelemetryRequest: vi.fn(),
handleRepeaterCommand: vi.fn(),
}),
getMessageContentKey: () => 'content-key',
};
});
+81
View File
@@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { getLocalLabel, setLocalLabel, getContrastTextColor } from '../utils/localLabel';
describe('localLabel utilities', () => {
beforeEach(() => {
localStorage.clear();
});
describe('getLocalLabel', () => {
it('returns default when nothing stored', () => {
const label = getLocalLabel();
expect(label.text).toBe('');
expect(label.color).toBe('#062d60');
});
it('returns stored label', () => {
localStorage.setItem(
'remoteterm-local-label',
JSON.stringify({ text: 'Dev', color: '#ff0000' })
);
const label = getLocalLabel();
expect(label.text).toBe('Dev');
expect(label.color).toBe('#ff0000');
});
it('handles corrupted JSON gracefully', () => {
localStorage.setItem('remoteterm-local-label', '{bad json');
const label = getLocalLabel();
expect(label.text).toBe('');
expect(label.color).toBe('#062d60');
});
it('handles partial stored data', () => {
localStorage.setItem('remoteterm-local-label', JSON.stringify({ text: 'Hi' }));
const label = getLocalLabel();
expect(label.text).toBe('Hi');
expect(label.color).toBe('#062d60'); // falls back to default color
});
it('handles non-string values in stored data', () => {
localStorage.setItem('remoteterm-local-label', JSON.stringify({ text: 123, color: true }));
const label = getLocalLabel();
expect(label.text).toBe(''); // non-string falls back
expect(label.color).toBe('#062d60');
});
});
describe('setLocalLabel', () => {
it('stores label to localStorage', () => {
setLocalLabel('Test', '#00ff00');
const raw = localStorage.getItem('remoteterm-local-label');
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw!);
expect(parsed.text).toBe('Test');
expect(parsed.color).toBe('#00ff00');
});
});
describe('getContrastTextColor', () => {
it('returns white for dark colors', () => {
expect(getContrastTextColor('#000000')).toBe('white');
expect(getContrastTextColor('#062d60')).toBe('white');
expect(getContrastTextColor('#333333')).toBe('white');
});
it('returns black for light colors', () => {
expect(getContrastTextColor('#ffffff')).toBe('black');
expect(getContrastTextColor('#ffff00')).toBe('black');
expect(getContrastTextColor('#00ff00')).toBe('black');
});
it('handles hex with # prefix', () => {
expect(getContrastTextColor('#000000')).toBe('white');
});
it('handles hex without # prefix', () => {
expect(getContrastTextColor('000000')).toBe('white');
expect(getContrastTextColor('ffffff')).toBe('black');
});
});
});
@@ -0,0 +1,270 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { RepeaterDashboard } from '../components/RepeaterDashboard';
import type { UseRepeaterDashboardResult } from '../hooks/useRepeaterDashboard';
import type { Contact, Conversation, Favorite } from '../types';
// Mock the hook — typed as mutable version of the return type
const mockHook: {
-readonly [K in keyof UseRepeaterDashboardResult]: UseRepeaterDashboardResult[K];
} = {
loggedIn: false,
loginLoading: false,
loginError: null,
paneData: {
status: null,
neighbors: null,
acl: null,
radioSettings: null,
advertIntervals: null,
ownerInfo: null,
lppTelemetry: null,
},
paneStates: {
status: { loading: false, attempt: 0, error: null },
neighbors: { loading: false, attempt: 0, error: null },
acl: { loading: false, attempt: 0, error: null },
radioSettings: { loading: false, attempt: 0, error: null },
advertIntervals: { loading: false, attempt: 0, error: null },
ownerInfo: { loading: false, attempt: 0, error: null },
lppTelemetry: { loading: false, attempt: 0, error: null },
},
consoleHistory: [],
consoleLoading: false,
login: vi.fn(),
loginAsGuest: vi.fn(),
refreshPane: vi.fn(),
loadAll: vi.fn(),
sendConsoleCommand: vi.fn(),
sendAdvert: vi.fn(),
rebootRepeater: vi.fn(),
syncClock: vi.fn(),
};
vi.mock('../hooks/useRepeaterDashboard', () => ({
useRepeaterDashboard: () => mockHook,
}));
// Mock sonner toast
vi.mock('../components/ui/sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
},
}));
// Mock leaflet imports (not needed in test)
vi.mock('react-leaflet', () => ({
MapContainer: () => null,
TileLayer: () => null,
CircleMarker: () => null,
Popup: () => null,
}));
const REPEATER_KEY = 'aa'.repeat(32);
const conversation: Conversation = {
type: 'contact',
id: REPEATER_KEY,
name: 'TestRepeater',
};
const contacts: Contact[] = [
{
public_key: REPEATER_KEY,
name: 'TestRepeater',
type: 2,
flags: 0,
last_path: null,
last_path_len: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
},
];
const favorites: Favorite[] = [];
const defaultProps = {
conversation,
contacts,
favorites,
radioLat: null,
radioLon: null,
radioName: null,
onTrace: vi.fn(),
onToggleFavorite: vi.fn(),
onDeleteContact: vi.fn(),
};
describe('RepeaterDashboard', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset mock hook state
mockHook.loggedIn = false;
mockHook.loginLoading = false;
mockHook.loginError = null;
mockHook.paneData = {
status: null,
neighbors: null,
acl: null,
radioSettings: null,
advertIntervals: null,
ownerInfo: null,
lppTelemetry: null,
};
mockHook.paneStates = {
status: { loading: false, attempt: 0, error: null },
neighbors: { loading: false, attempt: 0, error: null },
acl: { loading: false, attempt: 0, error: null },
radioSettings: { loading: false, attempt: 0, error: null },
advertIntervals: { loading: false, attempt: 0, error: null },
ownerInfo: { loading: false, attempt: 0, error: null },
lppTelemetry: { loading: false, attempt: 0, error: null },
};
mockHook.consoleHistory = [];
mockHook.consoleLoading = false;
});
it('renders login form when not logged in', () => {
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Login with Password')).toBeInTheDocument();
expect(screen.getByText('Login as Guest / ACLs')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Repeater password...')).toBeInTheDocument();
expect(screen.getByText('Log in to access repeater dashboard')).toBeInTheDocument();
});
it('renders dashboard panes when logged in', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Telemetry')).toBeInTheDocument();
expect(screen.getByText('Neighbors')).toBeInTheDocument();
expect(screen.getByText('ACL')).toBeInTheDocument();
expect(screen.getByText('Radio Settings')).toBeInTheDocument();
expect(screen.getByText('Advert Intervals')).toBeInTheDocument(); // sub-section inside Radio Settings
expect(screen.getByText('LPP Sensors')).toBeInTheDocument();
expect(screen.getByText('Owner Info')).toBeInTheDocument();
expect(screen.getByText('Actions')).toBeInTheDocument();
expect(screen.getByText('Console')).toBeInTheDocument();
});
it('shows not fetched placeholder for empty panes', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
// All panes should show <not fetched> since data is null
const notFetched = screen.getAllByText('<not fetched>');
expect(notFetched.length).toBeGreaterThanOrEqual(7); // At least 7 data panes (incl. LPP Sensors)
});
it('shows Load All button when logged in', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Load All')).toBeInTheDocument();
});
it('calls loadAll when Load All button is clicked', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
fireEvent.click(screen.getByText('Load All'));
expect(mockHook.loadAll).toHaveBeenCalledTimes(1);
});
it('shows login error when present', () => {
mockHook.loginError = 'Invalid password';
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Invalid password')).toBeInTheDocument();
});
it('shows pane error when fetch fails', () => {
mockHook.loggedIn = true;
mockHook.paneStates.status = { loading: false, attempt: 3, error: 'Timeout' };
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Timeout')).toBeInTheDocument();
});
it('shows fetching state with attempt counter', () => {
mockHook.loggedIn = true;
mockHook.paneStates.status = { loading: true, attempt: 2, error: null };
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Fetching (attempt 2/3)...')).toBeInTheDocument();
});
it('renders telemetry data when available', () => {
mockHook.loggedIn = true;
mockHook.paneData.status = {
battery_volts: 4.2,
tx_queue_len: 0,
noise_floor_dbm: -120,
last_rssi_dbm: -85,
last_snr_db: 7.5,
packets_received: 100,
packets_sent: 50,
airtime_seconds: 600,
rx_airtime_seconds: 1200,
uptime_seconds: 86400,
sent_flood: 10,
sent_direct: 40,
recv_flood: 30,
recv_direct: 70,
flood_dups: 1,
direct_dups: 0,
full_events: 0,
};
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('4.200V')).toBeInTheDocument();
expect(screen.getByText('-120 dBm')).toBeInTheDocument();
expect(screen.getByText('7.5 dB')).toBeInTheDocument();
});
it('renders action buttons', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Send Advert')).toBeInTheDocument();
expect(screen.getByText('Sync Clock')).toBeInTheDocument();
expect(screen.getByText('Reboot')).toBeInTheDocument();
});
it('calls onTrace when trace button clicked', () => {
render(<RepeaterDashboard {...defaultProps} />);
// The trace button has title "Direct Trace"
fireEvent.click(screen.getByTitle('Direct Trace'));
expect(defaultProps.onTrace).toHaveBeenCalledTimes(1);
});
it('console shows placeholder when empty', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Type a CLI command below...')).toBeInTheDocument();
});
});
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { formatDuration, formatClockDrift } from '../components/RepeaterDashboard';
describe('formatDuration', () => {
it('formats seconds only', () => {
expect(formatDuration(0)).toBe('0s');
expect(formatDuration(30)).toBe('30s');
expect(formatDuration(59)).toBe('59s');
});
it('formats minutes only', () => {
expect(formatDuration(60)).toBe('1m');
expect(formatDuration(300)).toBe('5m');
expect(formatDuration(3540)).toBe('59m');
});
it('formats hours and minutes', () => {
expect(formatDuration(3600)).toBe('1h');
expect(formatDuration(3660)).toBe('1h1m');
expect(formatDuration(7200)).toBe('2h');
expect(formatDuration(7260)).toBe('2h1m');
});
it('formats days', () => {
expect(formatDuration(86400)).toBe('1d');
expect(formatDuration(86400 + 3600)).toBe('1d1h');
expect(formatDuration(86400 + 60)).toBe('1d1m');
expect(formatDuration(86400 + 3600 + 60)).toBe('1d1h1m');
expect(formatDuration(172800)).toBe('2d');
});
it('formats multi-day durations', () => {
expect(formatDuration(3 * 86400 + 12 * 3600 + 30 * 60)).toBe('3d12h30m');
});
});
describe('formatClockDrift', () => {
afterEach(() => {
vi.useRealTimers();
});
it('parses firmware format HH:MM - D/M/YYYY UTC', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-09T12:30:00Z'));
const result = formatClockDrift('12:30 - 9/1/2025 UTC');
expect(result.isLarge).toBe(false);
expect(result.text).toBe('0s');
});
it('parses firmware format with seconds HH:MM:SS - D/M/YYYY', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-06-15T08:00:00Z'));
const result = formatClockDrift('08:00:00 - 15/6/2025 UTC');
expect(result.isLarge).toBe(false);
expect(result.text).toBe('0s');
});
it('reports large drift (>24h)', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-11T12:30:00Z'));
const result = formatClockDrift('12:30 - 9/1/2025 UTC');
expect(result.isLarge).toBe(true);
expect(result.text).toBe('>24 hours!');
});
it('handles invalid date strings', () => {
const result = formatClockDrift('not a date');
expect(result.text).toBe('(invalid)');
expect(result.isLarge).toBe(false);
});
it('formats multi-unit drift', () => {
vi.useFakeTimers();
// 1h30m5s drift
vi.setSystemTime(new Date('2025-01-09T14:00:05Z'));
const result = formatClockDrift('12:30 - 9/1/2025 UTC');
expect(result.isLarge).toBe(false);
expect(result.text).toBe('1h30m5s');
});
it('formats minutes and seconds drift', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-09T12:35:10Z'));
const result = formatClockDrift('12:30 - 9/1/2025 UTC');
expect(result.isLarge).toBe(false);
expect(result.text).toBe('5m10s');
});
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { RepeaterLogin } from '../components/RepeaterLogin';
describe('RepeaterLogin', () => {
const defaultProps = {
repeaterName: 'TestRepeater',
loading: false,
error: null as string | null,
onLogin: vi.fn(),
onLoginAsGuest: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders repeater name and description', () => {
render(<RepeaterLogin {...defaultProps} />);
expect(screen.getByText('TestRepeater')).toBeInTheDocument();
expect(screen.getByText('Log in to access repeater dashboard')).toBeInTheDocument();
});
it('renders password input and buttons', () => {
render(<RepeaterLogin {...defaultProps} />);
expect(screen.getByPlaceholderText('Repeater password...')).toBeInTheDocument();
expect(screen.getByText('Login with Password')).toBeInTheDocument();
expect(screen.getByText('Login as Guest / ACLs')).toBeInTheDocument();
});
it('calls onLogin with trimmed password on submit', () => {
render(<RepeaterLogin {...defaultProps} />);
const input = screen.getByPlaceholderText('Repeater password...');
fireEvent.change(input, { target: { value: ' secret ' } });
fireEvent.submit(screen.getByText('Login with Password').closest('form')!);
expect(defaultProps.onLogin).toHaveBeenCalledWith('secret');
});
it('calls onLoginAsGuest when guest button clicked', () => {
render(<RepeaterLogin {...defaultProps} />);
fireEvent.click(screen.getByText('Login as Guest / ACLs'));
expect(defaultProps.onLoginAsGuest).toHaveBeenCalledTimes(1);
});
it('disables inputs when loading', () => {
render(<RepeaterLogin {...defaultProps} loading={true} />);
expect(screen.getByPlaceholderText('Repeater password...')).toBeDisabled();
expect(screen.getByText('Logging in...')).toBeDisabled();
expect(screen.getByText('Login as Guest / ACLs')).toBeDisabled();
});
it('shows loading text on submit button', () => {
render(<RepeaterLogin {...defaultProps} loading={true} />);
expect(screen.getByText('Logging in...')).toBeInTheDocument();
expect(screen.queryByText('Login with Password')).not.toBeInTheDocument();
});
it('displays error message when present', () => {
render(<RepeaterLogin {...defaultProps} error="Invalid password" />);
expect(screen.getByText('Invalid password')).toBeInTheDocument();
});
it('does not call onLogin when loading', () => {
render(<RepeaterLogin {...defaultProps} loading={true} />);
fireEvent.submit(screen.getByText('Logging in...').closest('form')!);
expect(defaultProps.onLogin).not.toHaveBeenCalled();
});
});
@@ -1,126 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAirtimeTracking } from '../hooks/useAirtimeTracking';
import type { Message, TelemetryResponse } from '../types';
function createTelemetry(overrides: Partial<TelemetryResponse> = {}): TelemetryResponse {
return {
pubkey_prefix: 'AABB',
battery_volts: 3.7,
tx_queue_len: 0,
noise_floor_dbm: -120,
last_rssi_dbm: -80,
last_snr_db: 10,
packets_received: 100,
packets_sent: 50,
airtime_seconds: 10,
rx_airtime_seconds: 5,
uptime_seconds: 3600,
sent_flood: 30,
sent_direct: 20,
recv_flood: 60,
recv_direct: 40,
flood_dups: 5,
direct_dups: 2,
full_events: 0,
clock_output: null,
neighbors: [],
acl: [],
...overrides,
};
}
function createDeferred<T>() {
let resolve: (value: T | PromiseLike<T>) => void = () => {};
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
const mockRequestTelemetry = vi.fn<(...args: unknown[]) => Promise<TelemetryResponse>>();
vi.mock('../api', () => ({
api: {
requestTelemetry: (...args: unknown[]) => mockRequestTelemetry(...args),
},
}));
describe('useAirtimeTracking stale poll guard', () => {
beforeEach(() => {
mockRequestTelemetry.mockReset();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('discards poll response when tracking was stopped during in-flight request', async () => {
const setMessages = vi.fn<React.Dispatch<React.SetStateAction<Message[]>>>();
// Initial telemetry for dutycycle_start succeeds immediately
mockRequestTelemetry.mockResolvedValueOnce(createTelemetry());
const { result } = renderHook(() => useAirtimeTracking(setMessages));
// Start tracking
await act(async () => {
await result.current.handleAirtimeCommand('dutycycle_start', 'repeater_a');
});
// setMessages was called with the start message
const startCallCount = setMessages.mock.calls.length;
expect(startCallCount).toBeGreaterThanOrEqual(1);
// Set up a deferred telemetry response for the poll
const deferred = createDeferred<TelemetryResponse>();
mockRequestTelemetry.mockReturnValueOnce(deferred.promise);
// Advance timer to trigger the 5-minute poll
act(() => {
vi.advanceTimersByTime(5 * 60 * 1000);
});
// Poll is now in-flight. Stop tracking (simulates conversation switch).
act(() => {
result.current.stopTracking();
});
// Resolve the stale telemetry response
await act(async () => {
deferred.resolve(createTelemetry({ uptime_seconds: 7200 }));
});
// setMessages should NOT have been called with the stale poll result
// Only the start message calls should exist
expect(setMessages.mock.calls.length).toBe(startCallCount);
});
it('appends poll result when tracking is still active', async () => {
const setMessages = vi.fn<React.Dispatch<React.SetStateAction<Message[]>>>();
// Initial telemetry for dutycycle_start
mockRequestTelemetry.mockResolvedValueOnce(createTelemetry());
const { result } = renderHook(() => useAirtimeTracking(setMessages));
await act(async () => {
await result.current.handleAirtimeCommand('dutycycle_start', 'repeater_a');
});
const startCallCount = setMessages.mock.calls.length;
// Set up poll response
mockRequestTelemetry.mockResolvedValueOnce(createTelemetry({ uptime_seconds: 7200 }));
// Advance timer to trigger the 5-minute poll
await act(async () => {
vi.advanceTimersByTime(5 * 60 * 1000);
});
// setMessages SHOULD have been called with the poll result
expect(setMessages.mock.calls.length).toBeGreaterThan(startCallCount);
});
});
@@ -0,0 +1,289 @@
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import type { Conversation } from '../types';
// Mock the api module
vi.mock('../api', () => ({
api: {
repeaterLogin: vi.fn(),
repeaterStatus: vi.fn(),
repeaterNeighbors: vi.fn(),
repeaterAcl: vi.fn(),
repeaterRadioSettings: vi.fn(),
repeaterAdvertIntervals: vi.fn(),
repeaterOwnerInfo: vi.fn(),
repeaterLppTelemetry: vi.fn(),
sendRepeaterCommand: vi.fn(),
},
}));
// Mock sonner toast
vi.mock('../components/ui/sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
},
}));
// Get mock reference — cast to Record<string, Mock> for type-safe mock method access
const { api: _rawApi } = await import('../api');
const mockApi = _rawApi as unknown as Record<string, Mock>;
const REPEATER_KEY = 'aa'.repeat(32);
const repeaterConversation: Conversation = {
type: 'contact',
id: REPEATER_KEY,
name: 'TestRepeater',
};
describe('useRepeaterDashboard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('starts with logged out state', () => {
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
expect(result.current.loggedIn).toBe(false);
expect(result.current.loginLoading).toBe(false);
expect(result.current.loginError).toBe(null);
});
it('login sets loggedIn on success', async () => {
mockApi.repeaterLogin.mockResolvedValueOnce({ status: 'ok' });
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.login('secret');
});
expect(result.current.loggedIn).toBe(true);
expect(result.current.loginError).toBe(null);
expect(mockApi.repeaterLogin).toHaveBeenCalledWith(REPEATER_KEY, 'secret');
});
it('login sets error on failure', async () => {
mockApi.repeaterLogin.mockRejectedValueOnce(new Error('Auth failed'));
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.login('bad');
});
expect(result.current.loggedIn).toBe(false);
expect(result.current.loginError).toBe('Auth failed');
});
it('loginAsGuest calls login with empty password', async () => {
mockApi.repeaterLogin.mockResolvedValueOnce({ status: 'ok' });
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.loginAsGuest();
});
expect(mockApi.repeaterLogin).toHaveBeenCalledWith(REPEATER_KEY, '');
expect(result.current.loggedIn).toBe(true);
});
it('refreshPane stores data on success', async () => {
const statusData = {
battery_volts: 4.2,
tx_queue_len: 0,
noise_floor_dbm: -120,
last_rssi_dbm: -85,
last_snr_db: 7.5,
packets_received: 100,
packets_sent: 50,
airtime_seconds: 600,
rx_airtime_seconds: 1200,
uptime_seconds: 86400,
sent_flood: 10,
sent_direct: 40,
recv_flood: 30,
recv_direct: 70,
flood_dups: 1,
direct_dups: 0,
full_events: 0,
};
mockApi.repeaterStatus.mockResolvedValueOnce(statusData);
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.refreshPane('status');
});
expect(result.current.paneData.status).toEqual(statusData);
expect(result.current.paneStates.status.loading).toBe(false);
expect(result.current.paneStates.status.error).toBe(null);
});
it('refreshPane retries up to 3 times', async () => {
mockApi.repeaterStatus.mockRejectedValueOnce(new Error('fail1'));
mockApi.repeaterStatus.mockRejectedValueOnce(new Error('fail2'));
mockApi.repeaterStatus.mockRejectedValueOnce(new Error('fail3'));
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.refreshPane('status');
});
expect(mockApi.repeaterStatus).toHaveBeenCalledTimes(3);
expect(result.current.paneStates.status.error).toBe('fail3');
expect(result.current.paneData.status).toBe(null);
});
it('refreshPane succeeds on second attempt', async () => {
const statusData = { battery_volts: 3.7 };
mockApi.repeaterStatus.mockRejectedValueOnce(new Error('fail1'));
mockApi.repeaterStatus.mockResolvedValueOnce(statusData);
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.refreshPane('status');
});
expect(mockApi.repeaterStatus).toHaveBeenCalledTimes(2);
expect(result.current.paneData.status).toEqual(statusData);
expect(result.current.paneStates.status.error).toBe(null);
});
it('sendConsoleCommand adds entries to console history', async () => {
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
command: 'ver',
response: 'v2.1.0',
sender_timestamp: 1000,
});
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.sendConsoleCommand('ver');
});
expect(result.current.consoleHistory).toHaveLength(2);
expect(result.current.consoleHistory[0].outgoing).toBe(true);
expect(result.current.consoleHistory[0].command).toBe('ver');
expect(result.current.consoleHistory[1].outgoing).toBe(false);
expect(result.current.consoleHistory[1].response).toBe('v2.1.0');
});
it('sendConsoleCommand adds error entry on failure', async () => {
mockApi.sendRepeaterCommand.mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.sendConsoleCommand('ver');
});
expect(result.current.consoleHistory).toHaveLength(2);
expect(result.current.consoleHistory[0].outgoing).toBe(true);
expect(result.current.consoleHistory[0].command).toBe('ver');
expect(result.current.consoleHistory[1].outgoing).toBe(false);
expect(result.current.consoleHistory[1].response).toBe('Error: Network error');
expect(result.current.consoleLoading).toBe(false);
});
it('sendAdvert sends "advert" command', async () => {
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
command: 'advert',
response: 'ok',
sender_timestamp: 1000,
});
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.sendAdvert();
});
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'advert');
});
it('rebootRepeater sends "reboot" command', async () => {
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
command: 'reboot',
response: 'ok',
sender_timestamp: 1000,
});
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.rebootRepeater();
});
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'reboot');
});
it('syncClock sends "clock <epoch>" command', async () => {
const fakeNow = 1700000000000;
vi.spyOn(Date, 'now').mockReturnValue(fakeNow);
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
command: 'clock 1700000000',
response: 'ok',
sender_timestamp: 1000,
});
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.syncClock();
});
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'clock 1700000000');
vi.restoreAllMocks();
});
it('loadAll calls refreshPane for all panes serially', async () => {
mockApi.repeaterStatus.mockResolvedValueOnce({ battery_volts: 4.0 });
mockApi.repeaterNeighbors.mockResolvedValueOnce({ neighbors: [] });
mockApi.repeaterAcl.mockResolvedValueOnce({ acl: [] });
mockApi.repeaterRadioSettings.mockResolvedValueOnce({
firmware_version: 'v1.0',
radio: null,
tx_power: null,
airtime_factor: null,
repeat_enabled: null,
flood_max: null,
name: null,
lat: null,
lon: null,
clock_utc: null,
});
mockApi.repeaterAdvertIntervals.mockResolvedValueOnce({
advert_interval: null,
flood_advert_interval: null,
});
mockApi.repeaterOwnerInfo.mockResolvedValueOnce({
owner_info: null,
guest_password: null,
});
mockApi.repeaterLppTelemetry.mockResolvedValueOnce({ sensors: [] });
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.loadAll();
});
expect(mockApi.repeaterStatus).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterNeighbors).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterAcl).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterRadioSettings).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterAdvertIntervals).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterOwnerInfo).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterLppTelemetry).toHaveBeenCalledTimes(1);
});
});
+67 -9
View File
@@ -218,8 +218,19 @@ export interface AclEntry {
permission_name: string;
}
export interface TelemetryResponse {
pubkey_prefix: string;
export interface CommandResponse {
command: string;
response: string;
sender_timestamp: number | null;
}
// --- Granular repeater endpoint types ---
export interface RepeaterLoginResponse {
status: string;
}
export interface RepeaterStatusResponse {
battery_volts: number;
tx_queue_len: number;
noise_floor_dbm: number;
@@ -237,15 +248,62 @@ export interface TelemetryResponse {
flood_dups: number;
direct_dups: number;
full_events: number;
neighbors: NeighborInfo[];
acl: AclEntry[];
clock_output: string | null;
}
export interface CommandResponse {
command: string;
response: string;
sender_timestamp: number | null;
export interface RepeaterNeighborsResponse {
neighbors: NeighborInfo[];
}
export interface RepeaterAclResponse {
acl: AclEntry[];
}
export interface RepeaterRadioSettingsResponse {
firmware_version: string | null;
radio: string | null;
tx_power: string | null;
airtime_factor: string | null;
repeat_enabled: string | null;
flood_max: string | null;
name: string | null;
lat: string | null;
lon: string | null;
clock_utc: string | null;
}
export interface RepeaterAdvertIntervalsResponse {
advert_interval: string | null;
flood_advert_interval: string | null;
}
export interface RepeaterOwnerInfoResponse {
owner_info: string | null;
guest_password: string | null;
}
export interface LppSensor {
channel: number;
type_name: string;
value: number | Record<string, number>;
}
export interface RepeaterLppTelemetryResponse {
sensors: LppSensor[];
}
export type PaneName =
| 'status'
| 'neighbors'
| 'acl'
| 'radioSettings'
| 'advertIntervals'
| 'ownerInfo'
| 'lppTelemetry';
export interface PaneState {
loading: boolean;
attempt: number;
error: string | null;
}
export interface TraceResponse {