Massive frontend overhaul for settings menu and channel addition.

This commit is contained in:
Jack Kingsman
2026-01-17 18:17:11 -08:00
parent bbe2bba5b6
commit 9652cb3277
21 changed files with 1499 additions and 1069 deletions
+33 -39
View File
@@ -12,8 +12,7 @@ import { Sidebar } from './components/Sidebar';
import { MessageList } from './components/MessageList';
import { MessageInput, type MessageInputHandle } from './components/MessageInput';
import { NewMessageModal } from './components/NewMessageModal';
import { ConfigModal } from './components/ConfigModal';
import { MaintenanceModal } from './components/MaintenanceModal';
import { SettingsModal } from './components/SettingsModal';
import { RawPacketList } from './components/RawPacketList';
import { MapView } from './components/MapView';
import { CrackerPanel } from './components/CrackerPanel';
@@ -54,8 +53,7 @@ export function App() {
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
const [showNewMessage, setShowNewMessage] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [showMaintenance, setShowMaintenance] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [undecryptedCount, setUndecryptedCount] = useState(0);
const [showCracker, setShowCracker] = useState(false);
@@ -564,9 +562,7 @@ export function App() {
<StatusBar
health={health}
config={config}
onConfigClick={() => setShowConfig(true)}
onMaintenanceClick={() => setShowMaintenance(true)}
onAdvertise={handleAdvertise}
onSettingsClick={() => setShowSettings(true)}
onMenuClick={() => setSidebarOpen(true)}
/>
@@ -617,30 +613,32 @@ export function App() {
</span>
<span className="font-normal text-sm text-muted-foreground font-mono truncate">
{activeConversation.id}
{activeConversation.type === 'contact' &&
(() => {
const contact = contacts.find(
(c) => c.public_key === activeConversation.id
);
if (!contact) return null;
const parts: string[] = [];
if (contact.last_seen) {
parts.push(`Last heard: ${formatTime(contact.last_seen)}`);
}
if (contact.last_path_len === -1) {
parts.push('flood');
} else if (contact.last_path_len === 0) {
parts.push('direct');
} else if (contact.last_path_len > 0) {
parts.push(
`${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`
);
}
return parts.length > 0 ? (
<span className="ml-2 font-sans">({parts.join(', ')})</span>
) : null;
})()}
</span>
{activeConversation.type === 'contact' &&
(() => {
const contact = contacts.find(
(c) => c.public_key === activeConversation.id
);
if (!contact) return null;
const parts: string[] = [];
if (contact.last_seen) {
parts.push(`Last heard: ${formatTime(contact.last_seen)}`);
}
if (contact.last_path_len === -1) {
parts.push('flood');
} else if (contact.last_path_len === 0) {
parts.push('direct');
} else if (contact.last_path_len > 0) {
parts.push(
`${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`
);
}
return parts.length > 0 ? (
<span className="font-normal text-sm text-muted-foreground flex-shrink-0">
({parts.join(', ')})
</span>
) : null;
})()}
</span>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Favorite button */}
@@ -779,21 +777,17 @@ export function App() {
onCreateHashtagChannel={handleCreateHashtagChannel}
/>
<ConfigModal
open={showConfig}
<SettingsModal
open={showSettings}
config={config}
health={health}
appSettings={appSettings}
onClose={() => setShowConfig(false)}
onClose={() => setShowSettings(false)}
onSave={handleSaveConfig}
onSaveAppSettings={handleSaveAppSettings}
onSetPrivateKey={handleSetPrivateKey}
onReboot={handleReboot}
/>
<MaintenanceModal
open={showMaintenance}
health={health}
onClose={() => setShowMaintenance(false)}
onAdvertise={handleAdvertise}
onHealthRefresh={async () => {
const data = await api.getHealth();
setHealth(data);
-314
View File
@@ -1,314 +0,0 @@
import { useState, useEffect } from 'react';
import type { AppSettings, AppSettingsUpdate, RadioConfig, RadioConfigUpdate } from '../types';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Button } from './ui/button';
import { Separator } from './ui/separator';
import { Alert, AlertDescription } from './ui/alert';
interface ConfigModalProps {
open: boolean;
config: RadioConfig | null;
appSettings: AppSettings | null;
onClose: () => void;
onSave: (update: RadioConfigUpdate) => Promise<void>;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
onSetPrivateKey: (key: string) => Promise<void>;
onReboot: () => Promise<void>;
}
export function ConfigModal({
open,
config,
appSettings,
onClose,
onSave,
onSaveAppSettings,
onSetPrivateKey,
onReboot,
}: ConfigModalProps) {
const [name, setName] = useState('');
const [lat, setLat] = useState('');
const [lon, setLon] = useState('');
const [txPower, setTxPower] = useState('');
const [freq, setFreq] = useState('');
const [bw, setBw] = useState('');
const [sf, setSf] = useState('');
const [cr, setCr] = useState('');
const [privateKey, setPrivateKey] = useState('');
const [maxRadioContacts, setMaxRadioContacts] = useState('');
const [loading, setLoading] = useState(false);
const [rebooting, setRebooting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (config) {
setName(config.name);
setLat(String(config.lat));
setLon(String(config.lon));
setTxPower(String(config.tx_power));
setFreq(String(config.radio.freq));
setBw(String(config.radio.bw));
setSf(String(config.radio.sf));
setCr(String(config.radio.cr));
}
}, [config]);
useEffect(() => {
if (appSettings) {
setMaxRadioContacts(String(appSettings.max_radio_contacts));
}
}, [appSettings]);
const handleSave = async () => {
setError('');
setLoading(true);
try {
const update: RadioConfigUpdate = {
name,
lat: parseFloat(lat),
lon: parseFloat(lon),
tx_power: parseInt(txPower, 10),
radio: {
freq: parseFloat(freq),
bw: parseFloat(bw),
sf: parseInt(sf, 10),
cr: parseInt(cr, 10),
},
};
await onSave(update);
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings?.max_radio_contacts) {
await onSaveAppSettings({ max_radio_contacts: newMaxRadioContacts });
}
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setLoading(false);
}
};
const handleSetPrivateKey = async () => {
if (!privateKey.trim()) {
setError('Private key is required');
return;
}
setError('');
setLoading(true);
try {
await onSetPrivateKey(privateKey.trim());
setPrivateKey('');
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to set private key');
} finally {
setLoading(false);
}
};
const handleReboot = async () => {
if (
!confirm('Are you sure you want to reboot the radio? The connection will drop temporarily.')
) {
return;
}
setError('');
setRebooting(true);
try {
await onReboot();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reboot radio');
} finally {
setRebooting(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Radio Configuration</DialogTitle>
</DialogHeader>
{!config ? (
<div className="py-8 text-center text-muted-foreground">Loading configuration...</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="lat">Latitude</Label>
<Input
id="lat"
type="number"
step="any"
value={lat}
onChange={(e) => setLat(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lon">Longitude</Label>
<Input
id="lon"
type="number"
step="any"
value={lon}
onChange={(e) => setLon(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="freq">Frequency (MHz)</Label>
<Input
id="freq"
type="number"
step="any"
value={freq}
onChange={(e) => setFreq(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="bw">Bandwidth (kHz)</Label>
<Input
id="bw"
type="number"
step="any"
value={bw}
onChange={(e) => setBw(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sf">Spreading Factor</Label>
<Input
id="sf"
type="number"
min="7"
max="12"
value={sf}
onChange={(e) => setSf(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cr">Coding Rate</Label>
<Input
id="cr"
type="number"
min="1"
max="4"
value={cr}
onChange={(e) => setCr(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tx-power">TX Power (dBm)</Label>
<Input
id="tx-power"
type="number"
value={txPower}
onChange={(e) => setTxPower(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-tx">Max TX Power</Label>
<Input id="max-tx" type="number" value={config.max_tx_power} disabled />
</div>
</div>
<Separator className="my-4" />
<div className="space-y-2">
<Label htmlFor="max-contacts">Max Contacts on Radio</Label>
<Input
id="max-contacts"
type="number"
min="1"
max="1000"
value={maxRadioContacts}
onChange={(e) => setMaxRadioContacts(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Recent non-repeater contacts loaded to radio for DM auto-ACK (1-1000)
</p>
</div>
<Separator className="my-4" />
<div className="space-y-2">
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<div className="flex gap-2">
<Input
id="private-key"
type="password"
autoComplete="off"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
className="flex-1"
/>
<Button onClick={handleSetPrivateKey} disabled={loading || !privateKey.trim()}>
Set
</Button>
</div>
</div>
<Separator className="my-4" />
<div className="space-y-3">
<Label>Reboot Radio</Label>
<Alert variant="warning">
<AlertDescription>
Some configuration changes (like name) require a radio reboot to take effect. The
connection will temporarily drop and automatically reconnect.
</AlertDescription>
</Alert>
<Button
variant="outline"
onClick={handleReboot}
disabled={rebooting || loading}
className="border-yellow-500/50 text-yellow-200 hover:bg-yellow-500/10"
>
{rebooting ? 'Rebooting...' : 'Reboot Radio'}
</Button>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || !config}>
{loading ? 'Saving...' : 'Save Config'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,97 +0,0 @@
import { useState } from 'react';
import type { HealthStatus } from '../types';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Button } from './ui/button';
import { toast } from './ui/sonner';
import { api } from '../api';
interface MaintenanceModalProps {
open: boolean;
health: HealthStatus | null;
onClose: () => void;
onHealthRefresh: () => Promise<void>;
}
export function MaintenanceModal({
open,
health,
onClose,
onHealthRefresh,
}: MaintenanceModalProps) {
const [retentionDays, setRetentionDays] = useState('14');
const [cleaning, setCleaning] = useState(false);
const handleCleanup = async () => {
const days = parseInt(retentionDays, 10);
if (isNaN(days) || days < 1) {
toast.error('Invalid retention days', {
description: 'Retention days must be at least 1',
});
return;
}
setCleaning(true);
try {
const result = await api.runMaintenance(days);
toast.success('Database cleanup complete', {
description: `Deleted ${result.packets_deleted} old packet${result.packets_deleted === 1 ? '' : 's'}`,
});
// Refresh health to get updated database size
await onHealthRefresh();
} catch (err) {
console.error('Failed to run maintenance:', err);
toast.error('Database cleanup failed', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setCleaning(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Database Maintenance</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Current database size:{' '}
<span className="font-medium">{health?.database_size_mb ?? '?'} MB</span>
</p>
<div className="space-y-3">
<Label>Cleanup Old Packets</Label>
<p className="text-xs text-muted-foreground">
Delete undecrypted packets older than the specified days. This helps manage storage
for packets that couldn't be decrypted (unknown channel keys).
</p>
<div className="flex gap-2 items-end">
<div className="space-y-1">
<Label htmlFor="retention-days" className="text-xs">
Days to retain
</Label>
<Input
id="retention-days"
type="number"
min="1"
max="365"
value={retentionDays}
onChange={(e) => setRetentionDays(e.target.value)}
className="w-20"
/>
</div>
<Button variant="outline" onClick={handleCleanup} disabled={cleaning}>
{cleaning ? 'Cleaning...' : 'Cleanup'}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+48 -15
View File
@@ -1,7 +1,14 @@
import { useState, useRef } from 'react';
import type { Contact, Conversation } from '../types';
import { getContactDisplayName } from '../utils/pubkey';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from './ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
import { Input } from './ui/input';
import { Label } from './ui/label';
@@ -33,7 +40,8 @@ export function NewMessageModal({
}: NewMessageModalProps) {
const [tab, setTab] = useState<Tab>('existing');
const [name, setName] = useState('');
const [key, setKey] = useState('');
const [contactKey, setContactKey] = useState('');
const [roomKey, setRoomKey] = useState('');
const [tryHistorical, setTryHistorical] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
@@ -45,22 +53,22 @@ export function NewMessageModal({
try {
if (tab === 'new-contact') {
if (!name.trim() || !key.trim()) {
if (!name.trim() || !contactKey.trim()) {
setError('Name and public key are required');
return;
}
await onCreateContact(name.trim(), key.trim(), tryHistorical);
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
onSelectConversation({
type: 'contact',
id: key.trim(),
id: contactKey.trim(),
name: name.trim(),
});
} else if (tab === 'new-room') {
if (!name.trim() || !key.trim()) {
if (!name.trim() || !roomKey.trim()) {
setError('Room name and key are required');
return;
}
await onCreateChannel(name.trim(), key.trim(), tryHistorical);
await onCreateChannel(name.trim(), roomKey.trim(), tryHistorical);
} else if (tab === 'hashtag') {
const channelName = name.trim();
const validationError = validateHashtagName(channelName);
@@ -116,6 +124,12 @@ export function NewMessageModal({
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>New Conversation</DialogTitle>
<DialogDescription className="sr-only">
{tab === 'existing' && 'Select an existing contact to start a conversation'}
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
{tab === 'new-room' && 'Create a private room with a shared encryption key'}
{tab === 'hashtag' && 'Join a public hashtag channel'}
</DialogDescription>
</DialogHeader>
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="w-full">
@@ -165,8 +179,8 @@ export function NewMessageModal({
<Label htmlFor="contact-key">Public Key</Label>
<Input
id="contact-key"
value={key}
onChange={(e) => setKey(e.target.value)}
value={contactKey}
onChange={(e) => setContactKey(e.target.value)}
placeholder="64-character hex public key"
/>
</div>
@@ -184,12 +198,31 @@ export function NewMessageModal({
</div>
<div className="space-y-2">
<Label htmlFor="room-key">Room Key</Label>
<Input
id="room-key"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="Pre-shared key (hex)"
/>
<div className="flex gap-2">
<Input
id="room-key"
value={roomKey}
onChange={(e) => setRoomKey(e.target.value)}
placeholder="Pre-shared key (hex)"
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
setRoomKey(hex);
}}
title="Generate random key"
>
🎲
</Button>
</div>
</div>
</TabsContent>
+660
View File
@@ -0,0 +1,660 @@
import { useState, useEffect, useMemo } from 'react';
import type {
AppSettings,
AppSettingsUpdate,
HealthStatus,
RadioConfig,
RadioConfigUpdate,
} from '../types';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Button } from './ui/button';
import { Separator } from './ui/separator';
import { Alert, AlertDescription } from './ui/alert';
import { toast } from './ui/sonner';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
// Radio presets for common configurations
interface RadioPreset {
name: string;
freq: number;
bw: number;
sf: number;
cr: number;
}
const RADIO_PRESETS: RadioPreset[] = [
{ name: 'USA/Canada', freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
{ name: 'Australia', freq: 915.8, bw: 250, sf: 10, cr: 5 },
{ name: 'Australia (narrow)', freq: 916.575, bw: 62.5, sf: 7, cr: 8 },
{ name: 'Australia SA, WA', freq: 923.125, bw: 62.5, sf: 8, cr: 8 },
{ name: 'Australia QLD', freq: 923.125, bw: 62.5, sf: 8, cr: 5 },
{ name: 'New Zealand', freq: 917.375, bw: 250, sf: 11, cr: 5 },
{ name: 'New Zealand (narrow)', freq: 917.375, bw: 62.5, sf: 7, cr: 5 },
{ name: 'EU/UK/Switzerland Long Range', freq: 869.525, bw: 250, sf: 11, cr: 5 },
{ name: 'EU/UK/Switzerland Medium Range', freq: 869.525, bw: 250, sf: 10, cr: 5 },
{ name: 'EU/UK/Switzerland Narrow', freq: 869.618, bw: 62.5, sf: 8, cr: 8 },
{ name: 'Czech Republic (Narrow)', freq: 869.432, bw: 62.5, sf: 7, cr: 5 },
{ name: 'EU 433MHz Long Range', freq: 433.65, bw: 250, sf: 11, cr: 5 },
{ name: 'Portugal 433MHz', freq: 433.375, bw: 62.5, sf: 9, cr: 6 },
{ name: 'Portugal 868MHz', freq: 869.618, bw: 62.5, sf: 7, cr: 6 },
{ name: 'Vietnam', freq: 920.25, bw: 250, sf: 11, cr: 5 },
];
interface SettingsModalProps {
open: boolean;
config: RadioConfig | null;
health: HealthStatus | null;
appSettings: AppSettings | null;
onClose: () => void;
onSave: (update: RadioConfigUpdate) => Promise<void>;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
onSetPrivateKey: (key: string) => Promise<void>;
onReboot: () => Promise<void>;
onAdvertise: () => Promise<void>;
onHealthRefresh: () => Promise<void>;
}
export function SettingsModal({
open,
config,
health,
appSettings,
onClose,
onSave,
onSaveAppSettings,
onSetPrivateKey,
onReboot,
onAdvertise,
onHealthRefresh,
}: SettingsModalProps) {
// Tab state
type SettingsTab = 'radio' | 'identity' | 'serial' | 'database' | 'advertise';
const [activeTab, setActiveTab] = useState<SettingsTab>('radio');
// Radio config state
const [name, setName] = useState('');
const [lat, setLat] = useState('');
const [lon, setLon] = useState('');
const [txPower, setTxPower] = useState('');
const [freq, setFreq] = useState('');
const [bw, setBw] = useState('');
const [sf, setSf] = useState('');
const [cr, setCr] = useState('');
const [privateKey, setPrivateKey] = useState('');
const [maxRadioContacts, setMaxRadioContacts] = useState('');
// Loading states
const [loading, setLoading] = useState(false);
const [rebooting, setRebooting] = useState(false);
const [advertising, setAdvertising] = useState(false);
const [gettingLocation, setGettingLocation] = useState(false);
const [error, setError] = useState('');
// Database maintenance state
const [retentionDays, setRetentionDays] = useState('14');
const [cleaning, setCleaning] = useState(false);
useEffect(() => {
if (config) {
setName(config.name);
setLat(String(config.lat));
setLon(String(config.lon));
setTxPower(String(config.tx_power));
setFreq(String(config.radio.freq));
setBw(String(config.radio.bw));
setSf(String(config.radio.sf));
setCr(String(config.radio.cr));
}
}, [config]);
useEffect(() => {
if (appSettings) {
setMaxRadioContacts(String(appSettings.max_radio_contacts));
}
}, [appSettings]);
// Detect current preset from form values
const currentPreset = useMemo(() => {
const freqNum = parseFloat(freq);
const bwNum = parseFloat(bw);
const sfNum = parseInt(sf, 10);
const crNum = parseInt(cr, 10);
for (const preset of RADIO_PRESETS) {
if (
preset.freq === freqNum &&
preset.bw === bwNum &&
preset.sf === sfNum &&
preset.cr === crNum
) {
return preset.name;
}
}
return 'custom';
}, [freq, bw, sf, cr]);
const handlePresetChange = (presetName: string) => {
if (presetName === 'custom') return;
const preset = RADIO_PRESETS.find((p) => p.name === presetName);
if (preset) {
setFreq(String(preset.freq));
setBw(String(preset.bw));
setSf(String(preset.sf));
setCr(String(preset.cr));
}
};
const handleGetLocation = () => {
if (!navigator.geolocation) {
toast.error('Geolocation not supported', {
description: 'Your browser does not support geolocation',
});
return;
}
setGettingLocation(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setLat(position.coords.latitude.toFixed(6));
setLon(position.coords.longitude.toFixed(6));
setGettingLocation(false);
toast.success('Location updated');
},
(err) => {
setGettingLocation(false);
toast.error('Failed to get location', {
description: err.message,
});
},
{ enableHighAccuracy: true, timeout: 10000 }
);
};
const handleSaveRadioConfig = async () => {
setError('');
setLoading(true);
try {
const update: RadioConfigUpdate = {
lat: parseFloat(lat),
lon: parseFloat(lon),
tx_power: parseInt(txPower, 10),
radio: {
freq: parseFloat(freq),
bw: parseFloat(bw),
sf: parseInt(sf, 10),
cr: parseInt(cr, 10),
},
};
await onSave(update);
toast.success('Radio config saved');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setLoading(false);
}
};
const handleSaveIdentity = async () => {
setError('');
setLoading(true);
try {
const update: RadioConfigUpdate = { name };
await onSave(update);
toast.success('Identity saved');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setLoading(false);
}
};
const handleSaveSerial = async () => {
setError('');
setLoading(true);
try {
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings?.max_radio_contacts) {
await onSaveAppSettings({ max_radio_contacts: newMaxRadioContacts });
}
toast.success('Serial settings saved');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setLoading(false);
}
};
const handleSetPrivateKey = async () => {
if (!privateKey.trim()) {
setError('Private key is required');
return;
}
setError('');
setLoading(true);
try {
await onSetPrivateKey(privateKey.trim());
setPrivateKey('');
toast.success('Private key set');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to set private key');
} finally {
setLoading(false);
}
};
const handleReboot = async () => {
if (
!confirm('Are you sure you want to reboot the radio? The connection will drop temporarily.')
) {
return;
}
setError('');
setRebooting(true);
try {
await onReboot();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reboot radio');
} finally {
setRebooting(false);
}
};
const handleAdvertise = async () => {
setAdvertising(true);
try {
await onAdvertise();
} finally {
setAdvertising(false);
}
};
const handleCleanup = async () => {
const days = parseInt(retentionDays, 10);
if (isNaN(days) || days < 1) {
toast.error('Invalid retention days', {
description: 'Retention days must be at least 1',
});
return;
}
setCleaning(true);
try {
const result = await api.runMaintenance(days);
toast.success('Database cleanup complete', {
description: `Deleted ${result.packets_deleted} old packet${result.packets_deleted === 1 ? '' : 's'}`,
});
await onHealthRefresh();
} catch (err) {
console.error('Failed to run maintenance:', err);
toast.error('Database cleanup failed', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setCleaning(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Radio & Settings</DialogTitle>
<DialogDescription className="sr-only">
{activeTab === 'radio' && 'Configure radio frequency, power, and location settings'}
{activeTab === 'identity' && 'Manage radio name, public key, and private key'}
{activeTab === 'serial' && 'View serial port connection and configure contact sync'}
{activeTab === 'database' && 'View database statistics and clean up old packets'}
{activeTab === 'advertise' && 'Send a flood advertisement to announce your presence'}
</DialogDescription>
</DialogHeader>
{!config ? (
<div className="py-8 text-center text-muted-foreground">Loading configuration...</div>
) : (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as SettingsTab)}
className="w-full"
>
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="radio">Radio</TabsTrigger>
<TabsTrigger value="identity">Identity</TabsTrigger>
<TabsTrigger value="serial">Serial</TabsTrigger>
<TabsTrigger value="database">Database</TabsTrigger>
<TabsTrigger value="advertise">Advertise</TabsTrigger>
</TabsList>
{/* Radio Config Tab */}
<TabsContent value="radio" className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="preset">Preset</Label>
<select
id="preset"
value={currentPreset}
onChange={(e) => handlePresetChange(e.target.value)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="custom">Custom</option>
{RADIO_PRESETS.map((preset) => (
<option key={preset.name} value={preset.name}>
{preset.name}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="freq">Frequency (MHz)</Label>
<Input
id="freq"
type="number"
step="any"
value={freq}
onChange={(e) => setFreq(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="bw">Bandwidth (kHz)</Label>
<Input
id="bw"
type="number"
step="any"
value={bw}
onChange={(e) => setBw(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sf">Spreading Factor</Label>
<Input
id="sf"
type="number"
min="7"
max="12"
value={sf}
onChange={(e) => setSf(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cr">Coding Rate</Label>
<Input
id="cr"
type="number"
min="5"
max="8"
value={cr}
onChange={(e) => setCr(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tx-power">TX Power (dBm)</Label>
<Input
id="tx-power"
type="number"
value={txPower}
onChange={(e) => setTxPower(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-tx">Max TX Power</Label>
<Input id="max-tx" type="number" value={config.max_tx_power} disabled />
</div>
</div>
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Location</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGetLocation}
disabled={gettingLocation}
>
{gettingLocation ? 'Getting...' : '📍 Use My Location'}
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="lat" className="text-xs text-muted-foreground">
Latitude
</Label>
<Input
id="lat"
type="number"
step="any"
value={lat}
onChange={(e) => setLat(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lon" className="text-xs text-muted-foreground">
Longitude
</Label>
<Input
id="lon"
type="number"
step="any"
value={lon}
onChange={(e) => setLon(e.target.value)}
/>
</div>
</div>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
<Button onClick={handleSaveRadioConfig} disabled={loading} className="w-full">
{loading ? 'Saving...' : 'Save Radio Config'}
</Button>
</TabsContent>
{/* Identity Tab */}
<TabsContent value="identity" className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="public-key">Public Key</Label>
<Input
id="public-key"
value={config.public_key}
disabled
className="font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="name">Radio Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<Button onClick={handleSaveIdentity} disabled={loading} className="w-full">
{loading ? 'Saving...' : 'Set Name'}
</Button>
<Separator />
<div className="space-y-2">
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<Input
id="private-key"
type="password"
autoComplete="off"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
/>
<Button
onClick={handleSetPrivateKey}
disabled={loading || !privateKey.trim()}
className="w-full"
>
Set Private Key
</Button>
</div>
<Separator />
<Alert variant="warning">
<AlertDescription>
Changes to name or private key require a radio reboot to take effect.
</AlertDescription>
</Alert>
<Button
variant="outline"
onClick={handleReboot}
disabled={rebooting || loading}
className="w-full border-yellow-500/50 text-yellow-200 hover:bg-yellow-500/10"
>
{rebooting ? 'Rebooting...' : 'Reboot Radio'}
</Button>
{error && <div className="text-sm text-destructive">{error}</div>}
</TabsContent>
{/* Serial Tab */}
<TabsContent value="serial" className="space-y-4 mt-4">
<div className="space-y-2">
<Label>Serial Port</Label>
{health?.serial_port ? (
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<code className="px-2 py-1 bg-muted rounded text-foreground text-sm">
{health.serial_port}
</code>
</div>
) : (
<div className="flex items-center gap-2 text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-gray-500" />
<span>Not connected</span>
</div>
)}
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="max-contacts">Max Contacts on Radio</Label>
<Input
id="max-contacts"
type="number"
min="1"
max="1000"
value={maxRadioContacts}
onChange={(e) => setMaxRadioContacts(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Recent non-repeater contacts loaded to radio for DM auto-ACK (1-1000)
</p>
</div>
<Button onClick={handleSaveSerial} disabled={loading} className="w-full">
{loading ? 'Saving...' : 'Save Settings'}
</Button>
{error && <div className="text-sm text-destructive">{error}</div>}
</TabsContent>
{/* Database Tab */}
<TabsContent value="database" className="space-y-4 mt-4">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Database size</span>
<span className="font-medium">{health?.database_size_mb ?? '?'} MB</span>
</div>
{health?.oldest_undecrypted_timestamp ? (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
<span className="font-medium">
{formatTime(health.oldest_undecrypted_timestamp)}
<span className="text-muted-foreground ml-1">
(
{Math.floor(
(Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400
)}{' '}
days old)
</span>
</span>
</div>
) : (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
<span className="text-muted-foreground">None</span>
</div>
)}
</div>
<Separator />
<div className="space-y-3">
<Label>Cleanup Old Packets</Label>
<p className="text-xs text-muted-foreground">
Delete undecrypted packets older than the specified days. This helps manage
storage for packets that couldn't be decrypted (unknown channel keys).
</p>
<div className="flex gap-2 items-end">
<div className="space-y-1">
<Label htmlFor="retention-days" className="text-xs">
Days to retain
</Label>
<Input
id="retention-days"
type="number"
min="1"
max="365"
value={retentionDays}
onChange={(e) => setRetentionDays(e.target.value)}
className="w-24"
/>
</div>
<Button variant="outline" onClick={handleCleanup} disabled={cleaning}>
{cleaning ? 'Cleaning...' : 'Cleanup'}
</Button>
</div>
</div>
</TabsContent>
{/* Advertise Tab */}
<TabsContent value="advertise" className="space-y-4 mt-4">
<div className="text-center py-8">
<p className="text-muted-foreground mb-6">
Send a flood advertisement to announce your presence on the mesh network.
</p>
<Button
size="lg"
onClick={handleAdvertise}
disabled={advertising || !health?.radio_connected}
className="bg-green-600 hover:bg-green-700 text-white px-12 py-6 text-lg"
>
{advertising ? 'Sending...' : 'Send Advertisement'}
</Button>
{!health?.radio_connected && (
<p className="text-sm text-destructive mt-4">Radio not connected</p>
)}
</div>
</TabsContent>
</Tabs>
)}
</DialogContent>
</Dialog>
);
}
+9 -45
View File
@@ -7,20 +7,11 @@ import { toast } from './ui/sonner';
interface StatusBarProps {
health: HealthStatus | null;
config: RadioConfig | null;
onConfigClick: () => void;
onMaintenanceClick: () => void;
onAdvertise: () => void;
onSettingsClick: () => void;
onMenuClick?: () => void;
}
export function StatusBar({
health,
config,
onConfigClick,
onMaintenanceClick,
onAdvertise,
onMenuClick,
}: StatusBarProps) {
export function StatusBar({ health, config, onSettingsClick, onMenuClick }: StatusBarProps) {
const connected = health?.radio_connected ?? false;
const [reconnecting, setReconnecting] = useState(false);
@@ -62,21 +53,10 @@ export function StatusBar({
</span>
</div>
{health?.serial_port && (
<div className="hidden xl:flex items-center gap-1 text-[#888]">
Port: <span className="text-[#e0e0e0]">{health.serial_port}</span>
</div>
)}
{config && (
<>
<div className="hidden lg:flex items-center gap-1 text-[#888]">
<span className="text-[#e0e0e0]">{config.name || 'Unnamed'}</span>
</div>
<div className="hidden xl:flex items-center gap-1 text-[#888]">
{config.radio.freq} MHz/SF{config.radio.sf}/CR{config.radio.cr}/{config.tx_power}dBm
</div>
</>
<div className="hidden lg:flex items-center gap-1 text-[#888]">
<span className="text-[#e0e0e0]">{config.name || 'Unnamed'}</span>
</div>
)}
{/* Spacer to push buttons right on mobile */}
@@ -92,29 +72,13 @@ export function StatusBar({
</button>
)}
<button
onClick={onAdvertise}
disabled={!connected}
className="px-3 py-1 bg-[#333] border border-[#444] text-[#e0e0e0] rounded text-xs cursor-pointer hover:bg-[#444] disabled:bg-[#333] disabled:text-[#666] disabled:cursor-not-allowed"
>
Advertise
</button>
<button
onClick={onMaintenanceClick}
className="px-2 py-1 bg-[#333] border border-[#444] text-[#e0e0e0] rounded text-xs cursor-pointer hover:bg-[#444]"
title="Database Maintenance"
>
<span role="img" aria-label="Settings">
&#9881;&#65039;
</span>
</button>
<button
onClick={onConfigClick}
onClick={onSettingsClick}
className="px-3 py-1 bg-[#333] border border-[#444] text-[#e0e0e0] rounded text-xs cursor-pointer hover:bg-[#444]"
>
<span role="img" aria-label="Radio">
&#128251;
<span role="img" aria-label="Settings">
&#128295;
</span>{' '}
Config
Radio & Config
</button>
</div>
);
+152
View File
@@ -0,0 +1,152 @@
import { describe, it, expect } from 'vitest';
// Radio presets - duplicated from SettingsModal for testing
// In a real app, these would be in a shared constants file
interface RadioPreset {
name: string;
freq: number;
bw: number;
sf: number;
cr: number;
}
const RADIO_PRESETS: RadioPreset[] = [
{ name: 'USA/Canada', freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
{ name: 'Australia', freq: 915.8, bw: 250, sf: 10, cr: 5 },
{ name: 'Australia (narrow)', freq: 916.575, bw: 62.5, sf: 7, cr: 8 },
{ name: 'Australia SA, WA', freq: 923.125, bw: 62.5, sf: 8, cr: 8 },
{ name: 'Australia QLD', freq: 923.125, bw: 62.5, sf: 8, cr: 5 },
{ name: 'New Zealand', freq: 917.375, bw: 250, sf: 11, cr: 5 },
{ name: 'New Zealand (narrow)', freq: 917.375, bw: 62.5, sf: 7, cr: 5 },
{ name: 'EU/UK/Switzerland Long Range', freq: 869.525, bw: 250, sf: 11, cr: 5 },
{ name: 'EU/UK/Switzerland Medium Range', freq: 869.525, bw: 250, sf: 10, cr: 5 },
{ name: 'EU/UK/Switzerland Narrow', freq: 869.618, bw: 62.5, sf: 8, cr: 8 },
{ name: 'Czech Republic (Narrow)', freq: 869.432, bw: 62.5, sf: 7, cr: 5 },
{ name: 'EU 433MHz Long Range', freq: 433.65, bw: 250, sf: 11, cr: 5 },
{ name: 'Portugal 433MHz', freq: 433.375, bw: 62.5, sf: 9, cr: 6 },
{ name: 'Portugal 868MHz', freq: 869.618, bw: 62.5, sf: 7, cr: 6 },
{ name: 'Vietnam', freq: 920.25, bw: 250, sf: 11, cr: 5 },
];
// Preset detection function - matches the logic in SettingsModal
function detectPreset(freq: number, bw: number, sf: number, cr: number): string {
for (const preset of RADIO_PRESETS) {
if (preset.freq === freq && preset.bw === bw && preset.sf === sf && preset.cr === cr) {
return preset.name;
}
}
return 'custom';
}
// Find preset by name
function findPreset(name: string): RadioPreset | undefined {
return RADIO_PRESETS.find((p) => p.name === name);
}
describe('Radio Presets', () => {
describe('detectPreset', () => {
it('detects USA/Canada preset', () => {
expect(detectPreset(910.525, 62.5, 7, 5)).toBe('USA/Canada');
});
it('detects Australia preset', () => {
expect(detectPreset(915.8, 250, 10, 5)).toBe('Australia');
});
it('detects Australia (narrow) preset', () => {
expect(detectPreset(916.575, 62.5, 7, 8)).toBe('Australia (narrow)');
});
it('detects EU/UK/Switzerland Long Range preset', () => {
expect(detectPreset(869.525, 250, 11, 5)).toBe('EU/UK/Switzerland Long Range');
});
it('detects EU/UK/Switzerland Medium Range preset', () => {
expect(detectPreset(869.525, 250, 10, 5)).toBe('EU/UK/Switzerland Medium Range');
});
it('detects EU/UK/Switzerland Narrow preset', () => {
expect(detectPreset(869.618, 62.5, 8, 8)).toBe('EU/UK/Switzerland Narrow');
});
it('returns custom for non-matching values', () => {
expect(detectPreset(900, 250, 10, 5)).toBe('custom');
});
it('returns custom when one value differs', () => {
// Same as USA/Canada but with different SF
expect(detectPreset(910.525, 62.5, 8, 5)).toBe('custom');
});
it('returns custom when bandwidth differs', () => {
// Same as USA/Canada but with different BW
expect(detectPreset(910.525, 125, 7, 5)).toBe('custom');
});
it('returns custom when coding rate differs', () => {
// Same as USA/Canada but with different CR
expect(detectPreset(910.525, 62.5, 7, 6)).toBe('custom');
});
});
describe('findPreset', () => {
it('finds preset by exact name', () => {
const preset = findPreset('USA/Canada');
expect(preset).toBeDefined();
expect(preset?.freq).toBe(910.525);
expect(preset?.bw).toBe(62.5);
expect(preset?.sf).toBe(7);
expect(preset?.cr).toBe(5);
});
it('returns undefined for unknown preset', () => {
expect(findPreset('Unknown Preset')).toBeUndefined();
});
it('returns undefined for custom', () => {
expect(findPreset('custom')).toBeUndefined();
});
});
describe('preset round-trip', () => {
it('all presets can be detected after being applied', () => {
for (const preset of RADIO_PRESETS) {
const detected = detectPreset(preset.freq, preset.bw, preset.sf, preset.cr);
expect(detected).toBe(preset.name);
}
});
});
describe('preset values are valid LoRa parameters', () => {
it('all frequencies are in valid ISM bands', () => {
for (const preset of RADIO_PRESETS) {
// 433 MHz: 433.05-434.79, EU 868: 863-870, US/AU/NZ/VN 900: 902-928
const valid433 = preset.freq >= 433 && preset.freq <= 435;
const validEU = preset.freq >= 863 && preset.freq <= 870;
const valid900 = preset.freq >= 902 && preset.freq <= 928;
expect(valid433 || validEU || valid900).toBe(true);
}
});
it('all spreading factors are valid (7-12)', () => {
for (const preset of RADIO_PRESETS) {
expect(preset.sf).toBeGreaterThanOrEqual(7);
expect(preset.sf).toBeLessThanOrEqual(12);
}
});
it('all coding rates are valid (5-8 for 4/5 to 4/8)', () => {
for (const preset of RADIO_PRESETS) {
expect(preset.cr).toBeGreaterThanOrEqual(5);
expect(preset.cr).toBeLessThanOrEqual(8);
}
});
it('all bandwidths are standard LoRa values', () => {
const validBandwidths = [7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500];
for (const preset of RADIO_PRESETS) {
expect(validBandwidths).toContain(preset.bw);
}
});
});
});
+1
View File
@@ -42,6 +42,7 @@ export interface HealthStatus {
radio_connected: boolean;
serial_port: string | null;
database_size_mb: number;
oldest_undecrypted_timestamp: number | null;
}
export interface MaintenanceResult {