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 && (
+
+ {
+ setToggling(true);
+ try {
+ await onToggleTracked(contact.public_key);
+ } finally {
+ setToggling(false);
+ }
+ }}
+ className={`text-xs px-2 py-1 rounded border transition-colors w-full ${
+ isTracked
+ ? 'border-destructive/50 text-destructive hover:bg-destructive/10'
+ : 'border-green-600/50 text-green-600 hover:bg-green-600/10'
+ } disabled:opacity-50`}
+ >
+ {toggling
+ ? 'Updating...'
+ : isTracked
+ ? 'Stop Tracking Telemetry'
+ : 'Track Telemetry on Interval'}
+
+
+ )}
)}
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. */}
-
-
- Collection interval
-
-
- {
- const nextValue = Number(e.target.value);
- if (!Number.isFinite(nextValue) || nextValue === intervalDraft) return;
- const prevValue = intervalDraft;
- setIntervalDraft(nextValue);
- void persistAppSettings({ telemetry_interval_hours: nextValue }, () =>
- setIntervalDraft(prevValue)
- );
- }}
- className="h-9 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
- >
- {(schedule?.options ?? [1, 2, 3, 4, 6, 8, 12, 24]).map((hrs) => (
-
- Every {hrs} hour{hrs === 1 ? '' : 's'} ({Math.floor(24 / hrs)} check
- {Math.floor(24 / hrs) === 1 ? '' : 's'}/day)
-
- ))}
-
-
- {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 */}
-
- {
- const next = !appSettings.telemetry_routed_hourly;
- void persistAppSettings({ telemetry_routed_hourly: next }, () => {});
- }}
- className="w-4 h-4 rounded border-input accent-primary mt-0.5"
- />
-
-
Poll direct/routed-path repeaters hourly
-
- When enabled, tracked repeaters with a direct or routed path (not flood) are polled
- every hour instead of on the scheduled interval above. Flood-only repeaters still
- follow the normal schedule.
-
-
-
-
- {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 && (
-
onToggleTrackedTelemetry(key)}
- className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
- >
- Remove
-
- )}
-
- {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 && (
-
onToggleTrackedTelemetryContact(key)}
- className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
- >
- Remove
-
- )}
-
- {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 (
-
- {
- const prev = discoveryBlockedTypes;
- const next = checked
- ? prev.filter((t) => t !== typeCode)
- : [...prev, typeCode];
- setDiscoveryBlockedTypes(next);
- void persistAppSettings({ discovery_blocked_types: next }, () =>
- setDiscoveryBlockedTypes(prev)
- );
- }}
- className="rounded border-input"
- />
- {label}
-
- );
- })}
-
- {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 && (
- onToggleBlockedKey(key)}
- className="h-7 text-xs flex-shrink-0"
- >
- Unblock
-
- )}
-
- ))}
-
-
- )}
- {blockedNames.length > 0 && (
-
-
Blocked Names
-
- {blockedNames.map((name) => (
-
- {name}
- {onToggleBlockedName && (
- onToggleBlockedName(name)}
- className="h-7 text-xs flex-shrink-0"
- >
- Unblock
-
- )}
-
- ))}
-
-
- )}
-
- )}
-
-
- {/* 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(true)}>
- Open Bulk Delete
-
-
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).
+
+
+
+
+ Collection interval
+
+
+ {
+ const nextValue = Number(e.target.value);
+ if (!Number.isFinite(nextValue) || nextValue === intervalDraft) return;
+ const prevValue = intervalDraft;
+ setIntervalDraft(nextValue);
+ void persistAppSettings({ telemetry_interval_hours: nextValue }, () =>
+ setIntervalDraft(prevValue)
+ );
+ }}
+ className="h-9 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
+ >
+ {(schedule?.options ?? [1, 2, 3, 4, 6, 8, 12, 24]).map((hrs) => (
+
+ Every {hrs} hour{hrs === 1 ? '' : 's'} ({Math.floor(24 / hrs)} check
+ {Math.floor(24 / hrs) === 1 ? '' : 's'}/day)
+
+ ))}
+
+
+ {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.
+
+ )}
+
+
+
+ {
+ const next = !appSettings.telemetry_routed_hourly;
+ void persistAppSettings({ telemetry_routed_hourly: next }, () => {});
+ }}
+ className="w-4 h-4 rounded border-input accent-primary mt-0.5"
+ />
+
+
Poll direct/routed-path repeaters hourly
+
+ When enabled, tracked repeaters with a direct or routed path (not flood) are polled
+ every hour instead of on the scheduled interval above. Flood-only repeaters still
+ follow the normal schedule.
+
+
+
+
+ {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 && (
+
onToggleTrackedTelemetry(key)}
+ className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
+ >
+ Remove
+
+ )}
+
+ {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 && (
+
onToggleTrackedTelemetryContact(key)}
+ className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
+ >
+ Remove
+
+ )}
+
+ {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 (
+
+ {
+ const prev = discoveryBlockedTypes;
+ const next = checked
+ ? prev.filter((t) => t !== typeCode)
+ : [...prev, typeCode];
+ setDiscoveryBlockedTypes(next);
+ void persistAppSettings({ discovery_blocked_types: next }, () =>
+ setDiscoveryBlockedTypes(prev)
+ );
+ }}
+ className="rounded border-input"
+ />
+ {label}
+
+ );
+ })}
+
+ {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 && (
+ onToggleBlockedKey(key)}
+ className="h-7 text-xs flex-shrink-0"
+ >
+ Unblock
+
+ )}
+
+ ))}
+
+
+ )}
+ {blockedNames.length > 0 && (
+
+
Blocked Names
+
+ {blockedNames.map((name) => (
+
+ {name}
+ {onToggleBlockedName && (
+ onToggleBlockedName(name)}
+ className="h-7 text-xs flex-shrink-0"
+ >
+ Unblock
+
+ )}
+
+ ))}
+
+
+ )}
+
+ )}
+
+
+
+
Bulk Delete Contacts
+
+ Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
+ nodes. Message history will be preserved.
+
+
setBulkDeleteOpen(true)}>
+ Open Bulk Delete
+
+
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',