mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 01:03:34 +02:00
Add repeater region display. Closes #309.
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user