Files
Remote-Terminal-for-MeshCore/frontend/src/components/SettingsModal.tsx
T
2026-02-23 22:28:09 -08:00

1452 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useMemo, lazy, Suspense, type ReactNode } from 'react';
const BotCodeEditor = lazy(() =>
import('./BotCodeEditor').then((m) => ({ default: m.BotCodeEditor }))
);
import type {
AppSettings,
AppSettingsUpdate,
BotConfig,
HealthStatus,
RadioConfig,
RadioConfigUpdate,
StatisticsResponse,
} from '../types';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Button } from './ui/button';
import { Separator } from './ui/separator';
import { toast } from './ui/sonner';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import {
captureLastViewedConversationFromHash,
getReopenLastConversationEnabled,
setReopenLastConversationEnabled,
} from '../utils/lastViewedConversation';
import { RADIO_PRESETS } from '../utils/radioPresets';
// Import for local use + re-export so existing imports from this file still work
import {
SETTINGS_SECTION_ORDER,
SETTINGS_SECTION_LABELS,
type SettingsSection,
} from './settingsConstants';
export { SETTINGS_SECTION_ORDER, SETTINGS_SECTION_LABELS, type SettingsSection };
interface SettingsModalBaseProps {
open: boolean;
pageMode?: 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>;
onRefreshAppSettings: () => Promise<void>;
}
type SettingsModalProps = SettingsModalBaseProps &
(
| { externalSidebarNav: true; desktopSection: SettingsSection }
| { externalSidebarNav?: false; desktopSection?: never }
);
export function SettingsModal(props: SettingsModalProps) {
const {
open,
pageMode = false,
config,
health,
appSettings,
onClose,
onSave,
onSaveAppSettings,
onSetPrivateKey,
onReboot,
onAdvertise,
onHealthRefresh,
onRefreshAppSettings,
} = props;
const externalSidebarNav = props.externalSidebarNav === true;
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
const getIsMobileLayout = () => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia('(max-width: 767px)').matches;
};
const [isMobileLayout, setIsMobileLayout] = useState(getIsMobileLayout);
const externalDesktopSidebarMode = externalSidebarNav && !isMobileLayout;
const [expandedSections, setExpandedSections] = useState<Record<SettingsSection, boolean>>(() => {
const isMobile = getIsMobileLayout();
return {
radio: !isMobile,
identity: false,
connectivity: false,
database: false,
bot: false,
statistics: false,
};
});
// 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 [busySection, setBusySection] = useState<SettingsSection | null>(null);
const [rebooting, setRebooting] = useState(false);
const [advertising, setAdvertising] = useState(false);
const [gettingLocation, setGettingLocation] = useState(false);
const [sectionError, setSectionError] = useState<{
section: SettingsSection;
message: string;
} | null>(null);
// Database maintenance state
const [retentionDays, setRetentionDays] = useState('14');
const [cleaning, setCleaning] = useState(false);
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
const [reopenLastConversation, setReopenLastConversation] = useState(
getReopenLastConversationEnabled
);
// Advertisement interval state (displayed in hours, stored as seconds in DB)
const [advertIntervalHours, setAdvertIntervalHours] = useState('0');
// Bot state
const DEFAULT_BOT_CODE = `def bot(
sender_name: str | None,
sender_key: str | None,
message_text: str,
is_dm: bool,
channel_key: str | None,
channel_name: str | None,
sender_timestamp: int | None,
path: str | None,
is_outgoing: bool = False,
) -> str | list[str] | None:
"""
Process messages and optionally return a reply.
Args:
sender_name: Display name of sender (may be None)
sender_key: 64-char hex public key (None for channel msgs)
message_text: The message content
is_dm: True for direct messages, False for channel
channel_key: 32-char hex key for channels, None for DMs
channel_name: Channel name with hash (e.g. "#bot"), None for DMs
sender_timestamp: Sender's timestamp (unix seconds, may be None)
path: Hex-encoded routing path (may be None)
is_outgoing: True if this is our own outgoing message
Returns:
None for no reply, a string for a single reply,
or a list of strings to send multiple messages in order
"""
# Don't reply to our own outgoing messages
if is_outgoing:
return None
# Example: Only respond in #bot channel to "!pling" command
if channel_name == "#bot" and "!pling" in message_text.lower():
return "[BOT] Plong!"
return None`;
const [bots, setBots] = useState<BotConfig[]>([]);
const [expandedBotId, setExpandedBotId] = useState<string | null>(null);
const [editingNameId, setEditingNameId] = useState<string | null>(null);
const [editingNameValue, setEditingNameValue] = useState('');
// Statistics state
const [stats, setStats] = useState<StatisticsResponse | null>(null);
const [statsLoading, setStatsLoading] = 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));
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
setAdvertIntervalHours(String(Math.round(appSettings.advert_interval / 3600)));
setBots(appSettings.bots || []);
}
}, [appSettings]);
// Refresh settings from server when modal opens
// This ensures UI reflects actual server state (prevents stale UI after checkbox toggle without save)
useEffect(() => {
if (open || pageMode) {
onRefreshAppSettings();
}
}, [open, pageMode, onRefreshAppSettings]);
useEffect(() => {
if (open || pageMode) {
setReopenLastConversation(getReopenLastConversationEnabled());
}
}, [open, pageMode]);
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const query = window.matchMedia('(max-width: 767px)');
const onChange = (event: MediaQueryListEvent) => {
setIsMobileLayout(event.matches);
};
setIsMobileLayout(query.matches);
if (typeof query.addEventListener === 'function') {
query.addEventListener('change', onChange);
return () => query.removeEventListener('change', onChange);
}
query.addListener(onChange);
return () => query.removeListener(onChange);
}, []);
useEffect(() => {
if (!externalSidebarNav) return;
setSectionError(null);
}, [externalSidebarNav, desktopSection]);
// On mobile with external sidebar nav, auto-expand the selected section
useEffect(() => {
if (!externalSidebarNav || !isMobileLayout || !desktopSection) return;
setExpandedSections((prev) =>
prev[desktopSection] ? prev : { ...prev, [desktopSection]: true }
);
}, [externalSidebarNav, isMobileLayout, desktopSection]);
// Fetch statistics when the section becomes visible
const statisticsVisible = externalDesktopSidebarMode
? desktopSection === 'statistics'
: expandedSections.statistics;
useEffect(() => {
if (!statisticsVisible) return;
let cancelled = false;
setStatsLoading(true);
api.getStatistics().then(
(data) => {
if (!cancelled) {
setStats(data);
setStatsLoading(false);
}
},
() => {
if (!cancelled) setStatsLoading(false);
}
);
return () => {
cancelled = true;
};
}, [statisticsVisible]);
// 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 () => {
setSectionError(null);
setBusySection('radio');
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, rebooting...');
setRebooting(true);
await onReboot();
if (!pageMode) {
onClose();
}
} catch (err) {
setSectionError({
section: 'radio',
message: err instanceof Error ? err.message : 'Failed to save',
});
} finally {
setRebooting(false);
setBusySection(null);
}
};
const handleSaveIdentity = async () => {
setSectionError(null);
setBusySection('identity');
try {
// Save radio name
const update: RadioConfigUpdate = { name };
await onSave(update);
// Save advert interval to app settings (convert hours to seconds)
const hours = parseInt(advertIntervalHours, 10);
const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600;
if (newAdvertInterval !== appSettings?.advert_interval) {
await onSaveAppSettings({ advert_interval: newAdvertInterval });
}
toast.success('Identity settings saved');
} catch (err) {
setSectionError({
section: 'identity',
message: err instanceof Error ? err.message : 'Failed to save',
});
} finally {
setBusySection(null);
}
};
const handleSaveConnectivity = async () => {
setSectionError(null);
setBusySection('connectivity');
try {
const update: AppSettingsUpdate = {};
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings?.max_radio_contacts) {
update.max_radio_contacts = newMaxRadioContacts;
}
if (Object.keys(update).length > 0) {
await onSaveAppSettings(update);
}
toast.success('Connectivity settings saved');
} catch (err) {
setSectionError({
section: 'connectivity',
message: err instanceof Error ? err.message : 'Failed to save',
});
} finally {
setBusySection(null);
}
};
const handleSetPrivateKey = async () => {
if (!privateKey.trim()) {
setSectionError({ section: 'identity', message: 'Private key is required' });
return;
}
setSectionError(null);
setBusySection('identity');
try {
await onSetPrivateKey(privateKey.trim());
setPrivateKey('');
toast.success('Private key set, rebooting...');
setRebooting(true);
await onReboot();
if (!pageMode) {
onClose();
}
} catch (err) {
setSectionError({
section: 'identity',
message: err instanceof Error ? err.message : 'Failed to set private key',
});
} finally {
setRebooting(false);
setBusySection(null);
}
};
const handleReboot = async () => {
if (
!confirm('Are you sure you want to reboot the radio? The connection will drop temporarily.')
) {
return;
}
setSectionError(null);
setBusySection('connectivity');
setRebooting(true);
try {
await onReboot();
if (!pageMode) {
onClose();
}
} catch (err) {
setSectionError({
section: 'connectivity',
message: err instanceof Error ? err.message : 'Failed to reboot radio',
});
} finally {
setRebooting(false);
setBusySection(null);
}
};
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({ pruneUndecryptedDays: 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);
}
};
const handlePurgeDecryptedRawPackets = async () => {
setPurgingDecryptedRaw(true);
try {
const result = await api.runMaintenance({ purgeLinkedRawPackets: true });
toast.success('Decrypted raw packets purged', {
description: `Deleted ${result.packets_deleted} raw packet${result.packets_deleted === 1 ? '' : 's'}`,
});
await onHealthRefresh();
} catch (err) {
console.error('Failed to purge decrypted raw packets:', err);
toast.error('Failed to purge decrypted raw packets', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setPurgingDecryptedRaw(false);
}
};
const handleSaveDatabaseSettings = async () => {
setBusySection('database');
setSectionError(null);
try {
await onSaveAppSettings({ auto_decrypt_dm_on_advert: autoDecryptOnAdvert });
toast.success('Database settings saved');
} catch (err) {
console.error('Failed to save database settings:', err);
setSectionError({
section: 'database',
message: err instanceof Error ? err.message : 'Failed to save',
});
toast.error('Failed to save settings');
} finally {
setBusySection(null);
}
};
const handleToggleReopenLastConversation = (enabled: boolean) => {
setReopenLastConversation(enabled);
setReopenLastConversationEnabled(enabled);
if (enabled) {
captureLastViewedConversationFromHash();
}
};
const handleSaveBotSettings = async () => {
setBusySection('bot');
setSectionError(null);
try {
await onSaveAppSettings({ bots });
toast.success('Bot settings saved');
} catch (err) {
console.error('Failed to save bot settings:', err);
const errorMsg = err instanceof Error ? err.message : 'Failed to save';
setSectionError({ section: 'bot', message: errorMsg });
toast.error(errorMsg);
} finally {
setBusySection(null);
}
};
const handleAddBot = () => {
const newBot: BotConfig = {
id: crypto.randomUUID(),
name: `Bot ${bots.length + 1}`,
enabled: false,
code: DEFAULT_BOT_CODE,
};
setBots([...bots, newBot]);
setExpandedBotId(newBot.id);
};
const handleDeleteBot = (botId: string) => {
const bot = bots.find((b) => b.id === botId);
if (bot && bot.code.trim() && bot.code !== DEFAULT_BOT_CODE) {
if (!confirm(`Delete "${bot.name}"? This will remove all its code.`)) {
return;
}
}
setBots(bots.filter((b) => b.id !== botId));
if (expandedBotId === botId) {
setExpandedBotId(null);
}
};
const handleToggleBotEnabled = (botId: string) => {
setBots(bots.map((b) => (b.id === botId ? { ...b, enabled: !b.enabled } : b)));
};
const handleBotCodeChange = (botId: string, code: string) => {
setBots(bots.map((b) => (b.id === botId ? { ...b, code } : b)));
};
const handleStartEditingName = (bot: BotConfig) => {
setEditingNameId(bot.id);
setEditingNameValue(bot.name);
};
const handleFinishEditingName = () => {
if (editingNameId && editingNameValue.trim()) {
setBots(
bots.map((b) => (b.id === editingNameId ? { ...b, name: editingNameValue.trim() } : b))
);
}
setEditingNameId(null);
setEditingNameValue('');
};
const handleResetBotCode = (botId: string) => {
setBots(bots.map((b) => (b.id === botId ? { ...b, code: DEFAULT_BOT_CODE } : b)));
};
const toggleSection = (section: SettingsSection) => {
setExpandedSections((prev) => ({
...prev,
[section]: !prev[section],
}));
setSectionError(null);
};
const isSectionVisible = (section: SettingsSection) =>
externalDesktopSidebarMode ? desktopSection === section : expandedSections[section];
const showSectionButton = !externalDesktopSidebarMode;
const shouldRenderSection = (section: SettingsSection) =>
!externalDesktopSidebarMode || desktopSection === section;
const sectionWrapperClass = 'overflow-hidden';
const sectionContentClass = externalDesktopSidebarMode
? 'space-y-4 p-4'
: 'space-y-4 p-4 border-t border-input';
const settingsContainerClass = externalDesktopSidebarMode
? 'w-full h-full overflow-y-auto'
: 'w-full h-full overflow-y-auto space-y-3';
const sectionButtonClasses =
'w-full flex items-center justify-between px-4 py-3 text-left hover:bg-muted/40';
const renderSectionHeader = (section: SettingsSection): ReactNode => {
if (!showSectionButton) return null;
return (
<button type="button" className={sectionButtonClasses} onClick={() => toggleSection(section)}>
<span className="font-medium">{SETTINGS_SECTION_LABELS[section]}</span>
<span className="text-muted-foreground md:hidden">
{expandedSections[section] ? '' : '+'}
</span>
</button>
);
};
const isSectionBusy = (section: SettingsSection) => busySection === section;
const getSectionError = (section: SettingsSection) =>
sectionError?.section === section ? sectionError.message : null;
if (!pageMode && !open) {
return null;
}
return !config ? (
<div className="py-8 text-center text-muted-foreground">Loading configuration...</div>
) : (
<div className={settingsContainerClass}>
{shouldRenderSection('radio') && (
<div className={sectionWrapperClass}>
{renderSectionHeader('radio')}
{isSectionVisible('radio') && (
<div className={sectionContentClass}>
<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>
{getSectionError('radio') && (
<div className="text-sm text-destructive">{getSectionError('radio')}</div>
)}
<Button
onClick={handleSaveRadioConfig}
disabled={isSectionBusy('radio') || rebooting}
className="w-full"
>
{isSectionBusy('radio') || rebooting
? 'Saving & Rebooting...'
: 'Save Radio Config & Reboot'}
</Button>
</div>
)}
</div>
)}
{shouldRenderSection('identity') && (
<div className={sectionWrapperClass}>
{renderSectionHeader('identity')}
{isSectionVisible('identity') && (
<div className={sectionContentClass}>
<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>
<div className="space-y-2">
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
<div className="flex items-center gap-2">
<Input
id="advert-interval"
type="number"
min="0"
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(e.target.value)}
className="w-28"
/>
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
</div>
<p className="text-xs text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1
hour. Recommended: 24 hours or higher.
</p>
</div>
<Button
onClick={handleSaveIdentity}
disabled={isSectionBusy('identity')}
className="w-full"
>
{isSectionBusy('identity') ? 'Saving...' : 'Save Identity Settings'}
</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={isSectionBusy('identity') || rebooting || !privateKey.trim()}
className="w-full"
>
{isSectionBusy('identity') || rebooting
? 'Setting & Rebooting...'
: 'Set Private Key & Reboot'}
</Button>
</div>
<Separator />
<div className="space-y-2">
<Label>Send Advertisement</Label>
<p className="text-xs text-muted-foreground">
Send a flood advertisement to announce your presence on the mesh network.
</p>
<Button
onClick={handleAdvertise}
disabled={advertising || !health?.radio_connected}
className="w-full bg-yellow-600 hover:bg-yellow-700 text-white"
>
{advertising ? 'Sending...' : 'Send Advertisement'}
</Button>
{!health?.radio_connected && (
<p className="text-sm text-destructive">Radio not connected</p>
)}
</div>
{getSectionError('identity') && (
<div className="text-sm text-destructive">{getSectionError('identity')}</div>
)}
</div>
)}
</div>
)}
{shouldRenderSection('connectivity') && (
<div className={sectionWrapperClass}>
{renderSectionHeader('connectivity')}
{isSectionVisible('connectivity') && (
<div className={sectionContentClass}>
<div className="space-y-2">
<Label>Connection</Label>
{health?.connection_info ? (
<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.connection_info}
</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">
Favorite contacts load first, then recent non-repeater contacts until this limit
is reached (1-1000)
</p>
</div>
<Button
onClick={handleSaveConnectivity}
disabled={isSectionBusy('connectivity')}
className="w-full"
>
{isSectionBusy('connectivity') ? 'Saving...' : 'Save Settings'}
</Button>
<Separator />
<Button
variant="outline"
onClick={handleReboot}
disabled={rebooting || isSectionBusy('connectivity')}
className="w-full border-red-500/50 text-red-400 hover:bg-red-500/10"
>
{rebooting ? 'Rebooting...' : 'Reboot Radio'}
</Button>
{getSectionError('connectivity') && (
<div className="text-sm text-destructive">{getSectionError('connectivity')}</div>
)}
</div>
)}
</div>
)}
{shouldRenderSection('database') && (
<div className={sectionWrapperClass}>
{renderSectionHeader('database')}
{isSectionVisible('database') && (
<div className={sectionContentClass}>
<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>Delete Undecrypted Packets</Label>
<p className="text-xs text-muted-foreground">
Permanently deletes stored raw packets containing DMs and channel messages that
have not yet been decrypted. These packets are retained in case you later obtain
the correct key once deleted, these messages can never be recovered or
decrypted.
</p>
<div className="flex gap-2 items-end">
<div className="space-y-1">
<Label htmlFor="retention-days" className="text-xs">
Older than (days)
</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}
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
>
{cleaning ? 'Deleting...' : 'Permanently Delete'}
</Button>
</div>
</div>
<Separator />
<div className="space-y-3">
<Label>Purge Archival Raw Packets</Label>
<p className="text-xs text-muted-foreground">
Deletes archival copies of raw packet bytes for messages that are already
decrypted and visible in your chat history.{' '}
<em className="text-muted-foreground/80">
This will not affect any displayed messages or app functionality.
</em>{' '}
The raw bytes are only useful for manual packet analysis.
</p>
<Button
variant="outline"
onClick={handlePurgeDecryptedRawPackets}
disabled={purgingDecryptedRaw}
className="w-full border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10"
>
{purgingDecryptedRaw
? 'Purging Archival Raw Packets...'
: 'Purge Archival Raw Packets'}
</Button>
</div>
<Separator />
<div className="space-y-3">
<Label>DM Decryption</Label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={autoDecryptOnAdvert}
onChange={(e) => setAutoDecryptOnAdvert(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">
Auto-decrypt historical DMs when new contact advertises
</span>
</label>
<p className="text-xs text-muted-foreground">
When enabled, the server will automatically try to decrypt stored DM packets when
a new contact sends an advertisement. This may cause brief delays on large packet
backlogs.
</p>
</div>
<Separator />
<div className="space-y-3">
<Label>Interface</Label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={reopenLastConversation}
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Reopen to last viewed channel/conversation</span>
</label>
<p className="text-xs text-muted-foreground">
This applies only to this device/browser. It does not sync to server settings.
</p>
</div>
{getSectionError('database') && (
<div className="text-sm text-destructive">{getSectionError('database')}</div>
)}
<Button
onClick={handleSaveDatabaseSettings}
disabled={isSectionBusy('database')}
className="w-full"
>
{isSectionBusy('database') ? 'Saving...' : 'Save Settings'}
</Button>
</div>
)}
</div>
)}
{shouldRenderSection('bot') && (
<div className={sectionWrapperClass}>
{renderSectionHeader('bot')}
{isSectionVisible('bot') && (
<div className={sectionContentClass}>
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-md">
<p className="text-sm text-red-500">
<strong>Experimental:</strong> This is an alpha feature and introduces automated
message sending to your radio; unexpected behavior may occur. Use with caution,
and please report any bugs!
</p>
</div>
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-md">
<p className="text-sm text-yellow-500">
<strong>Security Warning:</strong> This feature executes arbitrary Python code on
the server. Only run trusted code, and be cautious of arbitrary usage of message
parameters.
</p>
</div>
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-md">
<p className="text-sm text-yellow-500">
<strong>Don&apos;t wreck the mesh!</strong> Bots process ALL messages, including
their own. Be careful of creating infinite loops!
</p>
</div>
<div className="flex justify-between items-center">
<Label>Bots</Label>
<Button type="button" variant="outline" size="sm" onClick={handleAddBot}>
+ New Bot
</Button>
</div>
{bots.length === 0 ? (
<div className="text-center py-8 border border-dashed border-input rounded-md">
<p className="text-muted-foreground mb-4">No bots configured</p>
<Button type="button" variant="outline" onClick={handleAddBot}>
Create your first bot
</Button>
</div>
) : (
<div className="space-y-2">
{bots.map((bot) => (
<div key={bot.id} className="border border-input rounded-md overflow-hidden">
<div
className="flex items-center gap-2 px-3 py-2 bg-muted/50 cursor-pointer hover:bg-muted/80"
onClick={(e) => {
if ((e.target as HTMLElement).closest('input, button')) return;
setExpandedBotId(expandedBotId === bot.id ? null : bot.id);
}}
>
<span className="text-muted-foreground">
{expandedBotId === bot.id ? '▼' : '▶'}
</span>
{editingNameId === bot.id ? (
<input
type="text"
value={editingNameValue}
onChange={(e) => setEditingNameValue(e.target.value)}
onBlur={handleFinishEditingName}
onKeyDown={(e) => {
if (e.key === 'Enter') handleFinishEditingName();
if (e.key === 'Escape') {
setEditingNameId(null);
setEditingNameValue('');
}
}}
autoFocus
className="px-2 py-0.5 text-sm bg-background border border-input rounded flex-1 max-w-[200px]"
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="text-sm font-medium flex-1 hover:text-primary cursor-text"
onClick={(e) => {
e.stopPropagation();
handleStartEditingName(bot);
}}
title="Click to rename"
>
{bot.name}
</span>
)}
<label
className="flex items-center gap-1.5 cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={bot.enabled}
onChange={() => handleToggleBotEnabled(bot.id)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-xs text-muted-foreground">Enabled</span>
</label>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteBot(bot.id);
}}
title="Delete bot"
>
🗑
</Button>
</div>
{expandedBotId === bot.id && (
<div className="p-3 space-y-3 border-t border-input">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Define a <code className="bg-muted px-1 rounded">bot()</code> function
that receives message data and optionally returns a reply.
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleResetBotCode(bot.id)}
>
Reset to Example
</Button>
</div>
<Suspense
fallback={
<div className="h-64 md:h-96 rounded-md border border-input bg-[#282c34] flex items-center justify-center text-muted-foreground">
Loading editor...
</div>
}
>
<BotCodeEditor
value={bot.code}
onChange={(code) => handleBotCodeChange(bot.id, code)}
id={`bot-code-${bot.id}`}
height={isMobileLayout ? '256px' : '384px'}
/>
</Suspense>
</div>
)}
</div>
))}
</div>
)}
<Separator />
<div className="text-xs text-muted-foreground space-y-1">
<p>
<strong>Available:</strong> Standard Python libraries and any modules installed in
the server environment.
</p>
<p>
<strong>Limits:</strong> 10 second timeout per bot.
</p>
<p>
<strong>Note:</strong> Bots respond to all messages, including your own. For
channel messages, <code>sender_key</code> is <code>None</code>. Multiple enabled
bots run serially, with a two-second delay between messages to prevent repeater
collision.
</p>
</div>
{getSectionError('bot') && (
<div className="text-sm text-destructive">{getSectionError('bot')}</div>
)}
<Button
onClick={handleSaveBotSettings}
disabled={isSectionBusy('bot')}
className="w-full"
>
{isSectionBusy('bot') ? 'Saving...' : 'Save Bot Settings'}
</Button>
</div>
)}
</div>
)}
{shouldRenderSection('statistics') && (
<div className={sectionWrapperClass}>
{renderSectionHeader('statistics')}
{isSectionVisible('statistics') && (
<div className={sectionContentClass}>
{statsLoading && !stats ? (
<div className="py-8 text-center text-muted-foreground">Loading statistics...</div>
) : stats ? (
<div className="space-y-6">
{/* Network */}
<div>
<h4 className="text-sm font-medium mb-2">Network</h4>
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.contact_count}</div>
<div className="text-xs text-muted-foreground">Contacts</div>
</div>
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.repeater_count}</div>
<div className="text-xs text-muted-foreground">Repeaters</div>
</div>
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.channel_count}</div>
<div className="text-xs text-muted-foreground">Channels</div>
</div>
</div>
</div>
<Separator />
{/* Messages */}
<div>
<h4 className="text-sm font-medium mb-2">Messages</h4>
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.total_dms}</div>
<div className="text-xs text-muted-foreground">Direct Messages</div>
</div>
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.total_channel_messages}</div>
<div className="text-xs text-muted-foreground">Channel Messages</div>
</div>
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.total_outgoing}</div>
<div className="text-xs text-muted-foreground">Sent (Outgoing)</div>
</div>
</div>
</div>
<Separator />
{/* Packets */}
<div>
<h4 className="text-sm font-medium mb-2">Packets</h4>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Total stored</span>
<span className="font-medium">{stats.total_packets}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-green-500">Decrypted</span>
<span className="font-medium text-green-500">
{stats.decrypted_packets}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-yellow-500">Undecrypted</span>
<span className="font-medium text-yellow-500">
{stats.undecrypted_packets}
</span>
</div>
</div>
</div>
<Separator />
{/* Activity */}
<div>
<h4 className="text-sm font-medium mb-2">Activity</h4>
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground">
<th className="text-left font-normal pb-1"></th>
<th className="text-right font-normal pb-1">1h</th>
<th className="text-right font-normal pb-1">24h</th>
<th className="text-right font-normal pb-1">7d</th>
</tr>
</thead>
<tbody>
<tr>
<td className="py-1">Contacts heard</td>
<td className="text-right py-1">{stats.contacts_heard.last_hour}</td>
<td className="text-right py-1">{stats.contacts_heard.last_24_hours}</td>
<td className="text-right py-1">{stats.contacts_heard.last_week}</td>
</tr>
<tr>
<td className="py-1">Repeaters heard</td>
<td className="text-right py-1">{stats.repeaters_heard.last_hour}</td>
<td className="text-right py-1">{stats.repeaters_heard.last_24_hours}</td>
<td className="text-right py-1">{stats.repeaters_heard.last_week}</td>
</tr>
</tbody>
</table>
</div>
{/* Busiest Channels */}
{stats.busiest_channels_24h.length > 0 && (
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Busiest Channels (24h)</h4>
<div className="space-y-1">
{stats.busiest_channels_24h.map((ch, i) => (
<div
key={ch.channel_key}
className="flex justify-between items-center text-sm"
>
<span>
<span className="text-muted-foreground mr-2">{i + 1}.</span>
{ch.channel_name}
</span>
<span className="text-muted-foreground">{ch.message_count} msgs</span>
</div>
))}
</div>
</div>
</>
)}
</div>
) : null}
</div>
)}
</div>
)}
</div>
);
}