import { useState, useEffect, useRef } 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 { lppDisplayUnit } from '../repeater/repeaterPaneShared'; import { useDistanceUnit } from '../../contexts/DistanceUnitContext'; import { BulkDeleteContactsModal } from './BulkDeleteContactsModal'; import type { AppSettings, AppSettingsUpdate, Contact, HealthStatus, TelemetryHistoryEntry, TelemetrySchedule, } 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) { 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); } }; /** * 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 { await onSaveAppSettings(update); } catch (err) { console.error('Failed to save database settings:', err); revert(); toast.error('Failed to save setting', { description: err instanceof Error ? err.message : 'Unknown error', }); } }); saveChainRef.current = chained; return chained; }; return (
{/* ── Database Overview ── */}

Database Overview

Database size {health?.database_size_mb ?? '?'} MB
Oldest undecrypted packet {health?.oldest_undecrypted_timestamp ? ( {formatTime(health.oldest_undecrypted_timestamp)} ({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '} days) ) : ( None )}
{/* ── Storage Cleanup ── */}

Storage Cleanup

Delete Undecrypted Packets

Permanently deletes stored raw packets that have not yet been decrypted. These are retained in case you later obtain the correct key — once deleted, these messages can never be recovered.

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

Purge Archival Raw Packets

Deletes the raw packet bytes behind messages that are already decrypted and visible in chat. This frees space but removes packet-analysis availability for those messages. It does not affect displayed messages or future decryption.

{/* ── DM Decryption ── */}

DM Decryption

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.

{/* ── 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)} />
); }