diff --git a/app/routers/repeaters.py b/app/routers/repeaters.py index e9ee9c9..d822195 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -153,6 +153,20 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse: return response +@router.get( + "/{public_key}/repeater/telemetry-history", + response_model=list[TelemetryHistoryEntry], +) +async def repeater_telemetry_history(public_key: str) -> list[TelemetryHistoryEntry]: + """Return stored telemetry history for a repeater (read-only, no radio access).""" + contact = await _resolve_contact_or_404(public_key) + _require_repeater(contact) + + since = int(time.time()) - 30 * 86400 + rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since) + return [TelemetryHistoryEntry(**row) for row in rows] + + @router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse) async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse: """Fetch CayenneLPP sensor telemetry from a repeater (single attempt, 10s timeout).""" diff --git a/frontend/src/api.ts b/frontend/src/api.ts index fc0a8af..76fb34f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -35,6 +35,7 @@ import type { RepeaterOwnerInfoResponse, RepeaterRadioSettingsResponse, RepeaterStatusResponse, + TelemetryHistoryEntry, StatisticsResponse, TraceResponse, UnreadCounts, @@ -414,6 +415,8 @@ export const api = { fetchJson(`/contacts/${publicKey}/repeater/lpp-telemetry`, { method: 'POST', }), + repeaterTelemetryHistory: (publicKey: string) => + fetchJson(`/contacts/${publicKey}/repeater/telemetry-history`), roomLogin: (publicKey: string, password: string) => fetchJson(`/contacts/${publicKey}/room/login`, { method: 'POST', diff --git a/frontend/src/components/RepeaterDashboard.tsx b/frontend/src/components/RepeaterDashboard.tsx index d34286d..bf8100e 100644 --- a/frontend/src/components/RepeaterDashboard.tsx +++ b/frontend/src/components/RepeaterDashboard.tsx @@ -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([]); + 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 */} - + )} diff --git a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx index caf7952..89c5508 100644 --- a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx +++ b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx @@ -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('battery_volts'); - // statusFetchedAt is used to indicate freshness; suppress unused lint - void statusFetchedAt; const config = METRIC_CONFIG[metric]; diff --git a/frontend/src/test/repeaterDashboard.test.tsx b/frontend/src/test/repeaterDashboard.test.tsx index d62fbf9..e93062d 100644 --- a/frontend/src/test/repeaterDashboard.test.tsx +++ b/frontend/src/test/repeaterDashboard.test.tsx @@ -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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(screen.getByText('1 samples')).toBeInTheDocument(); + }); + }); + }); }); diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 305dafc..6bff4cb 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -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 }); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 26d77c6..b2a9956 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 342acc1..6c3044e 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -1249,8 +1249,8 @@ class TestMigration039: applied = await run_migrations(conn) - assert applied == 11 - assert await get_version(conn) == 49 + assert applied == 12 + assert await get_version(conn) == 50 cursor = await conn.execute( """ @@ -1321,8 +1321,8 @@ class TestMigration039: applied = await run_migrations(conn) - assert applied == 11 - assert await get_version(conn) == 49 + assert applied == 12 + assert await get_version(conn) == 50 cursor = await conn.execute( """ @@ -1388,8 +1388,8 @@ class TestMigration039: applied = await run_migrations(conn) - assert applied == 5 - assert await get_version(conn) == 49 + assert applied == 6 + assert await get_version(conn) == 50 cursor = await conn.execute( """ @@ -1441,8 +1441,8 @@ class TestMigration040: applied = await run_migrations(conn) - assert applied == 10 - assert await get_version(conn) == 49 + assert applied == 11 + assert await get_version(conn) == 50 await conn.execute( """ @@ -1503,8 +1503,8 @@ class TestMigration041: applied = await run_migrations(conn) - assert applied == 9 - assert await get_version(conn) == 49 + assert applied == 10 + assert await get_version(conn) == 50 await conn.execute( """ @@ -1556,8 +1556,8 @@ class TestMigration042: applied = await run_migrations(conn) - assert applied == 8 - assert await get_version(conn) == 49 + assert applied == 9 + assert await get_version(conn) == 50 await conn.execute( """ @@ -1696,8 +1696,8 @@ class TestMigration046: applied = await run_migrations(conn) - assert applied == 4 - assert await get_version(conn) == 49 + assert applied == 5 + assert await get_version(conn) == 50 cursor = await conn.execute( """ @@ -1790,8 +1790,8 @@ class TestMigration047: applied = await run_migrations(conn) - assert applied == 3 - assert await get_version(conn) == 49 + assert applied == 4 + assert await get_version(conn) == 50 cursor = await conn.execute( """ diff --git a/tests/test_repeater_telemetry.py b/tests/test_repeater_telemetry.py index 46c3ecc..4a86579 100644 --- a/tests/test_repeater_telemetry.py +++ b/tests/test_repeater_telemetry.py @@ -137,18 +137,31 @@ class TestRepeaterTelemetryRepository: assert history[0]["data"] == SAMPLE_STATUS -class TestTelemetryHistoryInStatusResponse: - """Tests that history is embedded in the status response (no separate endpoint).""" +class TestTelemetryHistoryEndpoint: + """Tests for the read-only GET telemetry-history endpoint.""" @pytest.mark.asyncio - async def test_history_not_available_as_separate_endpoint(self, _db, client): - """The old GET telemetry-history endpoint should be gone.""" + async def test_returns_history_for_repeater(self, _db, client): await _insert_repeater(KEY_A) + now = int(time.time()) + await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS) + resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history") - assert resp.status_code in (404, 405) + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["data"]["battery_volts"] == 4.15 @pytest.mark.asyncio - async def test_history_endpoint_non_repeater_rejected(self, _db, client): + async def test_returns_empty_list_when_no_history(self, _db, client): + await _insert_repeater(KEY_A) + + resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history") + assert resp.status_code == 200 + assert resp.json() == [] + + @pytest.mark.asyncio + async def test_rejects_non_repeater(self, _db, client): await ContactRepository.upsert( { "public_key": KEY_A, @@ -168,5 +181,9 @@ class TestTelemetryHistoryInStatusResponse: } ) resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history") - # Either 404 (method not found) or 400 (not a repeater) — endpoint is gone - assert resp.status_code in (400, 404, 405) + assert resp.status_code == 400 + + @pytest.mark.asyncio + async def test_returns_404_for_unknown_contact(self, _db, client): + resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history") + assert resp.status_code == 404