diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index b8e3dec..c047da2 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -59,8 +59,7 @@ frontend/src/
│ ├── MessageList.tsx
│ ├── MessageInput.tsx
│ ├── NewMessageModal.tsx
-│ ├── SettingsModal.tsx
-│ ├── settingsConstants.ts # Settings section ordering and labels
+│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
│ ├── RawPacketList.tsx
│ ├── MapView.tsx
│ ├── VisualizerView.tsx
@@ -70,9 +69,28 @@ frontend/src/
│ ├── BotCodeEditor.tsx
│ ├── ContactAvatar.tsx
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
-│ ├── RepeaterDashboard.tsx # Repeater pane-based dashboard (telemetry, neighbors, ACL, etc.)
+│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
+│ ├── settings/
+│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
+│ │ ├── SettingsRadioSection.tsx # Preset, freq/bw/sf/cr, txPower, lat/lon
+│ │ ├── SettingsIdentitySection.tsx # Name, keys, advert interval
+│ │ ├── SettingsConnectivitySection.tsx # Connection status, max contacts, reboot
+│ │ ├── SettingsMqttSection.tsx # MQTT broker config, TLS, publish toggles
+│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
+│ │ ├── SettingsBotSection.tsx # Bot list, code editor, add/delete/reset
+│ │ └── SettingsStatisticsSection.tsx # Read-only mesh network stats
+│ ├── repeater/
+│ │ ├── repeaterPaneShared.tsx # Shared: RepeaterPane, KvRow, format helpers
+│ │ ├── RepeaterTelemetryPane.tsx # Battery, airtime, packet counts
+│ │ ├── RepeaterNeighborsPane.tsx # Neighbor table + lazy mini-map
+│ │ ├── RepeaterAclPane.tsx # Permission table
+│ │ ├── RepeaterRadioSettingsPane.tsx # Radio settings + advert intervals
+│ │ ├── RepeaterLppTelemetryPane.tsx # CayenneLPP sensor data
+│ │ ├── RepeaterOwnerInfoPane.tsx # Owner info + guest password
+│ │ ├── RepeaterActionsPane.tsx # Send Advert, Sync Clock, Reboot
+│ │ └── RepeaterConsolePane.tsx # CLI console with history
│ └── ui/ # shadcn/ui primitives
├── types/
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 461c641..0258c1d 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -31,7 +31,7 @@ import {
SETTINGS_SECTION_LABELS,
SETTINGS_SECTION_ORDER,
type SettingsSection,
-} from './components/settingsConstants';
+} from './components/settings/settingsConstants';
import { RawPacketList } from './components/RawPacketList';
import { ContactInfoPane } from './components/ContactInfoPane';
import { CONTACT_TYPE_REPEATER } from './types';
diff --git a/frontend/src/components/RepeaterDashboard.tsx b/frontend/src/components/RepeaterDashboard.tsx
index 2996f1f..46b8f77 100644
--- a/frontend/src/components/RepeaterDashboard.tsx
+++ b/frontend/src/components/RepeaterDashboard.tsx
@@ -1,780 +1,25 @@
-import {
- useState,
- useCallback,
- useRef,
- useEffect,
- useMemo,
- type FormEvent,
- type ReactNode,
- lazy,
- Suspense,
-} from 'react';
+import { type ReactNode } from 'react';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
-import { Input } from './ui/input';
-import { Separator } from './ui/separator';
import { RepeaterLogin } from './RepeaterLogin';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import { isFavorite } from '../utils/favorites';
-import { cn } from '@/lib/utils';
-import type {
- Contact,
- Conversation,
- Favorite,
- LppSensor,
- PaneState,
- RepeaterStatusResponse,
- RepeaterNeighborsResponse,
- RepeaterAclResponse,
- RepeaterRadioSettingsResponse,
- RepeaterAdvertIntervalsResponse,
- RepeaterOwnerInfoResponse,
- RepeaterLppTelemetryResponse,
- NeighborInfo,
-} from '../types';
+import type { Contact, Conversation, Favorite } from '../types';
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
import { getMapFocusHash } from '../utils/urlHash';
+import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
+import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
+import { AclPane } from './repeater/RepeaterAclPane';
+import { RadioSettingsPane } from './repeater/RepeaterRadioSettingsPane';
+import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
+import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
+import { ActionsPane } from './repeater/RepeaterActionsPane';
+import { ConsolePane } from './repeater/RepeaterConsolePane';
-// Lazy-load the entire mini-map file so react-leaflet imports are bundled together
-// and MapContainer only mounts once (avoids "already initialized" crash).
-const NeighborsMiniMap = lazy(() =>
- import('./NeighborsMiniMap').then((m) => ({ default: m.NeighborsMiniMap }))
-);
-
-// --- Shared Icons ---
-
-function RefreshIcon({ className }: { className?: string }) {
- return (
-
-
-
- );
-}
-
-// --- Utility ---
-
-export function formatDuration(seconds: number): string {
- if (seconds < 60) return `${seconds}s`;
- const days = Math.floor(seconds / 86400);
- const hours = Math.floor((seconds % 86400) / 3600);
- const mins = Math.floor((seconds % 3600) / 60);
- if (days > 0) {
- if (hours > 0 && mins > 0) return `${days}d${hours}h${mins}m`;
- if (hours > 0) return `${days}d${hours}h`;
- if (mins > 0) return `${days}d${mins}m`;
- return `${days}d`;
- }
- if (hours > 0) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
- return `${mins}m`;
-}
-
-// --- Generic Pane Wrapper ---
-
-function RepeaterPane({
- title,
- state,
- onRefresh,
- disabled,
- children,
- className,
- contentClassName,
-}: {
- title: string;
- state: PaneState;
- onRefresh?: () => void;
- disabled?: boolean;
- children: React.ReactNode;
- className?: string;
- contentClassName?: string;
-}) {
- return (
-
-
-
{title}
- {onRefresh && (
-
-
-
- )}
-
- {state.error && (
-
- {state.error}
-
- )}
-
- {state.loading ? (
-
- Fetching{state.attempt > 1 ? ` (attempt ${state.attempt}/${3})` : ''}...
-
- ) : (
- children
- )}
-
-
- );
-}
-
-function NotFetched() {
- return <not fetched>
;
-}
-
-function KvRow({ label, value }: { label: string; value: React.ReactNode }) {
- return (
-
- {label}
- {value}
-
- );
-}
-
-// --- Individual Panes ---
-
-function TelemetryPane({
- data,
- state,
- onRefresh,
- disabled,
-}: {
- data: RepeaterStatusResponse | null;
- state: PaneState;
- onRefresh: () => void;
- disabled?: boolean;
-}) {
- return (
-
- {!data ? (
-
- ) : (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )}
-
- );
-}
-
-function NeighborsPane({
- data,
- state,
- onRefresh,
- disabled,
- contacts,
- radioLat,
- radioLon,
- radioName,
-}: {
- data: RepeaterNeighborsResponse | null;
- state: PaneState;
- onRefresh: () => void;
- disabled?: boolean;
- contacts: Contact[];
- radioLat: number | null;
- radioLon: number | null;
- radioName: string | null;
-}) {
- // Resolve contact data for each neighbor in a single pass — used for
- // coords (mini-map), distances (table column), and sorted display order.
- const { neighborsWithCoords, sorted, hasDistances } = useMemo(() => {
- if (!data) {
- return {
- neighborsWithCoords: [] as Array,
- sorted: [] as Array,
- hasDistances: false,
- };
- }
-
- const withCoords: Array = [];
- const enriched: Array = [];
- let anyDist = false;
-
- for (const n of data.neighbors) {
- const contact = contacts.find((c) => c.public_key.startsWith(n.pubkey_prefix));
- const nLat = contact?.lat ?? null;
- const nLon = contact?.lon ?? null;
-
- let dist: string | null = null;
- if (isValidLocation(radioLat, radioLon) && isValidLocation(nLat, nLon)) {
- const distKm = calculateDistance(radioLat, radioLon, nLat, nLon);
- if (distKm != null) {
- dist = formatDistance(distKm);
- anyDist = true;
- }
- }
- enriched.push({ ...n, distance: dist });
-
- if (isValidLocation(nLat, nLon)) {
- withCoords.push({ ...n, lat: nLat, lon: nLon });
- }
- }
-
- enriched.sort((a, b) => b.snr - a.snr);
-
- return {
- neighborsWithCoords: withCoords,
- sorted: enriched,
- hasDistances: anyDist,
- };
- }, [data, contacts, radioLat, radioLon]);
-
- return (
-
- {!data ? (
-
- ) : sorted.length === 0 ? (
- No neighbors reported
- ) : (
-
-
-
-
-
- Name
- SNR
- {hasDistances && Dist }
- Last Heard
-
-
-
- {sorted.map((n, i) => {
- const dist = n.distance;
- const snrStr = n.snr >= 0 ? `+${n.snr.toFixed(1)}` : n.snr.toFixed(1);
- const snrColor =
- n.snr >= 6 ? 'text-green-500' : n.snr >= 0 ? 'text-yellow-500' : 'text-red-500';
- return (
-
- {n.name || n.pubkey_prefix}
- {snrStr} dB
- {hasDistances && (
-
- {dist ?? '—'}
-
- )}
-
- {formatDuration(n.last_heard_seconds)} ago
-
-
- );
- })}
-
-
-
- {(neighborsWithCoords.length > 0 || isValidLocation(radioLat, radioLon)) && (
-
- Loading map...
-
- }
- >
- n.pubkey_prefix).join(',')}
- neighbors={neighborsWithCoords}
- radioLat={radioLat}
- radioLon={radioLon}
- radioName={radioName}
- />
-
- )}
-
- )}
-
- );
-}
-
-function AclPane({
- data,
- state,
- onRefresh,
- disabled,
-}: {
- data: RepeaterAclResponse | null;
- state: PaneState;
- onRefresh: () => void;
- disabled?: boolean;
-}) {
- const permColor: Record = {
- 0: 'bg-muted text-muted-foreground',
- 1: 'bg-blue-500/10 text-blue-500',
- 2: 'bg-green-500/10 text-green-500',
- 3: 'bg-amber-500/10 text-amber-500',
- };
-
- return (
-
- {!data ? (
-
- ) : data.acl.length === 0 ? (
- No ACL entries
- ) : (
-
-
-
- Name
- Permission
-
-
-
- {data.acl.map((entry, i) => (
-
- {entry.name || entry.pubkey_prefix}
-
-
- {entry.permission_name}
-
-
-
- ))}
-
-
- )}
-
- );
-}
-
-export function formatClockDrift(clockUtc: string): { text: string; isLarge: boolean } {
- // Firmware format: "HH:MM - D/M/YYYY UTC" or "HH:MM:SS - D/M/YYYY UTC"
- // Also handle ISO-like: "YYYY-MM-DD HH:MM:SS"
- let parsed: Date;
- const fwMatch = clockUtc.match(
- /^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*-\s*(\d{1,2})\/(\d{1,2})\/(\d{4})/
- );
- if (fwMatch) {
- const [, hh, mm, ss, dd, mo, yyyy] = fwMatch;
- parsed = new Date(Date.UTC(+yyyy, +mo - 1, +dd, +hh, +mm, +(ss ?? 0)));
- } else {
- parsed = new Date(
- clockUtc.replace(' ', 'T') + (clockUtc.includes('Z') || clockUtc.includes('UTC') ? '' : 'Z')
- );
- }
- if (isNaN(parsed.getTime())) return { text: '(invalid)', isLarge: false };
-
- const driftMs = Math.abs(Date.now() - parsed.getTime());
- const driftSec = Math.floor(driftMs / 1000);
-
- if (driftSec >= 86400) return { text: '>24 hours!', isLarge: true };
-
- const h = Math.floor(driftSec / 3600);
- const m = Math.floor((driftSec % 3600) / 60);
- const s = driftSec % 60;
-
- const parts: string[] = [];
- if (h > 0) parts.push(`${h}h`);
- if (m > 0) parts.push(`${m}m`);
- parts.push(`${s}s`);
-
- return { text: parts.join(''), isLarge: false };
-}
-
-function RadioSettingsPane({
- data,
- state,
- onRefresh,
- disabled,
- advertData,
- advertState,
- onRefreshAdvert,
-}: {
- data: RepeaterRadioSettingsResponse | null;
- state: PaneState;
- onRefresh: () => void;
- disabled?: boolean;
- advertData: RepeaterAdvertIntervalsResponse | null;
- advertState: PaneState;
- onRefreshAdvert: () => void;
-}) {
- const clockDrift = useMemo(() => {
- if (!data?.clock_utc) return null;
- return formatClockDrift(data.clock_utc);
- }, [data?.clock_utc]);
-
- return (
-
- {!data ? (
-
- ) : (
-
-
-
-
-
-
-
-
-
-
-
-
- Clock (UTC)
-
- {data.clock_utc ?? '—'}
- {clockDrift && (
-
- (drift: {clockDrift.text})
-
- )}
-
-
-
- )}
- {/* Advert Intervals sub-section */}
-
-
- Advert Intervals
-
-
-
-
- {advertState.error && {advertState.error}
}
- {advertState.loading ? (
-
- Fetching{advertState.attempt > 1 ? ` (attempt ${advertState.attempt}/3)` : ''}...
-
- ) : !advertData ? (
-
- ) : (
-
-
-
-
- )}
-
- );
-}
-
-function formatAdvertInterval(val: string | null): string {
- if (val == null) return '—';
- const trimmed = val.trim();
- if (trimmed === '0') return '';
- return `${trimmed}h`;
-}
-
-const LPP_UNIT_MAP: Record = {
- temperature: '°C',
- humidity: '%',
- barometer: 'hPa',
- voltage: 'V',
- current: 'mA',
- luminosity: 'lux',
- altitude: 'm',
- power: 'W',
- distance: 'mm',
- energy: 'kWh',
- direction: '°',
- concentration: 'ppm',
- colour: '',
-};
-
-function formatLppLabel(typeName: string): string {
- return typeName.charAt(0).toUpperCase() + typeName.slice(1).replace(/_/g, ' ');
-}
-
-function LppSensorRow({ sensor }: { sensor: LppSensor }) {
- const label = formatLppLabel(sensor.type_name);
-
- if (typeof sensor.value === 'object' && sensor.value !== null) {
- // Multi-value sensor (GPS, accelerometer, etc.)
- return (
-
-
{label}
-
- {Object.entries(sensor.value).map(([k, v]) => (
-
- ))}
-
-
- );
- }
-
- const unit = LPP_UNIT_MAP[sensor.type_name] ?? '';
- const formatted =
- typeof sensor.value === 'number'
- ? `${sensor.value % 1 === 0 ? sensor.value : sensor.value.toFixed(2)}${unit ? ` ${unit}` : ''}`
- : String(sensor.value);
-
- return ;
-}
-
-function LppTelemetryPane({
- data,
- state,
- onRefresh,
- disabled,
-}: {
- data: RepeaterLppTelemetryResponse | null;
- state: PaneState;
- onRefresh: () => void;
- disabled?: boolean;
-}) {
- return (
-
- {!data ? (
-
- ) : data.sensors.length === 0 ? (
- No sensor data available
- ) : (
-
- {data.sensors.map((sensor, i) => (
-
- ))}
-
- )}
-
- );
-}
-
-function OwnerInfoPane({
- data,
- state,
- onRefresh,
- disabled,
-}: {
- data: RepeaterOwnerInfoResponse | null;
- state: PaneState;
- onRefresh: () => void;
- disabled?: boolean;
-}) {
- return (
-
- {!data ? (
-
- ) : (
-
-
-
-
- )}
-
- );
-}
-
-function ActionsPane({
- onSendAdvert,
- onSyncClock,
- onReboot,
- consoleLoading,
-}: {
- onSendAdvert: () => void;
- onSyncClock: () => void;
- onReboot: () => void;
- consoleLoading: boolean;
-}) {
- const [confirmReboot, setConfirmReboot] = useState(false);
-
- const handleReboot = useCallback(() => {
- if (!confirmReboot) {
- setConfirmReboot(true);
- return;
- }
- setConfirmReboot(false);
- onReboot();
- }, [confirmReboot, onReboot]);
-
- // Reset confirmation after 3 seconds
- useEffect(() => {
- if (!confirmReboot) return;
- const timer = setTimeout(() => setConfirmReboot(false), 3000);
- return () => clearTimeout(timer);
- }, [confirmReboot]);
-
- return (
-
-
-
Actions
-
-
-
- Send Advert
-
-
- Sync Clock
-
-
- {confirmReboot ? 'Confirm Reboot' : 'Reboot'}
-
-
-
- );
-}
-
-function ConsolePane({
- history,
- loading,
- onSend,
-}: {
- history: Array<{ command: string; response: string; timestamp: number; outgoing: boolean }>;
- loading: boolean;
- onSend: (command: string) => Promise;
-}) {
- const [input, setInput] = useState('');
- const outputRef = useRef(null);
-
- // Auto-scroll to bottom on new entries
- useEffect(() => {
- if (outputRef.current) {
- outputRef.current.scrollTop = outputRef.current.scrollHeight;
- }
- }, [history]);
-
- const handleSubmit = useCallback(
- async (e: FormEvent) => {
- e.preventDefault();
- const trimmed = input.trim();
- if (!trimmed || loading) return;
- setInput('');
- await onSend(trimmed);
- },
- [input, loading, onSend]
- );
-
- return (
-
-
-
Console
-
-
- {history.length === 0 && (
-
Type a CLI command below...
- )}
- {history.map((entry, i) =>
- entry.outgoing ? (
-
- > {entry.command}
-
- ) : (
-
- {entry.response}
-
- )
- )}
- {loading &&
...
}
-
-
-
- );
-}
+// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
+export { formatDuration, formatClockDrift } from './repeater/repeaterPaneShared';
// --- Main Dashboard ---
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx
index bfc62eb..989fbb3 100644
--- a/frontend/src/components/SettingsModal.tsx
+++ b/frontend/src/components/SettingsModal.tsx
@@ -1,33 +1,21 @@
-import { useState, useEffect, useMemo, lazy, Suspense, type ReactNode } from 'react';
-
-const BotCodeEditor = lazy(() =>
- import('./BotCodeEditor').then((m) => ({ default: m.BotCodeEditor }))
-);
+import { useState, useEffect, type ReactNode } from 'react';
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 { getLocalLabel, setLocalLabel, type LocalLabel } from '../utils/localLabel';
+import type { LocalLabel } from '../utils/localLabel';
+import { SETTINGS_SECTION_LABELS, type SettingsSection } from './settings/settingsConstants';
-import { SETTINGS_SECTION_LABELS, type SettingsSection } from './settingsConstants';
+import { SettingsRadioSection } from './settings/SettingsRadioSection';
+import { SettingsIdentitySection } from './settings/SettingsIdentitySection';
+import { SettingsConnectivitySection } from './settings/SettingsConnectivitySection';
+import { SettingsMqttSection } from './settings/SettingsMqttSection';
+import { SettingsDatabaseSection } from './settings/SettingsDatabaseSection';
+import { SettingsBotSection } from './settings/SettingsBotSection';
+import { SettingsStatisticsSection } from './settings/SettingsStatisticsSection';
interface SettingsModalBaseProps {
open: boolean;
@@ -92,145 +80,13 @@ export function SettingsModal(props: SettingsModalProps) {
};
});
- // 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(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
- );
- const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
- const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
-
- // Advertisement interval state (displayed in hours, stored as seconds in DB)
- const [advertIntervalHours, setAdvertIntervalHours] = useState('0');
-
- // MQTT state
- const [mqttBrokerHost, setMqttBrokerHost] = useState('');
- const [mqttBrokerPort, setMqttBrokerPort] = useState('1883');
- const [mqttUsername, setMqttUsername] = useState('');
- const [mqttPassword, setMqttPassword] = useState('');
- const [mqttUseTls, setMqttUseTls] = useState(false);
- const [mqttTlsInsecure, setMqttTlsInsecure] = useState(false);
- const [mqttTopicPrefix, setMqttTopicPrefix] = useState('meshcore');
- const [mqttPublishMessages, setMqttPublishMessages] = useState(false);
- const [mqttPublishRawPackets, setMqttPublishRawPackets] = useState(false);
-
- // 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([]);
- const [expandedBotId, setExpandedBotId] = useState(null);
- const [editingNameId, setEditingNameId] = useState(null);
- const [editingNameValue, setEditingNameValue] = useState('');
-
- // Statistics state
- const [stats, setStats] = useState(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 || []);
- setMqttBrokerHost(appSettings.mqtt_broker_host ?? '');
- setMqttBrokerPort(String(appSettings.mqtt_broker_port ?? 1883));
- setMqttUsername(appSettings.mqtt_username ?? '');
- setMqttPassword(appSettings.mqtt_password ?? '');
- setMqttUseTls(appSettings.mqtt_use_tls ?? false);
- setMqttTlsInsecure(appSettings.mqtt_tls_insecure ?? false);
- setMqttTopicPrefix(appSettings.mqtt_topic_prefix ?? 'meshcore');
- setMqttPublishMessages(appSettings.mqtt_publish_messages ?? false);
- setMqttPublishRawPackets(appSettings.mqtt_publish_raw_packets ?? false);
- }
- }, [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;
@@ -250,11 +106,6 @@ export function SettingsModal(props: SettingsModalProps) {
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;
@@ -263,411 +114,11 @@ export function SettingsModal(props: SettingsModalProps) {
);
}, [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 handleSaveMqtt = async () => {
- setSectionError(null);
- setBusySection('mqtt');
-
- try {
- const update: AppSettingsUpdate = {
- mqtt_broker_host: mqttBrokerHost,
- mqtt_broker_port: parseInt(mqttBrokerPort, 10) || 1883,
- mqtt_username: mqttUsername,
- mqtt_password: mqttPassword,
- mqtt_use_tls: mqttUseTls,
- mqtt_tls_insecure: mqttTlsInsecure,
- mqtt_topic_prefix: mqttTopicPrefix || 'meshcore',
- mqtt_publish_messages: mqttPublishMessages,
- mqtt_publish_raw_packets: mqttPublishRawPackets,
- };
- await onSaveAppSettings(update);
- toast.success('MQTT settings saved');
- } catch (err) {
- setSectionError({
- section: 'mqtt',
- 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) =>
@@ -702,10 +153,6 @@ export function SettingsModal(props: SettingsModalProps) {
);
};
- const isSectionBusy = (section: SettingsSection) => busySection === section;
- const getSectionError = (section: SettingsSection) =>
- sectionError?.section === section ? sectionError.message : null;
-
if (!pageMode && !open) {
return null;
}
@@ -718,145 +165,14 @@ export function SettingsModal(props: SettingsModalProps) {
{renderSectionHeader('radio')}
{isSectionVisible('radio') && (
-
-
- Preset
- 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"
- >
- Custom
- {RADIO_PRESETS.map((preset) => (
-
- {preset.name}
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
-
-
- Location
-
- {gettingLocation ? 'Getting...' : '📍 Use My Location'}
-
-
-
-
-
- {getSectionError('radio') && (
-
{getSectionError('radio')}
- )}
-
-
- {isSectionBusy('radio') || rebooting
- ? 'Saving & Rebooting...'
- : 'Save Radio Config & Reboot'}
-
-
+
)}
)}
@@ -864,96 +180,20 @@ export function SettingsModal(props: SettingsModalProps) {
{shouldRenderSection('identity') && (
{renderSectionHeader('identity')}
- {isSectionVisible('identity') && (
-
-
- Public Key
-
-
-
-
- Radio Name
- setName(e.target.value)} />
-
-
-
-
Periodic Advertising Interval
-
- setAdvertIntervalHours(e.target.value)}
- className="w-28"
- />
- hours (0 = off)
-
-
- How often to automatically advertise presence. Set to 0 to disable. Minimum: 1
- hour. Recommended: 24 hours or higher.
-
-
-
-
- {isSectionBusy('identity') ? 'Saving...' : 'Save Identity Settings'}
-
-
-
-
-
- Set Private Key (write-only)
- setPrivateKey(e.target.value)}
- placeholder="64-character hex private key"
- />
-
- {isSectionBusy('identity') || rebooting
- ? 'Setting & Rebooting...'
- : 'Set Private Key & Reboot'}
-
-
-
-
-
-
-
Send Advertisement
-
- Send a flood advertisement to announce your presence on the mesh network.
-
-
- {advertising ? 'Sending...' : 'Send Advertisement'}
-
- {!health?.radio_connected && (
-
Radio not connected
- )}
-
-
- {getSectionError('identity') && (
-
{getSectionError('identity')}
- )}
-
+ {isSectionVisible('identity') && appSettings && (
+
)}
)}
@@ -961,66 +201,16 @@ export function SettingsModal(props: SettingsModalProps) {
{shouldRenderSection('connectivity') && (
{renderSectionHeader('connectivity')}
- {isSectionVisible('connectivity') && (
-
-
-
Connection
- {health?.connection_info ? (
-
-
-
- {health.connection_info}
-
-
- ) : (
-
- )}
-
-
-
-
-
-
Max Contacts on Radio
-
setMaxRadioContacts(e.target.value)}
- />
-
- Favorite contacts load first, then recent non-repeater contacts until this limit
- is reached (1-1000)
-
-
-
-
- {isSectionBusy('connectivity') ? 'Saving...' : 'Save Settings'}
-
-
-
-
-
- {rebooting ? 'Rebooting...' : 'Reboot Radio'}
-
-
- {getSectionError('connectivity') && (
-
{getSectionError('connectivity')}
- )}
-
+ {isSectionVisible('connectivity') && appSettings && (
+
)}
)}
@@ -1028,153 +218,13 @@ export function SettingsModal(props: SettingsModalProps) {
{shouldRenderSection('mqtt') && (
{renderSectionHeader('mqtt')}
- {isSectionVisible('mqtt') && (
-
-
-
Status
- {health?.mqtt_status === 'connected' ? (
-
- ) : health?.mqtt_status === 'disconnected' ? (
-
- ) : (
-
- )}
-
-
-
-
-
- Broker Host
- setMqttBrokerHost(e.target.value)}
- />
-
-
-
- Broker Port
- setMqttBrokerPort(e.target.value)}
- />
-
-
-
- Username
- setMqttUsername(e.target.value)}
- />
-
-
-
- Password
- setMqttPassword(e.target.value)}
- />
-
-
-
- setMqttUseTls(e.target.checked)}
- className="h-4 w-4 rounded border-border"
- />
- Use TLS
-
-
- {mqttUseTls && (
- <>
-
- setMqttTlsInsecure(e.target.checked)}
- className="h-4 w-4 rounded border-border"
- />
- Skip certificate verification
-
-
- Allow self-signed or untrusted broker certificates
-
- >
- )}
-
-
-
-
-
Topic Prefix
-
setMqttTopicPrefix(e.target.value)}
- />
-
- Topics: {mqttTopicPrefix || 'meshcore'}/dm:<key>,{' '}
- {mqttTopicPrefix || 'meshcore'}/gm:<key>, {mqttTopicPrefix || 'meshcore'}
- /raw/...
-
-
-
-
-
-
- setMqttPublishMessages(e.target.checked)}
- className="h-4 w-4 rounded border-border"
- />
- Publish Messages
-
-
- Forward decrypted DM and channel messages
-
-
-
- setMqttPublishRawPackets(e.target.checked)}
- className="h-4 w-4 rounded border-border"
- />
- Publish Raw Packets
-
-
Forward all RF packets
-
-
- {isSectionBusy('mqtt') ? 'Saving...' : 'Save MQTT Settings'}
-
-
- {getSectionError('mqtt') && (
-
{getSectionError('mqtt')}
- )}
-
+ {isSectionVisible('mqtt') && appSettings && (
+
)}
)}
@@ -1182,182 +232,15 @@ export function SettingsModal(props: SettingsModalProps) {
{shouldRenderSection('database') && (
{renderSectionHeader('database')}
- {isSectionVisible('database') && (
-
-
-
- Database size
- {health?.database_size_mb ?? '?'} MB
-
-
- {health?.oldest_undecrypted_timestamp ? (
-
- Oldest undecrypted packet
-
- {formatTime(health.oldest_undecrypted_timestamp)}
-
- (
- {Math.floor(
- (Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400
- )}{' '}
- days old)
-
-
-
- ) : (
-
- Oldest undecrypted packet
- None
-
- )}
-
-
-
-
-
-
Delete Undecrypted Packets
-
- 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.
-
-
-
-
- Older than (days)
-
- setRetentionDays(e.target.value)}
- className="w-24"
- />
-
-
- {cleaning ? 'Deleting...' : 'Permanently Delete'}
-
-
-
-
-
-
-
-
Purge Archival Raw Packets
-
- Deletes archival copies of raw packet bytes for messages that are already
- decrypted and visible in your chat history.{' '}
-
- This will not affect any displayed messages or app functionality.
- {' '}
- The raw bytes are only useful for manual packet analysis.
-
-
- {purgingDecryptedRaw
- ? 'Purging Archival Raw Packets...'
- : 'Purge Archival Raw Packets'}
-
-
-
-
-
-
-
DM Decryption
-
- setAutoDecryptOnAdvert(e.target.checked)}
- className="w-4 h-4 rounded border-input accent-primary"
- />
-
- Auto-decrypt historical DMs when new contact advertises
-
-
-
- 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.
-
-
-
-
-
-
-
Interface
-
- handleToggleReopenLastConversation(e.target.checked)}
- className="w-4 h-4 rounded border-input accent-primary"
- />
- Reopen to last viewed channel/conversation
-
-
- This applies only to this device/browser. It does not sync to server settings.
-
-
-
-
-
-
-
Local Label
-
- {
- const text = e.target.value;
- setLocalLabelText(text);
- setLocalLabel(text, localLabelColor);
- onLocalLabelChange?.({ text, color: localLabelColor });
- }}
- placeholder="e.g. Home Base, Field Radio 2"
- className="flex-1"
- />
- {
- const color = e.target.value;
- setLocalLabelColor(color);
- setLocalLabel(localLabelText, color);
- onLocalLabelChange?.({ text: localLabelText, color });
- }}
- className="w-10 h-9 rounded border border-input cursor-pointer bg-transparent p-0.5"
- />
-
-
- Display a colored banner at the top of the page to identify this instance. This
- applies only to this device/browser.
-
-
-
- {getSectionError('database') && (
-
{getSectionError('database')}
- )}
-
-
- {isSectionBusy('database') ? 'Saving...' : 'Save Settings'}
-
-
+ {isSectionVisible('database') && appSettings && (
+
)}
)}
@@ -1365,185 +248,13 @@ export function SettingsModal(props: SettingsModalProps) {
{shouldRenderSection('bot') && (
{renderSectionHeader('bot')}
- {isSectionVisible('bot') && (
-
-
-
- Experimental: 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!
-
-
-
-
-
- Security Warning: This feature executes arbitrary Python code on
- the server. Only run trusted code, and be cautious of arbitrary usage of message
- parameters.
-
-
-
-
-
- Don't wreck the mesh! Bots process ALL messages, including
- their own. Be careful of creating infinite loops!
-
-
-
-
- Bots
-
- + New Bot
-
-
-
- {bots.length === 0 ? (
-
-
No bots configured
-
- Create your first bot
-
-
- ) : (
-
- {bots.map((bot) => (
-
-
{
- if ((e.target as HTMLElement).closest('input, button')) return;
- setExpandedBotId(expandedBotId === bot.id ? null : bot.id);
- }}
- >
-
- {expandedBotId === bot.id ? '▼' : '▶'}
-
-
- {editingNameId === bot.id ? (
- 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()}
- />
- ) : (
- {
- e.stopPropagation();
- handleStartEditingName(bot);
- }}
- title="Click to rename"
- >
- {bot.name}
-
- )}
-
- e.stopPropagation()}
- >
- handleToggleBotEnabled(bot.id)}
- className="w-4 h-4 rounded border-input accent-primary"
- />
- Enabled
-
-
- {
- e.stopPropagation();
- handleDeleteBot(bot.id);
- }}
- title="Delete bot"
- >
- 🗑
-
-
-
- {expandedBotId === bot.id && (
-
-
-
- Define a bot() function
- that receives message data and optionally returns a reply.
-
-
handleResetBotCode(bot.id)}
- >
- Reset to Example
-
-
-
- Loading editor...
-
- }
- >
-
handleBotCodeChange(bot.id, code)}
- id={`bot-code-${bot.id}`}
- height={isMobileLayout ? '256px' : '384px'}
- />
-
-
- )}
-
- ))}
-
- )}
-
-
-
-
-
- Available: Standard Python libraries and any modules installed in
- the server environment.
-
-
- Limits: 10 second timeout per bot.
-
-
- Note: Bots respond to all messages, including your own. For
- channel messages, sender_key is None. Multiple enabled
- bots run serially, with a two-second delay between messages to prevent repeater
- collision.
-
-
-
- {getSectionError('bot') && (
-
{getSectionError('bot')}
- )}
-
-
- {isSectionBusy('bot') ? 'Saving...' : 'Save Bot Settings'}
-
-
+ {isSectionVisible('bot') && appSettings && (
+
)}
)}
@@ -1552,133 +263,7 @@ export function SettingsModal(props: SettingsModalProps) {
{renderSectionHeader('statistics')}
{isSectionVisible('statistics') && (
-
- {statsLoading && !stats ? (
-
Loading statistics...
- ) : stats ? (
-
- {/* Network */}
-
-
Network
-
-
-
{stats.contact_count}
-
Contacts
-
-
-
{stats.repeater_count}
-
Repeaters
-
-
-
{stats.channel_count}
-
Channels
-
-
-
-
-
-
- {/* Messages */}
-
-
Messages
-
-
-
{stats.total_dms}
-
Direct Messages
-
-
-
{stats.total_channel_messages}
-
Channel Messages
-
-
-
{stats.total_outgoing}
-
Sent (Outgoing)
-
-
-
-
-
-
- {/* Packets */}
-
-
Packets
-
-
- Total stored
- {stats.total_packets}
-
-
- Decrypted
-
- {stats.decrypted_packets}
-
-
-
- Undecrypted
-
- {stats.undecrypted_packets}
-
-
-
-
-
-
-
- {/* Activity */}
-
-
Activity
-
-
-
-
- 1h
- 24h
- 7d
-
-
-
-
- Contacts heard
- {stats.contacts_heard.last_hour}
- {stats.contacts_heard.last_24_hours}
- {stats.contacts_heard.last_week}
-
-
- Repeaters heard
- {stats.repeaters_heard.last_hour}
- {stats.repeaters_heard.last_24_hours}
- {stats.repeaters_heard.last_week}
-
-
-
-
-
- {/* Busiest Channels */}
- {stats.busiest_channels_24h.length > 0 && (
- <>
-
-
-
Busiest Channels (24h)
-
- {stats.busiest_channels_24h.map((ch, i) => (
-
-
- {i + 1}.
- {ch.channel_name}
-
- {ch.message_count} msgs
-
- ))}
-
-
- >
- )}
-
- ) : null}
-
+
)}
)}
diff --git a/frontend/src/components/repeater/RepeaterAclPane.tsx b/frontend/src/components/repeater/RepeaterAclPane.tsx
new file mode 100644
index 0000000..454a63a
--- /dev/null
+++ b/frontend/src/components/repeater/RepeaterAclPane.tsx
@@ -0,0 +1,58 @@
+import { cn } from '@/lib/utils';
+import { RepeaterPane, NotFetched } from './repeaterPaneShared';
+import type { RepeaterAclResponse, PaneState } from '../../types';
+
+export function AclPane({
+ data,
+ state,
+ onRefresh,
+ disabled,
+}: {
+ data: RepeaterAclResponse | null;
+ state: PaneState;
+ onRefresh: () => void;
+ disabled?: boolean;
+}) {
+ const permColor: Record = {
+ 0: 'bg-muted text-muted-foreground',
+ 1: 'bg-blue-500/10 text-blue-500',
+ 2: 'bg-green-500/10 text-green-500',
+ 3: 'bg-amber-500/10 text-amber-500',
+ };
+
+ return (
+
+ {!data ? (
+
+ ) : data.acl.length === 0 ? (
+ No ACL entries
+ ) : (
+
+
+
+ Name
+ Permission
+
+
+
+ {data.acl.map((entry, i) => (
+
+ {entry.name || entry.pubkey_prefix}
+
+
+ {entry.permission_name}
+
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/repeater/RepeaterActionsPane.tsx b/frontend/src/components/repeater/RepeaterActionsPane.tsx
new file mode 100644
index 0000000..92e2ac9
--- /dev/null
+++ b/frontend/src/components/repeater/RepeaterActionsPane.tsx
@@ -0,0 +1,56 @@
+import { useState, useCallback, useEffect } from 'react';
+import { Button } from '../ui/button';
+
+export function ActionsPane({
+ onSendAdvert,
+ onSyncClock,
+ onReboot,
+ consoleLoading,
+}: {
+ onSendAdvert: () => void;
+ onSyncClock: () => void;
+ onReboot: () => void;
+ consoleLoading: boolean;
+}) {
+ const [confirmReboot, setConfirmReboot] = useState(false);
+
+ const handleReboot = useCallback(() => {
+ if (!confirmReboot) {
+ setConfirmReboot(true);
+ return;
+ }
+ setConfirmReboot(false);
+ onReboot();
+ }, [confirmReboot, onReboot]);
+
+ // Reset confirmation after 3 seconds
+ useEffect(() => {
+ if (!confirmReboot) return;
+ const timer = setTimeout(() => setConfirmReboot(false), 3000);
+ return () => clearTimeout(timer);
+ }, [confirmReboot]);
+
+ return (
+
+
+
Actions
+
+
+
+ Send Advert
+
+
+ Sync Clock
+
+
+ {confirmReboot ? 'Confirm Reboot' : 'Reboot'}
+
+
+
+ );
+}
diff --git a/frontend/src/components/repeater/RepeaterConsolePane.tsx b/frontend/src/components/repeater/RepeaterConsolePane.tsx
new file mode 100644
index 0000000..0d37ac2
--- /dev/null
+++ b/frontend/src/components/repeater/RepeaterConsolePane.tsx
@@ -0,0 +1,77 @@
+import { useState, useCallback, useRef, useEffect, type FormEvent } from 'react';
+import { Button } from '../ui/button';
+import { Input } from '../ui/input';
+
+export function ConsolePane({
+ history,
+ loading,
+ onSend,
+}: {
+ history: Array<{ command: string; response: string; timestamp: number; outgoing: boolean }>;
+ loading: boolean;
+ onSend: (command: string) => Promise;
+}) {
+ const [input, setInput] = useState('');
+ const outputRef = useRef(null);
+
+ // Auto-scroll to bottom on new entries
+ useEffect(() => {
+ if (outputRef.current) {
+ outputRef.current.scrollTop = outputRef.current.scrollHeight;
+ }
+ }, [history]);
+
+ const handleSubmit = useCallback(
+ async (e: FormEvent) => {
+ e.preventDefault();
+ const trimmed = input.trim();
+ if (!trimmed || loading) return;
+ setInput('');
+ await onSend(trimmed);
+ },
+ [input, loading, onSend]
+ );
+
+ return (
+
+
+
Console
+
+
+ {history.length === 0 && (
+
Type a CLI command below...
+ )}
+ {history.map((entry, i) =>
+ entry.outgoing ? (
+
+ > {entry.command}
+
+ ) : (
+
+ {entry.response}
+
+ )
+ )}
+ {loading &&
...
}
+
+
+
+ );
+}
diff --git a/frontend/src/components/repeater/RepeaterLppTelemetryPane.tsx b/frontend/src/components/repeater/RepeaterLppTelemetryPane.tsx
new file mode 100644
index 0000000..6680fd3
--- /dev/null
+++ b/frontend/src/components/repeater/RepeaterLppTelemetryPane.tsx
@@ -0,0 +1,30 @@
+import { RepeaterPane, NotFetched, LppSensorRow } from './repeaterPaneShared';
+import type { RepeaterLppTelemetryResponse, PaneState } from '../../types';
+
+export function LppTelemetryPane({
+ data,
+ state,
+ onRefresh,
+ disabled,
+}: {
+ data: RepeaterLppTelemetryResponse | null;
+ state: PaneState;
+ onRefresh: () => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ {!data ? (
+
+ ) : data.sensors.length === 0 ? (
+ No sensor data available
+ ) : (
+
+ {data.sensors.map((sensor, i) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/repeater/RepeaterNeighborsPane.tsx b/frontend/src/components/repeater/RepeaterNeighborsPane.tsx
new file mode 100644
index 0000000..53c4b8e
--- /dev/null
+++ b/frontend/src/components/repeater/RepeaterNeighborsPane.tsx
@@ -0,0 +1,144 @@
+import { useMemo, lazy, Suspense } from 'react';
+import { cn } from '@/lib/utils';
+import { RepeaterPane, NotFetched, formatDuration } from './repeaterPaneShared';
+import { isValidLocation, calculateDistance, formatDistance } from '../../utils/pathUtils';
+import type { Contact, RepeaterNeighborsResponse, PaneState, NeighborInfo } from '../../types';
+
+const NeighborsMiniMap = lazy(() =>
+ import('../NeighborsMiniMap').then((m) => ({ default: m.NeighborsMiniMap }))
+);
+
+export function NeighborsPane({
+ data,
+ state,
+ onRefresh,
+ disabled,
+ contacts,
+ radioLat,
+ radioLon,
+ radioName,
+}: {
+ data: RepeaterNeighborsResponse | null;
+ state: PaneState;
+ onRefresh: () => void;
+ disabled?: boolean;
+ contacts: Contact[];
+ radioLat: number | null;
+ radioLon: number | null;
+ radioName: string | null;
+}) {
+ // Resolve contact data for each neighbor in a single pass — used for
+ // coords (mini-map), distances (table column), and sorted display order.
+ const { neighborsWithCoords, sorted, hasDistances } = useMemo(() => {
+ if (!data) {
+ return {
+ neighborsWithCoords: [] as Array,
+ sorted: [] as Array,
+ hasDistances: false,
+ };
+ }
+
+ const withCoords: Array = [];
+ const enriched: Array = [];
+ let anyDist = false;
+
+ for (const n of data.neighbors) {
+ const contact = contacts.find((c) => c.public_key.startsWith(n.pubkey_prefix));
+ const nLat = contact?.lat ?? null;
+ const nLon = contact?.lon ?? null;
+
+ let dist: string | null = null;
+ if (isValidLocation(radioLat, radioLon) && isValidLocation(nLat, nLon)) {
+ const distKm = calculateDistance(radioLat, radioLon, nLat, nLon);
+ if (distKm != null) {
+ dist = formatDistance(distKm);
+ anyDist = true;
+ }
+ }
+ enriched.push({ ...n, distance: dist });
+
+ if (isValidLocation(nLat, nLon)) {
+ withCoords.push({ ...n, lat: nLat, lon: nLon });
+ }
+ }
+
+ enriched.sort((a, b) => b.snr - a.snr);
+
+ return {
+ neighborsWithCoords: withCoords,
+ sorted: enriched,
+ hasDistances: anyDist,
+ };
+ }, [data, contacts, radioLat, radioLon]);
+
+ return (
+
+ {!data ? (
+
+ ) : sorted.length === 0 ? (
+ No neighbors reported
+ ) : (
+
+
+
+
+
+ Name
+ SNR
+ {hasDistances && Dist }
+ Last Heard
+
+
+
+ {sorted.map((n, i) => {
+ const dist = n.distance;
+ const snrStr = n.snr >= 0 ? `+${n.snr.toFixed(1)}` : n.snr.toFixed(1);
+ const snrColor =
+ n.snr >= 6 ? 'text-green-500' : n.snr >= 0 ? 'text-yellow-500' : 'text-red-500';
+ return (
+
+ {n.name || n.pubkey_prefix}
+ {snrStr} dB
+ {hasDistances && (
+
+ {dist ?? '—'}
+
+ )}
+
+ {formatDuration(n.last_heard_seconds)} ago
+
+
+ );
+ })}
+
+
+
+ {(neighborsWithCoords.length > 0 || isValidLocation(radioLat, radioLon)) && (
+
+ Loading map...
+
+ }
+ >
+ n.pubkey_prefix).join(',')}
+ neighbors={neighborsWithCoords}
+ radioLat={radioLat}
+ radioLon={radioLon}
+ radioName={radioName}
+ />
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/repeater/RepeaterOwnerInfoPane.tsx b/frontend/src/components/repeater/RepeaterOwnerInfoPane.tsx
new file mode 100644
index 0000000..793b51b
--- /dev/null
+++ b/frontend/src/components/repeater/RepeaterOwnerInfoPane.tsx
@@ -0,0 +1,27 @@
+import { RepeaterPane, NotFetched, KvRow } from './repeaterPaneShared';
+import type { RepeaterOwnerInfoResponse, PaneState } from '../../types';
+
+export function OwnerInfoPane({
+ data,
+ state,
+ onRefresh,
+ disabled,
+}: {
+ data: RepeaterOwnerInfoResponse | null;
+ state: PaneState;
+ onRefresh: () => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ {!data ? (
+
+ ) : (
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/repeater/RepeaterRadioSettingsPane.tsx b/frontend/src/components/repeater/RepeaterRadioSettingsPane.tsx
new file mode 100644
index 0000000..4d18d90
--- /dev/null
+++ b/frontend/src/components/repeater/RepeaterRadioSettingsPane.tsx
@@ -0,0 +1,121 @@
+import { useMemo } from 'react';
+import { cn } from '@/lib/utils';
+import { Separator } from '../ui/separator';
+import {
+ RepeaterPane,
+ RefreshIcon,
+ NotFetched,
+ KvRow,
+ formatClockDrift,
+ formatAdvertInterval,
+} from './repeaterPaneShared';
+import type {
+ RepeaterRadioSettingsResponse,
+ RepeaterAdvertIntervalsResponse,
+ PaneState,
+} from '../../types';
+
+export function RadioSettingsPane({
+ data,
+ state,
+ onRefresh,
+ disabled,
+ advertData,
+ advertState,
+ onRefreshAdvert,
+}: {
+ data: RepeaterRadioSettingsResponse | null;
+ state: PaneState;
+ onRefresh: () => void;
+ disabled?: boolean;
+ advertData: RepeaterAdvertIntervalsResponse | null;
+ advertState: PaneState;
+ onRefreshAdvert: () => void;
+}) {
+ const clockDrift = useMemo(() => {
+ if (!data?.clock_utc) return null;
+ return formatClockDrift(data.clock_utc);
+ }, [data?.clock_utc]);
+
+ return (
+
+ {!data ? (
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+ Clock (UTC)
+
+ {data.clock_utc ?? '—'}
+ {clockDrift && (
+
+ (drift: {clockDrift.text})
+
+ )}
+
+
+
+ )}
+ {/* Advert Intervals sub-section */}
+
+
+ Advert Intervals
+
+
+
+
+ {advertState.error && {advertState.error}
}
+ {advertState.loading ? (
+
+ Fetching{advertState.attempt > 1 ? ` (attempt ${advertState.attempt}/3)` : ''}...
+
+ ) : !advertData ? (
+
+ ) : (
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/repeater/RepeaterTelemetryPane.tsx b/frontend/src/components/repeater/RepeaterTelemetryPane.tsx
new file mode 100644
index 0000000..7d3a5fb
--- /dev/null
+++ b/frontend/src/components/repeater/RepeaterTelemetryPane.tsx
@@ -0,0 +1,54 @@
+import { Separator } from '../ui/separator';
+import { RepeaterPane, NotFetched, KvRow, formatDuration } from './repeaterPaneShared';
+import type { RepeaterStatusResponse, PaneState } from '../../types';
+
+export function TelemetryPane({
+ data,
+ state,
+ onRefresh,
+ disabled,
+}: {
+ data: RepeaterStatusResponse | null;
+ state: PaneState;
+ onRefresh: () => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ {!data ? (
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/repeater/repeaterPaneShared.tsx b/frontend/src/components/repeater/repeaterPaneShared.tsx
new file mode 100644
index 0000000..fc98f0d
--- /dev/null
+++ b/frontend/src/components/repeater/repeaterPaneShared.tsx
@@ -0,0 +1,209 @@
+import type { ReactNode } from 'react';
+import { cn } from '@/lib/utils';
+import type { LppSensor, PaneState } from '../../types';
+
+// --- Shared Icons ---
+
+export function RefreshIcon({ className }: { className?: string }) {
+ return (
+
+
+
+ );
+}
+
+// --- Utility ---
+
+export function formatDuration(seconds: number): string {
+ if (seconds < 60) return `${seconds}s`;
+ const days = Math.floor(seconds / 86400);
+ const hours = Math.floor((seconds % 86400) / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ if (days > 0) {
+ if (hours > 0 && mins > 0) return `${days}d${hours}h${mins}m`;
+ if (hours > 0) return `${days}d${hours}h`;
+ if (mins > 0) return `${days}d${mins}m`;
+ return `${days}d`;
+ }
+ if (hours > 0) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
+ return `${mins}m`;
+}
+
+export function formatClockDrift(clockUtc: string): { text: string; isLarge: boolean } {
+ // Firmware format: "HH:MM - D/M/YYYY UTC" or "HH:MM:SS - D/M/YYYY UTC"
+ // Also handle ISO-like: "YYYY-MM-DD HH:MM:SS"
+ let parsed: Date;
+ const fwMatch = clockUtc.match(
+ /^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*-\s*(\d{1,2})\/(\d{1,2})\/(\d{4})/
+ );
+ if (fwMatch) {
+ const [, hh, mm, ss, dd, mo, yyyy] = fwMatch;
+ parsed = new Date(Date.UTC(+yyyy, +mo - 1, +dd, +hh, +mm, +(ss ?? 0)));
+ } else {
+ parsed = new Date(
+ clockUtc.replace(' ', 'T') + (clockUtc.includes('Z') || clockUtc.includes('UTC') ? '' : 'Z')
+ );
+ }
+ if (isNaN(parsed.getTime())) return { text: '(invalid)', isLarge: false };
+
+ const driftMs = Math.abs(Date.now() - parsed.getTime());
+ const driftSec = Math.floor(driftMs / 1000);
+
+ if (driftSec >= 86400) return { text: '>24 hours!', isLarge: true };
+
+ const h = Math.floor(driftSec / 3600);
+ const m = Math.floor((driftSec % 3600) / 60);
+ const s = driftSec % 60;
+
+ const parts: string[] = [];
+ if (h > 0) parts.push(`${h}h`);
+ if (m > 0) parts.push(`${m}m`);
+ parts.push(`${s}s`);
+
+ return { text: parts.join(''), isLarge: false };
+}
+
+export function formatAdvertInterval(val: string | null): string {
+ if (val == null) return '—';
+ const trimmed = val.trim();
+ if (trimmed === '0') return '';
+ return `${trimmed}h`;
+}
+
+// --- Generic Pane Wrapper ---
+
+export function RepeaterPane({
+ title,
+ state,
+ onRefresh,
+ disabled,
+ children,
+ className,
+ contentClassName,
+}: {
+ title: string;
+ state: PaneState;
+ onRefresh?: () => void;
+ disabled?: boolean;
+ children: ReactNode;
+ className?: string;
+ contentClassName?: string;
+}) {
+ return (
+
+
+
{title}
+ {onRefresh && (
+
+
+
+ )}
+
+ {state.error && (
+
+ {state.error}
+
+ )}
+
+ {state.loading ? (
+
+ Fetching{state.attempt > 1 ? ` (attempt ${state.attempt}/${3})` : ''}...
+
+ ) : (
+ children
+ )}
+
+
+ );
+}
+
+export function NotFetched() {
+ return <not fetched>
;
+}
+
+export function KvRow({ label, value }: { label: string; value: ReactNode }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+// --- LPP Utilities ---
+
+export const LPP_UNIT_MAP: Record = {
+ temperature: '°C',
+ humidity: '%',
+ barometer: 'hPa',
+ voltage: 'V',
+ current: 'mA',
+ luminosity: 'lux',
+ altitude: 'm',
+ power: 'W',
+ distance: 'mm',
+ energy: 'kWh',
+ direction: '°',
+ concentration: 'ppm',
+ colour: '',
+};
+
+export function formatLppLabel(typeName: string): string {
+ return typeName.charAt(0).toUpperCase() + typeName.slice(1).replace(/_/g, ' ');
+}
+
+export function LppSensorRow({ sensor }: { sensor: LppSensor }) {
+ const label = formatLppLabel(sensor.type_name);
+
+ if (typeof sensor.value === 'object' && sensor.value !== null) {
+ // Multi-value sensor (GPS, accelerometer, etc.)
+ return (
+
+
{label}
+
+ {Object.entries(sensor.value).map(([k, v]) => (
+
+ ))}
+
+
+ );
+ }
+
+ const unit = LPP_UNIT_MAP[sensor.type_name] ?? '';
+ const formatted =
+ typeof sensor.value === 'number'
+ ? `${sensor.value % 1 === 0 ? sensor.value : sensor.value.toFixed(2)}${unit ? ` ${unit}` : ''}`
+ : String(sensor.value);
+
+ return ;
+}
diff --git a/frontend/src/components/settings/SettingsBotSection.tsx b/frontend/src/components/settings/SettingsBotSection.tsx
new file mode 100644
index 0000000..c033884
--- /dev/null
+++ b/frontend/src/components/settings/SettingsBotSection.tsx
@@ -0,0 +1,313 @@
+import { useState, useEffect, lazy, Suspense } from 'react';
+import { Label } from '../ui/label';
+import { Button } from '../ui/button';
+import { Separator } from '../ui/separator';
+import { toast } from '../ui/sonner';
+import type { AppSettings, AppSettingsUpdate, BotConfig } from '../../types';
+
+const BotCodeEditor = lazy(() =>
+ import('../BotCodeEditor').then((m) => ({ default: m.BotCodeEditor }))
+);
+
+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`;
+
+export function SettingsBotSection({
+ appSettings,
+ isMobileLayout,
+ onSaveAppSettings,
+ className,
+}: {
+ appSettings: AppSettings;
+ isMobileLayout: boolean;
+ onSaveAppSettings: (update: AppSettingsUpdate) => Promise;
+ className?: string;
+}) {
+ const [bots, setBots] = useState([]);
+ const [expandedBotId, setExpandedBotId] = useState(null);
+ const [editingNameId, setEditingNameId] = useState(null);
+ const [editingNameValue, setEditingNameValue] = useState('');
+
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setBots(appSettings.bots || []);
+ }, [appSettings]);
+
+ const handleSave = async () => {
+ setBusy(true);
+ setError(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';
+ setError(errorMsg);
+ toast.error(errorMsg);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ 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)));
+ };
+
+ return (
+
+
+
+ Experimental: 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!
+
+
+
+
+
+ Security Warning: This feature executes arbitrary Python code on the
+ server. Only run trusted code, and be cautious of arbitrary usage of message parameters.
+
+
+
+
+
+ Don't wreck the mesh! Bots process ALL messages, including their
+ own. Be careful of creating infinite loops!
+
+
+
+
+ Bots
+
+ + New Bot
+
+
+
+ {bots.length === 0 ? (
+
+
No bots configured
+
+ Create your first bot
+
+
+ ) : (
+
+ {bots.map((bot) => (
+
+
{
+ if ((e.target as HTMLElement).closest('input, button')) return;
+ setExpandedBotId(expandedBotId === bot.id ? null : bot.id);
+ }}
+ >
+
+ {expandedBotId === bot.id ? '▼' : '▶'}
+
+
+ {editingNameId === bot.id ? (
+ 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()}
+ />
+ ) : (
+ {
+ e.stopPropagation();
+ handleStartEditingName(bot);
+ }}
+ title="Click to rename"
+ >
+ {bot.name}
+
+ )}
+
+ e.stopPropagation()}
+ >
+ handleToggleBotEnabled(bot.id)}
+ className="w-4 h-4 rounded border-input accent-primary"
+ />
+ Enabled
+
+
+ {
+ e.stopPropagation();
+ handleDeleteBot(bot.id);
+ }}
+ title="Delete bot"
+ >
+ 🗑
+
+
+
+ {expandedBotId === bot.id && (
+
+
+
+ Define a bot() function that
+ receives message data and optionally returns a reply.
+
+
handleResetBotCode(bot.id)}
+ >
+ Reset to Example
+
+
+
+ Loading editor...
+
+ }
+ >
+
handleBotCodeChange(bot.id, code)}
+ id={`bot-code-${bot.id}`}
+ height={isMobileLayout ? '256px' : '384px'}
+ />
+
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+
+
+ Available: Standard Python libraries and any modules installed in the
+ server environment.
+
+
+ Limits: 10 second timeout per bot.
+
+
+ Note: Bots respond to all messages, including your own. For channel
+ messages, sender_key is None. Multiple enabled bots run
+ serially, with a two-second delay between messages to prevent repeater collision.
+
+
+
+ {error && {error}
}
+
+
+ {busy ? 'Saving...' : 'Save Bot Settings'}
+
+
+ );
+}
diff --git a/frontend/src/components/settings/SettingsConnectivitySection.tsx b/frontend/src/components/settings/SettingsConnectivitySection.tsx
new file mode 100644
index 0000000..f58a3ff
--- /dev/null
+++ b/frontend/src/components/settings/SettingsConnectivitySection.tsx
@@ -0,0 +1,134 @@
+import { useState, useEffect } from 'react';
+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 type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
+
+export function SettingsConnectivitySection({
+ appSettings,
+ health,
+ pageMode,
+ onSaveAppSettings,
+ onReboot,
+ onClose,
+ className,
+}: {
+ appSettings: AppSettings;
+ health: HealthStatus | null;
+ pageMode: boolean;
+ onSaveAppSettings: (update: AppSettingsUpdate) => Promise;
+ onReboot: () => Promise;
+ onClose: () => void;
+ className?: string;
+}) {
+ const [maxRadioContacts, setMaxRadioContacts] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [rebooting, setRebooting] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setMaxRadioContacts(String(appSettings.max_radio_contacts));
+ }, [appSettings]);
+
+ const handleSave = async () => {
+ setError(null);
+ setBusy(true);
+
+ 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) {
+ setError(err instanceof Error ? err.message : 'Failed to save');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const handleReboot = async () => {
+ if (
+ !confirm('Are you sure you want to reboot the radio? The connection will drop temporarily.')
+ ) {
+ return;
+ }
+ setError(null);
+ setBusy(true);
+ setRebooting(true);
+
+ try {
+ await onReboot();
+ if (!pageMode) {
+ onClose();
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to reboot radio');
+ } finally {
+ setRebooting(false);
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
Connection
+ {health?.connection_info ? (
+
+
+
+ {health.connection_info}
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+
Max Contacts on Radio
+
setMaxRadioContacts(e.target.value)}
+ />
+
+ Favorite contacts load first, then recent non-repeater contacts until this limit is
+ reached (1-1000)
+
+
+
+
+ {busy ? 'Saving...' : 'Save Settings'}
+
+
+
+
+
+ {rebooting ? 'Rebooting...' : 'Reboot Radio'}
+
+
+ {error &&
{error}
}
+
+ );
+}
diff --git a/frontend/src/components/settings/SettingsDatabaseSection.tsx b/frontend/src/components/settings/SettingsDatabaseSection.tsx
new file mode 100644
index 0000000..9bcb8c4
--- /dev/null
+++ b/frontend/src/components/settings/SettingsDatabaseSection.tsx
@@ -0,0 +1,285 @@
+import { useState, useEffect } from 'react';
+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 { getLocalLabel, setLocalLabel, type LocalLabel } from '../../utils/localLabel';
+import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
+
+export function SettingsDatabaseSection({
+ appSettings,
+ health,
+ onSaveAppSettings,
+ onHealthRefresh,
+ onLocalLabelChange,
+ className,
+}: {
+ appSettings: AppSettings;
+ health: HealthStatus | null;
+ onSaveAppSettings: (update: AppSettingsUpdate) => Promise;
+ onHealthRefresh: () => Promise;
+ onLocalLabelChange?: (label: LocalLabel) => void;
+ className?: string;
+}) {
+ 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
+ );
+ const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
+ const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
+
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
+ }, [appSettings]);
+
+ useEffect(() => {
+ setReopenLastConversation(getReopenLastConversationEnabled());
+ }, []);
+
+ 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 handleSave = async () => {
+ setBusy(true);
+ setError(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);
+ setError(err instanceof Error ? err.message : 'Failed to save');
+ toast.error('Failed to save settings');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const handleToggleReopenLastConversation = (enabled: boolean) => {
+ setReopenLastConversation(enabled);
+ setReopenLastConversationEnabled(enabled);
+ if (enabled) {
+ captureLastViewedConversationFromHash();
+ }
+ };
+
+ return (
+
+
+
+ Database size
+ {health?.database_size_mb ?? '?'} MB
+
+
+ {health?.oldest_undecrypted_timestamp ? (
+
+ Oldest undecrypted packet
+
+ {formatTime(health.oldest_undecrypted_timestamp)}
+
+ ({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
+ days old)
+
+
+
+ ) : (
+
+ Oldest undecrypted packet
+ None
+
+ )}
+
+
+
+
+
+
Delete Undecrypted Packets
+
+ 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.
+
+
+
+
+ Older than (days)
+
+ setRetentionDays(e.target.value)}
+ className="w-24"
+ />
+
+
+ {cleaning ? 'Deleting...' : 'Permanently Delete'}
+
+
+
+
+
+
+
+
Purge Archival Raw Packets
+
+ Deletes archival copies of raw packet bytes for messages that are already decrypted and
+ visible in your chat history.{' '}
+
+ This will not affect any displayed messages or app functionality.
+ {' '}
+ The raw bytes are only useful for manual packet analysis.
+
+
+ {purgingDecryptedRaw ? 'Purging Archival Raw Packets...' : 'Purge Archival Raw Packets'}
+
+
+
+
+
+
+
DM Decryption
+
+ setAutoDecryptOnAdvert(e.target.checked)}
+ className="w-4 h-4 rounded border-input accent-primary"
+ />
+ Auto-decrypt historical DMs when new contact advertises
+
+
+ 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.
+
+
+
+
+
+
+
Interface
+
+ handleToggleReopenLastConversation(e.target.checked)}
+ className="w-4 h-4 rounded border-input accent-primary"
+ />
+ Reopen to last viewed channel/conversation
+
+
+ This applies only to this device/browser. It does not sync to server settings.
+
+
+
+
+
+
+
Local Label
+
+ {
+ const text = e.target.value;
+ setLocalLabelText(text);
+ setLocalLabel(text, localLabelColor);
+ onLocalLabelChange?.({ text, color: localLabelColor });
+ }}
+ placeholder="e.g. Home Base, Field Radio 2"
+ className="flex-1"
+ />
+ {
+ const color = e.target.value;
+ setLocalLabelColor(color);
+ setLocalLabel(localLabelText, color);
+ onLocalLabelChange?.({ text: localLabelText, color });
+ }}
+ className="w-10 h-9 rounded border border-input cursor-pointer bg-transparent p-0.5"
+ />
+
+
+ Display a colored banner at the top of the page to identify this instance. This applies
+ only to this device/browser.
+
+
+
+ {error &&
{error}
}
+
+
+ {busy ? 'Saving...' : 'Save Settings'}
+
+
+ );
+}
diff --git a/frontend/src/components/settings/SettingsIdentitySection.tsx b/frontend/src/components/settings/SettingsIdentitySection.tsx
new file mode 100644
index 0000000..9da0551
--- /dev/null
+++ b/frontend/src/components/settings/SettingsIdentitySection.tsx
@@ -0,0 +1,190 @@
+import { useState, useEffect } from 'react';
+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 type {
+ AppSettings,
+ AppSettingsUpdate,
+ HealthStatus,
+ RadioConfig,
+ RadioConfigUpdate,
+} from '../../types';
+
+export function SettingsIdentitySection({
+ config,
+ health,
+ appSettings,
+ pageMode,
+ onSave,
+ onSaveAppSettings,
+ onSetPrivateKey,
+ onReboot,
+ onAdvertise,
+ onClose,
+ className,
+}: {
+ config: RadioConfig;
+ health: HealthStatus | null;
+ appSettings: AppSettings;
+ pageMode: boolean;
+ onSave: (update: RadioConfigUpdate) => Promise;
+ onSaveAppSettings: (update: AppSettingsUpdate) => Promise;
+ onSetPrivateKey: (key: string) => Promise;
+ onReboot: () => Promise;
+ onAdvertise: () => Promise;
+ onClose: () => void;
+ className?: string;
+}) {
+ const [name, setName] = useState('');
+ const [privateKey, setPrivateKey] = useState('');
+ const [advertIntervalHours, setAdvertIntervalHours] = useState('0');
+ const [busy, setBusy] = useState(false);
+ const [rebooting, setRebooting] = useState(false);
+ const [advertising, setAdvertising] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setName(config.name);
+ }, [config]);
+
+ useEffect(() => {
+ setAdvertIntervalHours(String(Math.round(appSettings.advert_interval / 3600)));
+ }, [appSettings]);
+
+ const handleSaveIdentity = async () => {
+ setError(null);
+ setBusy(true);
+
+ try {
+ const update: RadioConfigUpdate = { name };
+ await onSave(update);
+
+ 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) {
+ setError(err instanceof Error ? err.message : 'Failed to save');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const handleSetPrivateKey = async () => {
+ if (!privateKey.trim()) {
+ setError('Private key is required');
+ return;
+ }
+ setError(null);
+ setBusy(true);
+
+ try {
+ await onSetPrivateKey(privateKey.trim());
+ setPrivateKey('');
+ toast.success('Private key set, rebooting...');
+ setRebooting(true);
+ await onReboot();
+ if (!pageMode) {
+ onClose();
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to set private key');
+ } finally {
+ setRebooting(false);
+ setBusy(false);
+ }
+ };
+
+ const handleAdvertise = async () => {
+ setAdvertising(true);
+ try {
+ await onAdvertise();
+ } finally {
+ setAdvertising(false);
+ }
+ };
+
+ return (
+
+
+ Public Key
+
+
+
+
+ Radio Name
+ setName(e.target.value)} />
+
+
+
+
Periodic Advertising Interval
+
+ setAdvertIntervalHours(e.target.value)}
+ className="w-28"
+ />
+ hours (0 = off)
+
+
+ How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
+ Recommended: 24 hours or higher.
+
+
+
+
+ {busy ? 'Saving...' : 'Save Identity Settings'}
+
+
+
+
+
+ Set Private Key (write-only)
+ setPrivateKey(e.target.value)}
+ placeholder="64-character hex private key"
+ />
+
+ {busy || rebooting ? 'Setting & Rebooting...' : 'Set Private Key & Reboot'}
+
+
+
+
+
+
+
Send Advertisement
+
+ Send a flood advertisement to announce your presence on the mesh network.
+
+
+ {advertising ? 'Sending...' : 'Send Advertisement'}
+
+ {!health?.radio_connected && (
+
Radio not connected
+ )}
+
+
+ {error &&
{error}
}
+
+ );
+}
diff --git a/frontend/src/components/settings/SettingsMqttSection.tsx b/frontend/src/components/settings/SettingsMqttSection.tsx
new file mode 100644
index 0000000..0d63bda
--- /dev/null
+++ b/frontend/src/components/settings/SettingsMqttSection.tsx
@@ -0,0 +1,216 @@
+import { useState, useEffect } from 'react';
+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 type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
+
+export function SettingsMqttSection({
+ appSettings,
+ health,
+ onSaveAppSettings,
+ className,
+}: {
+ appSettings: AppSettings;
+ health: HealthStatus | null;
+ onSaveAppSettings: (update: AppSettingsUpdate) => Promise;
+ className?: string;
+}) {
+ const [mqttBrokerHost, setMqttBrokerHost] = useState('');
+ const [mqttBrokerPort, setMqttBrokerPort] = useState('1883');
+ const [mqttUsername, setMqttUsername] = useState('');
+ const [mqttPassword, setMqttPassword] = useState('');
+ const [mqttUseTls, setMqttUseTls] = useState(false);
+ const [mqttTlsInsecure, setMqttTlsInsecure] = useState(false);
+ const [mqttTopicPrefix, setMqttTopicPrefix] = useState('meshcore');
+ const [mqttPublishMessages, setMqttPublishMessages] = useState(false);
+ const [mqttPublishRawPackets, setMqttPublishRawPackets] = useState(false);
+
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setMqttBrokerHost(appSettings.mqtt_broker_host ?? '');
+ setMqttBrokerPort(String(appSettings.mqtt_broker_port ?? 1883));
+ setMqttUsername(appSettings.mqtt_username ?? '');
+ setMqttPassword(appSettings.mqtt_password ?? '');
+ setMqttUseTls(appSettings.mqtt_use_tls ?? false);
+ setMqttTlsInsecure(appSettings.mqtt_tls_insecure ?? false);
+ setMqttTopicPrefix(appSettings.mqtt_topic_prefix ?? 'meshcore');
+ setMqttPublishMessages(appSettings.mqtt_publish_messages ?? false);
+ setMqttPublishRawPackets(appSettings.mqtt_publish_raw_packets ?? false);
+ }, [appSettings]);
+
+ const handleSave = async () => {
+ setError(null);
+ setBusy(true);
+
+ try {
+ const update: AppSettingsUpdate = {
+ mqtt_broker_host: mqttBrokerHost,
+ mqtt_broker_port: parseInt(mqttBrokerPort, 10) || 1883,
+ mqtt_username: mqttUsername,
+ mqtt_password: mqttPassword,
+ mqtt_use_tls: mqttUseTls,
+ mqtt_tls_insecure: mqttTlsInsecure,
+ mqtt_topic_prefix: mqttTopicPrefix || 'meshcore',
+ mqtt_publish_messages: mqttPublishMessages,
+ mqtt_publish_raw_packets: mqttPublishRawPackets,
+ };
+ await onSaveAppSettings(update);
+ toast.success('MQTT settings saved');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to save');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
Status
+ {health?.mqtt_status === 'connected' ? (
+
+ ) : health?.mqtt_status === 'disconnected' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ Broker Host
+ setMqttBrokerHost(e.target.value)}
+ />
+
+
+
+ Broker Port
+ setMqttBrokerPort(e.target.value)}
+ />
+
+
+
+ Username
+ setMqttUsername(e.target.value)}
+ />
+
+
+
+ Password
+ setMqttPassword(e.target.value)}
+ />
+
+
+
+ setMqttUseTls(e.target.checked)}
+ className="h-4 w-4 rounded border-border"
+ />
+ Use TLS
+
+
+ {mqttUseTls && (
+ <>
+
+ setMqttTlsInsecure(e.target.checked)}
+ className="h-4 w-4 rounded border-border"
+ />
+ Skip certificate verification
+
+
+ Allow self-signed or untrusted broker certificates
+
+ >
+ )}
+
+
+
+
+
Topic Prefix
+
setMqttTopicPrefix(e.target.value)}
+ />
+
+ Topics: {mqttTopicPrefix || 'meshcore'}/dm:<key>, {mqttTopicPrefix || 'meshcore'}
+ /gm:<key>, {mqttTopicPrefix || 'meshcore'}
+ /raw/...
+
+
+
+
+
+
+ setMqttPublishMessages(e.target.checked)}
+ className="h-4 w-4 rounded border-border"
+ />
+ Publish Messages
+
+
+ Forward decrypted DM and channel messages
+
+
+
+ setMqttPublishRawPackets(e.target.checked)}
+ className="h-4 w-4 rounded border-border"
+ />
+ Publish Raw Packets
+
+
Forward all RF packets
+
+
+ {busy ? 'Saving...' : 'Save MQTT Settings'}
+
+
+ {error &&
{error}
}
+
+ );
+}
diff --git a/frontend/src/components/settings/SettingsRadioSection.tsx b/frontend/src/components/settings/SettingsRadioSection.tsx
new file mode 100644
index 0000000..aaf0032
--- /dev/null
+++ b/frontend/src/components/settings/SettingsRadioSection.tsx
@@ -0,0 +1,268 @@
+import { useState, useEffect, useMemo } from 'react';
+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 { RADIO_PRESETS } from '../../utils/radioPresets';
+import type { RadioConfig, RadioConfigUpdate } from '../../types';
+
+export function SettingsRadioSection({
+ config,
+ pageMode,
+ onSave,
+ onReboot,
+ onClose,
+ className,
+}: {
+ config: RadioConfig;
+ pageMode: boolean;
+ onSave: (update: RadioConfigUpdate) => Promise;
+ onReboot: () => Promise;
+ onClose: () => void;
+ className?: string;
+}) {
+ 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 [gettingLocation, setGettingLocation] = useState(false);
+
+ const [busy, setBusy] = useState(false);
+ const [rebooting, setRebooting] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ 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]);
+
+ 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 handleSave = async () => {
+ setError(null);
+ setBusy(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, rebooting...');
+ setRebooting(true);
+ await onReboot();
+ if (!pageMode) {
+ onClose();
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to save');
+ } finally {
+ setRebooting(false);
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+ Preset
+ 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"
+ >
+ Custom
+ {RADIO_PRESETS.map((preset) => (
+
+ {preset.name}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Location
+
+ {gettingLocation ? 'Getting...' : '📍 Use My Location'}
+
+
+
+
+
+ {error &&
{error}
}
+
+
+ {busy || rebooting ? 'Saving & Rebooting...' : 'Save Radio Config & Reboot'}
+
+
+ );
+}
diff --git a/frontend/src/components/settings/SettingsStatisticsSection.tsx b/frontend/src/components/settings/SettingsStatisticsSection.tsx
new file mode 100644
index 0000000..0664084
--- /dev/null
+++ b/frontend/src/components/settings/SettingsStatisticsSection.tsx
@@ -0,0 +1,151 @@
+import { useState, useEffect } from 'react';
+import { Separator } from '../ui/separator';
+import { api } from '../../api';
+import type { StatisticsResponse } from '../../types';
+
+export function SettingsStatisticsSection({ className }: { className?: string }) {
+ const [stats, setStats] = useState(null);
+ const [statsLoading, setStatsLoading] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ setStatsLoading(true);
+ api.getStatistics().then(
+ (data) => {
+ if (!cancelled) {
+ setStats(data);
+ setStatsLoading(false);
+ }
+ },
+ () => {
+ if (!cancelled) setStatsLoading(false);
+ }
+ );
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ return (
+
+ {statsLoading && !stats ? (
+
Loading statistics...
+ ) : stats ? (
+
+ {/* Network */}
+
+
Network
+
+
+
{stats.contact_count}
+
Contacts
+
+
+
{stats.repeater_count}
+
Repeaters
+
+
+
{stats.channel_count}
+
Channels
+
+
+
+
+
+
+ {/* Messages */}
+
+
Messages
+
+
+
{stats.total_dms}
+
Direct Messages
+
+
+
{stats.total_channel_messages}
+
Channel Messages
+
+
+
{stats.total_outgoing}
+
Sent (Outgoing)
+
+
+
+
+
+
+ {/* Packets */}
+
+
Packets
+
+
+ Total stored
+ {stats.total_packets}
+
+
+ Decrypted
+ {stats.decrypted_packets}
+
+
+ Undecrypted
+ {stats.undecrypted_packets}
+
+
+
+
+
+
+ {/* Activity */}
+
+
Activity
+
+
+
+
+ 1h
+ 24h
+ 7d
+
+
+
+
+ Contacts heard
+ {stats.contacts_heard.last_hour}
+ {stats.contacts_heard.last_24_hours}
+ {stats.contacts_heard.last_week}
+
+
+ Repeaters heard
+ {stats.repeaters_heard.last_hour}
+ {stats.repeaters_heard.last_24_hours}
+ {stats.repeaters_heard.last_week}
+
+
+
+
+
+ {/* Busiest Channels */}
+ {stats.busiest_channels_24h.length > 0 && (
+ <>
+
+
+
Busiest Channels (24h)
+
+ {stats.busiest_channels_24h.map((ch, i) => (
+
+
+ {i + 1}.
+ {ch.channel_name}
+
+ {ch.message_count} msgs
+
+ ))}
+
+
+ >
+ )}
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/components/settingsConstants.ts b/frontend/src/components/settings/settingsConstants.ts
similarity index 100%
rename from frontend/src/components/settingsConstants.ts
rename to frontend/src/components/settings/settingsConstants.ts
diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx
index 19be53a..9c16ebd 100644
--- a/frontend/src/test/settingsModal.test.tsx
+++ b/frontend/src/test/settingsModal.test.tsx
@@ -10,7 +10,7 @@ import type {
RadioConfigUpdate,
StatisticsResponse,
} from '../types';
-import type { SettingsSection } from '../components/settingsConstants';
+import type { SettingsSection } from '../components/settings/settingsConstants';
import {
LAST_VIEWED_CONVERSATION_KEY,
REOPEN_LAST_CONVERSATION_KEY,