mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-12 04:16:05 +02:00
Compare commits
1 Commits
3.11.2
...
testbranch
| Author | SHA1 | Date | |
|---|---|---|---|
| a2547d7f62 |
@@ -382,6 +382,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
|||||||
await set_version(conn, 49)
|
await set_version(conn, 49)
|
||||||
applied += 1
|
applied += 1
|
||||||
|
|
||||||
|
# Migration 50: Repeater telemetry history table
|
||||||
|
if version < 50:
|
||||||
|
logger.info("Applying migration 50: repeater telemetry history table")
|
||||||
|
await _migrate_050_repeater_telemetry_history(conn)
|
||||||
|
await set_version(conn, 50)
|
||||||
|
applied += 1
|
||||||
|
|
||||||
if applied > 0:
|
if applied > 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||||
@@ -3099,3 +3106,26 @@ async def _migrate_049_foreign_key_cascade(conn: aiosqlite.Connection) -> None:
|
|||||||
)
|
)
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
logger.debug("Rebuilt contact_name_history with ON DELETE CASCADE")
|
logger.debug("Rebuilt contact_name_history with ON DELETE CASCADE")
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_050_repeater_telemetry_history(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Create repeater_telemetry_history table for JSON-blob telemetry snapshots.
|
||||||
|
|
||||||
|
Uses ON DELETE CASCADE so contact deletion automatically cleans up rows.
|
||||||
|
"""
|
||||||
|
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_history_key_ts "
|
||||||
|
"ON repeater_telemetry_history(public_key, timestamp DESC)"
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|||||||
@@ -530,6 +530,14 @@ class RepeaterStatusResponse(BaseModel):
|
|||||||
flood_dups: int = Field(description="Duplicate flood packets")
|
flood_dups: int = Field(description="Duplicate flood packets")
|
||||||
direct_dups: int = Field(description="Duplicate direct packets")
|
direct_dups: int = Field(description="Duplicate direct packets")
|
||||||
full_events: int = Field(description="Full event queue count")
|
full_events: int = Field(description="Full event queue count")
|
||||||
|
telemetry_history: list["TelemetryHistoryEntry"] = Field(
|
||||||
|
default_factory=list, description="Recent telemetry history snapshots"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TelemetryHistoryEntry(BaseModel):
|
||||||
|
timestamp: int
|
||||||
|
data: dict
|
||||||
|
|
||||||
|
|
||||||
class RepeaterNodeInfoResponse(BaseModel):
|
class RepeaterNodeInfoResponse(BaseModel):
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from app.repository.contacts import (
|
|||||||
from app.repository.fanout import FanoutConfigRepository
|
from app.repository.fanout import FanoutConfigRepository
|
||||||
from app.repository.messages import MessageRepository
|
from app.repository.messages import MessageRepository
|
||||||
from app.repository.raw_packets import RawPacketRepository
|
from app.repository.raw_packets import RawPacketRepository
|
||||||
|
from app.repository.repeater_telemetry import RepeaterTelemetryRepository
|
||||||
from app.repository.settings import AppSettingsRepository, StatisticsRepository
|
from app.repository.settings import AppSettingsRepository, StatisticsRepository
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -20,5 +21,6 @@ __all__ = [
|
|||||||
"FanoutConfigRepository",
|
"FanoutConfigRepository",
|
||||||
"MessageRepository",
|
"MessageRepository",
|
||||||
"RawPacketRepository",
|
"RawPacketRepository",
|
||||||
|
"RepeaterTelemetryRepository",
|
||||||
"StatisticsRepository",
|
"StatisticsRepository",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
@@ -21,8 +22,9 @@ from app.models import (
|
|||||||
RepeaterOwnerInfoResponse,
|
RepeaterOwnerInfoResponse,
|
||||||
RepeaterRadioSettingsResponse,
|
RepeaterRadioSettingsResponse,
|
||||||
RepeaterStatusResponse,
|
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.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||||
from app.routers.server_control import (
|
from app.routers.server_control import (
|
||||||
batch_cli_fetch,
|
batch_cli_fetch,
|
||||||
@@ -108,7 +110,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
|||||||
if status is None:
|
if status is None:
|
||||||
raise HTTPException(status_code=504, detail="No status response from repeater")
|
raise HTTPException(status_code=504, detail="No status response from repeater")
|
||||||
|
|
||||||
return RepeaterStatusResponse(
|
response = RepeaterStatusResponse(
|
||||||
battery_volts=status.get("bat", 0) / 1000.0,
|
battery_volts=status.get("bat", 0) / 1000.0,
|
||||||
tx_queue_len=status.get("tx_queue_len", 0),
|
tx_queue_len=status.get("tx_queue_len", 0),
|
||||||
noise_floor_dbm=status.get("noise_floor", 0),
|
noise_floor_dbm=status.get("noise_floor", 0),
|
||||||
@@ -128,6 +130,42 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
|||||||
full_events=status.get("full_evts", 0),
|
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",
|
||||||
|
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:
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -23,6 +30,7 @@ import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
|
|||||||
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
|
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
|
||||||
import { ActionsPane } from './repeater/RepeaterActionsPane';
|
import { ActionsPane } from './repeater/RepeaterActionsPane';
|
||||||
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
||||||
|
import { TelemetryHistoryPane } from './repeater/RepeaterTelemetryHistoryPane';
|
||||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||||
|
|
||||||
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
|
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
|
||||||
@@ -90,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) => {
|
||||||
await login(nextPassword);
|
await login(nextPassword);
|
||||||
@@ -291,6 +317,7 @@ export function RepeaterDashboard({
|
|||||||
onRefresh={() => refreshPane('status')}
|
onRefresh={() => refreshPane('status')}
|
||||||
disabled={anyLoading}
|
disabled={anyLoading}
|
||||||
/>
|
/>
|
||||||
|
<TelemetryHistoryPane entries={telemetryHistory} />
|
||||||
<RadioSettingsPane
|
<RadioSettingsPane
|
||||||
data={paneData.radioSettings}
|
data={paneData.radioSettings}
|
||||||
state={paneStates.radioSettings}
|
state={paneStates.radioSettings}
|
||||||
|
|||||||
@@ -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,
|
useRepeaterDashboard: () => mockHook,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock api module (TelemetryHistoryPane fetches on mount)
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
api: {
|
||||||
|
repeaterTelemetryHistory: vi.fn().mockResolvedValue([]),
|
||||||
|
setContactRoutingOverride: vi.fn().mockResolvedValue({ status: 'ok' }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock sonner toast
|
// Mock sonner toast
|
||||||
vi.mock('../components/ui/sonner', () => ({
|
vi.mock('../components/ui/sonner', () => ({
|
||||||
toast: {
|
toast: {
|
||||||
@@ -418,6 +426,7 @@ describe('RepeaterDashboard', () => {
|
|||||||
flood_dups: 1,
|
flood_dups: 1,
|
||||||
direct_dups: 0,
|
direct_dups: 0,
|
||||||
full_events: 0,
|
full_events: 0,
|
||||||
|
telemetry_history: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<RepeaterDashboard {...defaultProps} />);
|
render(<RepeaterDashboard {...defaultProps} />);
|
||||||
@@ -634,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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -416,6 +416,12 @@ export interface RepeaterStatusResponse {
|
|||||||
flood_dups: number;
|
flood_dups: number;
|
||||||
direct_dups: number;
|
direct_dups: number;
|
||||||
full_events: number;
|
full_events: number;
|
||||||
|
telemetry_history: TelemetryHistoryEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelemetryHistoryEntry {
|
||||||
|
timestamp: number;
|
||||||
|
data: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepeaterNeighborsResponse {
|
export interface RepeaterNeighborsResponse {
|
||||||
|
|||||||
+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(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Tests for repeater telemetry history: repository CRUD and read-only endpoint."""
|
||||||
|
|
||||||
|
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 TestTelemetryHistoryEndpoint:
|
||||||
|
"""Tests for the read-only GET telemetry-history endpoint."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
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 == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["data"]["battery_volts"] == 4.15
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
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,
|
||||||
|
"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")
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user