mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-18 07:16:17 +02:00
Massive frontend overhaul for settings menu and channel addition.
This commit is contained in:
@@ -499,6 +499,15 @@ class RawPacketRepository:
|
||||
row = await cursor.fetchone()
|
||||
return row["count"] if row else 0
|
||||
|
||||
@staticmethod
|
||||
async def get_oldest_undecrypted() -> int | None:
|
||||
"""Get timestamp of oldest undecrypted packet, or None if none exist."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT MIN(timestamp) as oldest FROM raw_packets WHERE message_id IS NULL"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["oldest"] if row and row["oldest"] is not None else None
|
||||
|
||||
@staticmethod
|
||||
async def get_all_undecrypted() -> list[tuple[int, bytes, int]]:
|
||||
"""Get all undecrypted packets as (id, data, timestamp) tuples."""
|
||||
|
||||
@@ -5,6 +5,7 @@ from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
from app.radio import radio_manager
|
||||
from app.repository import RawPacketRepository
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
@@ -14,6 +15,7 @@ class HealthResponse(BaseModel):
|
||||
radio_connected: bool
|
||||
serial_port: str | None
|
||||
database_size_mb: float
|
||||
oldest_undecrypted_timestamp: int | None
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
@@ -27,9 +29,17 @@ async def healthcheck() -> HealthResponse:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Get oldest undecrypted packet info (gracefully handle if DB not connected)
|
||||
oldest_ts = None
|
||||
try:
|
||||
oldest_ts = await RawPacketRepository.get_oldest_undecrypted()
|
||||
except RuntimeError:
|
||||
pass # Database not connected
|
||||
|
||||
return HealthResponse(
|
||||
status="ok" if radio_manager.is_connected else "degraded",
|
||||
radio_connected=radio_manager.is_connected,
|
||||
serial_port=radio_manager.port,
|
||||
database_size_mb=db_size_mb,
|
||||
oldest_undecrypted_timestamp=oldest_ts,
|
||||
)
|
||||
|
||||
+9
-1
@@ -7,7 +7,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.config import settings
|
||||
from app.radio import radio_manager
|
||||
from app.repository import ChannelRepository, ContactRepository
|
||||
from app.repository import ChannelRepository, ContactRepository, RawPacketRepository
|
||||
from app.websocket import ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -29,11 +29,19 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Get oldest undecrypted packet info
|
||||
oldest_ts = None
|
||||
try:
|
||||
oldest_ts = await RawPacketRepository.get_oldest_undecrypted()
|
||||
except RuntimeError:
|
||||
pass # Database not connected
|
||||
|
||||
health_data = {
|
||||
"status": "ok" if radio_manager.is_connected else "degraded",
|
||||
"radio_connected": radio_manager.is_connected,
|
||||
"serial_port": radio_manager.port,
|
||||
"database_size_mb": db_size_mb,
|
||||
"oldest_undecrypted_timestamp": oldest_ts,
|
||||
}
|
||||
await ws_manager.send_personal(websocket, "health", health_data)
|
||||
|
||||
|
||||
+21
-10
@@ -88,22 +88,33 @@ def broadcast_error(message: str, details: str | None = None) -> None:
|
||||
|
||||
def broadcast_health(radio_connected: bool, serial_port: str | None = None) -> None:
|
||||
"""Broadcast health status change to all connected clients."""
|
||||
# Get database file size in MB
|
||||
db_size_mb = 0.0
|
||||
try:
|
||||
db_size_bytes = os.path.getsize(settings.database_path)
|
||||
db_size_mb = round(db_size_bytes / (1024 * 1024), 2)
|
||||
except OSError:
|
||||
pass
|
||||
from app.repository import RawPacketRepository
|
||||
|
||||
asyncio.create_task(
|
||||
ws_manager.broadcast(
|
||||
async def _broadcast():
|
||||
# Get database file size in MB
|
||||
db_size_mb = 0.0
|
||||
try:
|
||||
db_size_bytes = os.path.getsize(settings.database_path)
|
||||
db_size_mb = round(db_size_bytes / (1024 * 1024), 2)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Get oldest undecrypted packet info
|
||||
oldest_ts = None
|
||||
try:
|
||||
oldest_ts = await RawPacketRepository.get_oldest_undecrypted()
|
||||
except RuntimeError:
|
||||
pass # Database not connected
|
||||
|
||||
await ws_manager.broadcast(
|
||||
"health",
|
||||
{
|
||||
"status": "ok" if radio_connected else "degraded",
|
||||
"radio_connected": radio_connected,
|
||||
"serial_port": serial_port,
|
||||
"database_size_mb": db_size_mb,
|
||||
"oldest_undecrypted_timestamp": oldest_ts,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
asyncio.create_task(_broadcast())
|
||||
|
||||
+1
-2
@@ -45,8 +45,7 @@ frontend/
|
||||
│ │ ├── MapView.tsx # Leaflet map showing node locations
|
||||
│ │ ├── CrackerPanel.tsx # WebGPU channel key cracker (lazy-loads wordlist)
|
||||
│ │ ├── NewMessageModal.tsx
|
||||
│ │ ├── ConfigModal.tsx # Radio config + app settings
|
||||
│ │ └── MaintenanceModal.tsx # Database maintenance (packet cleanup)
|
||||
│ │ └── SettingsModal.tsx # Unified settings: radio config, identity, serial, database, advertise
|
||||
│ └── test/
|
||||
│ ├── setup.ts # Test setup (jsdom, matchers)
|
||||
│ ├── messageParser.test.ts
|
||||
|
||||
-1
File diff suppressed because one or more lines are too long
+541
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-541
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -13,8 +13,8 @@
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<script type="module" crossorigin src="/assets/index-E6l20oLD.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C5j7uJOC.css">
|
||||
<script type="module" crossorigin src="/assets/index-CGSJJsxM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D2NVJvYv.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="MCTerm" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
|
||||
+33
-39
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
⚙️
|
||||
</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">
|
||||
📻
|
||||
<span role="img" aria-label="Settings">
|
||||
🔧
|
||||
</span>{' '}
|
||||
Config
|
||||
Radio & Config
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user