Add intervalized repeater metrics collection. Closes #151.

This commit is contained in:
Jack Kingsman
2026-04-03 13:45:39 -07:00
parent 35981d8f8b
commit be2b2604df
22 changed files with 624 additions and 31 deletions

View File

@@ -104,7 +104,8 @@ CREATE TABLE IF NOT EXISTS app_settings (
flood_scope TEXT DEFAULT '',
blocked_keys TEXT DEFAULT '[]',
blocked_names TEXT DEFAULT '[]',
discovery_blocked_types TEXT DEFAULT '[]'
discovery_blocked_types TEXT DEFAULT '[]',
tracked_telemetry_repeaters TEXT DEFAULT '[]'
);
INSERT OR IGNORE INTO app_settings (id) VALUES (1);

View File

@@ -21,6 +21,7 @@ from app.radio_sync import (
stop_message_polling,
stop_periodic_advert,
stop_periodic_sync,
stop_telemetry_collect,
)
from app.routers import (
channels,
@@ -103,6 +104,7 @@ async def lifespan(app: FastAPI):
await stop_noise_floor_sampling()
await stop_periodic_advert()
await stop_periodic_sync()
await stop_telemetry_collect()
if radio_manager.meshcore:
await radio_manager.meshcore.stop_auto_message_fetching()
await radio_manager.disconnect()

View File

@@ -401,6 +401,12 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 52)
applied += 1
if version < 53:
logger.info("Applying migration 53: add tracked_telemetry_repeaters to app_settings")
await _migrate_053_tracked_telemetry_repeaters(conn)
await set_version(conn, 53)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -3171,3 +3177,18 @@ async def _migrate_052_add_channel_path_hash_mode_override(conn: aiosqlite.Conne
await conn.commit()
else:
raise
async def _migrate_053_tracked_telemetry_repeaters(conn: aiosqlite.Connection) -> None:
"""Add tracked_telemetry_repeaters JSON list column to app_settings."""
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
await conn.commit()
return
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
columns = {row[1] for row in await col_cursor.fetchall()}
if "tracked_telemetry_repeaters" not in columns:
await conn.execute(
"ALTER TABLE app_settings ADD COLUMN tracked_telemetry_repeaters TEXT DEFAULT '[]'"
)
await conn.commit()

View File

@@ -826,6 +826,10 @@ class AppSettings(BaseModel):
"advertisements should not create new contacts; existing contacts are still updated"
),
)
tracked_telemetry_repeaters: list[str] = Field(
default_factory=list,
description="Public keys of repeaters opted into periodic telemetry collection (max 8)",
)
class FanoutConfig(BaseModel):

View File

@@ -28,6 +28,7 @@ from app.repository import (
AppSettingsRepository,
ChannelRepository,
ContactRepository,
RepeaterTelemetryRepository,
)
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
@@ -155,6 +156,15 @@ ADVERT_CHECK_INTERVAL = 60
# more frequently than this.
MIN_ADVERT_INTERVAL = 3600
# Periodic telemetry collection task handle
_telemetry_collect_task: asyncio.Task | None = None
# Telemetry collection interval (8 hours)
TELEMETRY_COLLECT_INTERVAL = 8 * 3600
# Initial delay before the first telemetry collection cycle (let radio settle)
TELEMETRY_COLLECT_INITIAL_DELAY = 60
# Counter to pause polling during repeater operations (supports nested pauses)
_polling_pause_count: int = 0
@@ -1524,3 +1534,158 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None
except Exception as e:
logger.error("Error syncing contacts to radio: %s", e, exc_info=True)
return {"loaded": 0, "error": str(e)}
# ---------------------------------------------------------------------------
# Periodic repeater telemetry collection
# ---------------------------------------------------------------------------
async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
"""Fetch status telemetry from a single repeater and record it.
Returns True on success, False on failure (logged, not raised).
"""
try:
await mc.commands.add_contact(contact.to_radio_dict())
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
except Exception as e:
logger.debug(
"Telemetry collect: radio command failed for %s: %s",
contact.public_key[:12],
e,
)
return False
if status is None:
logger.debug("Telemetry collect: no response from %s", contact.public_key[:12])
return False
# Map to the same field names as the manual repeater status endpoint
data = {
"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),
"last_rssi_dbm": status.get("last_rssi", 0),
"last_snr_db": status.get("last_snr", 0.0),
"packets_received": status.get("nb_recv", 0),
"packets_sent": status.get("nb_sent", 0),
"airtime_seconds": status.get("airtime", 0),
"rx_airtime_seconds": status.get("rx_airtime", 0),
"uptime_seconds": status.get("uptime", 0),
"sent_flood": status.get("sent_flood", 0),
"sent_direct": status.get("sent_direct", 0),
"recv_flood": status.get("recv_flood", 0),
"recv_direct": status.get("recv_direct", 0),
"flood_dups": status.get("flood_dups", 0),
"direct_dups": status.get("direct_dups", 0),
"full_events": status.get("full_evts", 0),
}
try:
await RepeaterTelemetryRepository.record(
public_key=contact.public_key,
timestamp=int(time.time()),
data=data,
)
logger.info(
"Telemetry collect: recorded snapshot for %s (%s)",
contact.name or contact.public_key[:12],
contact.public_key[:12],
)
return True
except Exception as e:
logger.warning(
"Telemetry collect: failed to record for %s: %s",
contact.public_key[:12],
e,
)
return False
async def _telemetry_collect_loop() -> None:
"""Background task that collects telemetry from tracked repeaters every 8 hours.
Runs a first cycle after a short initial delay (so newly tracked repeaters
get a sample promptly), then sleeps the full interval between subsequent cycles.
Acquires the radio lock per-repeater (non-blocking) so manual operations can
interleave. Failures are logged and skipped.
"""
first_run = True
while True:
try:
delay = TELEMETRY_COLLECT_INITIAL_DELAY if first_run else TELEMETRY_COLLECT_INTERVAL
await asyncio.sleep(delay)
first_run = False
if not radio_manager.is_connected:
logger.debug("Telemetry collect: radio not connected, skipping cycle")
continue
app_settings = await AppSettingsRepository.get()
tracked = app_settings.tracked_telemetry_repeaters
if not tracked:
continue
logger.info("Telemetry collect: starting cycle for %d repeater(s)", len(tracked))
collected = 0
for pub_key in tracked:
contact = await ContactRepository.get_by_key(pub_key)
if not contact or contact.type != 2:
logger.debug(
"Telemetry collect: skipping %s (not found or not repeater)",
pub_key[:12],
)
continue
try:
async with radio_manager.radio_operation(
"telemetry_collect",
blocking=False,
suspend_auto_fetch=True,
) as mc:
if await _collect_repeater_telemetry(mc, contact):
collected += 1
except RadioOperationBusyError:
logger.debug(
"Telemetry collect: radio busy, skipping %s",
pub_key[:12],
)
logger.info(
"Telemetry collect: cycle complete, %d/%d successful",
collected,
len(tracked),
)
except asyncio.CancelledError:
logger.info("Telemetry collect task cancelled")
break
except Exception as e:
logger.error("Error in telemetry collect loop: %s", e, exc_info=True)
def start_telemetry_collect() -> None:
"""Start the periodic telemetry collection background task."""
global _telemetry_collect_task
if _telemetry_collect_task is None or _telemetry_collect_task.done():
_telemetry_collect_task = asyncio.create_task(_telemetry_collect_loop())
logger.info(
"Started periodic telemetry collection (interval: %ds)",
TELEMETRY_COLLECT_INTERVAL,
)
async def stop_telemetry_collect() -> None:
"""Stop the periodic telemetry collection background task."""
global _telemetry_collect_task
if _telemetry_collect_task and not _telemetry_collect_task.done():
_telemetry_collect_task.cancel()
try:
await _telemetry_collect_task
except asyncio.CancelledError:
pass
_telemetry_collect_task = None
logger.info("Stopped periodic telemetry collection")

View File

@@ -29,7 +29,8 @@ class AppSettingsRepository:
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
last_message_times, preferences_migrated,
advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names, discovery_blocked_types
blocked_keys, blocked_names, discovery_blocked_types,
tracked_telemetry_repeaters
FROM app_settings WHERE id = 1
"""
)
@@ -89,6 +90,15 @@ class AppSettingsRepository:
except (json.JSONDecodeError, TypeError):
discovery_blocked_types = []
# Parse tracked_telemetry_repeaters JSON
tracked_telemetry_repeaters: list[str] = []
try:
raw_tracked = row["tracked_telemetry_repeaters"]
if raw_tracked:
tracked_telemetry_repeaters = json.loads(raw_tracked)
except (json.JSONDecodeError, TypeError, KeyError):
tracked_telemetry_repeaters = []
return AppSettings(
max_radio_contacts=row["max_radio_contacts"],
favorites=favorites,
@@ -101,6 +111,7 @@ class AppSettingsRepository:
blocked_keys=blocked_keys,
blocked_names=blocked_names,
discovery_blocked_types=discovery_blocked_types,
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
)
@staticmethod
@@ -116,6 +127,7 @@ class AppSettingsRepository:
blocked_keys: list[str] | None = None,
blocked_names: list[str] | None = None,
discovery_blocked_types: list[int] | None = None,
tracked_telemetry_repeaters: list[str] | None = None,
) -> AppSettings:
"""Update app settings. Only provided fields are updated."""
updates = []
@@ -166,6 +178,10 @@ class AppSettingsRepository:
updates.append("discovery_blocked_types = ?")
params.append(json.dumps(discovery_blocked_types))
if tracked_telemetry_repeaters is not None:
updates.append("tracked_telemetry_repeaters = ?")
params.append(json.dumps(tracked_telemetry_repeaters))
if updates:
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
await db.conn.execute(query, params)

View File

@@ -2,16 +2,18 @@ import asyncio
import logging
from typing import Literal
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.models import AppSettings
from app.models import CONTACT_TYPE_REPEATER, AppSettings
from app.region_scope import normalize_region_scope
from app.repository import AppSettingsRepository
from app.repository import AppSettingsRepository, ContactRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/settings", tags=["settings"])
MAX_TRACKED_TELEMETRY_REPEATERS = 8
class AppSettingsUpdate(BaseModel):
max_radio_contacts: int | None = Field(
@@ -66,6 +68,19 @@ class FavoriteRequest(BaseModel):
id: str = Field(description="Channel key or contact public key")
class TrackedTelemetryRequest(BaseModel):
public_key: str = Field(description="Public key of the repeater to toggle tracking")
class TrackedTelemetryResponse(BaseModel):
tracked_telemetry_repeaters: list[str] = Field(
description="Current list of tracked repeater public keys"
)
names: dict[str, str] = Field(
description="Map of public key to display name for tracked repeaters"
)
class MigratePreferencesRequest(BaseModel):
favorites: list[FavoriteRequest] = Field(
default_factory=list,
@@ -191,6 +206,61 @@ async def toggle_blocked_name(request: BlockNameRequest) -> AppSettings:
return await AppSettingsRepository.toggle_blocked_name(request.name)
@router.post("/tracked-telemetry/toggle", response_model=TrackedTelemetryResponse)
async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedTelemetryResponse:
"""Toggle periodic telemetry collection for a repeater.
Max 8 repeaters may be tracked. Returns 409 if the limit is reached and
the requested repeater is not already tracked.
"""
key = request.public_key.lower()
settings = await AppSettingsRepository.get()
current = settings.tracked_telemetry_repeaters
async def _resolve_names(keys: list[str]) -> dict[str, str]:
names: dict[str, str] = {}
for k in keys:
contact = await ContactRepository.get_by_key(k)
names[k] = contact.name if contact and contact.name else k[:12]
return names
if key in current:
# Remove
new_list = [k for k in current if k != key]
logger.info("Removing repeater %s from tracked telemetry", key[:12])
await AppSettingsRepository.update(tracked_telemetry_repeaters=new_list)
return TrackedTelemetryResponse(
tracked_telemetry_repeaters=new_list,
names=await _resolve_names(new_list),
)
# Validate it's a repeater
contact = await ContactRepository.get_by_key(key)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
if contact.type != CONTACT_TYPE_REPEATER:
raise HTTPException(status_code=400, detail="Contact is not a repeater")
if len(current) >= MAX_TRACKED_TELEMETRY_REPEATERS:
names = await _resolve_names(current)
raise HTTPException(
status_code=409,
detail={
"message": f"Limit of {MAX_TRACKED_TELEMETRY_REPEATERS} tracked repeaters reached",
"tracked_telemetry_repeaters": current,
"names": names,
},
)
new_list = current + [key]
logger.info("Adding repeater %s to tracked telemetry", key[:12])
await AppSettingsRepository.update(tracked_telemetry_repeaters=new_list)
return TrackedTelemetryResponse(
tracked_telemetry_repeaters=new_list,
names=await _resolve_names(new_list),
)
@router.post("/migrate", response_model=MigratePreferencesResponse)
async def migrate_preferences(request: MigratePreferencesRequest) -> MigratePreferencesResponse:
"""Migrate all preferences from frontend localStorage to database.

View File

@@ -33,6 +33,7 @@ async def run_post_connect_setup(radio_manager) -> None:
start_message_polling,
start_periodic_advert,
start_periodic_sync,
start_telemetry_collect,
sync_and_offload_all,
sync_radio_time,
)
@@ -241,6 +242,7 @@ async def run_post_connect_setup(radio_manager) -> None:
start_periodic_sync()
start_periodic_advert()
start_message_polling()
start_telemetry_collect()
radio_manager._setup_complete = True
finally:

View File

@@ -156,6 +156,7 @@ export function App() {
handleToggleFavorite,
handleToggleBlockedKey,
handleToggleBlockedName,
handleToggleTrackedTelemetry,
} = useAppSettings();
// Keep user's name in ref for mention detection in WebSocket callback
@@ -555,6 +556,8 @@ export function App() {
);
}
},
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
};
const searchProps = {
contacts,
@@ -588,6 +591,8 @@ export function App() {
const keySet = new Set(deletedKeys.map((k) => k.toLowerCase()));
setContacts((prev) => prev.filter((c) => !keySet.has(c.public_key.toLowerCase())));
},
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
};
const crackerProps = {
packets: rawPackets,

View File

@@ -36,6 +36,7 @@ import type {
RepeaterRadioSettingsResponse,
RepeaterStatusResponse,
TelemetryHistoryEntry,
TrackedTelemetryResponse,
StatisticsResponse,
TraceResponse,
UnreadCounts,
@@ -327,6 +328,13 @@ export const api = {
body: JSON.stringify({ name }),
}),
// Tracked telemetry
toggleTrackedTelemetry: (publicKey: string) =>
fetchJson<TrackedTelemetryResponse>('/settings/tracked-telemetry/toggle', {
method: 'POST',
body: JSON.stringify({ public_key: publicKey }),
}),
// Favorites
toggleFavorite: (type: Favorite['type'], id: string) =>
fetchJson<AppSettings>('/settings/favorites/toggle', {

View File

@@ -79,6 +79,8 @@ interface ConversationPaneProps {
onDismissUnreadMarker: () => void;
onSendMessage: (text: string) => Promise<void>;
onToggleNotifications: () => void;
trackedTelemetryRepeaters: string[];
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
}
function LoadingPane({ label }: { label: string }) {
@@ -148,6 +150,8 @@ export function ConversationPane({
onDismissUnreadMarker,
onSendMessage,
onToggleNotifications,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
}: ConversationPaneProps) {
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
const activeContactIsRepeater = useMemo(() => {
@@ -241,6 +245,8 @@ export function ConversationPane({
onToggleFavorite={onToggleFavorite}
onDeleteContact={onDeleteContact}
onOpenContactInfo={onOpenContactInfo}
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
/>
</Suspense>
);

View File

@@ -54,6 +54,8 @@ interface RepeaterDashboardProps {
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onDeleteContact: (publicKey: string) => void;
onOpenContactInfo?: (publicKey: string) => void;
trackedTelemetryRepeaters: string[];
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
}
export function RepeaterDashboard({
@@ -72,6 +74,8 @@ export function RepeaterDashboard({
onToggleFavorite,
onDeleteContact,
onOpenContactInfo,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
}: RepeaterDashboardProps) {
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
@@ -396,7 +400,13 @@ export function RepeaterDashboard({
/>
{/* Telemetry history chart — full width, below console */}
<TelemetryHistoryPane entries={telemetryHistory} />
<TelemetryHistoryPane
entries={telemetryHistory}
publicKey={conversation.id}
contacts={contacts}
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
/>
</div>
)}
</div>

View File

@@ -50,6 +50,8 @@ interface SettingsModalBaseProps {
onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
trackedTelemetryRepeaters?: string[];
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
}
export type SettingsModalProps = SettingsModalBaseProps &
@@ -85,6 +87,8 @@ export function SettingsModal(props: SettingsModalProps) {
onToggleBlockedName,
contacts,
onBulkDeleteContacts,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
} = props;
const externalSidebarNav = props.externalSidebarNav === true;
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
@@ -246,6 +250,8 @@ export function SettingsModal(props: SettingsModalProps) {
onToggleBlockedName={onToggleBlockedName}
contacts={contacts}
onBulkDeleteContacts={onBulkDeleteContacts}
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
className={sectionContentClass}
/>
) : (

View File

@@ -9,7 +9,11 @@ import {
ResponsiveContainer,
} from 'recharts';
import { cn } from '@/lib/utils';
import type { TelemetryHistoryEntry } from '../../types';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import type { TelemetryHistoryEntry, Contact } from '../../types';
const MAX_TRACKED = 8;
type Metric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds';
@@ -47,8 +51,26 @@ function formatUptime(seconds: number): string {
return `${(seconds / 86400).toFixed(1)}d`;
}
export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEntry[] }) {
interface TelemetryHistoryPaneProps {
entries: TelemetryHistoryEntry[];
publicKey: string;
contacts: Contact[];
trackedTelemetryRepeaters: string[];
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
}
export function TelemetryHistoryPane({
entries,
publicKey,
contacts,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
}: TelemetryHistoryPaneProps) {
const [metric, setMetric] = useState<Metric>('battery_volts');
const [toggling, setToggling] = useState(false);
const isTracked = trackedTelemetryRepeaters.includes(publicKey);
const slotsFull = trackedTelemetryRepeaters.length >= MAX_TRACKED && !isTracked;
const config = METRIC_CONFIG[metric];
@@ -68,13 +90,87 @@ export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEnt
const dataKeys = metric === 'packets' ? ['packets_received', 'packets_sent'] : [metric];
const handleToggle = async () => {
setToggling(true);
try {
await onToggleTrackedTelemetry(publicKey);
} finally {
setToggling(false);
}
};
const trackedNames = useMemo(() => {
if (!slotsFull) return [];
return trackedTelemetryRepeaters.map((key) => {
const contact = contacts.find((c) => c.public_key === key);
return { key, name: contact?.name ?? key.slice(0, 12) };
});
}, [slotsFull, trackedTelemetryRepeaters, contacts]);
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 className="flex items-center gap-2">
<h3 className="text-sm font-medium">Telemetry History</h3>
{entries.length > 0 && (
<span className="text-[10px] text-muted-foreground">{entries.length} samples</span>
)}
</div>
</div>
<div className="p-3">
{/* Explanation + tracking toggle */}
<div className="mb-3 space-y-3">
<p className="text-xs text-muted-foreground leading-relaxed">
Any time repeater telemetry is fetched, the metrics are stored for 30 days (or 1,000
samples, whichever comes first). This telemetry is stored on normal interactive fetches
via the repeater pane, API calls to the endpoint (
<code className="text-[11px]">POST /api/contacts/&lt;key&gt;/repeater/status</code>), or
when the repeater is opted into interval telemetry polling, in which case the repeater
will be polled for metrics every 8 hours. You can see which repeaters are opted into
this flow in the{' '}
<a
href="#settings/database"
className="underline text-primary hover:text-primary/80 transition-colors"
>
Database &amp; Messaging
</a>{' '}
settings pane. A maximum of {MAX_TRACKED} repeaters may be opted into this for the sake
of keeping mesh congestion reasonable.
</p>
{isTracked ? (
<Button
variant="outline"
onClick={handleToggle}
disabled={toggling}
className="border-destructive/50 text-destructive hover:bg-destructive/10"
>
{toggling ? 'Updating...' : 'Remove Repeater from Interval Metrics Tracking'}
</Button>
) : slotsFull ? (
<div className="space-y-2">
<Button variant="outline" disabled>
Tracking Full ({trackedTelemetryRepeaters.length}/{MAX_TRACKED} slots used)
</Button>
<p className="text-xs text-muted-foreground">
Disable tracking on another repeater to free a slot:{' '}
{trackedNames.map((t) => t.name).join(', ')}
</p>
</div>
) : (
<Button
variant="outline"
onClick={handleToggle}
disabled={toggling}
className="border-green-600/50 text-green-600 hover:bg-green-600/10"
>
{toggling ? 'Updating...' : 'Opt Repeater into 8hr Interval Metrics Tracking'}
</Button>
)}
</div>
<Separator className="mb-3" />
{/* Metric selector */}
<div className="flex gap-1 mb-2">
{(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => (
@@ -149,10 +245,15 @@ export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEnt
fill={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
fillOpacity={0.15}
strokeWidth={1.5}
dot={false}
activeDot={{
dot={{
r: 4,
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
strokeWidth: 1.5,
stroke: 'hsl(var(--popover))',
}}
activeDot={{
r: 6,
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
strokeWidth: 2,
stroke: 'hsl(var(--popover))',
}}

View File

@@ -20,6 +20,8 @@ export function SettingsDatabaseSection({
onToggleBlockedName,
contacts = [],
onBulkDeleteContacts,
trackedTelemetryRepeaters = [],
onToggleTrackedTelemetry,
className,
}: {
appSettings: AppSettings;
@@ -32,6 +34,8 @@ export function SettingsDatabaseSection({
onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
trackedTelemetryRepeaters?: string[];
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
className?: string;
}) {
const [retentionDays, setRetentionDays] = useState('14');
@@ -223,6 +227,50 @@ export function SettingsDatabaseSection({
</p>
</div>
<Separator />
{/* ── Tracked Repeater Telemetry ── */}
<div className="space-y-3">
<Label className="text-base">Tracked Repeater Telemetry</Label>
<p className="text-xs text-muted-foreground">
Repeaters opted into automatic telemetry collection are polled every 8 hours. Up to 8
repeaters may be tracked at a time ({trackedTelemetryRepeaters.length} / 8 slots used).
</p>
{trackedTelemetryRepeaters.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
</p>
) : (
<div className="space-y-1">
{trackedTelemetryRepeaters.map((key) => {
const contact = contacts.find((c) => c.public_key === key);
const displayName = contact?.name ?? key.slice(0, 12);
return (
<div key={key} className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">{displayName}</span>
<span className="text-[10px] text-muted-foreground font-mono">
{key.slice(0, 12)}
</span>
</div>
{onToggleTrackedTelemetry && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleTrackedTelemetry(key)}
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
>
Remove
</Button>
)}
</div>
);
})}
</div>
)}
</div>
{error && (
<div className="text-sm text-destructive" role="alert">
{error}

View File

@@ -120,6 +120,39 @@ export function useAppSettings() {
}
}, []);
const handleToggleTrackedTelemetry = useCallback(async (publicKey: string) => {
const key = publicKey.toLowerCase();
setAppSettings((prev) => {
if (!prev) return prev;
const current = prev.tracked_telemetry_repeaters ?? [];
const wasTracked = current.includes(key);
const optimistic = wasTracked ? current.filter((k) => k !== key) : [...current, key];
return { ...prev, tracked_telemetry_repeaters: optimistic };
});
try {
const result = await api.toggleTrackedTelemetry(publicKey);
setAppSettings((prev) =>
prev ? { ...prev, tracked_telemetry_repeaters: result.tracked_telemetry_repeaters } : prev
);
} catch (err) {
console.error('Failed to toggle tracked telemetry:', err);
try {
const settings = await api.getSettings();
setAppSettings(settings);
} catch {
// If refetch also fails, leave optimistic state
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const detail = (err as any)?.body?.detail;
if (typeof detail === 'object' && detail?.message) {
toast.error(detail.message);
} else {
toast.error('Failed to update tracked telemetry');
}
}
}, []);
// One-time migration of localStorage preferences to server
useEffect(() => {
if (!appSettings || hasMigratedRef.current) return;
@@ -182,5 +215,6 @@ export function useAppSettings() {
handleToggleFavorite,
handleToggleBlockedKey,
handleToggleBlockedName,
handleToggleTrackedTelemetry,
};
}

View File

@@ -164,6 +164,8 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
onDismissUnreadMarker: vi.fn(),
onSendMessage: vi.fn(async () => {}),
onToggleNotifications: vi.fn(),
trackedTelemetryRepeaters: [],
onToggleTrackedTelemetry: vi.fn(async () => {}),
...overrides,
};
}

View File

@@ -124,6 +124,8 @@ const defaultProps = {
onToggleNotifications: vi.fn(),
onToggleFavorite: vi.fn(),
onDeleteContact: vi.fn(),
trackedTelemetryRepeaters: [] as string[],
onToggleTrackedTelemetry: vi.fn(async () => {}),
};
function createDeferred<T>() {
@@ -677,7 +679,7 @@ describe('RepeaterDashboard', () => {
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Telemetry History')).toBeInTheDocument();
expect(screen.getByText('0 samples')).toBeInTheDocument();
expect(screen.getByText(/No history yet/)).toBeInTheDocument();
});
it('updates history from live status fetch', async () => {

View File

@@ -69,6 +69,7 @@ const baseSettings: AppSettings = {
blocked_keys: [],
blocked_names: [],
discovery_blocked_types: [],
tracked_telemetry_repeaters: [],
};
function renderModal(overrides?: {

View File

@@ -325,6 +325,7 @@ export interface AppSettings {
blocked_keys: string[];
blocked_names: string[];
discovery_blocked_types: number[];
tracked_telemetry_repeaters: string[];
}
export interface AppSettingsUpdate {
@@ -337,6 +338,11 @@ export interface AppSettingsUpdate {
discovery_blocked_types?: number[];
}
export interface TrackedTelemetryResponse {
tracked_telemetry_repeaters: string[];
names: Record<string, string>;
}
export interface MigratePreferencesRequest {
favorites: Favorite[];
sort_order: string;

View File

@@ -1249,8 +1249,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 14
assert await get_version(conn) == 52
assert applied == 15
assert await get_version(conn) == 53
cursor = await conn.execute(
"""
@@ -1321,8 +1321,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 14
assert await get_version(conn) == 52
assert applied == 15
assert await get_version(conn) == 53
cursor = await conn.execute(
"""
@@ -1388,8 +1388,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 8
assert await get_version(conn) == 52
assert applied == 9
assert await get_version(conn) == 53
cursor = await conn.execute(
"""
@@ -1441,8 +1441,8 @@ class TestMigration040:
applied = await run_migrations(conn)
assert applied == 13
assert await get_version(conn) == 52
assert applied == 14
assert await get_version(conn) == 53
await conn.execute(
"""
@@ -1503,8 +1503,8 @@ class TestMigration041:
applied = await run_migrations(conn)
assert applied == 12
assert await get_version(conn) == 52
assert applied == 13
assert await get_version(conn) == 53
await conn.execute(
"""
@@ -1556,8 +1556,8 @@ class TestMigration042:
applied = await run_migrations(conn)
assert applied == 11
assert await get_version(conn) == 52
assert applied == 12
assert await get_version(conn) == 53
await conn.execute(
"""
@@ -1696,8 +1696,8 @@ class TestMigration046:
applied = await run_migrations(conn)
assert applied == 7
assert await get_version(conn) == 52
assert applied == 8
assert await get_version(conn) == 53
cursor = await conn.execute(
"""
@@ -1790,8 +1790,8 @@ class TestMigration047:
applied = await run_migrations(conn)
assert applied == 6
assert await get_version(conn) == 52
assert applied == 7
assert await get_version(conn) == 53
cursor = await conn.execute(
"""

View File

@@ -3,15 +3,18 @@
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import HTTPException
from app.models import AppSettings
from app.repository import AppSettingsRepository
from app.models import CONTACT_TYPE_REPEATER, AppSettings, ContactUpsert
from app.repository import AppSettingsRepository, ContactRepository
from app.routers.settings import (
AppSettingsUpdate,
FavoriteRequest,
MigratePreferencesRequest,
TrackedTelemetryRequest,
migrate_preferences,
toggle_favorite,
toggle_tracked_telemetry,
update_settings,
)
@@ -202,3 +205,83 @@ class TestMigratePreferences:
assert response.migrated is False
assert response.settings.preferences_migrated is True
class TestToggleTrackedTelemetry:
"""Tests for POST /settings/tracked-telemetry/toggle."""
async def _create_repeater(self, key: str, name: str = "TestRepeater") -> None:
await ContactRepository.upsert(
ContactUpsert(public_key=key, name=name, type=CONTACT_TYPE_REPEATER)
)
@pytest.mark.asyncio
async def test_add_repeater_to_tracking(self, test_db):
key = "aa" * 32
await self._create_repeater(key)
result = await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=key))
assert key in result.tracked_telemetry_repeaters
assert result.names[key] == "TestRepeater"
# Verify persisted
settings = await AppSettingsRepository.get()
assert key in settings.tracked_telemetry_repeaters
@pytest.mark.asyncio
async def test_remove_repeater_from_tracking(self, test_db):
key = "bb" * 32
await self._create_repeater(key)
await AppSettingsRepository.update(tracked_telemetry_repeaters=[key])
result = await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=key))
assert key not in result.tracked_telemetry_repeaters
@pytest.mark.asyncio
async def test_rejects_non_repeater_contact(self, test_db):
key = "cc" * 32
await ContactRepository.upsert(ContactUpsert(public_key=key, name="Client", type=1))
with pytest.raises(HTTPException) as exc_info:
await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=key))
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_rejects_unknown_contact(self, test_db):
with pytest.raises(HTTPException) as exc_info:
await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key="dd" * 32))
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_rejects_when_limit_reached(self, test_db):
existing_keys = []
for i in range(8):
key = f"{i:02x}" * 32
await self._create_repeater(key, name=f"Repeater{i}")
existing_keys.append(key)
await AppSettingsRepository.update(tracked_telemetry_repeaters=existing_keys)
new_key = "ff" * 32
await self._create_repeater(new_key, name="NewRepeater")
with pytest.raises(HTTPException) as exc_info:
await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=new_key))
assert exc_info.value.status_code == 409
detail = exc_info.value.detail
assert len(detail["tracked_telemetry_repeaters"]) == 8
@pytest.mark.asyncio
async def test_remove_still_works_when_limit_reached(self, test_db):
"""Toggling OFF an already-tracked repeater should work even at max capacity."""
keys = []
for i in range(8):
key = f"{i:02x}" * 32
await self._create_repeater(key)
keys.append(key)
await AppSettingsRepository.update(tracked_telemetry_repeaters=keys)
result = await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=keys[0]))
assert keys[0] not in result.tracked_telemetry_repeaters
assert len(result.tracked_telemetry_repeaters) == 7