mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 17:23:05 +02:00
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:
@@ -153,6 +153,20 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
|||||||
return response
|
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)
|
@router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
||||||
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||||
"""Fetch CayenneLPP sensor telemetry from a repeater (single attempt, 10s timeout)."""
|
"""Fetch CayenneLPP sensor telemetry from a repeater (single attempt, 10s timeout)."""
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import type {
|
|||||||
RepeaterOwnerInfoResponse,
|
RepeaterOwnerInfoResponse,
|
||||||
RepeaterRadioSettingsResponse,
|
RepeaterRadioSettingsResponse,
|
||||||
RepeaterStatusResponse,
|
RepeaterStatusResponse,
|
||||||
|
TelemetryHistoryEntry,
|
||||||
StatisticsResponse,
|
StatisticsResponse,
|
||||||
TraceResponse,
|
TraceResponse,
|
||||||
UnreadCounts,
|
UnreadCounts,
|
||||||
@@ -414,6 +415,8 @@ export const api = {
|
|||||||
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
|
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}),
|
}),
|
||||||
|
repeaterTelemetryHistory: (publicKey: string) =>
|
||||||
|
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
|
||||||
roomLogin: (publicKey: string, password: string) =>
|
roomLogin: (publicKey: string, password: string) =>
|
||||||
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
|
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
import { api } from '../api';
|
||||||
import { toast } from './ui/sonner';
|
import { toast } from './ui/sonner';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Bell, Info, Route, Star, Trash2 } from 'lucide-react';
|
import { Bell, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||||
@@ -12,7 +13,13 @@ import { isFavorite } from '../utils/favorites';
|
|||||||
import { handleKeyboardActivate } from '../utils/a11y';
|
import { handleKeyboardActivate } from '../utils/a11y';
|
||||||
import { isValidLocation } from '../utils/pathUtils';
|
import { isValidLocation } from '../utils/pathUtils';
|
||||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
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 { cn } from '../lib/utils';
|
||||||
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
||||||
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
|
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
|
||||||
@@ -91,6 +98,24 @@ export function RepeaterDashboard({
|
|||||||
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
|
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
|
||||||
useRememberedServerPassword('repeater', conversation.id);
|
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 isFav = isFavorite(favorites, 'contact', conversation.id);
|
||||||
|
|
||||||
const handleRepeaterLogin = async (nextPassword: string) => {
|
const handleRepeaterLogin = async (nextPassword: string) => {
|
||||||
@@ -357,10 +382,7 @@ export function RepeaterDashboard({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Telemetry history chart — full width, below console */}
|
{/* Telemetry history chart — full width, below console */}
|
||||||
<TelemetryHistoryPane
|
<TelemetryHistoryPane entries={telemetryHistory} />
|
||||||
entries={paneData.status?.telemetry_history ?? []}
|
|
||||||
statusFetchedAt={paneStates.status.fetched_at}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,16 +47,8 @@ function formatUptime(seconds: number): string {
|
|||||||
return `${(seconds / 86400).toFixed(1)}d`;
|
return `${(seconds / 86400).toFixed(1)}d`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TelemetryHistoryPane({
|
export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEntry[] }) {
|
||||||
entries,
|
|
||||||
statusFetchedAt,
|
|
||||||
}: {
|
|
||||||
entries: TelemetryHistoryEntry[];
|
|
||||||
statusFetchedAt?: number | null;
|
|
||||||
}) {
|
|
||||||
const [metric, setMetric] = useState<Metric>('battery_volts');
|
const [metric, setMetric] = useState<Metric>('battery_volts');
|
||||||
// statusFetchedAt is used to indicate freshness; suppress unused lint
|
|
||||||
void statusFetchedAt;
|
|
||||||
|
|
||||||
const config = METRIC_CONFIG[metric];
|
const config = METRIC_CONFIG[metric];
|
||||||
|
|
||||||
|
|||||||
@@ -51,9 +51,10 @@ vi.mock('../hooks/useRepeaterDashboard', () => ({
|
|||||||
useRepeaterDashboard: () => mockHook,
|
useRepeaterDashboard: () => mockHook,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock api module
|
// Mock api module (TelemetryHistoryPane fetches on mount)
|
||||||
vi.mock('../api', () => ({
|
vi.mock('../api', () => ({
|
||||||
api: {
|
api: {
|
||||||
|
repeaterTelemetryHistory: vi.fn().mockResolvedValue([]),
|
||||||
setContactRoutingOverride: vi.fn().mockResolvedValue({ status: 'ok' }),
|
setContactRoutingOverride: vi.fn().mockResolvedValue({ status: 'ok' }),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -642,4 +643,61 @@ describe('RepeaterDashboard', () => {
|
|||||||
overrideSpy.mockRestore();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -616,10 +616,10 @@ describe('SettingsModal', () => {
|
|||||||
openDatabaseSection();
|
openDatabaseSection();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/remove packet-analysis availability for those historical messages/i)
|
screen.getByText(/removes packet-analysis availability for those messages/i)
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Packets' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true });
|
expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true });
|
||||||
|
|||||||
@@ -235,6 +235,15 @@ export interface ChannelTopSender {
|
|||||||
message_count: number;
|
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 {
|
export interface ChannelDetail {
|
||||||
channel: Channel;
|
channel: Channel;
|
||||||
message_counts: ChannelMessageCounts;
|
message_counts: ChannelMessageCounts;
|
||||||
|
|||||||
+16
-16
@@ -1249,8 +1249,8 @@ class TestMigration039:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 11
|
assert applied == 12
|
||||||
assert await get_version(conn) == 49
|
assert await get_version(conn) == 50
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1321,8 +1321,8 @@ class TestMigration039:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 11
|
assert applied == 12
|
||||||
assert await get_version(conn) == 49
|
assert await get_version(conn) == 50
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1388,8 +1388,8 @@ class TestMigration039:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 5
|
assert applied == 6
|
||||||
assert await get_version(conn) == 49
|
assert await get_version(conn) == 50
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1441,8 +1441,8 @@ class TestMigration040:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 10
|
assert applied == 11
|
||||||
assert await get_version(conn) == 49
|
assert await get_version(conn) == 50
|
||||||
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1503,8 +1503,8 @@ class TestMigration041:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 9
|
assert applied == 10
|
||||||
assert await get_version(conn) == 49
|
assert await get_version(conn) == 50
|
||||||
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1556,8 +1556,8 @@ class TestMigration042:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 8
|
assert applied == 9
|
||||||
assert await get_version(conn) == 49
|
assert await get_version(conn) == 50
|
||||||
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1696,8 +1696,8 @@ class TestMigration046:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 4
|
assert applied == 5
|
||||||
assert await get_version(conn) == 49
|
assert await get_version(conn) == 50
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1790,8 +1790,8 @@ class TestMigration047:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 3
|
assert applied == 4
|
||||||
assert await get_version(conn) == 49
|
assert await get_version(conn) == 50
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -137,18 +137,31 @@ class TestRepeaterTelemetryRepository:
|
|||||||
assert history[0]["data"] == SAMPLE_STATUS
|
assert history[0]["data"] == SAMPLE_STATUS
|
||||||
|
|
||||||
|
|
||||||
class TestTelemetryHistoryInStatusResponse:
|
class TestTelemetryHistoryEndpoint:
|
||||||
"""Tests that history is embedded in the status response (no separate endpoint)."""
|
"""Tests for the read-only GET telemetry-history endpoint."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_history_not_available_as_separate_endpoint(self, _db, client):
|
async def test_returns_history_for_repeater(self, _db, client):
|
||||||
"""The old GET telemetry-history endpoint should be gone."""
|
|
||||||
await _insert_repeater(KEY_A)
|
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")
|
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
|
@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(
|
await ContactRepository.upsert(
|
||||||
{
|
{
|
||||||
"public_key": KEY_A,
|
"public_key": KEY_A,
|
||||||
@@ -168,5 +181,9 @@ class TestTelemetryHistoryInStatusResponse:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
|
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 == 400
|
||||||
assert resp.status_code in (400, 404, 405)
|
|
||||||
|
@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
|
||||||
|
|||||||
Reference in New Issue
Block a user