Compare commits

...

18 Commits

Author SHA1 Message Date
Jack Kingsman 2b5937b9e9 Always show on logged-in pane 2026-04-01 20:45:10 -07:00
Jack Kingsman 2bef62dd87 Show telemetry history within repeater view on load 2026-04-01 19:35:55 -07:00
Jack Kingsman 1d4e25d97c Add delete to not depend on FK 2026-04-01 17:29:12 -07:00
Jack Kingsman dc804d4646 Minor comment correction 2026-04-01 17:24:08 -07:00
Jack Kingsman 2d5024de8f Remove statusFetchedAt unused prop 2026-04-01 17:18:30 -07:00
Jack Kingsman 18f4abcb71 Update migrations to account for my new ones 2026-04-01 17:16:42 -07:00
Gnome Adrift 771e809c11 Prune telemetry entries, remove uplot comments, format code 2026-04-01 13:02:02 -07:00
Gnome Adrift 7ded8e1e71 Oops, remove drop table command in migration :/ 2026-04-01 12:34:23 -07:00
Gnome Adrift 4c9a2273e4 Remove reference to tracking opt-in from database migration 2026-04-01 11:59:57 -07:00
Gnome Adrift 7cad06399c Merge branch 'main' of github.com:jkingsman/Remote-Terminal-for-MeshCore into gnomeadrift/repeater_telemetry_history 2026-04-01 11:55:33 -07:00
Gnome Adrift 04c8ccfa45 Remove automatic telemetry querying, remove battery pane, add telemetry history pane 2026-04-01 11:54:39 -07:00
Jack Kingsman b4f3d1f14c Add additional info to debug endpoint. Closes #142. 2026-04-01 11:31:20 -07:00
Jack Kingsman 416166b07c Add system arch data to debug output 2026-03-31 23:09:12 -07:00
Gnome Adrift 7ba61ef01d Merge branch 'main' of github.com:maplemesh/Remote-Terminal-for-MeshCore into gnomeadrift/repeater_telemetry_history 2026-03-31 09:11:49 -07:00
Gnome Adrift cd8382f9fb Fix for telemetry polling 2026-03-30 11:38:05 -07:00
Gnome Adrift 8e48e1e817 Merge branch 'main' of github.com:maplemesh/Remote-Terminal-for-MeshCore into gnomeadrift/repeater_telemetry_history 2026-03-30 10:31:28 -07:00
Gnome Adrift c393e8c03e Make battery history update when fetching telemetry 2026-03-30 10:07:20 -07:00
Gnome Adrift 7f7e8cacd1 First draft of repeater telemetry feature 2026-03-29 06:14:14 -07:00
18 changed files with 663 additions and 23 deletions
+29
View File
@@ -367,6 +367,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 47)
applied += 1
# Migration 49: Repeater telemetry history table
if version < 49:
logger.info("Applying migration 49: repeater telemetry history")
await _migrate_049_repeater_telemetry_history(conn)
await set_version(conn, 49)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -2909,3 +2916,25 @@ async def _migrate_047_add_statistics_indexes(conn: aiosqlite.Connection) -> Non
"""
)
await conn.commit()
async def _migrate_049_repeater_telemetry_history(conn: aiosqlite.Connection) -> None:
"""Create repeater_telemetry_history table for JSON-blob telemetry snapshots."""
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
timestamp INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_repeater_telemetry_pk_ts
ON repeater_telemetry_history (public_key, timestamp)
"""
)
await conn.commit()
+8
View File
@@ -530,6 +530,9 @@ class RepeaterStatusResponse(BaseModel):
flood_dups: int = Field(description="Duplicate flood packets")
direct_dups: int = Field(description="Duplicate direct packets")
full_events: int = Field(description="Full event queue count")
telemetry_history: list["TelemetryHistoryEntry"] = Field(
default_factory=list, description="Recent telemetry history snapshots"
)
class RepeaterNodeInfoResponse(BaseModel):
@@ -914,3 +917,8 @@ class StatisticsResponse(BaseModel):
known_channels_active: ContactActivityCounts
path_hash_width_24h: PathHashWidthStats
noise_floor_24h: NoiseFloorHistoryStats
class TelemetryHistoryEntry(BaseModel):
timestamp: int
data: dict
+2
View File
@@ -8,6 +8,7 @@ from app.repository.contacts import (
from app.repository.fanout import FanoutConfigRepository
from app.repository.messages import MessageRepository
from app.repository.raw_packets import RawPacketRepository
from app.repository.repeater_telemetry import RepeaterTelemetryRepository
from app.repository.settings import AppSettingsRepository, StatisticsRepository
__all__ = [
@@ -20,5 +21,6 @@ __all__ = [
"FanoutConfigRepository",
"MessageRepository",
"RawPacketRepository",
"RepeaterTelemetryRepository",
"StatisticsRepository",
]
+3
View File
@@ -398,6 +398,9 @@ class ContactRepository:
await db.conn.execute(
"DELETE FROM contact_advert_paths WHERE public_key = ?", (normalized,)
)
await db.conn.execute(
"DELETE FROM repeater_telemetry_history WHERE public_key = ?", (normalized,)
)
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (normalized,))
await db.conn.commit()
+75
View File
@@ -0,0 +1,75 @@
import json
import logging
import time
from app.database import db
logger = logging.getLogger(__name__)
# Maximum age for telemetry history entries (30 days)
_MAX_AGE_SECONDS = 30 * 86400
# Maximum entries to keep per repeater (sanity cap)
_MAX_ENTRIES_PER_REPEATER = 1000
class RepeaterTelemetryRepository:
@staticmethod
async def record(
public_key: str,
timestamp: int,
data: dict,
) -> None:
"""Insert a telemetry history row and prune stale entries."""
await db.conn.execute(
"""
INSERT INTO repeater_telemetry_history
(public_key, timestamp, data)
VALUES (?, ?, ?)
""",
(public_key, timestamp, json.dumps(data)),
)
# Prune entries older than 30 days
cutoff = int(time.time()) - _MAX_AGE_SECONDS
await db.conn.execute(
"DELETE FROM repeater_telemetry_history WHERE public_key = ? AND timestamp < ?",
(public_key, cutoff),
)
# Cap at _MAX_ENTRIES_PER_REPEATER (keep newest)
await db.conn.execute(
"""
DELETE FROM repeater_telemetry_history
WHERE public_key = ? AND id NOT IN (
SELECT id FROM repeater_telemetry_history
WHERE public_key = ?
ORDER BY timestamp DESC
LIMIT ?
)
""",
(public_key, public_key, _MAX_ENTRIES_PER_REPEATER),
)
await db.conn.commit()
@staticmethod
async def get_history(public_key: str, since_timestamp: int) -> list[dict]:
"""Return telemetry rows for a repeater since a given timestamp, ordered ASC."""
cursor = await db.conn.execute(
"""
SELECT timestamp, data
FROM repeater_telemetry_history
WHERE public_key = ? AND timestamp >= ?
ORDER BY timestamp ASC
""",
(public_key, since_timestamp),
)
rows = await cursor.fetchall()
return [
{
"timestamp": row["timestamp"],
"data": json.loads(row["data"]),
}
for row in rows
]
+54 -1
View File
@@ -1,5 +1,8 @@
import hashlib
import logging
import os
import platform
import struct
import sys
from datetime import datetime, timezone
from typing import Any
@@ -9,8 +12,9 @@ from meshcore import EventType
from pydantic import BaseModel, Field
from app.config import get_recent_log_lines, settings
from app.models import AppSettings
from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
from app.repository import MessageRepository, StatisticsRepository
from app.repository import AppSettingsRepository, MessageRepository, StatisticsRepository
from app.routers.health import HealthResponse, build_health_data
from app.services.radio_runtime import radio_runtime
from app.version_info import get_app_build_info, git_output
@@ -34,6 +38,13 @@ LOG_COPY_BOUNDARY_PREFIX = [
]
class DebugSystemInfo(BaseModel):
os: str
arch: str
arch_bits: int
total_ram_mb: int
class DebugApplicationInfo(BaseModel):
version: str
version_source: str
@@ -93,16 +104,44 @@ class DebugDatabaseInfo(BaseModel):
total_outgoing: int
class DebugAppSettings(BaseModel):
max_radio_contacts: int
auto_decrypt_dm_on_advert: bool
advert_interval: int
flood_scope: str
blocked_keys_count: int
blocked_names_count: int
class DebugSnapshotResponse(BaseModel):
captured_at: str
system: DebugSystemInfo
application: DebugApplicationInfo
health: HealthResponse
settings: DebugAppSettings
runtime: DebugRuntimeInfo
database: DebugDatabaseInfo
radio_probe: DebugRadioProbe
logs: list[str]
def _build_system_info() -> DebugSystemInfo:
try:
# os.sysconf is available on Linux/macOS
page_size = os.sysconf("SC_PAGE_SIZE")
page_count = os.sysconf("SC_PHYS_PAGES")
total_ram_mb = (page_size * page_count) // (1024 * 1024)
except (AttributeError, ValueError, OSError):
total_ram_mb = 0
return DebugSystemInfo(
os=f"{platform.system()} {platform.release()}",
arch=platform.machine(),
arch_bits=struct.calcsize("P") * 8,
total_ram_mb=total_ram_mb,
)
def _build_application_info() -> DebugApplicationInfo:
build_info = get_app_build_info()
dirty_output = git_output("status", "--porcelain")
@@ -158,6 +197,17 @@ def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None:
return None
def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings:
return DebugAppSettings(
max_radio_contacts=app_settings.max_radio_contacts,
auto_decrypt_dm_on_advert=app_settings.auto_decrypt_dm_on_advert,
advert_interval=app_settings.advert_interval,
flood_scope=app_settings.flood_scope,
blocked_keys_count=len(app_settings.blocked_keys),
blocked_names_count=len(app_settings.blocked_names),
)
async def _build_contact_audit(
observed_contacts_payload: dict[str, dict[str, Any]],
) -> DebugContactAudit:
@@ -265,6 +315,7 @@ async def _probe_radio() -> DebugRadioProbe:
async def debug_support_snapshot() -> DebugSnapshotResponse:
"""Return a support/debug snapshot with recent logs and live radio state."""
health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info)
app_settings = await AppSettingsRepository.get()
message_totals = await StatisticsRepository.get_database_message_totals()
radio_probe = await _probe_radio()
channels_with_incoming_messages = (
@@ -272,8 +323,10 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
)
return DebugSnapshotResponse(
captured_at=datetime.now(timezone.utc).isoformat(),
system=_build_system_info(),
application=_build_application_info(),
health=HealthResponse(**health_data),
settings=_build_debug_app_settings(app_settings),
runtime=DebugRuntimeInfo(
connection_info=radio_runtime.connection_info,
connection_desired=radio_runtime.connection_desired,
+37 -2
View File
@@ -1,4 +1,5 @@
import logging
import time
from fastapi import APIRouter, HTTPException
@@ -21,8 +22,9 @@ from app.models import (
RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse,
RepeaterStatusResponse,
TelemetryHistoryEntry,
)
from app.repository import ContactRepository
from app.repository import ContactRepository, RepeaterTelemetryRepository
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
from app.routers.server_control import (
batch_cli_fetch,
@@ -108,7 +110,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
if status is None:
raise HTTPException(status_code=504, detail="No status response from repeater")
return RepeaterStatusResponse(
response = RepeaterStatusResponse(
battery_volts=status.get("bat", 0) / 1000.0,
tx_queue_len=status.get("tx_queue_len", 0),
noise_floor_dbm=status.get("noise_floor", 0),
@@ -128,6 +130,39 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
full_events=status.get("full_evts", 0),
)
# Record to telemetry history as a JSON blob (best-effort)
now = int(time.time())
status_dict = response.model_dump(exclude={"telemetry_history"})
try:
await RepeaterTelemetryRepository.record(
public_key=contact.public_key,
timestamp=now,
data=status_dict,
)
except Exception as e:
logger.warning("Failed to record telemetry history: %s", e)
# Fetch recent history and embed in response
try:
since = now - 30 * 86400 # last 30 days
rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since)
response.telemetry_history = [TelemetryHistoryEntry(**row) for row in rows]
except Exception as e:
logger.warning("Failed to fetch telemetry history: %s", e)
return response
@router.get("/{public_key}/repeater/telemetry-history")
async def repeater_telemetry_history(public_key: str) -> list[TelemetryHistoryEntry]:
"""Return stored telemetry history for a repeater (no radio command needed)."""
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:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "remoteterm-meshcore-frontend",
"version": "3.6.2",
"version": "3.6.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "remoteterm-meshcore-frontend",
"version": "3.6.2",
"version": "3.6.3",
"dependencies": {
"@codemirror/lang-python": "^6.2.1",
"@codemirror/theme-one-dark": "^6.1.3",
+3
View File
@@ -35,6 +35,7 @@ import type {
RepeaterRadioSettingsResponse,
RepeaterStatusResponse,
StatisticsResponse,
TelemetryHistoryEntry,
TraceResponse,
UnreadCounts,
} from './types';
@@ -374,6 +375,8 @@ export const api = {
fetchJson<RepeaterStatusResponse>(`/contacts/${publicKey}/repeater/status`, {
method: 'POST',
}),
repeaterTelemetryHistory: (publicKey: string) =>
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
repeaterNeighbors: (publicKey: string) =>
fetchJson<RepeaterNeighborsResponse>(`/contacts/${publicKey}/repeater/neighbors`, {
method: 'POST',
+22 -2
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { api } from '../api';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Bell, Route, Star, Trash2 } from 'lucide-react';
@@ -12,7 +13,7 @@ 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';
@@ -23,6 +24,7 @@ import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
import { ActionsPane } from './repeater/RepeaterActionsPane';
import { ConsolePane } from './repeater/RepeaterConsolePane';
import { TelemetryHistoryPane } from './repeater/RepeaterTelemetryHistoryPane';
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
@@ -64,6 +66,7 @@ export function RepeaterDashboard({
onDeleteContact,
}: RepeaterDashboardProps) {
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
const hasAdvertLocation = isValidLocation(contact?.lat ?? null, contact?.lon ?? null);
const {
@@ -88,7 +91,21 @@ export function RepeaterDashboard({
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
useRememberedServerPassword('repeater', conversation.id);
// Auto-fetch stored telemetry history from DB (no mesh traffic)
useEffect(() => {
api.repeaterTelemetryHistory(conversation.id).then(setTelemetryHistory).catch(() => {});
}, [conversation.id]);
// Refresh when a live status fetch returns newer data
const statusHistory = paneData.status?.telemetry_history;
useEffect(() => {
if (statusHistory && statusHistory.length > 0) {
setTelemetryHistory(statusHistory);
}
}, [statusHistory]);
const isFav = isFavorite(favorites, 'contact', conversation.id);
const handleRepeaterLogin = async (nextPassword: string) => {
await login(nextPassword);
persistAfterLogin(nextPassword);
@@ -336,6 +353,9 @@ export function RepeaterDashboard({
loading={consoleLoading}
onSend={sendConsoleCommand}
/>
{/* Telemetry history chart — full width, below console */}
<TelemetryHistoryPane entries={telemetryHistory} />
</div>
)}
</div>
@@ -0,0 +1,167 @@
import { useState, useMemo } from 'react';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
} from 'recharts';
import { cn } from '@/lib/utils';
import type { TelemetryHistoryEntry } from '../../types';
type Metric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds';
const METRIC_CONFIG: Record<Metric, { label: string; unit: string; color: string }> = {
battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' },
noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' },
packets: { label: 'Packets', unit: '', color: '#0ea5e9' },
uptime_seconds: { label: 'Uptime', unit: 's', color: '#f59e0b' },
};
const TOOLTIP_STYLE = {
contentStyle: {
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: '11px',
color: 'hsl(var(--popover-foreground))',
},
itemStyle: { color: 'hsl(var(--popover-foreground))' },
labelStyle: { color: 'hsl(var(--muted-foreground))' },
} as const;
function formatTime(ts: number): string {
return new Date(ts * 1000).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function formatUptime(seconds: number): string {
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`;
return `${(seconds / 86400).toFixed(1)}d`;
}
export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEntry[] }) {
const [metric, setMetric] = useState<Metric>('battery_volts');
const config = METRIC_CONFIG[metric];
const chartData = useMemo(() => {
return entries.map((e) => {
const d = e.data;
return {
timestamp: e.timestamp,
battery_volts: d.battery_volts,
noise_floor_dbm: d.noise_floor_dbm,
packets_received: d.packets_received,
packets_sent: d.packets_sent,
uptime_seconds: d.uptime_seconds,
};
});
}, [entries]);
const dataKeys = metric === 'packets' ? ['packets_received', 'packets_sent'] : [metric];
return (
<div className="border border-border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium">Telemetry History</h3>
<span className="text-[10px] text-muted-foreground">{entries.length} samples</span>
</div>
<div className="p-3">
{/* Metric selector */}
<div className="flex gap-1 mb-2">
{(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => (
<button
key={m}
type="button"
onClick={() => setMetric(m)}
className={cn(
'text-[11px] px-2 py-0.5 rounded transition-colors',
metric === m
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
{METRIC_CONFIG[m].label}
</button>
))}
</div>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No history yet. Fetch status above to record data points.
</p>
) : (
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="timestamp"
type="number"
domain={['dataMin', 'dataMax']}
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
tickFormatter={formatTime}
/>
<YAxis
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => (metric === 'uptime_seconds' ? formatUptime(v) : `${v}`)}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{
stroke: 'hsl(var(--muted-foreground))',
strokeWidth: 1,
strokeDasharray: '3 3',
}}
labelFormatter={(ts) => formatTime(Number(ts))}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(value: any, name: any) => {
const numVal = typeof value === 'number' ? value : Number(value);
const display = metric === 'uptime_seconds' ? formatUptime(numVal) : `${value}`;
const suffix =
metric === 'uptime_seconds' ? '' : config.unit ? ` ${config.unit}` : '';
const label =
metric === 'packets'
? name === 'packets_received'
? 'Received'
: 'Sent'
: config.label;
return [`${display}${suffix}`, label];
}}
/>
{dataKeys.map((key, i) => (
<Area
key={key}
type="linear"
dataKey={key}
stroke={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
fill={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
fillOpacity={0.15}
strokeWidth={1.5}
dot={false}
activeDot={{
r: 4,
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
strokeWidth: 2,
stroke: 'hsl(var(--popover))',
}}
/>
))}
</AreaChart>
</ResponsiveContainer>
)}
</div>
</div>
);
}
@@ -51,6 +51,14 @@ vi.mock('../hooks/useRepeaterDashboard', () => ({
useRepeaterDashboard: () => mockHook,
}));
// Mock api module (used by routing override tests + telemetry history fetch on mount)
vi.mock('../api', () => ({
api: {
setContactRoutingOverride: vi.fn().mockResolvedValue({ status: 'ok' }),
repeaterTelemetryHistory: vi.fn().mockResolvedValue([]),
},
}));
// Mock sonner toast
vi.mock('../components/ui/sonner', () => ({
toast: {
@@ -418,6 +426,7 @@ describe('RepeaterDashboard', () => {
flood_dups: 1,
direct_dups: 0,
full_events: 0,
telemetry_history: [],
};
render(<RepeaterDashboard {...defaultProps} />);
+16
View File
@@ -7,3 +7,19 @@ class ResizeObserver {
}
globalThis.ResizeObserver = ResizeObserver;
// Several components call matchMedia at import time for responsive detection
if (typeof globalThis.matchMedia === 'undefined') {
Object.defineProperty(globalThis, 'matchMedia', {
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
+6
View File
@@ -405,6 +405,7 @@ export interface RepeaterStatusResponse {
flood_dups: number;
direct_dups: number;
full_events: number;
telemetry_history: TelemetryHistoryEntry[];
}
export interface RepeaterNeighborsResponse {
@@ -468,6 +469,11 @@ export interface PaneState {
fetched_at?: number | null;
}
export interface TelemetryHistoryEntry {
timestamp: number;
data: Record<string, number>;
}
export interface TraceResponse {
remote_snr: number | null;
local_snr: number | null;
+41
View File
@@ -213,6 +213,47 @@ class TestDebugEndpoint:
assert response.status_code == 200
@pytest.mark.asyncio
async def test_support_snapshot_includes_persisted_app_settings(self, test_db, client):
"""Debug snapshot should expose the stored app settings row."""
pub_key = "ab" * 32
await _insert_contact(pub_key, "Alice")
response = await client.patch(
"/api/settings",
json={
"max_radio_contacts": 321,
"auto_decrypt_dm_on_advert": True,
"sidebar_sort_order": "alpha",
"advert_interval": 7200,
"flood_scope": "US-CA",
"blocked_keys": [pub_key],
"blocked_names": ["Mallory"],
},
)
assert response.status_code == 200
response = await client.post(
"/api/settings/favorites/toggle",
json={"type": "contact", "id": pub_key},
)
assert response.status_code == 200
response = await client.get("/api/debug")
assert response.status_code == 200
payload = response.json()
assert payload["settings"]["max_radio_contacts"] == 321
assert payload["settings"]["auto_decrypt_dm_on_advert"] is True
assert payload["settings"]["advert_interval"] == 7200
assert payload["settings"]["flood_scope"] == "#US-CA"
assert payload["settings"]["blocked_keys_count"] == 1
assert payload["settings"]["blocked_names_count"] == 1
assert "favorites" not in payload["settings"]
assert "blocked_keys" not in payload["settings"]
assert "blocked_names" not in payload["settings"]
assert "sidebar_sort_order" not in payload["settings"]
class TestRadioDisconnectedHandler:
"""Test that RadioDisconnectedError maps to 503."""
+16 -16
View File
@@ -1247,8 +1247,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 9
assert await get_version(conn) == 47
assert applied == 10
assert await get_version(conn) == 49
cursor = await conn.execute(
"""
@@ -1319,8 +1319,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 9
assert await get_version(conn) == 47
assert applied == 10
assert await get_version(conn) == 49
cursor = await conn.execute(
"""
@@ -1386,8 +1386,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 3
assert await get_version(conn) == 47
assert applied == 4
assert await get_version(conn) == 49
cursor = await conn.execute(
"""
@@ -1439,8 +1439,8 @@ class TestMigration040:
applied = await run_migrations(conn)
assert applied == 8
assert await get_version(conn) == 47
assert applied == 9
assert await get_version(conn) == 49
await conn.execute(
"""
@@ -1501,8 +1501,8 @@ class TestMigration041:
applied = await run_migrations(conn)
assert applied == 7
assert await get_version(conn) == 47
assert applied == 8
assert await get_version(conn) == 49
await conn.execute(
"""
@@ -1554,8 +1554,8 @@ class TestMigration042:
applied = await run_migrations(conn)
assert applied == 6
assert await get_version(conn) == 47
assert applied == 7
assert await get_version(conn) == 49
await conn.execute(
"""
@@ -1694,8 +1694,8 @@ class TestMigration046:
applied = await run_migrations(conn)
assert applied == 2
assert await get_version(conn) == 47
assert applied == 3
assert await get_version(conn) == 49
cursor = await conn.execute(
"""
@@ -1788,8 +1788,8 @@ class TestMigration047:
applied = await run_migrations(conn)
assert applied == 1
assert await get_version(conn) == 47
assert applied == 2
assert await get_version(conn) == 49
cursor = await conn.execute(
"""
+172
View File
@@ -0,0 +1,172 @@
"""Tests for repeater telemetry history: repository CRUD and embedded status response."""
import time
import pytest
from app.models import CONTACT_TYPE_REPEATER
from app.repository import (
ContactRepository,
RepeaterTelemetryRepository,
)
KEY_A = "aa" * 32
KEY_B = "bb" * 32
SAMPLE_STATUS = {
"battery_volts": 4.15,
"tx_queue_len": 0,
"noise_floor_dbm": -100,
"last_rssi_dbm": -80,
"last_snr_db": 5.0,
"packets_received": 100,
"packets_sent": 50,
"airtime_seconds": 300,
"rx_airtime_seconds": 200,
"uptime_seconds": 1000,
"sent_flood": 10,
"sent_direct": 40,
"recv_flood": 60,
"recv_direct": 40,
"flood_dups": 5,
"direct_dups": 2,
"full_events": 0,
}
async def _insert_repeater(public_key: str, name: str = "Repeater"):
"""Insert a repeater contact into the test database."""
await ContactRepository.upsert(
{
"public_key": public_key,
"name": name,
"type": CONTACT_TYPE_REPEATER,
"flags": 0,
"direct_path": None,
"direct_path_len": -1,
"direct_path_hash_mode": -1,
"last_advert": None,
"lat": None,
"lon": None,
"last_seen": None,
"on_radio": False,
"last_contacted": None,
"first_seen": None,
}
)
@pytest.fixture
async def _db(test_db):
"""Set up test DB and patch the repeater_telemetry module's db reference."""
from app.repository import repeater_telemetry
original = repeater_telemetry.db
repeater_telemetry.db = test_db
try:
yield test_db
finally:
repeater_telemetry.db = original
class TestRepeaterTelemetryRepository:
"""Tests for RepeaterTelemetryRepository CRUD operations with JSON blob storage."""
@pytest.mark.asyncio
async def test_record_and_get_history(self, _db):
await _insert_repeater(KEY_A)
now = int(time.time())
await RepeaterTelemetryRepository.record(
public_key=KEY_A,
timestamp=now - 3600,
data={**SAMPLE_STATUS, "battery_volts": 4.15},
)
await RepeaterTelemetryRepository.record(
public_key=KEY_A,
timestamp=now,
data={**SAMPLE_STATUS, "battery_volts": 4.10},
)
history = await RepeaterTelemetryRepository.get_history(KEY_A, now - 7200)
assert len(history) == 2
assert history[0]["data"]["battery_volts"] == 4.15
assert history[1]["data"]["battery_volts"] == 4.10
assert history[0]["timestamp"] < history[1]["timestamp"]
@pytest.mark.asyncio
async def test_get_history_filters_by_time(self, _db):
await _insert_repeater(KEY_A)
now = int(time.time())
await RepeaterTelemetryRepository.record(KEY_A, now - 7200, SAMPLE_STATUS)
await RepeaterTelemetryRepository.record(KEY_A, now - 3600, SAMPLE_STATUS)
await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS)
history = await RepeaterTelemetryRepository.get_history(KEY_A, now - 3601)
assert len(history) == 2
@pytest.mark.asyncio
async def test_get_history_isolates_by_key(self, _db):
await _insert_repeater(KEY_A)
await _insert_repeater(KEY_B)
now = int(time.time())
await RepeaterTelemetryRepository.record(
KEY_A, now, {**SAMPLE_STATUS, "battery_volts": 4.1}
)
await RepeaterTelemetryRepository.record(
KEY_B, now, {**SAMPLE_STATUS, "battery_volts": 3.9}
)
history_a = await RepeaterTelemetryRepository.get_history(KEY_A, 0)
history_b = await RepeaterTelemetryRepository.get_history(KEY_B, 0)
assert len(history_a) == 1
assert len(history_b) == 1
assert history_a[0]["data"]["battery_volts"] == 4.1
@pytest.mark.asyncio
async def test_data_stored_as_json(self, _db):
"""Verify the data column stores valid JSON that round-trips correctly."""
await _insert_repeater(KEY_A)
now = int(time.time())
await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS)
history = await RepeaterTelemetryRepository.get_history(KEY_A, 0)
assert len(history) == 1
assert history[0]["data"] == SAMPLE_STATUS
class TestTelemetryHistoryInStatusResponse:
"""Tests that history is embedded in the status response (no separate 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."""
await _insert_repeater(KEY_A)
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
assert resp.status_code in (404, 405)
@pytest.mark.asyncio
async def test_history_endpoint_non_repeater_rejected(self, _db, client):
await ContactRepository.upsert(
{
"public_key": KEY_A,
"name": "Node",
"type": 0,
"flags": 0,
"direct_path": None,
"direct_path_len": -1,
"direct_path_hash_mode": -1,
"last_advert": None,
"lat": None,
"lon": None,
"last_seen": None,
"on_radio": False,
"last_contacted": None,
"first_seen": None,
}
)
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)
+1
View File
@@ -630,6 +630,7 @@ class TestAppSettingsRepository:
"flood_scope": "",
"blocked_keys": "[]",
"blocked_names": "[]",
"discovery_blocked_types": "[]",
}
)
mock_conn.execute = AsyncMock(return_value=mock_cursor)