Add some tests, make it an actual endpoint (whoops said we didn't need that) and tidy things up a bit

This commit is contained in:
Jack Kingsman
2026-04-02 12:43:42 -07:00
parent 967dd05fad
commit 5f969017f7
9 changed files with 157 additions and 42 deletions

View File

@@ -35,6 +35,7 @@ import type {
RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse,
RepeaterStatusResponse,
TelemetryHistoryEntry,
StatisticsResponse,
TraceResponse,
UnreadCounts,
@@ -414,6 +415,8 @@ export const api = {
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
method: 'POST',
}),
repeaterTelemetryHistory: (publicKey: string) =>
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
roomLogin: (publicKey: string, password: string) =>
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
method: 'POST',

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { api } from '../api';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Bell, Info, Route, Star, Trash2 } from 'lucide-react';
@@ -12,7 +13,13 @@ import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { isValidLocation } from '../utils/pathUtils';
import { ContactStatusInfo } from './ContactStatusInfo';
import type { Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
import type {
Contact,
Conversation,
Favorite,
PathDiscoveryResponse,
TelemetryHistoryEntry,
} from '../types';
import { cn } from '../lib/utils';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
@@ -91,6 +98,24 @@ export function RepeaterDashboard({
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
useRememberedServerPassword('repeater', conversation.id);
// Telemetry history: preload from stored data, refresh from live status
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
useEffect(() => {
if (!loggedIn) return;
api
.repeaterTelemetryHistory(conversation.id)
.then(setTelemetryHistory)
.catch(() => {});
}, [loggedIn, conversation.id]);
// When a live status fetch returns embedded telemetry_history, replace local state
useEffect(() => {
const liveHistory = paneData.status?.telemetry_history;
if (liveHistory && liveHistory.length > 0) {
setTelemetryHistory(liveHistory);
}
}, [paneData.status?.telemetry_history]);
const isFav = isFavorite(favorites, 'contact', conversation.id);
const handleRepeaterLogin = async (nextPassword: string) => {
@@ -357,10 +382,7 @@ export function RepeaterDashboard({
/>
{/* Telemetry history chart — full width, below console */}
<TelemetryHistoryPane
entries={paneData.status?.telemetry_history ?? []}
statusFetchedAt={paneStates.status.fetched_at}
/>
<TelemetryHistoryPane entries={telemetryHistory} />
</div>
)}
</div>

View File

@@ -47,16 +47,8 @@ function formatUptime(seconds: number): string {
return `${(seconds / 86400).toFixed(1)}d`;
}
export function TelemetryHistoryPane({
entries,
statusFetchedAt,
}: {
entries: TelemetryHistoryEntry[];
statusFetchedAt?: number | null;
}) {
export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEntry[] }) {
const [metric, setMetric] = useState<Metric>('battery_volts');
// statusFetchedAt is used to indicate freshness; suppress unused lint
void statusFetchedAt;
const config = METRIC_CONFIG[metric];

View File

@@ -51,9 +51,10 @@ vi.mock('../hooks/useRepeaterDashboard', () => ({
useRepeaterDashboard: () => mockHook,
}));
// Mock api module
// Mock api module (TelemetryHistoryPane fetches on mount)
vi.mock('../api', () => ({
api: {
repeaterTelemetryHistory: vi.fn().mockResolvedValue([]),
setContactRoutingOverride: vi.fn().mockResolvedValue({ status: 'ok' }),
},
}));
@@ -642,4 +643,61 @@ describe('RepeaterDashboard', () => {
overrideSpy.mockRestore();
});
});
describe('telemetry history', () => {
it('loads telemetry history on mount when logged in', async () => {
const { api } = await import('../api');
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
await waitFor(() => {
expect(api.repeaterTelemetryHistory).toHaveBeenCalledWith(REPEATER_KEY);
});
});
it('shows telemetry history pane in logged-in view even before status fetch', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Telemetry History')).toBeInTheDocument();
expect(screen.getByText('0 samples')).toBeInTheDocument();
});
it('updates history from live status fetch', async () => {
const { api } = await import('../api');
const historySpy = vi.mocked(api.repeaterTelemetryHistory);
const liveEntry = { timestamp: 1700000000, data: { battery_volts: 4.2 } };
historySpy.mockResolvedValue([]);
mockHook.loggedIn = true;
mockHook.paneData.status = {
battery_volts: 4.2,
tx_queue_len: 0,
noise_floor_dbm: -120,
last_rssi_dbm: -85,
last_snr_db: 7.5,
packets_received: 100,
packets_sent: 50,
airtime_seconds: 600,
rx_airtime_seconds: 1200,
uptime_seconds: 86400,
sent_flood: 10,
sent_direct: 40,
recv_flood: 30,
recv_direct: 70,
flood_dups: 1,
direct_dups: 0,
full_events: 0,
telemetry_history: [liveEntry],
};
render(<RepeaterDashboard {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('1 samples')).toBeInTheDocument();
});
});
});
});

View File

@@ -616,10 +616,10 @@ describe('SettingsModal', () => {
openDatabaseSection();
expect(
screen.getByText(/remove packet-analysis availability for those historical messages/i)
screen.getByText(/removes packet-analysis availability for those messages/i)
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' }));
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Packets' }));
await waitFor(() => {
expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true });

View File

@@ -235,6 +235,15 @@ export interface ChannelTopSender {
message_count: number;
}
export interface BulkCreateHashtagChannelsResult {
created_channels: Channel[];
existing_count: number;
invalid_names: string[];
decrypt_started: boolean;
decrypt_total_packets: number;
message: string;
}
export interface ChannelDetail {
channel: Channel;
message_counts: ChannelMessageCounts;