From 8aac6a97713e2edc8d79fba51a19b8999a2fcc96 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 27 Apr 2026 13:14:43 -0700 Subject: [PATCH] Split up setting to be a bit neater --- frontend/AGENTS.md | 5 +- frontend/src/App.tsx | 2 + frontend/src/components/ContactInfoPane.tsx | 40 ++ frontend/src/components/SettingsModal.tsx | 34 +- .../repeater/RepeaterTelemetryHistoryPane.tsx | 4 +- .../settings/SettingsDatabaseSection.tsx | 542 +---------------- .../settings/SettingsRadioAppSection.tsx | 555 ++++++++++++++++++ .../components/settings/settingsConstants.ts | 17 +- frontend/src/test/appFavorites.test.tsx | 5 +- frontend/src/test/appStartupHash.test.tsx | 5 +- frontend/src/test/settingsModal.test.tsx | 10 +- frontend/src/utils/urlHash.ts | 1 + 12 files changed, 657 insertions(+), 563 deletions(-) create mode 100644 frontend/src/components/settings/SettingsRadioAppSection.tsx diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index c00c174..f05a8c4 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -141,7 +141,8 @@ frontend/src/ │ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery │ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, relative font scale, local label, reopen last conversation │ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD -│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label +│ │ ├── SettingsRadioAppSection.tsx # Radio-App Management: tracked telemetry, contact management, blocked lists +│ │ ├── SettingsDatabaseSection.tsx # Database: DB size, storage cleanup, auto-decrypt │ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats │ │ ├── SettingsAboutSection.tsx # Version, author, license, links │ │ ├── ThemeSelector.tsx # Color theme picker @@ -323,7 +324,7 @@ Supported routes: - `#contact/{publicKey}` - `#contact/{publicKey}/{label}` -Where `{section}` is one of `radio`, `local`, `fanout`, `database`, `statistics`, or `about`. +Where `{section}` is one of `radio`, `local`, `radio-app`, `database`, `fanout`, `statistics`, or `about`. Legacy name-based channel/contact hashes are still accepted for compatibility. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 341a794..2ae1a42 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -751,6 +751,8 @@ export function App() { onToggleBlockedName: handleBlockName, blockedKeys: appSettings?.blocked_keys ?? [], blockedNames: appSettings?.blocked_names ?? [], + trackedTelemetryContacts: appSettings?.tracked_telemetry_contacts ?? [], + onToggleTrackedTelemetryContact: handleToggleTrackedTelemetryContact, }; const channelInfoPaneProps = { channelKey: infoPaneChannelKey, diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx index dc39063..5609b16 100644 --- a/frontend/src/components/ContactInfoPane.tsx +++ b/frontend/src/components/ContactInfoPane.tsx @@ -81,6 +81,8 @@ interface ContactInfoPaneProps { blockedNames?: string[]; onToggleBlockedKey?: (key: string) => void; onToggleBlockedName?: (name: string) => void; + trackedTelemetryContacts?: string[]; + onToggleTrackedTelemetryContact?: (publicKey: string) => Promise; } export function ContactInfoPane({ @@ -97,6 +99,8 @@ export function ContactInfoPane({ blockedNames = [], onToggleBlockedKey, onToggleBlockedName, + trackedTelemetryContacts = [], + onToggleTrackedTelemetryContact, }: ContactInfoPaneProps) { const { distanceUnit } = useDistanceUnit(); const isNameOnly = contactKey?.startsWith('name:') ?? false; @@ -422,6 +426,8 @@ export function ContactInfoPane({ loading={telemetryLoading} onFetch={handleFetchTelemetry} telemetryHistory={telemetryHistory} + isTracked={trackedTelemetryContacts.includes(contact.public_key)} + onToggleTracked={onToggleTrackedTelemetryContact} /> {/* Favorite toggle */} @@ -971,16 +977,21 @@ function ContactTelemetrySection({ loading, onFetch, telemetryHistory, + isTracked, + onToggleTracked, }: { contact: Contact; loading: boolean; onFetch: () => void; telemetryHistory: TelemetryHistoryEntry[]; + isTracked: boolean; + onToggleTracked?: (publicKey: string) => Promise; }) { const { distanceUnit } = useDistanceUnit(); const [expanded, setExpanded] = useState(true); const [mapExpanded, setMapExpanded] = useState(false); const [chartExpanded, setChartExpanded] = useState(false); + const [toggling, setToggling] = useState(false); // Latest telemetry snapshot from history const latestEntry = @@ -1230,6 +1241,35 @@ function ContactTelemetrySection({ )} )} + + {/* Tracking toggle */} + {onToggleTracked && ( +
+ +
+ )} )} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index b4fd03c..4999401 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -20,6 +20,7 @@ import { import { SettingsRadioSection } from './settings/SettingsRadioSection'; import { SettingsLocalSection } from './settings/SettingsLocalSection'; +import { SettingsRadioAppSection } from './settings/SettingsRadioAppSection'; import { SettingsFanoutSection } from './settings/SettingsFanoutSection'; import { SettingsDatabaseSection } from './settings/SettingsDatabaseSection'; import { SettingsStatisticsSection } from './settings/SettingsStatisticsSection'; @@ -110,6 +111,7 @@ export function SettingsModal(props: SettingsModalProps) { const [expandedSections, setExpandedSections] = useState>({ radio: false, local: false, + 'radio-app': false, fanout: false, database: false, statistics: false, @@ -243,16 +245,14 @@ export function SettingsModal(props: SettingsModalProps) { )} - {shouldRenderSection('database') && ( + {shouldRenderSection('radio-app') && (
- {renderSectionHeader('database')} - {isSectionVisible('database') && + {renderSectionHeader('radio-app')} + {isSectionVisible('radio-app') && (appSettings ? ( - )} + {shouldRenderSection('database') && ( +
+ {renderSectionHeader('database')} + {isSectionVisible('database') && + (appSettings ? ( + + ) : ( +
+
+ Loading app settings... +
+
+ ))} +
+ )} + {shouldRenderSection('fanout') && (
{renderSectionHeader('fanout')} diff --git a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx index 16876eb..dd02aec 100644 --- a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx +++ b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx @@ -247,10 +247,10 @@ export function TelemetryHistoryPane({ ), or when the repeater is opted into interval telemetry polling, in which case the repeater will be polled for metrics automatically. Fetch frequency can be configured in{' '} - Settings → Database & Messaging + Settings → Radio-App Management , where you can also see which repeaters are currently opted in. A maximum of{' '} {MAX_TRACKED} repeaters may be opted into this for the sake of keeping mesh congestion diff --git a/frontend/src/components/settings/SettingsDatabaseSection.tsx b/frontend/src/components/settings/SettingsDatabaseSection.tsx index 7d91b95..72553b3 100644 --- a/frontend/src/components/settings/SettingsDatabaseSection.tsx +++ b/frontend/src/components/settings/SettingsDatabaseSection.tsx @@ -6,146 +6,32 @@ import { Separator } from '../ui/separator'; import { toast } from '../ui/sonner'; import { api } from '../../api'; import { formatTime } from '../../utils/messageParser'; -import { lppDisplayUnit } from '../repeater/repeaterPaneShared'; -import { useDistanceUnit } from '../../contexts/DistanceUnitContext'; -import { BulkDeleteContactsModal } from './BulkDeleteContactsModal'; -import type { - AppSettings, - AppSettingsUpdate, - Contact, - HealthStatus, - TelemetryHistoryEntry, - TelemetrySchedule, -} from '../../types'; +import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types'; export function SettingsDatabaseSection({ appSettings, health, onSaveAppSettings, onHealthRefresh, - blockedKeys = [], - blockedNames = [], - onToggleBlockedKey, - onToggleBlockedName, - contacts = [], - onBulkDeleteContacts, - trackedTelemetryRepeaters = [], - onToggleTrackedTelemetry, - trackedTelemetryContacts = [], - onToggleTrackedTelemetryContact, className, }: { appSettings: AppSettings; health: HealthStatus | null; onSaveAppSettings: (update: AppSettingsUpdate) => Promise; onHealthRefresh: () => Promise; - blockedKeys?: string[]; - blockedNames?: string[]; - onToggleBlockedKey?: (key: string) => void; - onToggleBlockedName?: (name: string) => void; - contacts?: Contact[]; - onBulkDeleteContacts?: (deletedKeys: string[]) => void; - trackedTelemetryRepeaters?: string[]; - onToggleTrackedTelemetry?: (publicKey: string) => Promise; - trackedTelemetryContacts?: string[]; - onToggleTrackedTelemetryContact?: (publicKey: string) => Promise; className?: string; }) { - const { distanceUnit } = useDistanceUnit(); const [retentionDays, setRetentionDays] = useState('14'); const [cleaning, setCleaning] = useState(false); const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false); const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false); - const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState([]); - const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); - const [latestTelemetry, setLatestTelemetry] = useState< - Record - >({}); - const telemetryFetchedRef = useRef(false); - - const [latestContactTelemetry, setLatestContactTelemetry] = useState< - Record - >({}); - const contactTelemetryFetchedRef = useRef(false); - - const [schedule, setSchedule] = useState(null); - const [intervalDraft, setIntervalDraft] = useState(appSettings.telemetry_interval_hours); - - // Serialization chain for every auto-persisted control on this page. - // Without this, rapid successive toggles (or mixed dropdown + checkbox - // interactions) can dispatch overlapping PATCHes that land out of order - // on HTTP/2 — a stale write then wins, reverting the user's last click. - // Each call awaits the previous one before sending its request, so the - // server sees updates in the order the user made them. const saveChainRef = useRef>(Promise.resolve()); useEffect(() => { setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert); - setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []); - setIntervalDraft(appSettings.telemetry_interval_hours); }, [appSettings]); - // Re-fetch the scheduler derivation whenever the tracked list changes or - // the stored preference changes. Cheap: single GET, no radio lock. - useEffect(() => { - let cancelled = false; - api - .getTelemetrySchedule() - .then((s) => { - if (!cancelled) setSchedule(s); - }) - .catch(() => { - // Non-critical: dropdown falls back to the unfiltered menu. - }); - return () => { - cancelled = true; - }; - }, [ - trackedTelemetryRepeaters.length, - trackedTelemetryContacts.length, - appSettings.telemetry_interval_hours, - appSettings.telemetry_routed_hourly, - ]); - - useEffect(() => { - if (trackedTelemetryRepeaters.length === 0 || telemetryFetchedRef.current) return; - telemetryFetchedRef.current = true; - let cancelled = false; - const fetches = trackedTelemetryRepeaters.map((key) => - api.repeaterTelemetryHistory(key).then( - (history) => [key, history.length > 0 ? history[history.length - 1] : null] as const, - () => [key, null] as const - ) - ); - Promise.all(fetches).then((entries) => { - if (cancelled) return; - setLatestTelemetry(Object.fromEntries(entries)); - }); - return () => { - cancelled = true; - }; - }, [trackedTelemetryRepeaters]); - - useEffect(() => { - if (trackedTelemetryContacts.length === 0 || contactTelemetryFetchedRef.current) return; - contactTelemetryFetchedRef.current = true; - let cancelled = false; - const fetches = trackedTelemetryContacts.map((key) => - api.contactTelemetryHistory(key).then( - (history) => [key, history.length > 0 ? history[history.length - 1] : null] as const, - () => [key, null] as const - ) - ); - Promise.all(fetches).then((entries) => { - if (cancelled) return; - setLatestContactTelemetry(Object.fromEntries(entries)); - }); - return () => { - cancelled = true; - }; - }, [trackedTelemetryContacts]); - const handleCleanup = async () => { const days = parseInt(retentionDays, 10); if (isNaN(days) || days < 1) { @@ -192,12 +78,6 @@ export function SettingsDatabaseSection({ } }; - /** - * Apply an AppSettings PATCH after any already-queued saves finish, and - * revert local state if the save fails. Every auto-persist control on - * this page routes through here so the user-visible order of clicks is - * the order the backend sees, regardless of network reordering. - */ const persistAppSettings = (update: AppSettingsUpdate, revert: () => void): Promise => { const chained = saveChainRef.current.then(async () => { try { @@ -324,426 +204,6 @@ export function SettingsDatabaseSection({ contact sends an advertisement. This may cause brief delays on large packet backlogs.

- - - - {/* ── Tracked Repeater Telemetry ── */} -
-

Tracked Repeater Telemetry

-

- Repeaters opted into automatic telemetry collection are polled on a scheduled interval. To - limit mesh traffic, the app caps telemetry at 24 checks per day across all tracked - repeaters — so fewer tracked repeaters allows shorter intervals, and more tracked - repeaters forces longer ones. Up to {schedule?.max_tracked ?? 8} repeaters may be tracked - at once ({trackedTelemetryRepeaters.length} / {schedule?.max_tracked ?? 8} slots used). -

- - {/* Interval picker. Legal options depend on current tracked count; - we list only those. If the saved preference is no longer legal, - the effective interval is shown below so the user knows what the - scheduler is actually using. */} -
- -
- -
- {schedule && schedule.effective_hours !== schedule.preferred_hours && ( -

- Saved preference is {schedule.preferred_hours} hour - {schedule.preferred_hours === 1 ? '' : 's'}, but the scheduler is using{' '} - {schedule.effective_hours} hours because {schedule.tracked_count} repeater - {schedule.tracked_count === 1 ? '' : 's'}{' '} - {schedule.tracked_count === 1 ? 'is' : 'are'} tracked. Your preference will be - restored if you drop back to a supported count. -

- )} -
- - {/* Routed hourly toggle */} - - - {schedule?.next_run_at != null && ( -

- {schedule.routed_hourly ? 'Next flood run at' : 'Next run at'}{' '} - {formatTime(schedule.next_run_at)} (UTC top of hour). -

- )} - {schedule?.next_routed_run_at != null && ( -

- Next direct/routed run at {formatTime(schedule.next_routed_run_at)} (UTC top of hour). -

- )} - - {trackedTelemetryRepeaters.length === 0 ? ( -

- No repeaters are being tracked. Enable tracking from a repeater's dashboard. -

- ) : ( -
- {trackedTelemetryRepeaters.map((key) => { - const contact = contacts.find((c) => c.public_key === key); - const displayName = contact?.name ?? key.slice(0, 12); - const routeSource = contact?.effective_route_source ?? 'flood'; - // A forced-flood override (path_len < 0) still reports source - // "override", but the actual route is flood. Check the real path. - const hasRealPath = - contact?.effective_route != null && contact.effective_route.path_len >= 0; - const routeLabel = !hasRealPath - ? 'flood' - : routeSource === 'override' - ? 'routed' - : routeSource === 'direct' - ? 'direct' - : 'flood'; - const routeColor = hasRealPath - ? 'text-primary bg-primary/10' - : 'text-muted-foreground bg-muted'; - const snap = latestTelemetry[key]; - const d = snap?.data; - return ( -
-
-
- {displayName} -
- - {key.slice(0, 12)} - - - {routeLabel} - -
-
- {onToggleTrackedTelemetry && ( - - )} -
- {d ? ( -
- {d.battery_volts?.toFixed(2)}V - noise {d.noise_floor_dbm} dBm - - rx {d.packets_received != null ? d.packets_received.toLocaleString() : '?'} - - - tx {d.packets_sent != null ? d.packets_sent.toLocaleString() : '?'} - - {d.lpp_sensors?.map((s) => { - const display = lppDisplayUnit(s.type_name, s.value, distanceUnit); - const val = - typeof display.value === 'number' - ? display.value % 1 === 0 - ? display.value - : display.value.toFixed(1) - : display.value; - const label = s.type_name.charAt(0).toUpperCase() + s.type_name.slice(1); - return ( - - {label} {val} - {display.unit ? ` ${display.unit}` : ''} - - ); - })} - checked {formatTime(snap.timestamp)} -
- ) : snap === null ? ( -
- No telemetry recorded yet -
- ) : null} -
- ); - })} -
- )} -
- - - - {/* ── Tracked Contact Telemetry ── */} -
-

Tracked Contact Telemetry

-

- Non-repeater contacts (companions, rooms, sensors) can also be tracked for periodic LPP - telemetry collection (battery, sensors, GPS). Up to 8 contacts may be tracked. The daily - check ceiling is shared with tracked repeaters — adding contacts may clamp the interval - upward. -

- - {trackedTelemetryContacts.length === 0 ? ( -

- No contacts are being tracked. Enable tracking from a contact's info pane. -

- ) : ( -
- {trackedTelemetryContacts.map((key) => { - const contact = contacts.find((c) => c.public_key === key); - const displayName = contact?.name ?? key.slice(0, 12); - const routeSource = contact?.effective_route_source ?? 'flood'; - const hasRealPath = - contact?.effective_route != null && contact.effective_route.path_len >= 0; - const routeLabel = !hasRealPath - ? 'flood' - : routeSource === 'override' - ? 'routed' - : routeSource === 'direct' - ? 'direct' - : 'flood'; - const routeColor = hasRealPath - ? 'text-primary bg-primary/10' - : 'text-muted-foreground bg-muted'; - const snap = latestContactTelemetry[key]; - const d = snap?.data; - return ( -
-
-
- {displayName} -
- - {key.slice(0, 12)} - - - {routeLabel} - -
-
- {onToggleTrackedTelemetryContact && ( - - )} -
- {d ? ( -
- {d.lpp_sensors?.map((s) => { - if (typeof s.value !== 'number') return null; - const display = lppDisplayUnit(s.type_name, s.value, distanceUnit); - const val = - typeof display.value === 'number' - ? display.value % 1 === 0 - ? display.value - : display.value.toFixed(1) - : display.value; - const label = s.type_name.charAt(0).toUpperCase() + s.type_name.slice(1); - return ( - - {label} {val} - {display.unit ? ` ${display.unit}` : ''} - - ); - })} - checked {formatTime(snap.timestamp)} -
- ) : snap === null ? ( -
- No telemetry recorded yet -
- ) : null} -
- ); - })} -
- )} -
- - - - {/* ── Contact Management ── */} -
-

Contact Management

- - {/* Block discovery of new node types */} -
-

Block Discovery of New Node Types

-

- Checked types will be ignored when heard via advertisement. Existing contacts of these - types are still updated. This does not affect contacts added manually or via DM. -

-
- {( - [ - [1, 'Block clients'], - [2, 'Block repeaters'], - [3, 'Block room servers'], - [4, 'Block sensors'], - ] as const - ).map(([typeCode, label]) => { - const checked = discoveryBlockedTypes.includes(typeCode); - return ( - - ); - })} -
- {discoveryBlockedTypes.length > 0 && ( -

- New{' '} - {discoveryBlockedTypes - .map((t) => - t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors' - ) - .join(', ')}{' '} - heard via advertisement will not be added to your contact list. -

- )} -
- - {/* Blocked contacts list */} -
-

Blocked Contacts

-

- Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI — - MQTT forwarding and bot responses are not affected. Messages are still stored and will - reappear if unblocked. -

- - {blockedKeys.length === 0 && blockedNames.length === 0 ? ( -

- No blocked contacts. Block contacts from their info pane, viewed by clicking their - avatar in any channel, or their name within the top status bar with the conversation - open. -

- ) : ( -
- {blockedKeys.length > 0 && ( -
- Blocked Keys -
- {blockedKeys.map((key) => ( -
- {key} - {onToggleBlockedKey && ( - - )} -
- ))} -
-
- )} - {blockedNames.length > 0 && ( -
- Blocked Names -
- {blockedNames.map((name) => ( -
- {name} - {onToggleBlockedName && ( - - )} -
- ))} -
-
- )} -
- )} -
- - {/* Bulk delete */} -
-

Bulk Delete Contacts

-

- Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted - nodes. Message history will be preserved. -

- - setBulkDeleteOpen(false)} - contacts={contacts} - onDeleted={(keys) => onBulkDeleteContacts?.(keys)} - /> -
-
); } diff --git a/frontend/src/components/settings/SettingsRadioAppSection.tsx b/frontend/src/components/settings/SettingsRadioAppSection.tsx new file mode 100644 index 0000000..8029e3d --- /dev/null +++ b/frontend/src/components/settings/SettingsRadioAppSection.tsx @@ -0,0 +1,555 @@ +import { useState, useEffect, useRef } from 'react'; +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 { lppDisplayUnit } from '../repeater/repeaterPaneShared'; +import { useDistanceUnit } from '../../contexts/DistanceUnitContext'; +import { BulkDeleteContactsModal } from './BulkDeleteContactsModal'; +import type { + AppSettings, + AppSettingsUpdate, + Contact, + TelemetryHistoryEntry, + TelemetrySchedule, +} from '../../types'; + +export function SettingsRadioAppSection({ + appSettings, + onSaveAppSettings, + blockedKeys = [], + blockedNames = [], + onToggleBlockedKey, + onToggleBlockedName, + contacts = [], + onBulkDeleteContacts, + trackedTelemetryRepeaters = [], + onToggleTrackedTelemetry, + trackedTelemetryContacts = [], + onToggleTrackedTelemetryContact, + className, +}: { + appSettings: AppSettings; + onSaveAppSettings: (update: AppSettingsUpdate) => Promise; + blockedKeys?: string[]; + blockedNames?: string[]; + onToggleBlockedKey?: (key: string) => void; + onToggleBlockedName?: (name: string) => void; + contacts?: Contact[]; + onBulkDeleteContacts?: (deletedKeys: string[]) => void; + trackedTelemetryRepeaters?: string[]; + onToggleTrackedTelemetry?: (publicKey: string) => Promise; + trackedTelemetryContacts?: string[]; + onToggleTrackedTelemetryContact?: (publicKey: string) => Promise; + className?: string; +}) { + const { distanceUnit } = useDistanceUnit(); + const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState([]); + const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); + + const [latestTelemetry, setLatestTelemetry] = useState< + Record + >({}); + const telemetryFetchedRef = useRef(false); + + const [latestContactTelemetry, setLatestContactTelemetry] = useState< + Record + >({}); + const contactTelemetryFetchedRef = useRef(false); + + const [schedule, setSchedule] = useState(null); + const [intervalDraft, setIntervalDraft] = useState(appSettings.telemetry_interval_hours); + + const saveChainRef = useRef>(Promise.resolve()); + + useEffect(() => { + setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []); + setIntervalDraft(appSettings.telemetry_interval_hours); + }, [appSettings]); + + useEffect(() => { + let cancelled = false; + api + .getTelemetrySchedule() + .then((s) => { + if (!cancelled) setSchedule(s); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [ + trackedTelemetryRepeaters.length, + trackedTelemetryContacts.length, + appSettings.telemetry_interval_hours, + appSettings.telemetry_routed_hourly, + ]); + + useEffect(() => { + if (trackedTelemetryRepeaters.length === 0 || telemetryFetchedRef.current) return; + telemetryFetchedRef.current = true; + let cancelled = false; + const fetches = trackedTelemetryRepeaters.map((key) => + api.repeaterTelemetryHistory(key).then( + (history) => [key, history.length > 0 ? history[history.length - 1] : null] as const, + () => [key, null] as const + ) + ); + Promise.all(fetches).then((entries) => { + if (cancelled) return; + setLatestTelemetry(Object.fromEntries(entries)); + }); + return () => { + cancelled = true; + }; + }, [trackedTelemetryRepeaters]); + + useEffect(() => { + if (trackedTelemetryContacts.length === 0 || contactTelemetryFetchedRef.current) return; + contactTelemetryFetchedRef.current = true; + let cancelled = false; + const fetches = trackedTelemetryContacts.map((key) => + api.contactTelemetryHistory(key).then( + (history) => [key, history.length > 0 ? history[history.length - 1] : null] as const, + () => [key, null] as const + ) + ); + Promise.all(fetches).then((entries) => { + if (cancelled) return; + setLatestContactTelemetry(Object.fromEntries(entries)); + }); + return () => { + cancelled = true; + }; + }, [trackedTelemetryContacts]); + + const persistAppSettings = (update: AppSettingsUpdate, revert: () => void): Promise => { + const chained = saveChainRef.current.then(async () => { + try { + await onSaveAppSettings(update); + } catch (err) { + console.error('Failed to save radio-app settings:', err); + revert(); + toast.error('Failed to save setting', { + description: err instanceof Error ? err.message : 'Unknown error', + }); + } + }); + saveChainRef.current = chained; + return chained; + }; + + return ( +
+ {/* ── Tracked Repeater Telemetry ── */} +
+

Tracked Repeater Telemetry

+

+ Repeaters opted into automatic telemetry collection are polled on a scheduled interval. To + limit mesh traffic, the app caps telemetry at 24 checks per day across all tracked + repeaters — so fewer tracked repeaters allows shorter intervals, and more tracked + repeaters forces longer ones. Up to {schedule?.max_tracked ?? 8} repeaters may be tracked + at once ({trackedTelemetryRepeaters.length} / {schedule?.max_tracked ?? 8} slots used). +

+ +
+ +
+ +
+ {schedule && schedule.effective_hours !== schedule.preferred_hours && ( +

+ Saved preference is {schedule.preferred_hours} hour + {schedule.preferred_hours === 1 ? '' : 's'}, but the scheduler is using{' '} + {schedule.effective_hours} hours because {schedule.tracked_count} repeater + {schedule.tracked_count === 1 ? '' : 's'}{' '} + {schedule.tracked_count === 1 ? 'is' : 'are'} tracked. Your preference will be + restored if you drop back to a supported count. +

+ )} +
+ + + + {schedule?.next_run_at != null && ( +

+ {schedule.routed_hourly ? 'Next flood run at' : 'Next run at'}{' '} + {formatTime(schedule.next_run_at)} (UTC top of hour). +

+ )} + {schedule?.next_routed_run_at != null && ( +

+ Next direct/routed run at {formatTime(schedule.next_routed_run_at)} (UTC top of hour). +

+ )} + + {trackedTelemetryRepeaters.length === 0 ? ( +

+ No repeaters are being tracked. Enable tracking from a repeater's dashboard. +

+ ) : ( +
+ {trackedTelemetryRepeaters.map((key) => { + const contact = contacts.find((c) => c.public_key === key); + const displayName = contact?.name ?? key.slice(0, 12); + const routeSource = contact?.effective_route_source ?? 'flood'; + const hasRealPath = + contact?.effective_route != null && contact.effective_route.path_len >= 0; + const routeLabel = !hasRealPath + ? 'flood' + : routeSource === 'override' + ? 'routed' + : routeSource === 'direct' + ? 'direct' + : 'flood'; + const routeColor = hasRealPath + ? 'text-primary bg-primary/10' + : 'text-muted-foreground bg-muted'; + const snap = latestTelemetry[key]; + const d = snap?.data; + return ( +
+
+
+ {displayName} +
+ + {key.slice(0, 12)} + + + {routeLabel} + +
+
+ {onToggleTrackedTelemetry && ( + + )} +
+ {d ? ( +
+ {d.battery_volts?.toFixed(2)}V + noise {d.noise_floor_dbm} dBm + + rx {d.packets_received != null ? d.packets_received.toLocaleString() : '?'} + + + tx {d.packets_sent != null ? d.packets_sent.toLocaleString() : '?'} + + {d.lpp_sensors?.map((s) => { + const display = lppDisplayUnit(s.type_name, s.value, distanceUnit); + const val = + typeof display.value === 'number' + ? display.value % 1 === 0 + ? display.value + : display.value.toFixed(1) + : display.value; + const label = s.type_name.charAt(0).toUpperCase() + s.type_name.slice(1); + return ( + + {label} {val} + {display.unit ? ` ${display.unit}` : ''} + + ); + })} + checked {formatTime(snap.timestamp)} +
+ ) : snap === null ? ( +
+ No telemetry recorded yet +
+ ) : null} +
+ ); + })} +
+ )} +
+ + + + {/* ── Tracked Contact Telemetry ── */} +
+

Tracked Contact Telemetry

+

+ Non-repeater contacts (companions, rooms, sensors) can also be tracked for periodic LPP + telemetry collection (battery, sensors, GPS). Up to 8 contacts may be tracked. The daily + check ceiling is shared with tracked repeaters — adding contacts may clamp the interval + upward. +

+ + {trackedTelemetryContacts.length === 0 ? ( +

+ No contacts are being tracked. Enable tracking from a contact's info pane. +

+ ) : ( +
+ {trackedTelemetryContacts.map((key) => { + const contact = contacts.find((c) => c.public_key === key); + const displayName = contact?.name ?? key.slice(0, 12); + const routeSource = contact?.effective_route_source ?? 'flood'; + const hasRealPath = + contact?.effective_route != null && contact.effective_route.path_len >= 0; + const routeLabel = !hasRealPath + ? 'flood' + : routeSource === 'override' + ? 'routed' + : routeSource === 'direct' + ? 'direct' + : 'flood'; + const routeColor = hasRealPath + ? 'text-primary bg-primary/10' + : 'text-muted-foreground bg-muted'; + const snap = latestContactTelemetry[key]; + const d = snap?.data; + return ( +
+
+
+ {displayName} +
+ + {key.slice(0, 12)} + + + {routeLabel} + +
+
+ {onToggleTrackedTelemetryContact && ( + + )} +
+ {d ? ( +
+ {d.lpp_sensors?.map((s) => { + if (typeof s.value !== 'number') return null; + const display = lppDisplayUnit(s.type_name, s.value, distanceUnit); + const val = + typeof display.value === 'number' + ? display.value % 1 === 0 + ? display.value + : display.value.toFixed(1) + : display.value; + const label = s.type_name.charAt(0).toUpperCase() + s.type_name.slice(1); + return ( + + {label} {val} + {display.unit ? ` ${display.unit}` : ''} + + ); + })} + checked {formatTime(snap.timestamp)} +
+ ) : snap === null ? ( +
+ No telemetry recorded yet +
+ ) : null} +
+ ); + })} +
+ )} +
+ + + + {/* ── Contact Management ── */} +
+

Contact Management

+ +
+

Block Discovery of New Node Types

+

+ Checked types will be ignored when heard via advertisement. Existing contacts of these + types are still updated. This does not affect contacts added manually or via DM. +

+
+ {( + [ + [1, 'Block clients'], + [2, 'Block repeaters'], + [3, 'Block room servers'], + [4, 'Block sensors'], + ] as const + ).map(([typeCode, label]) => { + const checked = discoveryBlockedTypes.includes(typeCode); + return ( + + ); + })} +
+ {discoveryBlockedTypes.length > 0 && ( +

+ New{' '} + {discoveryBlockedTypes + .map((t) => + t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors' + ) + .join(', ')}{' '} + heard via advertisement will not be added to your contact list. +

+ )} +
+ +
+

Blocked Contacts

+

+ Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI — + MQTT forwarding and bot responses are not affected. Messages are still stored and will + reappear if unblocked. +

+ + {blockedKeys.length === 0 && blockedNames.length === 0 ? ( +

+ No blocked contacts. Block contacts from their info pane, viewed by clicking their + avatar in any channel, or their name within the top status bar with the conversation + open. +

+ ) : ( +
+ {blockedKeys.length > 0 && ( +
+ Blocked Keys +
+ {blockedKeys.map((key) => ( +
+ {key} + {onToggleBlockedKey && ( + + )} +
+ ))} +
+
+ )} + {blockedNames.length > 0 && ( +
+ Blocked Names +
+ {blockedNames.map((name) => ( +
+ {name} + {onToggleBlockedName && ( + + )} +
+ ))} +
+
+ )} +
+ )} +
+ +
+

Bulk Delete Contacts

+

+ Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted + nodes. Message history will be preserved. +

+ + setBulkDeleteOpen(false)} + contacts={contacts} + onDeleted={(keys) => onBulkDeleteContacts?.(keys)} + /> +
+
+
+ ); +} diff --git a/frontend/src/components/settings/settingsConstants.ts b/frontend/src/components/settings/settingsConstants.ts index 64d4f2c..23f56f9 100644 --- a/frontend/src/components/settings/settingsConstants.ts +++ b/frontend/src/components/settings/settingsConstants.ts @@ -5,16 +5,25 @@ import { MonitorCog, RadioTower, Share2, + SlidersHorizontal, type LucideIcon, } from 'lucide-react'; -export type SettingsSection = 'radio' | 'local' | 'database' | 'fanout' | 'statistics' | 'about'; +export type SettingsSection = + | 'radio' + | 'local' + | 'radio-app' + | 'database' + | 'fanout' + | 'statistics' + | 'about'; export const SETTINGS_SECTION_ORDER: SettingsSection[] = [ 'radio', 'local', - 'database', 'fanout', + 'radio-app', + 'database', 'statistics', 'about', ]; @@ -22,7 +31,8 @@ export const SETTINGS_SECTION_ORDER: SettingsSection[] = [ export const SETTINGS_SECTION_LABELS: Record = { radio: 'Radio', local: 'Local Configuration', - database: 'Database & Messaging', + 'radio-app': 'Radio-App Management', + database: 'Database', fanout: 'MQTT & Automation', statistics: 'Statistics', about: 'About', @@ -31,6 +41,7 @@ export const SETTINGS_SECTION_LABELS: Record = { export const SETTINGS_SECTION_ICONS: Record = { radio: RadioTower, local: MonitorCog, + 'radio-app': SlidersHorizontal, database: Database, fanout: Share2, statistics: BarChart3, diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx index 22dbeed..022f653 100644 --- a/frontend/src/test/appFavorites.test.tsx +++ b/frontend/src/test/appFavorites.test.tsx @@ -149,11 +149,12 @@ vi.mock('../components/SettingsModal', () => ({ SettingsModal: ({ desktopSection }: { desktopSection?: string }) => (
{desktopSection ?? 'none'}
), - SETTINGS_SECTION_ORDER: ['radio', 'local', 'database', 'bot'], + SETTINGS_SECTION_ORDER: ['radio', 'local', 'radio-app', 'database', 'bot'], SETTINGS_SECTION_LABELS: { radio: '📻 Radio', local: '🖥️ Local Configuration', - database: '🗄️ Database & Messaging', + 'radio-app': '🗄️ Radio-App Management', + database: '🗄️ Database', bot: '🤖 Bot', }, })); diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx index 90013c3..c938b74 100644 --- a/frontend/src/test/appStartupHash.test.tsx +++ b/frontend/src/test/appStartupHash.test.tsx @@ -92,11 +92,12 @@ vi.mock('../components/SettingsModal', () => ({ SettingsModal: ({ desktopSection }: { desktopSection?: string }) => (
{desktopSection ?? 'none'}
), - SETTINGS_SECTION_ORDER: ['radio', 'local', 'database', 'bot'], + SETTINGS_SECTION_ORDER: ['radio', 'local', 'radio-app', 'database', 'bot'], SETTINGS_SECTION_LABELS: { radio: 'Radio', local: 'Local Configuration', - database: 'Database & Messaging', + 'radio-app': 'Radio-App Management', + database: 'Database', bot: 'Bot', }, })); diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 23c5b10..ec881c3 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -178,7 +178,7 @@ function setMatchMedia(matches: boolean) { } function openRadioSection() { - const radioToggle = screen.getByRole('button', { name: /Radio/i }); + const radioToggle = screen.getByRole('button', { name: /^Radio$/i }); fireEvent.click(radioToggle); } @@ -251,7 +251,7 @@ describe('SettingsModal', () => { it('shows radio-unavailable message when config is null', () => { renderModal({ config: null }); - const radioToggle = screen.getByRole('button', { name: /Radio/i }); + const radioToggle = screen.getByRole('button', { name: /^Radio$/i }); expect(radioToggle).not.toBeDisabled(); fireEvent.click(radioToggle); @@ -500,7 +500,7 @@ describe('SettingsModal', () => { renderModal({ externalSidebarNav: true, - desktopSection: 'database', + desktopSection: 'radio-app', onSaveAppSettings, }); @@ -807,7 +807,7 @@ describe('SettingsModal', () => { renderModal({ externalSidebarNav: true, - desktopSection: 'database', + desktopSection: 'radio-app', onSaveAppSettings, }); @@ -832,7 +832,7 @@ describe('SettingsModal', () => { renderModal({ externalSidebarNav: true, - desktopSection: 'database', + desktopSection: 'radio-app', appSettings: { ...baseSettings, tracked_telemetry_repeaters: [directKey], diff --git a/frontend/src/utils/urlHash.ts b/frontend/src/utils/urlHash.ts index c2b6f8d..5f2563b 100644 --- a/frontend/src/utils/urlHash.ts +++ b/frontend/src/utils/urlHash.ts @@ -16,6 +16,7 @@ interface ParsedHashConversation { const SETTINGS_SECTIONS: SettingsSection[] = [ 'radio', 'local', + 'radio-app', 'fanout', 'database', 'statistics',