mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-06-13 01:44:50 +02:00
299 lines
8.6 KiB
TypeScript
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,
|
|
};
|
|
}
|