Add repeater region display. Closes #309.

This commit is contained in:
jkingsman
2026-07-09 16:37:14 -07:00
parent 387c9b0e0a
commit 6c977f9108
10 changed files with 459 additions and 12 deletions
+5
View File
@@ -33,6 +33,7 @@ import type {
RepeaterNodeInfoResponse,
RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse,
RepeaterRegionsResponse,
RepeaterStatusResponse,
TelemetryHistoryEntry,
TelemetrySchedule,
@@ -438,6 +439,10 @@ export const api = {
fetchJson<RepeaterOwnerInfoResponse>(`/contacts/${publicKey}/repeater/owner-info`, {
method: 'POST',
}),
repeaterRegions: (publicKey: string) =>
fetchJson<RepeaterRegionsResponse>(`/contacts/${publicKey}/repeater/regions`, {
method: 'POST',
}),
repeaterLppTelemetry: (publicKey: string) =>
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
method: 'POST',
+16 -7
View File
@@ -21,6 +21,7 @@ import { NodeInfoPane } from './repeater/RepeaterNodeInfoPane';
import { RadioSettingsPane } from './repeater/RepeaterRadioSettingsPane';
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
import { RegionsPane } from './repeater/RepeaterRegionsPane';
import { ActionsPane } from './repeater/RepeaterActionsPane';
import { ConsolePane } from './repeater/RepeaterConsolePane';
import { TelemetryHistoryPane } from './repeater/RepeaterTelemetryHistoryPane';
@@ -372,14 +373,22 @@ export function RepeaterDashboard({
</div>
</div>
{/* Remaining panes: ACL | Owner Info + Actions */}
{/* Remaining panes: ACL + Regions | Owner Info + Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<AclPane
data={paneData.acl}
state={paneStates.acl}
onRefresh={() => refreshPane('acl')}
disabled={anyLoading}
/>
<div className="flex flex-col gap-4">
<AclPane
data={paneData.acl}
state={paneStates.acl}
onRefresh={() => refreshPane('acl')}
disabled={anyLoading}
/>
<RegionsPane
data={paneData.regions}
state={paneStates.regions}
onRefresh={() => refreshPane('regions')}
disabled={anyLoading}
/>
</div>
<div className="flex flex-col gap-4">
<OwnerInfoPane
data={paneData.ownerInfo}
@@ -0,0 +1,74 @@
import { RepeaterPane, NotFetched } from './repeaterPaneShared';
import { cn } from '@/lib/utils';
import type { RepeaterRegionsResponse, PaneState } from '../../types';
export function RegionsPane({
data,
state,
onRefresh,
disabled,
}: {
data: RepeaterRegionsResponse | null;
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
}) {
const headerNote = data?.truncated
? 'List truncated by the radio — showing the first regions only'
: data?.source === 'anon'
? 'Guest view: flood-allowed regions only (log in as admin for the full hierarchy)'
: 'Region hierarchy and flood permissions';
return (
<RepeaterPane
title="Regions"
state={state}
onRefresh={onRefresh}
disabled={disabled}
headerNote={headerNote}
>
{!data ? (
<NotFetched />
) : data.regions.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No regions returned. The repeater may be unreachable, or full region details require admin
access.
</p>
) : (
<div className="space-y-0.5">
{data.regions.map((region, index) => (
<div
key={`${region.depth}-${region.name}-${index}`}
className="flex items-center gap-2 text-sm py-0.5"
style={{ paddingLeft: `${region.depth * 0.9}rem` }}
>
<span className="font-mono truncate">
{region.name === '*' ? ' (all regions)' : region.name}
</span>
{region.is_home && (
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
Home
</span>
)}
<span
className={cn(
'ml-auto shrink-0 text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded',
region.flood_allowed
? 'bg-success/15 text-success'
: 'bg-muted text-muted-foreground'
)}
title={
region.flood_allowed
? 'Flood is allowed for this region'
: 'Flood is blocked for this region'
}
>
{region.flood_allowed ? 'Flood' : 'Blocked'}
</span>
</div>
))}
</div>
)}
</RepeaterPane>
);
}
@@ -13,6 +13,7 @@ import type {
RepeaterAdvertIntervalsResponse,
RepeaterOwnerInfoResponse,
RepeaterLppTelemetryResponse,
RepeaterRegionsResponse,
CommandResponse,
} from '../types';
import {
@@ -41,6 +42,7 @@ interface PaneData {
advertIntervals: RepeaterAdvertIntervalsResponse | null;
ownerInfo: RepeaterOwnerInfoResponse | null;
lppTelemetry: RepeaterLppTelemetryResponse | null;
regions: RepeaterRegionsResponse | null;
}
interface RepeaterDashboardCacheEntry {
@@ -64,6 +66,7 @@ function createInitialPaneStates(): Record<PaneName, PaneState> {
advertIntervals: { ...INITIAL_PANE_STATE },
ownerInfo: { ...INITIAL_PANE_STATE },
lppTelemetry: { ...INITIAL_PANE_STATE },
regions: { ...INITIAL_PANE_STATE },
};
}
@@ -77,6 +80,7 @@ function createInitialPaneData(): PaneData {
advertIntervals: null,
ownerInfo: null,
lppTelemetry: null,
regions: null,
};
}
@@ -107,6 +111,7 @@ function normalizePaneStates(paneStates: Record<PaneName, PaneState>): Record<Pa
advertIntervals: { ...paneStates.advertIntervals, loading: false },
ownerInfo: { ...paneStates.ownerInfo, loading: false },
lppTelemetry: { ...paneStates.lppTelemetry, loading: false },
regions: { ...paneStates.regions, loading: false },
};
}
@@ -174,6 +179,8 @@ function fetchPaneData(publicKey: string, pane: PaneName) {
return api.repeaterOwnerInfo(publicKey);
case 'lppTelemetry':
return api.repeaterLppTelemetry(publicKey);
case 'regions':
return api.repeaterRegions(publicKey);
}
}
@@ -427,6 +434,7 @@ export function useRepeaterDashboard(
'advertIntervals',
'ownerInfo',
'lppTelemetry',
'regions',
];
// Serial execution — parallel calls just queue behind the radio lock anyway
for (const pane of panes) {
+4 -4
View File
@@ -20,8 +20,8 @@ const mockHook: {
radioSettings: null,
advertIntervals: null,
ownerInfo: null,
lppTelemetry: null,
regions: null,
},
paneStates: {
status: { loading: false, attempt: 0, error: null },
@@ -31,8 +31,8 @@ const mockHook: {
radioSettings: { loading: false, attempt: 0, error: null },
advertIntervals: { loading: false, attempt: 0, error: null },
ownerInfo: { loading: false, attempt: 0, error: null },
lppTelemetry: { loading: false, attempt: 0, error: null },
regions: { loading: false, attempt: 0, error: null },
},
consoleHistory: [],
consoleLoading: false,
@@ -151,8 +151,8 @@ describe('RepeaterDashboard', () => {
radioSettings: null,
advertIntervals: null,
ownerInfo: null,
lppTelemetry: null,
regions: null,
};
mockHook.paneStates = {
status: { loading: false, attempt: 0, error: null },
@@ -162,8 +162,8 @@ describe('RepeaterDashboard', () => {
radioSettings: { loading: false, attempt: 0, error: null },
advertIntervals: { loading: false, attempt: 0, error: null },
ownerInfo: { loading: false, attempt: 0, error: null },
lppTelemetry: { loading: false, attempt: 0, error: null },
regions: { loading: false, attempt: 0, error: null },
};
mockHook.consoleHistory = [];
mockHook.consoleLoading = false;
@@ -19,6 +19,7 @@ vi.mock('../api', () => ({
repeaterAdvertIntervals: vi.fn(),
repeaterOwnerInfo: vi.fn(),
repeaterLppTelemetry: vi.fn(),
repeaterRegions: vi.fn(),
sendRepeaterCommand: vi.fn(),
},
}));
@@ -351,6 +352,12 @@ describe('useRepeaterDashboard', () => {
guest_password: null,
});
mockApi.repeaterLppTelemetry.mockResolvedValueOnce({ sensors: [] });
mockApi.repeaterRegions.mockResolvedValueOnce({
regions: [],
raw: null,
truncated: false,
source: 'cli',
});
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
@@ -366,6 +373,7 @@ describe('useRepeaterDashboard', () => {
expect(mockApi.repeaterAdvertIntervals).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterOwnerInfo).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterLppTelemetry).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterRegions).toHaveBeenCalledTimes(1);
});
it('refreshing neighbors fetches node info first', async () => {
+17 -1
View File
@@ -506,6 +506,21 @@ export interface RepeaterOwnerInfoResponse {
guest_password: string | null;
}
export interface RepeaterRegionEntry {
name: string;
depth: number;
flood_allowed: boolean;
is_home: boolean;
}
export interface RepeaterRegionsResponse {
regions: RepeaterRegionEntry[];
raw: string | null;
truncated: boolean;
/** 'cli' = full admin hierarchy; 'anon' = guest flood-allowed names only. */
source: 'cli' | 'anon' | null;
}
export interface LppSensor {
channel: number;
type_name: string;
@@ -536,7 +551,8 @@ export type PaneName =
| 'radioSettings'
| 'advertIntervals'
| 'ownerInfo'
| 'lppTelemetry';
| 'lppTelemetry'
| 'regions';
export interface PaneState {
loading: boolean;