mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Preserve repeater values when browsing away
This commit is contained in:
@@ -80,6 +80,28 @@ export function formatAdvertInterval(val: string | null): string {
|
||||
return `${trimmed}h`;
|
||||
}
|
||||
|
||||
function formatFetchedRelative(fetchedAt: number): string {
|
||||
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - fetchedAt) / 1000));
|
||||
|
||||
if (elapsedSeconds < 60) return 'Just now';
|
||||
|
||||
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
|
||||
if (elapsedMinutes < 60) {
|
||||
return `${elapsedMinutes} minute${elapsedMinutes === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
const elapsedHours = Math.floor(elapsedMinutes / 60);
|
||||
return `${elapsedHours} hour${elapsedHours === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
function formatFetchedTime(fetchedAt: number): string {
|
||||
return new Date(fetchedAt).toLocaleTimeString([], {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// --- Generic Pane Wrapper ---
|
||||
|
||||
export function RepeaterPane({
|
||||
@@ -99,10 +121,22 @@ export function RepeaterPane({
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}) {
|
||||
const fetchedAt = state.fetched_at ?? null;
|
||||
|
||||
return (
|
||||
<div className={cn('border border-border rounded-lg overflow-hidden', className)}>
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{fetchedAt && (
|
||||
<p
|
||||
className="text-[11px] text-muted-foreground"
|
||||
title={new Date(fetchedAt).toLocaleString()}
|
||||
>
|
||||
Fetched {formatFetchedTime(fetchedAt)} ({formatFetchedRelative(fetchedAt)})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
const MAX_CACHED_REPEATERS = 20;
|
||||
|
||||
interface ConsoleEntry {
|
||||
command: string;
|
||||
@@ -35,7 +36,15 @@ interface PaneData {
|
||||
lppTelemetry: RepeaterLppTelemetryResponse | null;
|
||||
}
|
||||
|
||||
const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null };
|
||||
interface RepeaterDashboardCacheEntry {
|
||||
loggedIn: boolean;
|
||||
loginError: string | null;
|
||||
paneData: PaneData;
|
||||
paneStates: Record<PaneName, PaneState>;
|
||||
consoleHistory: ConsoleEntry[];
|
||||
}
|
||||
|
||||
const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null, fetched_at: null };
|
||||
|
||||
function createInitialPaneStates(): Record<PaneName, PaneState> {
|
||||
return {
|
||||
@@ -61,6 +70,67 @@ function createInitialPaneData(): PaneData {
|
||||
};
|
||||
}
|
||||
|
||||
const repeaterDashboardCache = new Map<string, RepeaterDashboardCacheEntry>();
|
||||
|
||||
function clonePaneData(data: PaneData): PaneData {
|
||||
return { ...data };
|
||||
}
|
||||
|
||||
function normalizePaneStates(paneStates: Record<PaneName, PaneState>): Record<PaneName, PaneState> {
|
||||
return {
|
||||
status: { ...paneStates.status, loading: false },
|
||||
neighbors: { ...paneStates.neighbors, loading: false },
|
||||
acl: { ...paneStates.acl, loading: false },
|
||||
radioSettings: { ...paneStates.radioSettings, loading: false },
|
||||
advertIntervals: { ...paneStates.advertIntervals, loading: false },
|
||||
ownerInfo: { ...paneStates.ownerInfo, loading: false },
|
||||
lppTelemetry: { ...paneStates.lppTelemetry, loading: false },
|
||||
};
|
||||
}
|
||||
|
||||
function cloneConsoleHistory(consoleHistory: ConsoleEntry[]): ConsoleEntry[] {
|
||||
return consoleHistory.map((entry) => ({ ...entry }));
|
||||
}
|
||||
|
||||
function getCachedState(publicKey: string | null): RepeaterDashboardCacheEntry | null {
|
||||
if (!publicKey) return null;
|
||||
const cached = repeaterDashboardCache.get(publicKey);
|
||||
if (!cached) return null;
|
||||
|
||||
repeaterDashboardCache.delete(publicKey);
|
||||
repeaterDashboardCache.set(publicKey, cached);
|
||||
|
||||
return {
|
||||
loggedIn: cached.loggedIn,
|
||||
loginError: cached.loginError,
|
||||
paneData: clonePaneData(cached.paneData),
|
||||
paneStates: normalizePaneStates(cached.paneStates),
|
||||
consoleHistory: cloneConsoleHistory(cached.consoleHistory),
|
||||
};
|
||||
}
|
||||
|
||||
function cacheState(publicKey: string, entry: RepeaterDashboardCacheEntry) {
|
||||
repeaterDashboardCache.delete(publicKey);
|
||||
repeaterDashboardCache.set(publicKey, {
|
||||
loggedIn: entry.loggedIn,
|
||||
loginError: entry.loginError,
|
||||
paneData: clonePaneData(entry.paneData),
|
||||
paneStates: normalizePaneStates(entry.paneStates),
|
||||
consoleHistory: cloneConsoleHistory(entry.consoleHistory),
|
||||
});
|
||||
|
||||
if (repeaterDashboardCache.size > MAX_CACHED_REPEATERS) {
|
||||
const lruKey = repeaterDashboardCache.keys().next().value as string | undefined;
|
||||
if (lruKey) {
|
||||
repeaterDashboardCache.delete(lruKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resetRepeaterDashboardCacheForTests() {
|
||||
repeaterDashboardCache.clear();
|
||||
}
|
||||
|
||||
// Maps pane name to the API call
|
||||
function fetchPaneData(publicKey: string, pane: PaneName) {
|
||||
switch (pane) {
|
||||
@@ -102,15 +172,24 @@ export interface UseRepeaterDashboardResult {
|
||||
export function useRepeaterDashboard(
|
||||
activeConversation: Conversation | null
|
||||
): UseRepeaterDashboardResult {
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const conversationId =
|
||||
activeConversation && activeConversation.type === 'contact' ? activeConversation.id : null;
|
||||
const cachedState = getCachedState(conversationId);
|
||||
|
||||
const [loggedIn, setLoggedIn] = useState(cachedState?.loggedIn ?? false);
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [loginError, setLoginError] = useState<string | null>(cachedState?.loginError ?? null);
|
||||
|
||||
const [paneData, setPaneData] = useState<PaneData>(createInitialPaneData);
|
||||
const [paneStates, setPaneStates] =
|
||||
useState<Record<PaneName, PaneState>>(createInitialPaneStates);
|
||||
const [paneData, setPaneData] = useState<PaneData>(
|
||||
cachedState?.paneData ?? createInitialPaneData
|
||||
);
|
||||
const [paneStates, setPaneStates] = useState<Record<PaneName, PaneState>>(
|
||||
cachedState?.paneStates ?? createInitialPaneStates
|
||||
);
|
||||
|
||||
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>([]);
|
||||
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>(
|
||||
cachedState?.consoleHistory ?? []
|
||||
);
|
||||
const [consoleLoading, setConsoleLoading] = useState(false);
|
||||
|
||||
// Track which conversation we're operating on to avoid stale updates after
|
||||
@@ -120,6 +199,10 @@ export function useRepeaterDashboard(
|
||||
|
||||
// Guard against setting state after unmount (retry timers firing late)
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
activeIdRef.current = conversationId;
|
||||
}, [conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
@@ -127,6 +210,17 @@ export function useRepeaterDashboard(
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId) return;
|
||||
cacheState(conversationId, {
|
||||
loggedIn,
|
||||
loginError,
|
||||
paneData,
|
||||
paneStates,
|
||||
consoleHistory,
|
||||
});
|
||||
}, [consoleHistory, conversationId, loggedIn, loginError, paneData, paneStates]);
|
||||
|
||||
const getPublicKey = useCallback((): string | null => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return null;
|
||||
return activeConversation.id;
|
||||
@@ -172,7 +266,12 @@ export function useRepeaterDashboard(
|
||||
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: { loading: true, attempt, error: null },
|
||||
[pane]: {
|
||||
loading: true,
|
||||
attempt,
|
||||
error: null,
|
||||
fetched_at: prev[pane].fetched_at ?? null,
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
@@ -182,7 +281,7 @@ export function useRepeaterDashboard(
|
||||
setPaneData((prev) => ({ ...prev, [pane]: data }));
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: { loading: false, attempt, error: null },
|
||||
[pane]: { loading: false, attempt, error: null, fetched_at: Date.now() },
|
||||
}));
|
||||
return; // Success
|
||||
} catch (err) {
|
||||
@@ -193,7 +292,12 @@ export function useRepeaterDashboard(
|
||||
if (attempt === MAX_RETRIES) {
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: { loading: false, attempt, error: msg },
|
||||
[pane]: {
|
||||
loading: false,
|
||||
attempt,
|
||||
error: msg,
|
||||
fetched_at: prev[pane].fetched_at ?? null,
|
||||
},
|
||||
}));
|
||||
toast.error(`Failed to fetch ${pane}`, { description: msg });
|
||||
} else {
|
||||
|
||||
@@ -244,6 +244,20 @@ describe('RepeaterDashboard', () => {
|
||||
expect(screen.getByText('7.5 dB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows fetched time and relative age when pane data has been loaded', () => {
|
||||
mockHook.loggedIn = true;
|
||||
mockHook.paneStates.status = {
|
||||
loading: false,
|
||||
attempt: 1,
|
||||
error: null,
|
||||
fetched_at: Date.now(),
|
||||
};
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/Fetched .*Just now/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders action buttons', () => {
|
||||
mockHook.loggedIn = true;
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { StrictMode, createElement, type ReactNode } from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
||||
import {
|
||||
resetRepeaterDashboardCacheForTests,
|
||||
useRepeaterDashboard,
|
||||
} from '../hooks/useRepeaterDashboard';
|
||||
import type { Conversation } from '../types';
|
||||
|
||||
// Mock the api module
|
||||
@@ -43,6 +46,7 @@ const repeaterConversation: Conversation = {
|
||||
describe('useRepeaterDashboard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetRepeaterDashboardCacheForTests();
|
||||
});
|
||||
|
||||
it('starts with logged out state', () => {
|
||||
@@ -123,6 +127,7 @@ describe('useRepeaterDashboard', () => {
|
||||
expect(result.current.paneData.status).toEqual(statusData);
|
||||
expect(result.current.paneStates.status.loading).toBe(false);
|
||||
expect(result.current.paneStates.status.error).toBe(null);
|
||||
expect(result.current.paneStates.status.fetched_at).toEqual(expect.any(Number));
|
||||
});
|
||||
|
||||
it('refreshPane still issues requests under StrictMode remount probing', async () => {
|
||||
@@ -304,4 +309,38 @@ describe('useRepeaterDashboard', () => {
|
||||
expect(mockApi.repeaterOwnerInfo).toHaveBeenCalledTimes(1);
|
||||
expect(mockApi.repeaterLppTelemetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('restores dashboard state when navigating away and back to the same repeater', async () => {
|
||||
const statusData = { battery_volts: 4.2 };
|
||||
mockApi.repeaterLogin.mockResolvedValueOnce({ status: 'ok' });
|
||||
mockApi.repeaterStatus.mockResolvedValueOnce(statusData);
|
||||
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
|
||||
command: 'ver',
|
||||
response: 'v2.1.0',
|
||||
sender_timestamp: 1000,
|
||||
});
|
||||
|
||||
const firstMount = renderHook(() => useRepeaterDashboard(repeaterConversation));
|
||||
|
||||
await act(async () => {
|
||||
await firstMount.result.current.login('secret');
|
||||
await firstMount.result.current.refreshPane('status');
|
||||
await firstMount.result.current.sendConsoleCommand('ver');
|
||||
});
|
||||
|
||||
expect(firstMount.result.current.loggedIn).toBe(true);
|
||||
expect(firstMount.result.current.paneData.status).toEqual(statusData);
|
||||
expect(firstMount.result.current.consoleHistory).toHaveLength(2);
|
||||
|
||||
firstMount.unmount();
|
||||
|
||||
const secondMount = renderHook(() => useRepeaterDashboard(repeaterConversation));
|
||||
|
||||
expect(secondMount.result.current.loggedIn).toBe(true);
|
||||
expect(secondMount.result.current.loginError).toBe(null);
|
||||
expect(secondMount.result.current.paneData.status).toEqual(statusData);
|
||||
expect(secondMount.result.current.paneStates.status.loading).toBe(false);
|
||||
expect(secondMount.result.current.consoleHistory).toHaveLength(2);
|
||||
expect(secondMount.result.current.consoleHistory[1].response).toBe('v2.1.0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -364,6 +364,7 @@ export interface PaneState {
|
||||
loading: boolean;
|
||||
attempt: number;
|
||||
error: string | null;
|
||||
fetched_at?: number | null;
|
||||
}
|
||||
|
||||
export interface TraceResponse {
|
||||
|
||||
Reference in New Issue
Block a user