mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-07 02:11:22 +02:00
Break up repeater and settings into consituent files
This commit is contained in:
+21
-3
@@ -59,8 +59,7 @@ frontend/src/
|
|||||||
│ ├── MessageList.tsx
|
│ ├── MessageList.tsx
|
||||||
│ ├── MessageInput.tsx
|
│ ├── MessageInput.tsx
|
||||||
│ ├── NewMessageModal.tsx
|
│ ├── NewMessageModal.tsx
|
||||||
│ ├── SettingsModal.tsx
|
│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
|
||||||
│ ├── settingsConstants.ts # Settings section ordering and labels
|
|
||||||
│ ├── RawPacketList.tsx
|
│ ├── RawPacketList.tsx
|
||||||
│ ├── MapView.tsx
|
│ ├── MapView.tsx
|
||||||
│ ├── VisualizerView.tsx
|
│ ├── VisualizerView.tsx
|
||||||
@@ -70,9 +69,28 @@ frontend/src/
|
|||||||
│ ├── BotCodeEditor.tsx
|
│ ├── BotCodeEditor.tsx
|
||||||
│ ├── ContactAvatar.tsx
|
│ ├── ContactAvatar.tsx
|
||||||
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
|
│ ├── 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)
|
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
|
||||||
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
│ ├── 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
|
│ └── ui/ # shadcn/ui primitives
|
||||||
├── types/
|
├── types/
|
||||||
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
|
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
SETTINGS_SECTION_LABELS,
|
SETTINGS_SECTION_LABELS,
|
||||||
SETTINGS_SECTION_ORDER,
|
SETTINGS_SECTION_ORDER,
|
||||||
type SettingsSection,
|
type SettingsSection,
|
||||||
} from './components/settingsConstants';
|
} from './components/settings/settingsConstants';
|
||||||
import { RawPacketList } from './components/RawPacketList';
|
import { RawPacketList } from './components/RawPacketList';
|
||||||
import { ContactInfoPane } from './components/ContactInfoPane';
|
import { ContactInfoPane } from './components/ContactInfoPane';
|
||||||
import { CONTACT_TYPE_REPEATER } from './types';
|
import { CONTACT_TYPE_REPEATER } from './types';
|
||||||
|
|||||||
@@ -1,780 +1,25 @@
|
|||||||
import {
|
import { type ReactNode } from 'react';
|
||||||
useState,
|
|
||||||
useCallback,
|
|
||||||
useRef,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
type FormEvent,
|
|
||||||
type ReactNode,
|
|
||||||
lazy,
|
|
||||||
Suspense,
|
|
||||||
} from 'react';
|
|
||||||
import { toast } from './ui/sonner';
|
import { toast } from './ui/sonner';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Input } from './ui/input';
|
|
||||||
import { Separator } from './ui/separator';
|
|
||||||
import { RepeaterLogin } from './RepeaterLogin';
|
import { RepeaterLogin } from './RepeaterLogin';
|
||||||
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import { formatTime } from '../utils/messageParser';
|
import { formatTime } from '../utils/messageParser';
|
||||||
import { isFavorite } from '../utils/favorites';
|
import { isFavorite } from '../utils/favorites';
|
||||||
import { cn } from '@/lib/utils';
|
import type { Contact, Conversation, Favorite } from '../types';
|
||||||
import type {
|
|
||||||
Contact,
|
|
||||||
Conversation,
|
|
||||||
Favorite,
|
|
||||||
LppSensor,
|
|
||||||
PaneState,
|
|
||||||
RepeaterStatusResponse,
|
|
||||||
RepeaterNeighborsResponse,
|
|
||||||
RepeaterAclResponse,
|
|
||||||
RepeaterRadioSettingsResponse,
|
|
||||||
RepeaterAdvertIntervalsResponse,
|
|
||||||
RepeaterOwnerInfoResponse,
|
|
||||||
RepeaterLppTelemetryResponse,
|
|
||||||
NeighborInfo,
|
|
||||||
} from '../types';
|
|
||||||
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
|
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
|
||||||
import { getMapFocusHash } from '../utils/urlHash';
|
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
|
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
|
||||||
// and MapContainer only mounts once (avoids "already initialized" crash).
|
export { formatDuration, formatClockDrift } from './repeater/repeaterPaneShared';
|
||||||
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"><not fetched></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">
|
|
||||||
> {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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Main Dashboard ---
|
// --- Main Dashboard ---
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
> {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"><not fetched></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} />;
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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:<key>, {mqttTopicPrefix || 'meshcore'}
|
||||||
|
/gm:<key>, {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
RadioConfigUpdate,
|
RadioConfigUpdate,
|
||||||
StatisticsResponse,
|
StatisticsResponse,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import type { SettingsSection } from '../components/settingsConstants';
|
import type { SettingsSection } from '../components/settings/settingsConstants';
|
||||||
import {
|
import {
|
||||||
LAST_VIEWED_CONVERSATION_KEY,
|
LAST_VIEWED_CONVERSATION_KEY,
|
||||||
REOPEN_LAST_CONVERSATION_KEY,
|
REOPEN_LAST_CONVERSATION_KEY,
|
||||||
|
|||||||
Reference in New Issue
Block a user