Files
Remote-Terminal-for-MeshCore/frontend/src/hooks/useRepeaterDashboard.ts
T
2026-02-27 14:20:52 -08:00

299 lines
8.6 KiB
TypeScript

import { useState, useCallback, useRef, useEffect } from 'react';
import { api } from '../api';
import { toast } from '../components/ui/sonner';
import type {
Conversation,
PaneName,
PaneState,
RepeaterStatusResponse,
RepeaterNeighborsResponse,
RepeaterAclResponse,
RepeaterRadioSettingsResponse,
RepeaterAdvertIntervalsResponse,
RepeaterOwnerInfoResponse,
RepeaterLppTelemetryResponse,
CommandResponse,
} from '../types';
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 2000;
interface ConsoleEntry {
command: string;
response: string;
timestamp: number;
outgoing: boolean;
}
interface PaneData {
status: RepeaterStatusResponse | null;
neighbors: RepeaterNeighborsResponse | null;
acl: RepeaterAclResponse | null;
radioSettings: RepeaterRadioSettingsResponse | null;
advertIntervals: RepeaterAdvertIntervalsResponse | null;
ownerInfo: RepeaterOwnerInfoResponse | null;
lppTelemetry: RepeaterLppTelemetryResponse | null;
}
const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null };
function createInitialPaneStates(): Record<PaneName, PaneState> {
return {
status: { ...INITIAL_PANE_STATE },
neighbors: { ...INITIAL_PANE_STATE },
acl: { ...INITIAL_PANE_STATE },
radioSettings: { ...INITIAL_PANE_STATE },
advertIntervals: { ...INITIAL_PANE_STATE },
ownerInfo: { ...INITIAL_PANE_STATE },
lppTelemetry: { ...INITIAL_PANE_STATE },
};
}
function createInitialPaneData(): PaneData {
return {
status: null,
neighbors: null,
acl: null,
radioSettings: null,
advertIntervals: null,
ownerInfo: null,
lppTelemetry: null,
};
}
// Maps pane name to the API call
function fetchPaneData(publicKey: string, pane: PaneName) {
switch (pane) {
case 'status':
return api.repeaterStatus(publicKey);
case 'neighbors':
return api.repeaterNeighbors(publicKey);
case 'acl':
return api.repeaterAcl(publicKey);
case 'radioSettings':
return api.repeaterRadioSettings(publicKey);
case 'advertIntervals':
return api.repeaterAdvertIntervals(publicKey);
case 'ownerInfo':
return api.repeaterOwnerInfo(publicKey);
case 'lppTelemetry':
return api.repeaterLppTelemetry(publicKey);
}
}
export interface UseRepeaterDashboardResult {
loggedIn: boolean;
loginLoading: boolean;
loginError: string | null;
paneData: PaneData;
paneStates: Record<PaneName, PaneState>;
consoleHistory: ConsoleEntry[];
consoleLoading: boolean;
login: (password: string) => Promise<void>;
loginAsGuest: () => Promise<void>;
refreshPane: (pane: PaneName) => Promise<void>;
loadAll: () => Promise<void>;
sendConsoleCommand: (command: string) => Promise<void>;
sendAdvert: () => Promise<void>;
rebootRepeater: () => Promise<void>;
syncClock: () => Promise<void>;
}
export function useRepeaterDashboard(
activeConversation: Conversation | null
): UseRepeaterDashboardResult {
const [loggedIn, setLoggedIn] = useState(false);
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [paneData, setPaneData] = useState<PaneData>(createInitialPaneData);
const [paneStates, setPaneStates] =
useState<Record<PaneName, PaneState>>(createInitialPaneStates);
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>([]);
const [consoleLoading, setConsoleLoading] = useState(false);
// Track which conversation we're operating on to avoid stale updates after
// unmount. Initialised from activeConversation because the parent renders
// <RepeaterDashboard key={id}>, so this hook only ever sees one conversation.
const activeIdRef = useRef(activeConversation?.id ?? null);
// Guard against setting state after unmount (retry timers firing late)
const mountedRef = useRef(true);
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
const getPublicKey = useCallback((): string | null => {
if (!activeConversation || activeConversation.type !== 'contact') return null;
return activeConversation.id;
}, [activeConversation]);
const login = useCallback(
async (password: string) => {
const publicKey = getPublicKey();
if (!publicKey) return;
const conversationId = publicKey;
setLoginLoading(true);
setLoginError(null);
try {
await api.repeaterLogin(publicKey, password);
if (activeIdRef.current !== conversationId) return;
setLoggedIn(true);
} catch (err) {
if (activeIdRef.current !== conversationId) return;
const msg = err instanceof Error ? err.message : 'Login failed';
setLoginError(msg);
} finally {
if (activeIdRef.current === conversationId) {
setLoginLoading(false);
}
}
},
[getPublicKey]
);
const loginAsGuest = useCallback(async () => {
await login('');
}, [login]);
const refreshPane = useCallback(
async (pane: PaneName) => {
const publicKey = getPublicKey();
if (!publicKey) return;
const conversationId = publicKey;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
setPaneStates((prev) => ({
...prev,
[pane]: { loading: true, attempt, error: null },
}));
try {
const data = await fetchPaneData(publicKey, pane);
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
setPaneData((prev) => ({ ...prev, [pane]: data }));
setPaneStates((prev) => ({
...prev,
[pane]: { loading: false, attempt, error: null },
}));
return; // Success
} catch (err) {
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
const msg = err instanceof Error ? err.message : 'Request failed';
if (attempt === MAX_RETRIES) {
setPaneStates((prev) => ({
...prev,
[pane]: { loading: false, attempt, error: msg },
}));
toast.error(`Failed to fetch ${pane}`, { description: msg });
} else {
// Wait before retrying
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
}
}
}
},
[getPublicKey]
);
const loadAll = useCallback(async () => {
const panes: PaneName[] = [
'status',
'neighbors',
'acl',
'radioSettings',
'advertIntervals',
'ownerInfo',
'lppTelemetry',
];
// Serial execution — parallel calls just queue behind the radio lock anyway
for (const pane of panes) {
await refreshPane(pane);
}
}, [refreshPane]);
const sendConsoleCommand = useCallback(
async (command: string) => {
const publicKey = getPublicKey();
if (!publicKey) return;
const conversationId = publicKey;
const now = Math.floor(Date.now() / 1000);
// Add outgoing command entry
setConsoleHistory((prev) => [
...prev,
{ command, response: '', timestamp: now, outgoing: true },
]);
setConsoleLoading(true);
try {
const result: CommandResponse = await api.sendRepeaterCommand(publicKey, command);
if (activeIdRef.current !== conversationId) return;
setConsoleHistory((prev) => [
...prev,
{
command,
response: result.response,
timestamp: result.sender_timestamp ?? now,
outgoing: false,
},
]);
} catch (err) {
if (activeIdRef.current !== conversationId) return;
const msg = err instanceof Error ? err.message : 'Command failed';
setConsoleHistory((prev) => [
...prev,
{ command, response: `Error: ${msg}`, timestamp: now, outgoing: false },
]);
} finally {
if (activeIdRef.current === conversationId) {
setConsoleLoading(false);
}
}
},
[getPublicKey]
);
const sendAdvert = useCallback(async () => {
await sendConsoleCommand('advert');
}, [sendConsoleCommand]);
const rebootRepeater = useCallback(async () => {
await sendConsoleCommand('reboot');
}, [sendConsoleCommand]);
const syncClock = useCallback(async () => {
const epoch = Math.floor(Date.now() / 1000);
await sendConsoleCommand(`clock ${epoch}`);
}, [sendConsoleCommand]);
return {
loggedIn,
loginLoading,
loginError,
paneData,
paneStates,
consoleHistory,
consoleLoading,
login,
loginAsGuest,
refreshPane,
loadAll,
sendConsoleCommand,
sendAdvert,
rebootRepeater,
syncClock,
};
}