Make repeater neighbor display need a GPS fix to show map + distance, and fetch before display. Closes #58.

This commit is contained in:
Jack Kingsman
2026-03-12 16:15:03 -07:00
parent 07934093e6
commit 07fd88a4d6
15 changed files with 516 additions and 102 deletions
+21 -12
View File
@@ -11,6 +11,7 @@ import type { Contact, Conversation, Favorite } from '../types';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
import { AclPane } from './repeater/RepeaterAclPane';
import { NodeInfoPane } from './repeater/RepeaterNodeInfoPane';
import { RadioSettingsPane } from './repeater/RepeaterRadioSettingsPane';
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
@@ -47,7 +48,7 @@ export function RepeaterDashboard({
notificationsPermission,
radioLat,
radioLon,
radioName,
radioName: _radioName,
onTrace,
onToggleNotifications,
onToggleFavorite,
@@ -197,9 +198,15 @@ export function RepeaterDashboard({
/>
) : (
<div className="space-y-4">
{/* Top row: Telemetry + Radio Settings | Neighbors (with expanding map) */}
{/* Top row: Telemetry + Radio Settings | Node Info + Neighbors */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-4">
<NodeInfoPane
data={paneData.nodeInfo}
state={paneStates.nodeInfo}
onRefresh={() => refreshPane('nodeInfo')}
disabled={anyLoading}
/>
<TelemetryPane
data={paneData.status}
state={paneStates.status}
@@ -222,16 +229,18 @@ export function RepeaterDashboard({
disabled={anyLoading}
/>
</div>
<NeighborsPane
data={paneData.neighbors}
state={paneStates.neighbors}
onRefresh={() => refreshPane('neighbors')}
disabled={anyLoading}
contacts={contacts}
radioLat={radioLat}
radioLon={radioLon}
radioName={radioName}
/>
<div className="flex flex-col gap-4">
<NeighborsPane
data={paneData.neighbors}
state={paneStates.neighbors}
onRefresh={() => refreshPane('neighbors')}
disabled={anyLoading}
contacts={contacts}
nodeInfo={paneData.nodeInfo}
nodeInfoState={paneStates.nodeInfo}
repeaterName={conversation.name}
/>
</div>
</div>
{/* Remaining panes: ACL | Owner Info + Actions */}
@@ -2,7 +2,13 @@ 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';
import type {
Contact,
RepeaterNeighborsResponse,
PaneState,
NeighborInfo,
RepeaterNodeInfoResponse,
} from '../../types';
const NeighborsMiniMap = lazy(() =>
import('../NeighborsMiniMap').then((m) => ({ default: m.NeighborsMiniMap }))
@@ -14,19 +20,35 @@ export function NeighborsPane({
onRefresh,
disabled,
contacts,
radioLat,
radioLon,
radioName,
nodeInfo,
nodeInfoState,
repeaterName,
}: {
data: RepeaterNeighborsResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
contacts: Contact[];
radioLat: number | null;
radioLon: number | null;
radioName: string | null;
nodeInfo: RepeaterNodeInfoResponse | null;
nodeInfoState: PaneState;
repeaterName: string | null;
}) {
const radioLat = useMemo(() => {
const parsed = nodeInfo?.lat != null ? parseFloat(nodeInfo.lat) : null;
return Number.isFinite(parsed) ? parsed : null;
}, [nodeInfo?.lat]);
const radioLon = useMemo(() => {
const parsed = nodeInfo?.lon != null ? parseFloat(nodeInfo.lon) : null;
return Number.isFinite(parsed) ? parsed : null;
}, [nodeInfo?.lon]);
const radioName = nodeInfo?.name || repeaterName;
const hasValidRepeaterGps = isValidLocation(radioLat, radioLon);
const showGpsUnavailableMessage =
!hasValidRepeaterGps &&
(nodeInfoState.error !== null || nodeInfoState.fetched_at != null || nodeInfo !== 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(() => {
@@ -48,7 +70,7 @@ export function NeighborsPane({
const nLon = contact?.lon ?? null;
let dist: string | null = null;
if (isValidLocation(radioLat, radioLon) && isValidLocation(nLat, nLon)) {
if (hasValidRepeaterGps && isValidLocation(nLat, nLon)) {
const distKm = calculateDistance(radioLat, radioLon, nLat, nLon);
if (distKm != null) {
dist = formatDistance(distKm);
@@ -69,7 +91,7 @@ export function NeighborsPane({
sorted: enriched,
hasDistances: anyDist,
};
}, [data, contacts, radioLat, radioLon]);
}, [contacts, data, hasValidRepeaterGps, radioLat, radioLon]);
return (
<RepeaterPane
@@ -120,7 +142,7 @@ export function NeighborsPane({
</tbody>
</table>
</div>
{(neighborsWithCoords.length > 0 || isValidLocation(radioLat, radioLon)) && (
{hasValidRepeaterGps && (neighborsWithCoords.length > 0 || hasValidRepeaterGps) ? (
<Suspense
fallback={
<div className="h-48 flex items-center justify-center text-xs text-muted-foreground">
@@ -136,7 +158,13 @@ export function NeighborsPane({
radioName={radioName}
/>
</Suspense>
)}
) : showGpsUnavailableMessage ? (
<div className="rounded border border-border/70 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
GPS info failed to fetch; map and distance data not available. This may be due to
missing or zero-zero GPS data on the repeater, or due to transient fetch failure. Try
refreshing.
</div>
) : null}
</div>
)}
</RepeaterPane>
@@ -0,0 +1,55 @@
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { RepeaterPane, NotFetched, KvRow, formatClockDrift } from './repeaterPaneShared';
import type { RepeaterNodeInfoResponse, PaneState } from '../../types';
export function NodeInfoPane({
data,
state,
onRefresh,
disabled,
}: {
data: RepeaterNodeInfoResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
}) {
const clockDrift = useMemo(() => {
if (!data?.clock_utc) return null;
return formatClockDrift(data.clock_utc);
}, [data?.clock_utc]);
return (
<RepeaterPane title="Node Info" state={state} onRefresh={onRefresh} disabled={disabled}>
{!data ? (
<NotFetched />
) : (
<div>
<KvRow label="Name" value={data.name ?? '—'} />
<KvRow
label="Lat / Lon"
value={
data.lat != null || data.lon != null ? `${data.lat ?? '—'}, ${data.lon ?? '—'}` : '—'
}
/>
<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-destructive' : 'text-muted-foreground'
)}
>
(drift: {clockDrift.text})
</span>
)}
</span>
</div>
</div>
)}
</RepeaterPane>
);
}
@@ -1,4 +1,3 @@
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { Separator } from '../ui/separator';
import {
@@ -6,7 +5,6 @@ import {
RefreshIcon,
NotFetched,
KvRow,
formatClockDrift,
formatAdvertInterval,
} from './repeaterPaneShared';
import type {
@@ -15,6 +13,35 @@ import type {
PaneState,
} from '../../types';
function formatRadioTuple(radio: string | null): { display: string; raw: string | null } {
if (radio == null) {
return { display: '—', raw: null };
}
const trimmed = radio.trim();
const parts = trimmed.split(',').map((part) => part.trim());
if (parts.length !== 4) {
return { display: trimmed || '—', raw: trimmed || null };
}
const [freqRaw, bwRaw, sfRaw, crRaw] = parts;
const freq = Number.parseFloat(freqRaw);
const bw = Number.parseFloat(bwRaw);
const sf = Number.parseInt(sfRaw, 10);
const cr = Number.parseInt(crRaw, 10);
if (![freq, bw, sf, cr].every(Number.isFinite)) {
return { display: trimmed || '—', raw: trimmed || null };
}
const formattedFreq = Number(freq.toFixed(3)).toString();
const formattedBw = Number(bw.toFixed(3)).toString();
return {
display: `${formattedFreq} MHz, BW ${formattedBw} kHz, SF${sf}, CR${cr}`,
raw: trimmed,
};
}
export function RadioSettingsPane({
data,
state,
@@ -32,10 +59,7 @@ export function RadioSettingsPane({
advertState: PaneState;
onRefreshAdvert: () => void;
}) {
const clockDrift = useMemo(() => {
if (!data?.clock_utc) return null;
return formatClockDrift(data.clock_utc);
}, [data?.clock_utc]);
const formattedRadio = formatRadioTuple(data?.radio ?? null);
return (
<RepeaterPane title="Radio Settings" state={state} onRefresh={onRefresh} disabled={disabled}>
@@ -44,36 +68,14 @@ export function RadioSettingsPane({
) : (
<div>
<KvRow label="Firmware" value={data.firmware_version ?? '—'} />
<KvRow label="Radio" value={data.radio ?? '—'} />
<KvRow
label="Radio"
value={<span title={formattedRadio.raw ?? undefined}>{formattedRadio.display}</span>}
/>
<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-destructive' : 'text-muted-foreground'
)}
>
(drift: {clockDrift.text})
</span>
)}
</span>
</div>
</div>
)}
{/* Advert Intervals sub-section */}