Break up repeater and settings into consituent files

This commit is contained in:
Jack Kingsman
2026-03-01 17:08:01 -08:00
parent 18ac86b4c0
commit a8af9b10f3
22 changed files with 2434 additions and 2253 deletions

View File

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

View File

@@ -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';

View File

@@ -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 (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
);
}
// --- 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 (
<div className={cn('border border-border rounded-lg overflow-hidden', className)}>
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium">{title}</h3>
{onRefresh && (
<button
type="button"
onClick={onRefresh}
disabled={disabled || state.loading}
className={cn(
'p-1 rounded transition-colors disabled:opacity-50',
disabled || state.loading
? 'text-muted-foreground'
: 'text-green-500 hover:bg-accent hover:text-green-400'
)}
title="Refresh"
>
<RefreshIcon
className={cn(
'w-3.5 h-3.5',
state.loading && 'animate-spin [animation-direction:reverse]'
)}
/>
</button>
)}
</div>
{state.error && (
<div className="px-3 py-1.5 text-xs text-destructive bg-destructive/5 border-b border-border">
{state.error}
</div>
)}
<div className={cn('p-3', contentClassName)}>
{state.loading ? (
<p className="text-sm text-muted-foreground italic">
Fetching{state.attempt > 1 ? ` (attempt ${state.attempt}/${3})` : ''}...
</p>
) : (
children
)}
</div>
</div>
);
}
function NotFetched() {
return <p className="text-sm text-muted-foreground italic">&lt;not fetched&gt;</p>;
}
function KvRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between items-center text-sm py-0.5">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium text-right">{value}</span>
</div>
);
}
// --- Individual Panes ---
function TelemetryPane({
data,
state,
onRefresh,
disabled,
}: {
data: RepeaterStatusResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
}) {
return (
<RepeaterPane title="Telemetry" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : (
<div className="space-y-2">
<KvRow label="Battery" value={`${data.battery_volts.toFixed(3)}V`} />
<KvRow label="Uptime" value={formatDuration(data.uptime_seconds)} />
<KvRow label="TX Airtime" value={formatDuration(data.airtime_seconds)} />
<KvRow label="RX Airtime" value={formatDuration(data.rx_airtime_seconds)} />
<Separator className="my-1" />
<KvRow label="Noise Floor" value={`${data.noise_floor_dbm} dBm`} />
<KvRow label="Last RSSI" value={`${data.last_rssi_dbm} dBm`} />
<KvRow label="Last SNR" value={`${data.last_snr_db.toFixed(1)} dB`} />
<Separator className="my-1" />
<KvRow
label="Packets"
value={`${data.packets_received.toLocaleString()} rx / ${data.packets_sent.toLocaleString()} tx`}
/>
<KvRow
label="Flood"
value={`${data.recv_flood.toLocaleString()} rx / ${data.sent_flood.toLocaleString()} tx`}
/>
<KvRow
label="Direct"
value={`${data.recv_direct.toLocaleString()} rx / ${data.sent_direct.toLocaleString()} tx`}
/>
<KvRow
label="Duplicates"
value={`${data.flood_dups.toLocaleString()} flood / ${data.direct_dups.toLocaleString()} direct`}
/>
<Separator className="my-1" />
<KvRow label="TX Queue" value={data.tx_queue_len} />
<KvRow label="Debug Flags" value={data.full_events} />
</div>
)}
</RepeaterPane>
);
}
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<NeighborInfo & { lat: number | null; lon: number | null }>,
sorted: [] as Array<NeighborInfo & { distance: string | null }>,
hasDistances: false,
};
}
const withCoords: Array<NeighborInfo & { lat: number | null; lon: number | null }> = [];
const enriched: Array<NeighborInfo & { distance: string | null }> = [];
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 (
<RepeaterPane
title="Neighbors"
state={state}
onRefresh={onRefresh}
disabled={disabled}
className="flex flex-col"
contentClassName="flex-1 flex flex-col"
>
{!data ? (
<NotFetched />
) : sorted.length === 0 ? (
<p className="text-sm text-muted-foreground">No neighbors reported</p>
) : (
<div className="flex-1 flex flex-col gap-2">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-muted-foreground text-xs">
<th className="pb-1 font-medium">Name</th>
<th className="pb-1 font-medium text-right">SNR</th>
{hasDistances && <th className="pb-1 font-medium text-right">Dist</th>}
<th className="pb-1 font-medium text-right">Last Heard</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={i} className="border-t border-border/50">
<td className="py-1">{n.name || n.pubkey_prefix}</td>
<td className={cn('py-1 text-right font-mono', snrColor)}>{snrStr} dB</td>
{hasDistances && (
<td className="py-1 text-right text-muted-foreground font-mono">
{dist ?? '—'}
</td>
)}
<td className="py-1 text-right text-muted-foreground">
{formatDuration(n.last_heard_seconds)} ago
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{(neighborsWithCoords.length > 0 || isValidLocation(radioLat, radioLon)) && (
<Suspense
fallback={
<div className="h-48 flex items-center justify-center text-xs text-muted-foreground">
Loading map...
</div>
}
>
<NeighborsMiniMap
key={neighborsWithCoords.map((n) => n.pubkey_prefix).join(',')}
neighbors={neighborsWithCoords}
radioLat={radioLat}
radioLon={radioLon}
radioName={radioName}
/>
</Suspense>
)}
</div>
)}
</RepeaterPane>
);
}
function AclPane({
data,
state,
onRefresh,
disabled,
}: {
data: RepeaterAclResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
}) {
const permColor: Record<number, string> = {
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 (
<RepeaterPane title="ACL" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : data.acl.length === 0 ? (
<p className="text-sm text-muted-foreground">No ACL entries</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-muted-foreground text-xs">
<th className="pb-1 font-medium">Name</th>
<th className="pb-1 font-medium text-right">Permission</th>
</tr>
</thead>
<tbody>
{data.acl.map((entry, i) => (
<tr key={i} className="border-t border-border/50">
<td className="py-1">{entry.name || entry.pubkey_prefix}</td>
<td className="py-1 text-right">
<span
className={cn(
'text-xs px-1.5 py-0.5 rounded',
permColor[entry.permission] ?? 'bg-muted text-muted-foreground'
)}
>
{entry.permission_name}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</RepeaterPane>
);
}
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 (
<RepeaterPane title="Radio Settings" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : (
<div>
<KvRow label="Firmware" value={data.firmware_version ?? '—'} />
<KvRow label="Radio" value={data.radio ?? '—'} />
<KvRow label="TX Power" value={data.tx_power != null ? `${data.tx_power} dBm` : '—'} />
<KvRow label="Airtime Factor" value={data.airtime_factor ?? '—'} />
<KvRow label="Repeat Mode" value={data.repeat_enabled ?? '—'} />
<KvRow label="Max Flood Hops" value={data.flood_max ?? '—'} />
<Separator className="my-1" />
<KvRow label="Name" value={data.name ?? '—'} />
<KvRow
label="Lat / Lon"
value={
data.lat != null || data.lon != null ? `${data.lat ?? '—'}, ${data.lon ?? '—'}` : '—'
}
/>
<Separator className="my-1" />
<div className="flex justify-between text-sm py-0.5">
<span className="text-muted-foreground">Clock (UTC)</span>
<span>
{data.clock_utc ?? '—'}
{clockDrift && (
<span
className={cn(
'ml-2 text-xs',
clockDrift.isLarge ? 'text-red-500' : 'text-muted-foreground'
)}
>
(drift: {clockDrift.text})
</span>
)}
</span>
</div>
</div>
)}
{/* Advert Intervals sub-section */}
<Separator className="my-2" />
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-muted-foreground">Advert Intervals</span>
<button
type="button"
onClick={onRefreshAdvert}
disabled={disabled || advertState.loading}
className={cn(
'p-1 rounded transition-colors disabled:opacity-50',
disabled || advertState.loading
? 'text-muted-foreground'
: 'text-green-500 hover:bg-accent hover:text-green-400'
)}
title="Refresh Advert Intervals"
>
<RefreshIcon
className={cn(
'w-3 h-3',
advertState.loading && 'animate-spin [animation-direction:reverse]'
)}
/>
</button>
</div>
{advertState.error && <p className="text-xs text-destructive mb-1">{advertState.error}</p>}
{advertState.loading ? (
<p className="text-sm text-muted-foreground italic">
Fetching{advertState.attempt > 1 ? ` (attempt ${advertState.attempt}/3)` : ''}...
</p>
) : !advertData ? (
<NotFetched />
) : (
<div>
<KvRow label="Local Advert" value={formatAdvertInterval(advertData.advert_interval)} />
<KvRow
label="Flood Advert"
value={formatAdvertInterval(advertData.flood_advert_interval)}
/>
</div>
)}
</RepeaterPane>
);
}
function formatAdvertInterval(val: string | null): string {
if (val == null) return '—';
const trimmed = val.trim();
if (trimmed === '0') return '<disabled>';
return `${trimmed}h`;
}
const LPP_UNIT_MAP: Record<string, string> = {
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 (
<div className="py-0.5">
<span className="text-sm text-muted-foreground">{label}</span>
<div className="pl-3">
{Object.entries(sensor.value).map(([k, v]) => (
<KvRow
key={k}
label={k.charAt(0).toUpperCase() + k.slice(1)}
value={typeof v === 'number' ? v.toFixed(2) : String(v)}
/>
))}
</div>
</div>
);
}
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 <KvRow label={label} value={formatted} />;
}
function LppTelemetryPane({
data,
state,
onRefresh,
disabled,
}: {
data: RepeaterLppTelemetryResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
}) {
return (
<RepeaterPane title="LPP Sensors" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : data.sensors.length === 0 ? (
<p className="text-sm text-muted-foreground">No sensor data available</p>
) : (
<div className="space-y-0.5">
{data.sensors.map((sensor, i) => (
<LppSensorRow key={i} sensor={sensor} />
))}
</div>
)}
</RepeaterPane>
);
}
function OwnerInfoPane({
data,
state,
onRefresh,
disabled,
}: {
data: RepeaterOwnerInfoResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
}) {
return (
<RepeaterPane title="Owner Info" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : (
<div className="break-all">
<KvRow label="Owner Info" value={data.owner_info ?? '—'} />
<KvRow label="Guest Password" value={data.guest_password ?? '—'} />
</div>
)}
</RepeaterPane>
);
}
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 (
<div className="border border-border rounded-lg overflow-hidden">
<div className="px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium">Actions</h3>
</div>
<div className="p-3 flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={onSendAdvert} disabled={consoleLoading}>
Send Advert
</Button>
<Button variant="outline" size="sm" onClick={onSyncClock} disabled={consoleLoading}>
Sync Clock
</Button>
<Button
variant={confirmReboot ? 'destructive' : 'outline'}
size="sm"
onClick={handleReboot}
disabled={consoleLoading}
>
{confirmReboot ? 'Confirm Reboot' : 'Reboot'}
</Button>
</div>
</div>
);
}
function ConsolePane({
history,
loading,
onSend,
}: {
history: Array<{ command: string; response: string; timestamp: number; outgoing: boolean }>;
loading: boolean;
onSend: (command: string) => Promise<void>;
}) {
const [input, setInput] = useState('');
const outputRef = useRef<HTMLDivElement>(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 (
<div className="border border-border rounded-lg overflow-hidden col-span-full">
<div className="px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium">Console</h3>
</div>
<div
ref={outputRef}
className="h-48 overflow-y-auto p-3 font-mono text-xs bg-black/50 text-green-400 space-y-1"
>
{history.length === 0 && (
<p className="text-muted-foreground italic">Type a CLI command below...</p>
)}
{history.map((entry, i) =>
entry.outgoing ? (
<div key={i} className="text-green-300">
&gt; {entry.command}
</div>
) : (
<div key={i} className="text-green-400/80 whitespace-pre-wrap">
{entry.response}
</div>
)
)}
{loading && <div className="text-muted-foreground animate-pulse">...</div>}
</div>
<form onSubmit={handleSubmit} className="flex gap-2 p-2 border-t border-border">
<Input
type="text"
autoComplete="off"
name="console-input"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="CLI command..."
disabled={loading}
className="flex-1 font-mono text-sm"
/>
<Button type="submit" size="sm" disabled={loading || !input.trim()}>
Send
</Button>
</form>
</div>
);
}
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
export { formatDuration, formatClockDrift } from './repeater/repeaterPaneShared';
// --- Main Dashboard ---

File diff suppressed because it is too large Load Diff

View File

@@ -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<number, string> = {
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 (
<RepeaterPane title="ACL" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : data.acl.length === 0 ? (
<p className="text-sm text-muted-foreground">No ACL entries</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-muted-foreground text-xs">
<th className="pb-1 font-medium">Name</th>
<th className="pb-1 font-medium text-right">Permission</th>
</tr>
</thead>
<tbody>
{data.acl.map((entry, i) => (
<tr key={i} className="border-t border-border/50">
<td className="py-1">{entry.name || entry.pubkey_prefix}</td>
<td className="py-1 text-right">
<span
className={cn(
'text-xs px-1.5 py-0.5 rounded',
permColor[entry.permission] ?? 'bg-muted text-muted-foreground'
)}
>
{entry.permission_name}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</RepeaterPane>
);
}

View File

@@ -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 (
<div className="border border-border rounded-lg overflow-hidden">
<div className="px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium">Actions</h3>
</div>
<div className="p-3 flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={onSendAdvert} disabled={consoleLoading}>
Send Advert
</Button>
<Button variant="outline" size="sm" onClick={onSyncClock} disabled={consoleLoading}>
Sync Clock
</Button>
<Button
variant={confirmReboot ? 'destructive' : 'outline'}
size="sm"
onClick={handleReboot}
disabled={consoleLoading}
>
{confirmReboot ? 'Confirm Reboot' : 'Reboot'}
</Button>
</div>
</div>
);
}

View File

@@ -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<void>;
}) {
const [input, setInput] = useState('');
const outputRef = useRef<HTMLDivElement>(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 (
<div className="border border-border rounded-lg overflow-hidden col-span-full">
<div className="px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium">Console</h3>
</div>
<div
ref={outputRef}
className="h-48 overflow-y-auto p-3 font-mono text-xs bg-black/50 text-green-400 space-y-1"
>
{history.length === 0 && (
<p className="text-muted-foreground italic">Type a CLI command below...</p>
)}
{history.map((entry, i) =>
entry.outgoing ? (
<div key={i} className="text-green-300">
&gt; {entry.command}
</div>
) : (
<div key={i} className="text-green-400/80 whitespace-pre-wrap">
{entry.response}
</div>
)
)}
{loading && <div className="text-muted-foreground animate-pulse">...</div>}
</div>
<form onSubmit={handleSubmit} className="flex gap-2 p-2 border-t border-border">
<Input
type="text"
autoComplete="off"
name="console-input"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="CLI command..."
disabled={loading}
className="flex-1 font-mono text-sm"
/>
<Button type="submit" size="sm" disabled={loading || !input.trim()}>
Send
</Button>
</form>
</div>
);
}

View File

@@ -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 (
<RepeaterPane title="LPP Sensors" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : data.sensors.length === 0 ? (
<p className="text-sm text-muted-foreground">No sensor data available</p>
) : (
<div className="space-y-0.5">
{data.sensors.map((sensor, i) => (
<LppSensorRow key={i} sensor={sensor} />
))}
</div>
)}
</RepeaterPane>
);
}

View File

@@ -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<NeighborInfo & { lat: number | null; lon: number | null }>,
sorted: [] as Array<NeighborInfo & { distance: string | null }>,
hasDistances: false,
};
}
const withCoords: Array<NeighborInfo & { lat: number | null; lon: number | null }> = [];
const enriched: Array<NeighborInfo & { distance: string | null }> = [];
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 (
<RepeaterPane
title="Neighbors"
state={state}
onRefresh={onRefresh}
disabled={disabled}
className="flex flex-col"
contentClassName="flex-1 flex flex-col"
>
{!data ? (
<NotFetched />
) : sorted.length === 0 ? (
<p className="text-sm text-muted-foreground">No neighbors reported</p>
) : (
<div className="flex-1 flex flex-col gap-2">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-muted-foreground text-xs">
<th className="pb-1 font-medium">Name</th>
<th className="pb-1 font-medium text-right">SNR</th>
{hasDistances && <th className="pb-1 font-medium text-right">Dist</th>}
<th className="pb-1 font-medium text-right">Last Heard</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={i} className="border-t border-border/50">
<td className="py-1">{n.name || n.pubkey_prefix}</td>
<td className={cn('py-1 text-right font-mono', snrColor)}>{snrStr} dB</td>
{hasDistances && (
<td className="py-1 text-right text-muted-foreground font-mono">
{dist ?? '—'}
</td>
)}
<td className="py-1 text-right text-muted-foreground">
{formatDuration(n.last_heard_seconds)} ago
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{(neighborsWithCoords.length > 0 || isValidLocation(radioLat, radioLon)) && (
<Suspense
fallback={
<div className="h-48 flex items-center justify-center text-xs text-muted-foreground">
Loading map...
</div>
}
>
<NeighborsMiniMap
key={neighborsWithCoords.map((n) => n.pubkey_prefix).join(',')}
neighbors={neighborsWithCoords}
radioLat={radioLat}
radioLon={radioLon}
radioName={radioName}
/>
</Suspense>
)}
</div>
)}
</RepeaterPane>
);
}

View File

@@ -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 (
<RepeaterPane title="Owner Info" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : (
<div className="break-all">
<KvRow label="Owner Info" value={data.owner_info ?? '—'} />
<KvRow label="Guest Password" value={data.guest_password ?? '—'} />
</div>
)}
</RepeaterPane>
);
}

View File

@@ -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 (
<RepeaterPane title="Radio Settings" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : (
<div>
<KvRow label="Firmware" value={data.firmware_version ?? '—'} />
<KvRow label="Radio" value={data.radio ?? '—'} />
<KvRow label="TX Power" value={data.tx_power != null ? `${data.tx_power} dBm` : '—'} />
<KvRow label="Airtime Factor" value={data.airtime_factor ?? '—'} />
<KvRow label="Repeat Mode" value={data.repeat_enabled ?? '—'} />
<KvRow label="Max Flood Hops" value={data.flood_max ?? '—'} />
<Separator className="my-1" />
<KvRow label="Name" value={data.name ?? '—'} />
<KvRow
label="Lat / Lon"
value={
data.lat != null || data.lon != null ? `${data.lat ?? '—'}, ${data.lon ?? '—'}` : '—'
}
/>
<Separator className="my-1" />
<div className="flex justify-between text-sm py-0.5">
<span className="text-muted-foreground">Clock (UTC)</span>
<span>
{data.clock_utc ?? '—'}
{clockDrift && (
<span
className={cn(
'ml-2 text-xs',
clockDrift.isLarge ? 'text-red-500' : 'text-muted-foreground'
)}
>
(drift: {clockDrift.text})
</span>
)}
</span>
</div>
</div>
)}
{/* Advert Intervals sub-section */}
<Separator className="my-2" />
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-muted-foreground">Advert Intervals</span>
<button
type="button"
onClick={onRefreshAdvert}
disabled={disabled || advertState.loading}
className={cn(
'p-1 rounded transition-colors disabled:opacity-50',
disabled || advertState.loading
? 'text-muted-foreground'
: 'text-green-500 hover:bg-accent hover:text-green-400'
)}
title="Refresh Advert Intervals"
>
<RefreshIcon
className={cn(
'w-3 h-3',
advertState.loading && 'animate-spin [animation-direction:reverse]'
)}
/>
</button>
</div>
{advertState.error && <p className="text-xs text-destructive mb-1">{advertState.error}</p>}
{advertState.loading ? (
<p className="text-sm text-muted-foreground italic">
Fetching{advertState.attempt > 1 ? ` (attempt ${advertState.attempt}/3)` : ''}...
</p>
) : !advertData ? (
<NotFetched />
) : (
<div>
<KvRow label="Local Advert" value={formatAdvertInterval(advertData.advert_interval)} />
<KvRow
label="Flood Advert"
value={formatAdvertInterval(advertData.flood_advert_interval)}
/>
</div>
)}
</RepeaterPane>
);
}

View File

@@ -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 (
<RepeaterPane title="Telemetry" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : (
<div className="space-y-2">
<KvRow label="Battery" value={`${data.battery_volts.toFixed(3)}V`} />
<KvRow label="Uptime" value={formatDuration(data.uptime_seconds)} />
<KvRow label="TX Airtime" value={formatDuration(data.airtime_seconds)} />
<KvRow label="RX Airtime" value={formatDuration(data.rx_airtime_seconds)} />
<Separator className="my-1" />
<KvRow label="Noise Floor" value={`${data.noise_floor_dbm} dBm`} />
<KvRow label="Last RSSI" value={`${data.last_rssi_dbm} dBm`} />
<KvRow label="Last SNR" value={`${data.last_snr_db.toFixed(1)} dB`} />
<Separator className="my-1" />
<KvRow
label="Packets"
value={`${data.packets_received.toLocaleString()} rx / ${data.packets_sent.toLocaleString()} tx`}
/>
<KvRow
label="Flood"
value={`${data.recv_flood.toLocaleString()} rx / ${data.sent_flood.toLocaleString()} tx`}
/>
<KvRow
label="Direct"
value={`${data.recv_direct.toLocaleString()} rx / ${data.sent_direct.toLocaleString()} tx`}
/>
<KvRow
label="Duplicates"
value={`${data.flood_dups.toLocaleString()} flood / ${data.direct_dups.toLocaleString()} direct`}
/>
<Separator className="my-1" />
<KvRow label="TX Queue" value={data.tx_queue_len} />
<KvRow label="Debug Flags" value={data.full_events} />
</div>
)}
</RepeaterPane>
);
}

View File

@@ -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 (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
);
}
// --- 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 '<disabled>';
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 (
<div className={cn('border border-border rounded-lg overflow-hidden', className)}>
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium">{title}</h3>
{onRefresh && (
<button
type="button"
onClick={onRefresh}
disabled={disabled || state.loading}
className={cn(
'p-1 rounded transition-colors disabled:opacity-50',
disabled || state.loading
? 'text-muted-foreground'
: 'text-green-500 hover:bg-accent hover:text-green-400'
)}
title="Refresh"
>
<RefreshIcon
className={cn(
'w-3.5 h-3.5',
state.loading && 'animate-spin [animation-direction:reverse]'
)}
/>
</button>
)}
</div>
{state.error && (
<div className="px-3 py-1.5 text-xs text-destructive bg-destructive/5 border-b border-border">
{state.error}
</div>
)}
<div className={cn('p-3', contentClassName)}>
{state.loading ? (
<p className="text-sm text-muted-foreground italic">
Fetching{state.attempt > 1 ? ` (attempt ${state.attempt}/${3})` : ''}...
</p>
) : (
children
)}
</div>
</div>
);
}
export function NotFetched() {
return <p className="text-sm text-muted-foreground italic">&lt;not fetched&gt;</p>;
}
export function KvRow({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="flex justify-between items-center text-sm py-0.5">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium text-right">{value}</span>
</div>
);
}
// --- LPP Utilities ---
export const LPP_UNIT_MAP: Record<string, string> = {
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 (
<div className="py-0.5">
<span className="text-sm text-muted-foreground">{label}</span>
<div className="pl-3">
{Object.entries(sensor.value).map(([k, v]) => (
<KvRow
key={k}
label={k.charAt(0).toUpperCase() + k.slice(1)}
value={typeof v === 'number' ? v.toFixed(2) : String(v)}
/>
))}
</div>
</div>
);
}
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 <KvRow label={label} value={formatted} />;
}

View File

@@ -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<void>;
className?: string;
}) {
const [bots, setBots] = useState<BotConfig[]>([]);
const [expandedBotId, setExpandedBotId] = useState<string | null>(null);
const [editingNameId, setEditingNameId] = useState<string | null>(null);
const [editingNameValue, setEditingNameValue] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className={className}>
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-md">
<p className="text-sm text-red-500">
<strong>Experimental:</strong> 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!
</p>
</div>
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-md">
<p className="text-sm text-yellow-500">
<strong>Security Warning:</strong> This feature executes arbitrary Python code on the
server. Only run trusted code, and be cautious of arbitrary usage of message parameters.
</p>
</div>
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-md">
<p className="text-sm text-yellow-500">
<strong>Don&apos;t wreck the mesh!</strong> Bots process ALL messages, including their
own. Be careful of creating infinite loops!
</p>
</div>
<div className="flex justify-between items-center">
<Label>Bots</Label>
<Button type="button" variant="outline" size="sm" onClick={handleAddBot}>
+ New Bot
</Button>
</div>
{bots.length === 0 ? (
<div className="text-center py-8 border border-dashed border-input rounded-md">
<p className="text-muted-foreground mb-4">No bots configured</p>
<Button type="button" variant="outline" onClick={handleAddBot}>
Create your first bot
</Button>
</div>
) : (
<div className="space-y-2">
{bots.map((bot) => (
<div key={bot.id} className="border border-input rounded-md overflow-hidden">
<div
className="flex items-center gap-2 px-3 py-2 bg-muted/50 cursor-pointer hover:bg-muted/80"
onClick={(e) => {
if ((e.target as HTMLElement).closest('input, button')) return;
setExpandedBotId(expandedBotId === bot.id ? null : bot.id);
}}
>
<span className="text-muted-foreground">
{expandedBotId === bot.id ? '▼' : '▶'}
</span>
{editingNameId === bot.id ? (
<input
type="text"
value={editingNameValue}
onChange={(e) => 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()}
/>
) : (
<span
className="text-sm font-medium flex-1 hover:text-primary cursor-text"
onClick={(e) => {
e.stopPropagation();
handleStartEditingName(bot);
}}
title="Click to rename"
>
{bot.name}
</span>
)}
<label
className="flex items-center gap-1.5 cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={bot.enabled}
onChange={() => handleToggleBotEnabled(bot.id)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-xs text-muted-foreground">Enabled</span>
</label>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteBot(bot.id);
}}
title="Delete bot"
>
🗑
</Button>
</div>
{expandedBotId === bot.id && (
<div className="p-3 space-y-3 border-t border-input">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Define a <code className="bg-muted px-1 rounded">bot()</code> function that
receives message data and optionally returns a reply.
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleResetBotCode(bot.id)}
>
Reset to Example
</Button>
</div>
<Suspense
fallback={
<div className="h-64 md:h-96 rounded-md border border-input bg-[#282c34] flex items-center justify-center text-muted-foreground">
Loading editor...
</div>
}
>
<BotCodeEditor
value={bot.code}
onChange={(code) => handleBotCodeChange(bot.id, code)}
id={`bot-code-${bot.id}`}
height={isMobileLayout ? '256px' : '384px'}
/>
</Suspense>
</div>
)}
</div>
))}
</div>
)}
<Separator />
<div className="text-xs text-muted-foreground space-y-1">
<p>
<strong>Available:</strong> Standard Python libraries and any modules installed in the
server environment.
</p>
<p>
<strong>Limits:</strong> 10 second timeout per bot.
</p>
<p>
<strong>Note:</strong> Bots respond to all messages, including your own. For channel
messages, <code>sender_key</code> is <code>None</code>. Multiple enabled bots run
serially, with a two-second delay between messages to prevent repeater collision.
</p>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Bot Settings'}
</Button>
</div>
);
}

View File

@@ -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<void>;
onReboot: () => Promise<void>;
onClose: () => void;
className?: string;
}) {
const [maxRadioContacts, setMaxRadioContacts] = useState('');
const [busy, setBusy] = useState(false);
const [rebooting, setRebooting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className={className}>
<div className="space-y-2">
<Label>Connection</Label>
{health?.connection_info ? (
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<code className="px-2 py-1 bg-muted rounded text-foreground text-sm">
{health.connection_info}
</code>
</div>
) : (
<div className="flex items-center gap-2 text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-gray-500" />
<span>Not connected</span>
</div>
)}
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="max-contacts">Max Contacts on Radio</Label>
<Input
id="max-contacts"
type="number"
min="1"
max="1000"
value={maxRadioContacts}
onChange={(e) => setMaxRadioContacts(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Favorite contacts load first, then recent non-repeater contacts until this limit is
reached (1-1000)
</p>
</div>
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Settings'}
</Button>
<Separator />
<Button
variant="outline"
onClick={handleReboot}
disabled={rebooting || busy}
className="w-full border-red-500/50 text-red-400 hover:bg-red-500/10"
>
{rebooting ? 'Rebooting...' : 'Reboot Radio'}
</Button>
{error && <div className="text-sm text-destructive">{error}</div>}
</div>
);
}

View File

@@ -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<void>;
onHealthRefresh: () => Promise<void>;
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<string | null>(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 (
<div className={className}>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Database size</span>
<span className="font-medium">{health?.database_size_mb ?? '?'} MB</span>
</div>
{health?.oldest_undecrypted_timestamp ? (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
<span className="font-medium">
{formatTime(health.oldest_undecrypted_timestamp)}
<span className="text-muted-foreground ml-1">
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
days old)
</span>
</span>
</div>
) : (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
<span className="text-muted-foreground">None</span>
</div>
)}
</div>
<Separator />
<div className="space-y-3">
<Label>Delete Undecrypted Packets</Label>
<p className="text-xs text-muted-foreground">
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.
</p>
<div className="flex gap-2 items-end">
<div className="space-y-1">
<Label htmlFor="retention-days" className="text-xs">
Older than (days)
</Label>
<Input
id="retention-days"
type="number"
min="1"
max="365"
value={retentionDays}
onChange={(e) => setRetentionDays(e.target.value)}
className="w-24"
/>
</div>
<Button
variant="outline"
onClick={handleCleanup}
disabled={cleaning}
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
>
{cleaning ? 'Deleting...' : 'Permanently Delete'}
</Button>
</div>
</div>
<Separator />
<div className="space-y-3">
<Label>Purge Archival Raw Packets</Label>
<p className="text-xs text-muted-foreground">
Deletes archival copies of raw packet bytes for messages that are already decrypted and
visible in your chat history.{' '}
<em className="text-muted-foreground/80">
This will not affect any displayed messages or app functionality.
</em>{' '}
The raw bytes are only useful for manual packet analysis.
</p>
<Button
variant="outline"
onClick={handlePurgeDecryptedRawPackets}
disabled={purgingDecryptedRaw}
className="w-full border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10"
>
{purgingDecryptedRaw ? 'Purging Archival Raw Packets...' : 'Purge Archival Raw Packets'}
</Button>
</div>
<Separator />
<div className="space-y-3">
<Label>DM Decryption</Label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={autoDecryptOnAdvert}
onChange={(e) => setAutoDecryptOnAdvert(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Auto-decrypt historical DMs when new contact advertises</span>
</label>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
<Separator />
<div className="space-y-3">
<Label>Interface</Label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={reopenLastConversation}
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Reopen to last viewed channel/conversation</span>
</label>
<p className="text-xs text-muted-foreground">
This applies only to this device/browser. It does not sync to server settings.
</p>
</div>
<Separator />
<div className="space-y-3">
<Label>Local Label</Label>
<div className="flex items-center gap-2">
<Input
value={localLabelText}
onChange={(e) => {
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"
/>
<input
type="color"
value={localLabelColor}
onChange={(e) => {
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"
/>
</div>
<p className="text-xs text-muted-foreground">
Display a colored banner at the top of the page to identify this instance. This applies
only to this device/browser.
</p>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Settings'}
</Button>
</div>
);
}

View File

@@ -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<void>;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
onSetPrivateKey: (key: string) => Promise<void>;
onReboot: () => Promise<void>;
onAdvertise: () => Promise<void>;
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<string | null>(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 (
<div className={className}>
<div className="space-y-2">
<Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
</div>
<div className="space-y-2">
<Label htmlFor="name">Radio Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
<div className="flex items-center gap-2">
<Input
id="advert-interval"
type="number"
min="0"
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(e.target.value)}
className="w-28"
/>
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
</div>
<p className="text-xs text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher.
</p>
</div>
<Button onClick={handleSaveIdentity} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Identity Settings'}
</Button>
<Separator />
<div className="space-y-2">
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<Input
id="private-key"
type="password"
autoComplete="off"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
/>
<Button
onClick={handleSetPrivateKey}
disabled={busy || rebooting || !privateKey.trim()}
className="w-full"
>
{busy || rebooting ? 'Setting & Rebooting...' : 'Set Private Key & Reboot'}
</Button>
</div>
<Separator />
<div className="space-y-2">
<Label>Send Advertisement</Label>
<p className="text-xs text-muted-foreground">
Send a flood advertisement to announce your presence on the mesh network.
</p>
<Button
onClick={handleAdvertise}
disabled={advertising || !health?.radio_connected}
className="w-full bg-yellow-600 hover:bg-yellow-700 text-white"
>
{advertising ? 'Sending...' : 'Send Advertisement'}
</Button>
{!health?.radio_connected && (
<p className="text-sm text-destructive">Radio not connected</p>
)}
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
</div>
);
}

View File

@@ -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<void>;
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<string | null>(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 (
<div className={className}>
<div className="space-y-2">
<Label>Status</Label>
{health?.mqtt_status === 'connected' ? (
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-sm text-green-400">Connected</span>
</div>
) : health?.mqtt_status === 'disconnected' ? (
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-sm text-red-400">Disconnected</span>
</div>
) : (
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-gray-500" />
<span className="text-sm text-muted-foreground">Disabled</span>
</div>
)}
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="mqtt-host">Broker Host</Label>
<Input
id="mqtt-host"
type="text"
placeholder="e.g. 192.168.1.100"
value={mqttBrokerHost}
onChange={(e) => setMqttBrokerHost(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="mqtt-port">Broker Port</Label>
<Input
id="mqtt-port"
type="number"
min="1"
max="65535"
value={mqttBrokerPort}
onChange={(e) => setMqttBrokerPort(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="mqtt-username">Username</Label>
<Input
id="mqtt-username"
type="text"
placeholder="Optional"
value={mqttUsername}
onChange={(e) => setMqttUsername(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="mqtt-password">Password</Label>
<Input
id="mqtt-password"
type="password"
placeholder="Optional"
value={mqttPassword}
onChange={(e) => setMqttPassword(e.target.value)}
/>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={mqttUseTls}
onChange={(e) => setMqttUseTls(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm">Use TLS</span>
</label>
{mqttUseTls && (
<>
<label className="flex items-center gap-3 cursor-pointer ml-7">
<input
type="checkbox"
checked={mqttTlsInsecure}
onChange={(e) => setMqttTlsInsecure(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm">Skip certificate verification</span>
</label>
<p className="text-xs text-muted-foreground ml-7">
Allow self-signed or untrusted broker certificates
</p>
</>
)}
<Separator />
<div className="space-y-2">
<Label htmlFor="mqtt-prefix">Topic Prefix</Label>
<Input
id="mqtt-prefix"
type="text"
value={mqttTopicPrefix}
onChange={(e) => setMqttTopicPrefix(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Topics: {mqttTopicPrefix || 'meshcore'}/dm:&lt;key&gt;, {mqttTopicPrefix || 'meshcore'}
/gm:&lt;key&gt;, {mqttTopicPrefix || 'meshcore'}
/raw/...
</p>
</div>
<Separator />
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={mqttPublishMessages}
onChange={(e) => setMqttPublishMessages(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm">Publish Messages</span>
</label>
<p className="text-xs text-muted-foreground ml-7">
Forward decrypted DM and channel messages
</p>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={mqttPublishRawPackets}
onChange={(e) => setMqttPublishRawPackets(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm">Publish Raw Packets</span>
</label>
<p className="text-xs text-muted-foreground ml-7">Forward all RF packets</p>
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save MQTT Settings'}
</Button>
{error && <div className="text-sm text-destructive">{error}</div>}
</div>
);
}

View File

@@ -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<void>;
onReboot: () => Promise<void>;
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<string | null>(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 (
<div className={className}>
<div className="space-y-2">
<Label htmlFor="preset">Preset</Label>
<select
id="preset"
value={currentPreset}
onChange={(e) => handlePresetChange(e.target.value)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="custom">Custom</option>
{RADIO_PRESETS.map((preset) => (
<option key={preset.name} value={preset.name}>
{preset.name}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="freq">Frequency (MHz)</Label>
<Input
id="freq"
type="number"
step="any"
value={freq}
onChange={(e) => setFreq(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="bw">Bandwidth (kHz)</Label>
<Input
id="bw"
type="number"
step="any"
value={bw}
onChange={(e) => setBw(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sf">Spreading Factor</Label>
<Input
id="sf"
type="number"
min="7"
max="12"
value={sf}
onChange={(e) => setSf(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cr">Coding Rate</Label>
<Input
id="cr"
type="number"
min="5"
max="8"
value={cr}
onChange={(e) => setCr(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tx-power">TX Power (dBm)</Label>
<Input
id="tx-power"
type="number"
value={txPower}
onChange={(e) => setTxPower(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-tx">Max TX Power</Label>
<Input id="max-tx" type="number" value={config.max_tx_power} disabled />
</div>
</div>
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Location</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGetLocation}
disabled={gettingLocation}
>
{gettingLocation ? 'Getting...' : '📍 Use My Location'}
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="lat" className="text-xs text-muted-foreground">
Latitude
</Label>
<Input
id="lat"
type="number"
step="any"
value={lat}
onChange={(e) => setLat(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lon" className="text-xs text-muted-foreground">
Longitude
</Label>
<Input
id="lon"
type="number"
step="any"
value={lon}
onChange={(e) => setLon(e.target.value)}
/>
</div>
</div>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
<Button onClick={handleSave} disabled={busy || rebooting} className="w-full">
{busy || rebooting ? 'Saving & Rebooting...' : 'Save Radio Config & Reboot'}
</Button>
</div>
);
}

View File

@@ -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<StatisticsResponse | null>(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 (
<div className={className}>
{statsLoading && !stats ? (
<div className="py-8 text-center text-muted-foreground">Loading statistics...</div>
) : stats ? (
<div className="space-y-6">
{/* Network */}
<div>
<h4 className="text-sm font-medium mb-2">Network</h4>
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.contact_count}</div>
<div className="text-xs text-muted-foreground">Contacts</div>
</div>
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.repeater_count}</div>
<div className="text-xs text-muted-foreground">Repeaters</div>
</div>
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.channel_count}</div>
<div className="text-xs text-muted-foreground">Channels</div>
</div>
</div>
</div>
<Separator />
{/* Messages */}
<div>
<h4 className="text-sm font-medium mb-2">Messages</h4>
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.total_dms}</div>
<div className="text-xs text-muted-foreground">Direct Messages</div>
</div>
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.total_channel_messages}</div>
<div className="text-xs text-muted-foreground">Channel Messages</div>
</div>
<div className="text-center p-3 bg-muted/50 rounded-md">
<div className="text-2xl font-bold">{stats.total_outgoing}</div>
<div className="text-xs text-muted-foreground">Sent (Outgoing)</div>
</div>
</div>
</div>
<Separator />
{/* Packets */}
<div>
<h4 className="text-sm font-medium mb-2">Packets</h4>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Total stored</span>
<span className="font-medium">{stats.total_packets}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-green-500">Decrypted</span>
<span className="font-medium text-green-500">{stats.decrypted_packets}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-yellow-500">Undecrypted</span>
<span className="font-medium text-yellow-500">{stats.undecrypted_packets}</span>
</div>
</div>
</div>
<Separator />
{/* Activity */}
<div>
<h4 className="text-sm font-medium mb-2">Activity</h4>
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground">
<th className="text-left font-normal pb-1"></th>
<th className="text-right font-normal pb-1">1h</th>
<th className="text-right font-normal pb-1">24h</th>
<th className="text-right font-normal pb-1">7d</th>
</tr>
</thead>
<tbody>
<tr>
<td className="py-1">Contacts heard</td>
<td className="text-right py-1">{stats.contacts_heard.last_hour}</td>
<td className="text-right py-1">{stats.contacts_heard.last_24_hours}</td>
<td className="text-right py-1">{stats.contacts_heard.last_week}</td>
</tr>
<tr>
<td className="py-1">Repeaters heard</td>
<td className="text-right py-1">{stats.repeaters_heard.last_hour}</td>
<td className="text-right py-1">{stats.repeaters_heard.last_24_hours}</td>
<td className="text-right py-1">{stats.repeaters_heard.last_week}</td>
</tr>
</tbody>
</table>
</div>
{/* Busiest Channels */}
{stats.busiest_channels_24h.length > 0 && (
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Busiest Channels (24h)</h4>
<div className="space-y-1">
{stats.busiest_channels_24h.map((ch, i) => (
<div key={ch.channel_key} className="flex justify-between items-center text-sm">
<span>
<span className="text-muted-foreground mr-2">{i + 1}.</span>
{ch.channel_name}
</span>
<span className="text-muted-foreground">{ch.message_count} msgs</span>
</div>
))}
</div>
</div>
</>
)}
</div>
) : null}
</div>
);
}

View File

@@ -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,