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

- ) : ( -
-
- - - - - - {hasDistances && } - - - - - {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 ( - - - - {hasDistances && ( - - )} - - - ); - })} - -
NameSNRDistLast Heard
{n.name || n.pubkey_prefix}{snrStr} dB - {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

- ) : ( - - - - - - - - - {data.acl.map((entry, i) => ( - - - - - ))} - -
NamePermission
{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

-
-
- - - -
-
- ); -} - -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 &&
...
} -
-
- setInput(e.target.value)} - placeholder="CLI command..." - disabled={loading} - className="flex-1 font-mono text-sm" - /> - -
-
- ); -} +// 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') && ( -
-
- - -
- -
-
- - setFreq(e.target.value)} - /> -
-
- - setBw(e.target.value)} - /> -
-
- -
-
- - setSf(e.target.value)} - /> -
-
- - setCr(e.target.value)} - /> -
-
- -
-
- - setTxPower(e.target.value)} - /> -
-
- - -
-
- - - -
-
- - -
-
-
- - setLat(e.target.value)} - /> -
-
- - setLon(e.target.value)} - /> -
-
-
- - {getSectionError('radio') && ( -
{getSectionError('radio')}
- )} - - -
+ )}
)} @@ -864,96 +180,20 @@ export function SettingsModal(props: SettingsModalProps) { {shouldRenderSection('identity') && (
{renderSectionHeader('identity')} - {isSectionVisible('identity') && ( -
-
- - -
- -
- - setName(e.target.value)} /> -
- -
- -
- 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. -

-
- - - - - -
- - setPrivateKey(e.target.value)} - placeholder="64-character hex private key" - /> - -
- - - -
- -

- Send a flood advertisement to announce your presence on the mesh network. -

- - {!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') && ( -
-
- - {health?.connection_info ? ( -
-
- - {health.connection_info} - -
- ) : ( -
-
- Not connected -
- )} -
- - - -
- - setMaxRadioContacts(e.target.value)} - /> -

- Favorite contacts load first, then recent non-repeater contacts until this limit - is reached (1-1000) -

-
- - - - - - - - {getSectionError('connectivity') && ( -
{getSectionError('connectivity')}
- )} -
+ {isSectionVisible('connectivity') && appSettings && ( + )}
)} @@ -1028,153 +218,13 @@ export function SettingsModal(props: SettingsModalProps) { {shouldRenderSection('mqtt') && (
{renderSectionHeader('mqtt')} - {isSectionVisible('mqtt') && ( -
-
- - {health?.mqtt_status === 'connected' ? ( -
-
- Connected -
- ) : health?.mqtt_status === 'disconnected' ? ( -
-
- Disconnected -
- ) : ( -
-
- Disabled -
- )} -
- - - -
- - setMqttBrokerHost(e.target.value)} - /> -
- -
- - setMqttBrokerPort(e.target.value)} - /> -
- -
- - setMqttUsername(e.target.value)} - /> -
- -
- - setMqttPassword(e.target.value)} - /> -
- - - - {mqttUseTls && ( - <> - -

- Allow self-signed or untrusted broker certificates -

- - )} - - - -
- - setMqttTopicPrefix(e.target.value)} - /> -

- Topics: {mqttTopicPrefix || 'meshcore'}/dm:<key>,{' '} - {mqttTopicPrefix || 'meshcore'}/gm:<key>, {mqttTopicPrefix || 'meshcore'} - /raw/... -

-
- - - - -

- Forward decrypted DM and channel messages -

- - -

Forward all RF packets

- - - - {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 -
- )} -
- - - -
- -

- 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. -

-
-
- - setRetentionDays(e.target.value)} - className="w-24" - /> -
- -
-
- - - -
- -

- 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. -

- -
- - - -
- - -

- 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. -

-
- - - -
- - -

- This applies only to this device/browser. It does not sync to server settings. -

-
- - - -
- -
- { - 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')}
- )} - - -
+ {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.length === 0 ? ( -
-

No bots configured

- -
- ) : ( -
- {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} - - )} - - - - -
- - {expandedBotId === bot.id && ( -
-
-

- Define a bot() function - that receives message data and optionally returns a reply. -

- -
- - 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')}
- )} - - -
+ {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

- - - - - - - - - - - - - - - - - - - - - - - -
1h24h7d
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

+ ) : ( + + + + + + + + + {data.acl.map((entry, i) => ( + + + + + ))} + +
NamePermission
{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

+
+
+ + + +
+
+ ); +} 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 &&
...
} +
+
+ setInput(e.target.value)} + placeholder="CLI command..." + disabled={loading} + className="flex-1 font-mono text-sm" + /> + +
+
+ ); +} 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

+ ) : ( +
+
+ + + + + + {hasDistances && } + + + + + {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 ( + + + + {hasDistances && ( + + )} + + + ); + })} + +
NameSNRDistLast Heard
{n.name || n.pubkey_prefix}{snrStr} dB + {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.length === 0 ? ( +
+

No bots configured

+ +
+ ) : ( +
+ {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} + + )} + + + + +
+ + {expandedBotId === bot.id && ( +
+
+

+ Define a bot() function that + receives message data and optionally returns a reply. +

+ +
+ + 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}
} + + +
+ ); +} 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 ( +
+
+ + {health?.connection_info ? ( +
+
+ + {health.connection_info} + +
+ ) : ( +
+
+ Not connected +
+ )} +
+ + + +
+ + setMaxRadioContacts(e.target.value)} + /> +

+ Favorite contacts load first, then recent non-repeater contacts until this limit is + reached (1-1000) +

+
+ + + + + + + + {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 +
+ )} +
+ + + +
+ +

+ 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. +

+
+
+ + setRetentionDays(e.target.value)} + className="w-24" + /> +
+ +
+
+ + + +
+ +

+ 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. +

+ +
+ + + +
+ + +

+ 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. +

+
+ + + +
+ + +

+ This applies only to this device/browser. It does not sync to server settings. +

+
+ + + +
+ +
+ { + 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}
} + + +
+ ); +} 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 ( +
+
+ + +
+ +
+ + setName(e.target.value)} /> +
+ +
+ +
+ 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. +

+
+ + + + + +
+ + setPrivateKey(e.target.value)} + placeholder="64-character hex private key" + /> + +
+ + + +
+ +

+ Send a flood advertisement to announce your presence on the mesh network. +

+ + {!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 ( +
+
+ + {health?.mqtt_status === 'connected' ? ( +
+
+ Connected +
+ ) : health?.mqtt_status === 'disconnected' ? ( +
+
+ Disconnected +
+ ) : ( +
+
+ Disabled +
+ )} +
+ + + +
+ + setMqttBrokerHost(e.target.value)} + /> +
+ +
+ + setMqttBrokerPort(e.target.value)} + /> +
+ +
+ + setMqttUsername(e.target.value)} + /> +
+ +
+ + setMqttPassword(e.target.value)} + /> +
+ + + + {mqttUseTls && ( + <> + +

+ Allow self-signed or untrusted broker certificates +

+ + )} + + + +
+ + setMqttTopicPrefix(e.target.value)} + /> +

+ Topics: {mqttTopicPrefix || 'meshcore'}/dm:<key>, {mqttTopicPrefix || 'meshcore'} + /gm:<key>, {mqttTopicPrefix || 'meshcore'} + /raw/... +

+
+ + + + +

+ Forward decrypted DM and channel messages +

+ + +

Forward all RF packets

+ + + + {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 ( +
+
+ + +
+ +
+
+ + setFreq(e.target.value)} + /> +
+
+ + setBw(e.target.value)} + /> +
+
+ +
+
+ + setSf(e.target.value)} + /> +
+
+ + setCr(e.target.value)} + /> +
+
+ +
+
+ + setTxPower(e.target.value)} + /> +
+
+ + +
+
+ + + +
+
+ + +
+
+
+ + setLat(e.target.value)} + /> +
+
+ + setLon(e.target.value)} + /> +
+
+
+ + {error &&
{error}
} + + +
+ ); +} 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

+ + + + + + + + + + + + + + + + + + + + + + + +
1h24h7d
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,