You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
DroneDetector/telemetry/telemetry_server.py

864 lines
28 KiB
Python

import asyncio
import os
import time
from collections import defaultdict, deque
from pathlib import Path
from typing import Any, Deque, Dict, List, Optional
from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel, Field
from common.runtime import load_root_env
load_root_env(__file__)
TELEMETRY_BIND_HOST = os.getenv('telemetry_bind_host', os.getenv('lochost', '0.0.0.0'))
TELEMETRY_BIND_PORT = int(os.getenv('telemetry_bind_port', os.getenv('telemetry_port', '5020')))
TELEMETRY_HISTORY_SEC = int(float(os.getenv('telemetry_history_sec', '900')))
TELEMETRY_MAX_POINTS_PER_FREQ = int(os.getenv('telemetry_max_points_per_freq', '5000'))
INFERENCE_HISTORY_SEC = int(float(os.getenv('inference_history_sec', str(TELEMETRY_HISTORY_SEC))))
INFERENCE_MAX_RESULTS_PER_FREQ = int(os.getenv('inference_max_results_per_freq', '100'))
INFERENCE_RESULT_DIR = Path(os.getenv('inference_result_dir', '/app/inference_result')).resolve()
def _new_buffer() -> Deque[Dict[str, Any]]:
return deque(maxlen=TELEMETRY_MAX_POINTS_PER_FREQ)
def _new_inference_buffer() -> Deque[Dict[str, Any]]:
return deque(maxlen=INFERENCE_MAX_RESULTS_PER_FREQ)
app = FastAPI(title='DroneDetector Telemetry Server')
_buffers: Dict[str, Deque[Dict[str, Any]]] = defaultdict(_new_buffer)
_ws_clients: List[WebSocket] = []
_inference_buffers: Dict[str, Deque[Dict[str, Any]]] = defaultdict(_new_inference_buffer)
_inference_ws_clients: List[WebSocket] = []
_state_lock = asyncio.Lock()
class TelemetryPoint(BaseModel):
freq: str
ts: float = Field(default_factory=lambda: time.time())
dbfs_current: float
dbfs_threshold: Optional[float] = None
alarm: bool = False
channel_idx: int = 0
channels_total: int = 1
channel_values: Optional[List[float]] = None
channel_thresholds: Optional[List[Optional[float]]] = None
alarm_channels: Optional[List[int]] = None
class InferenceResult(BaseModel):
result_id: int
ts: float = Field(default_factory=lambda: time.time())
freq: str
model: str
prediction: str
probability: float
drone_probability: Optional[float] = None
drone_threshold: Optional[str] = None
images: List[str] = Field(default_factory=list)
def _prune_freq_locked(freq: str, now_ts: float) -> None:
cutoff = now_ts - TELEMETRY_HISTORY_SEC
buf = _buffers[freq]
while buf and float(buf[0].get('ts', 0.0)) < cutoff:
buf.popleft()
def _copy_series_locked(seconds: int, freq: Optional[str] = None) -> Dict[str, List[Dict[str, Any]]]:
now_ts = time.time()
cutoff = now_ts - seconds
if freq is not None:
data = [point for point in _buffers.get(freq, []) if float(point.get('ts', 0.0)) >= cutoff]
return {freq: data}
series: Dict[str, List[Dict[str, Any]]] = {}
for key, buf in _buffers.items():
series[key] = [point for point in buf if float(point.get('ts', 0.0)) >= cutoff]
return series
def _prune_inference_freq_locked(freq: str, now_ts: float) -> None:
cutoff = now_ts - INFERENCE_HISTORY_SEC
buf = _inference_buffers[freq]
while buf and float(buf[0].get('ts', 0.0)) < cutoff:
buf.popleft()
def _copy_inference_series_locked(limit: int, freq: Optional[str] = None) -> Dict[str, List[Dict[str, Any]]]:
now_ts = time.time()
cutoff = now_ts - INFERENCE_HISTORY_SEC
def _slice(buf: Deque[Dict[str, Any]]) -> List[Dict[str, Any]]:
recent = [item for item in buf if float(item.get('ts', 0.0)) >= cutoff]
return recent[-limit:]
if freq is not None:
return {freq: _slice(_inference_buffers.get(freq, deque()))}
series: Dict[str, List[Dict[str, Any]]] = {}
for key, buf in _inference_buffers.items():
series[key] = _slice(buf)
return series
def _sanitize_image_names(names: List[str]) -> List[str]:
safe_names: List[str] = []
for name in names:
base = Path(str(name)).name
if not base or not base.endswith('.png'):
continue
safe_names.append(base)
return safe_names
async def _broadcast(message: Dict[str, Any]) -> None:
dead: List[WebSocket] = []
for ws in list(_ws_clients):
try:
await ws.send_json(message)
except Exception:
dead.append(ws)
if dead:
async with _state_lock:
for ws in dead:
if ws in _ws_clients:
_ws_clients.remove(ws)
async def _broadcast_inference(message: Dict[str, Any]) -> None:
dead: List[WebSocket] = []
for ws in list(_inference_ws_clients):
try:
await ws.send_json(message)
except Exception:
dead.append(ws)
if dead:
async with _state_lock:
for ws in dead:
if ws in _inference_ws_clients:
_inference_ws_clients.remove(ws)
@app.post('/telemetry')
async def ingest_telemetry(point: TelemetryPoint):
payload = point.model_dump()
freq = str(payload['freq'])
now_ts = time.time()
async with _state_lock:
_buffers[freq].append(payload)
_prune_freq_locked(freq, now_ts)
await _broadcast({'type': 'point', 'data': payload})
return {'ok': True}
@app.get('/telemetry/history')
async def telemetry_history(
freq: Optional[str] = Query(default=None),
seconds: int = Query(default=300, ge=10, le=86400),
):
seconds = min(seconds, TELEMETRY_HISTORY_SEC)
async with _state_lock:
series = _copy_series_locked(seconds=seconds, freq=freq)
return {'seconds': seconds, 'series': series}
@app.post('/inference/result')
async def ingest_inference_result(result: InferenceResult):
payload = result.model_dump()
payload['images'] = _sanitize_image_names(payload.get('images', []))
freq = str(payload['freq'])
now_ts = time.time()
async with _state_lock:
_inference_buffers[freq].append(payload)
_prune_inference_freq_locked(freq, now_ts)
await _broadcast_inference({'type': 'inference_result', 'data': payload})
return {'ok': True}
@app.get('/inference/history')
async def inference_history(
freq: Optional[str] = Query(default=None),
limit: int = Query(default=20, ge=1, le=200),
):
async with _state_lock:
series = _copy_inference_series_locked(limit=limit, freq=freq)
return {'limit': limit, 'series': series}
@app.websocket('/telemetry/ws')
async def telemetry_ws(websocket: WebSocket):
await websocket.accept()
async with _state_lock:
_ws_clients.append(websocket)
snapshot = _copy_series_locked(seconds=min(300, TELEMETRY_HISTORY_SEC), freq=None)
await websocket.send_json({'type': 'snapshot', 'data': snapshot})
try:
while True:
# Keepalive channel from browser; content is ignored.
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
async with _state_lock:
if websocket in _ws_clients:
_ws_clients.remove(websocket)
@app.websocket('/inference/ws')
async def inference_ws(websocket: WebSocket):
await websocket.accept()
async with _state_lock:
_inference_ws_clients.append(websocket)
snapshot = _copy_inference_series_locked(limit=20, freq=None)
await websocket.send_json({'type': 'snapshot', 'data': snapshot})
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
async with _state_lock:
if websocket in _inference_ws_clients:
_inference_ws_clients.remove(websocket)
MONITOR_HTML = """
<!doctype html>
<html>
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>DroneDetector Telemetry</title>
<script src=\"https://cdn.plot.ly/plotly-2.35.2.min.js\"></script>
<style>
:root {
--bg: #f6f8fb;
--card: #ffffff;
--line: #d9dde5;
--text: #1c232e;
--green: #12b76a;
--red: #ef4444;
--muted: #5b6574;
}
body { margin: 0; background: var(--bg); color: var(--text); font-family: system-ui, -apple-system, Segoe UI, sans-serif; }
.wrap { max-width: 1800px; margin: 0 auto; padding: 14px; }
.head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.meta { font-size: 13px; color: var(--muted); }
.grid { display: flex; flex-direction: column; gap: 10px; }
.card { width: 100%; background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 8px 8px 8px; }
.title-row { display: flex; justify-content: space-between; align-items: center; margin: 4px 8px; }
.title { font-size: 20px; font-weight: 700; }
.ctrl { display: flex; align-items: center; gap: 6px; }
.ctrl label { font-size: 12px; color: var(--muted); }
.ctrl select { border: 1px solid var(--line); border-radius: 6px; padding: 2px 6px; }
.plot { height: 260px; width: 100%; }
.events-title { font-size: 12px; color: var(--muted); margin: 2px 8px 4px; }
.events { max-height: 110px; overflow-y: auto; border-top: 1px dashed var(--line); margin: 0 8px; padding-top: 4px; }
.ev { display: flex; justify-content: space-between; font-size: 12px; line-height: 1.4; color: var(--text); }
.ev-t { color: var(--muted); }
.ev-empty { color: var(--muted); font-size: 12px; }
</style>
</head>
<body>
<div class=\"wrap\">
<div class=\"head\">
<div>
<h2 style=\"margin:0;\">DroneDetector Telemetry Monitor</h2>
<div class=\"meta\">Green: dBFS current, Red: channel threshold, Red dots: alarm points</div>
</div>
<div class=\"meta\" id=\"status\">connecting...</div>
</div>
<div class=\"grid\" id=\"plots\"></div>
</div>
<script>
const windowSec = 300;
const state = {}; // freq -> points[]
const selectedChannel = {}; // freq -> 'max' | channel index as string
function numericSortFreq(a, b) {
return Number(a) - Number(b);
}
function formatTime(ts) {
return new Date(Number(ts) * 1000).toLocaleTimeString('ru-RU', {hour12: false});
}
function getChannelCount(freq) {
const pts = state[freq] || [];
let maxCount = 1;
for (const p of pts) {
if (Number.isFinite(Number(p.channels_total))) {
maxCount = Math.max(maxCount, Number(p.channels_total));
}
if (Array.isArray(p.channel_values)) {
maxCount = Math.max(maxCount, p.channel_values.length);
}
}
return maxCount;
}
function ensurePlot(freq) {
if (document.getElementById(`plot-${freq}`)) return;
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
<div class=\"title-row\">
<div class=\"title\">${freq} MHz</div>
<div class=\"ctrl\">
<label for=\"chan-${freq}\">channel</label>
<select id=\"chan-${freq}\"></select>
</div>
</div>
<div class=\"plot\" id=\"plot-${freq}\"></div>
<div class=\"events-title\">Alarms (time -> channel)</div>
<div class=\"events\" id=\"events-${freq}\"></div>
`;
document.getElementById('plots').appendChild(card);
selectedChannel[freq] = 'max';
const sel = document.getElementById(`chan-${freq}`);
sel.addEventListener('change', () => {
selectedChannel[freq] = sel.value;
render(freq);
});
}
function updateChannelSelector(freq) {
const sel = document.getElementById(`chan-${freq}`);
if (!sel) return;
const prev = selectedChannel[freq] ?? 'max';
const count = getChannelCount(freq);
const opts = ['max'];
for (let i = 0; i < count; i += 1) opts.push(String(i));
sel.innerHTML = '';
for (const v of opts) {
const option = document.createElement('option');
option.value = v;
option.textContent = v === 'max' ? 'max' : `ch ${v}`;
sel.appendChild(option);
}
selectedChannel[freq] = opts.includes(prev) ? prev : 'max';
sel.value = selectedChannel[freq];
}
function trimPoints(freq) {
const arr = state[freq] || [];
const cutoff = Date.now() / 1000 - windowSec;
state[freq] = arr.filter(p => Number(p.ts) >= cutoff);
}
function getPointValueForSelection(point, selection) {
if (selection === 'max') {
return {
y: point.dbfs_current ?? null,
threshold: point.dbfs_threshold ?? null,
};
}
const idx = Number(selection);
if (!Number.isInteger(idx)) {
return {y: null, threshold: null};
}
const y = Array.isArray(point.channel_values) && idx < point.channel_values.length
? point.channel_values[idx]
: null;
const threshold = Array.isArray(point.channel_thresholds) && idx < point.channel_thresholds.length
? point.channel_thresholds[idx]
: null;
return {y, threshold};
}
function isAlarmForSelection(point, selection) {
if (point.alarm !== true) return false;
if (selection === 'max') return true;
const idx = Number(selection);
if (!Number.isInteger(idx)) return false;
if (Array.isArray(point.alarm_channels) && point.alarm_channels.length > 0) {
return point.alarm_channels.includes(idx);
}
return Number(point.channel_idx) === idx;
}
function renderAlarmEvents(freq, pts) {
const el = document.getElementById(`events-${freq}`);
if (!el) return;
const alarmPts = pts.filter(p => p.alarm === true);
if (alarmPts.length === 0) {
el.innerHTML = '<div class=\"ev-empty\">no alarms</div>';
return;
}
const rows = alarmPts.slice(-20).reverse().map((p) => {
const channels = Array.isArray(p.alarm_channels) && p.alarm_channels.length > 0
? p.alarm_channels.join(',')
: String(p.channel_idx ?? '-');
return `<div class=\"ev\"><span class=\"ev-t\">${formatTime(p.ts)}</span><span>ch ${channels}</span></div>`;
});
el.innerHTML = rows.join('');
}
function render(freq) {
ensurePlot(freq);
trimPoints(freq);
updateChannelSelector(freq);
const pts = state[freq] || [];
const sel = selectedChannel[freq] ?? 'max';
const x = [];
const y = [];
const thr = [];
const alarmX = [];
const alarmY = [];
for (const p of pts) {
const metric = getPointValueForSelection(p, sel);
if (metric.y === null || metric.y === undefined) {
continue;
}
const ts = new Date(Number(p.ts) * 1000);
x.push(ts);
y.push(metric.y);
thr.push(metric.threshold);
if (isAlarmForSelection(p, sel)) {
alarmX.push(ts);
alarmY.push(metric.y);
}
}
const labelSuffix = sel === 'max' ? 'max' : `ch ${sel}`;
const traces = [
{
x,
y,
mode: 'lines',
name: `dBFS (${labelSuffix})`,
line: {color: '#12b76a', width: 2},
},
{
x,
y: thr,
mode: 'lines',
name: `Threshold (${labelSuffix})`,
line: {color: '#ef4444', width: 2, dash: 'dash'},
},
{
x: alarmX,
y: alarmY,
mode: 'markers',
name: 'Alarm',
marker: {color: '#ef4444', size: 6, symbol: 'circle'},
},
];
Plotly.react(`plot-${freq}`, traces, {
margin: {l: 40, r: 12, t: 12, b: 32},
showlegend: true,
legend: {orientation: 'h', y: 1.16},
xaxis: {
title: 'time',
tickformat: '%H:%M:%S',
hoverformat: '%H:%M:%S',
range: [new Date(Date.now() - windowSec * 1000), new Date()],
},
yaxis: {title: 'dBFS'},
}, {displayModeBar: false, responsive: true});
renderAlarmEvents(freq, pts);
}
function renderAll() {
const freqs = Object.keys(state).sort(numericSortFreq);
freqs.forEach(render);
}
async function loadInitial() {
const res = await fetch(`/telemetry/history?seconds=${windowSec}`);
const payload = await res.json();
const series = payload.series || {};
for (const [freq, points] of Object.entries(series)) {
state[freq] = points;
}
renderAll();
}
function connectWs() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/telemetry/ws`);
ws.onopen = () => {
document.getElementById('status').textContent = 'ws connected';
setInterval(() => {
if (ws.readyState === 1) ws.send('ping');
}, 20000);
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'snapshot' && msg.data) {
for (const [freq, points] of Object.entries(msg.data)) {
state[freq] = points;
}
renderAll();
return;
}
if (msg.type !== 'point') return;
const p = msg.data;
const freq = String(p.freq);
if (!state[freq]) state[freq] = [];
state[freq].push(p);
render(freq);
};
ws.onclose = () => {
document.getElementById('status').textContent = 'ws disconnected, retrying...';
setTimeout(connectWs, 1500);
};
ws.onerror = () => {
document.getElementById('status').textContent = 'ws error';
};
}
setInterval(() => {
renderAll();
}, 1000);
loadInitial().then(connectWs).catch((e) => {
document.getElementById('status').textContent = `init error: ${e}`;
connectWs();
});
</script>
</body>
</html>
"""
INFERENCE_VIEWER_HTML = """
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DroneDetector Inference Viewer</title>
<style>
:root {
--bg: #f4f6f8;
--card: #ffffff;
--line: #d7dde5;
--text: #1c232e;
--muted: #647084;
--ok: #16a34a;
--warn: #b45309;
--accent: #0f6fff;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font-family: system-ui, -apple-system, Segoe UI, sans-serif; }
.wrap { max-width: 1800px; margin: 0 auto; padding: 16px; }
.head { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; }
.meta { color: var(--muted); font-size: 13px; }
.actions { display: flex; gap: 8px; align-items: center; }
button { border: 1px solid var(--line); background: var(--card); color: var(--text); border-radius: 8px; padding: 8px 12px; cursor: pointer; }
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(520px, 1fr)); gap: 14px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 14px; }
.card-head { display: flex; justify-content: space-between; align-items: start; gap: 12px; margin-bottom: 10px; }
.freq { font-size: 28px; font-weight: 700; }
.pill { display: inline-flex; align-items: center; gap: 6px; border-radius: 999px; padding: 4px 10px; font-size: 12px; }
.pill.live { background: #dcfce7; color: #166534; }
.pill.paused { background: #fef3c7; color: #92400e; }
.summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 12px; margin-bottom: 12px; }
.summary-item { border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; }
.summary-label { color: var(--muted); font-size: 12px; margin-bottom: 4px; }
.summary-value { font-size: 14px; font-weight: 600; word-break: break-word; }
.images { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 12px; }
.image-card { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; background: #f8fafc; }
.image-card img { width: 100%; height: 180px; object-fit: contain; display: block; background: #fff; }
.image-card .cap { padding: 6px 8px; font-size: 12px; color: var(--muted); border-top: 1px solid var(--line); }
.history-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; }
.history { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
.thumb { min-width: 110px; max-width: 110px; border: 1px solid var(--line); border-radius: 10px; background: #fff; padding: 6px; cursor: pointer; }
.thumb.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.thumb img { width: 100%; height: 70px; object-fit: contain; display: block; background: #f8fafc; border-radius: 6px; }
.thumb .t1 { font-size: 12px; font-weight: 600; margin-top: 6px; }
.thumb .t2 { font-size: 11px; color: var(--muted); }
.empty { color: var(--muted); font-size: 13px; padding: 16px 0; }
</style>
</head>
<body>
<div class="wrap">
<div class="head">
<div>
<h2 style="margin:0 0 4px;">DroneDetector Inference Viewer</h2>
<div class="meta">Latest inference card per frequency. Browser keeps last 20 results per frequency.</div>
</div>
<div class="actions">
<div class="meta" id="ws-status">connecting...</div>
<button id="pause-btn" class="primary">Pause updates</button>
</div>
</div>
<div class="grid" id="cards"></div>
</div>
<script>
const MAX_RESULTS = 20;
const state = {};
const selected = {};
let paused = false;
let wsKeepalive = null;
function freqSort(a, b) { return Number(a) - Number(b); }
function formatTs(ts) {
return new Date(Number(ts) * 1000).toLocaleString('ru-RU', { hour12: false });
}
function escapeHtml(value) {
return String(value ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
}
function imageUrl(name) {
return `/inference/images/${encodeURIComponent(name)}`;
}
function primaryImage(result) {
return (result.images || [])[0] || '';
}
function trimResults(freq) {
const arr = state[freq] || [];
state[freq] = arr.slice(-MAX_RESULTS);
}
function ensureCard(freq) {
if (document.getElementById(`card-${freq}`)) return;
const card = document.createElement('div');
card.className = 'card';
card.id = `card-${freq}`;
card.innerHTML = `
<div class="card-head">
<div class="freq">${escapeHtml(freq)} MHz</div>
<div class="pill ${paused ? 'paused' : 'live'}" id="badge-${freq}">${paused ? 'paused' : 'live'}</div>
</div>
<div class="summary" id="summary-${freq}"></div>
<div class="images" id="images-${freq}"></div>
<div class="history-title">Recent results</div>
<div class="history" id="history-${freq}"></div>
`;
document.getElementById('cards').appendChild(card);
}
function renderSummary(freq, result) {
const el = document.getElementById(`summary-${freq}`);
el.innerHTML = `
<div class="summary-item"><div class="summary-label">Model</div><div class="summary-value">${escapeHtml(result.model)}</div></div>
<div class="summary-item"><div class="summary-label">Prediction</div><div class="summary-value">${escapeHtml(result.prediction)}</div></div>
<div class="summary-item"><div class="summary-label">Confidence</div><div class="summary-value">${Number(result.probability).toFixed(3)}</div></div>
<div class="summary-item"><div class="summary-label">Time</div><div class="summary-value">${escapeHtml(formatTs(result.ts))}</div></div>
`;
}
function renderImages(freq, result) {
const el = document.getElementById(`images-${freq}`);
const images = result.images || [];
if (!images.length) {
el.innerHTML = '<div class="empty">No images for this inference.</div>';
return;
}
el.innerHTML = images.map((name) => `
<div class="image-card">
<img loading="lazy" src="${imageUrl(name)}" alt="${escapeHtml(name)}" />
<div class="cap">${escapeHtml(name)}</div>
</div>
`).join('');
}
function renderHistory(freq) {
const el = document.getElementById(`history-${freq}`);
const results = state[freq] || [];
if (!results.length) {
el.innerHTML = '<div class="empty">No results yet.</div>';
return;
}
el.innerHTML = results.slice().reverse().map((result) => {
const active = String(selected[freq]) === String(result.result_id) ? 'active' : '';
const image = primaryImage(result);
return `
<button class="thumb ${active}" data-freq="${escapeHtml(freq)}" data-result-id="${result.result_id}">
${image ? `<img loading="lazy" src="${imageUrl(image)}" alt="${escapeHtml(result.prediction)}" />` : '<div class="empty" style="padding:20px 0;">no image</div>'}
<div class="t1">${escapeHtml(result.prediction)}</div>
<div class="t2">p=${Number(result.probability).toFixed(2)}</div>
</button>
`;
}).join('');
el.querySelectorAll('.thumb').forEach((node) => {
node.addEventListener('click', () => {
selected[freq] = Number(node.dataset.resultId);
render(freq);
});
});
}
function render(freq) {
ensureCard(freq);
trimResults(freq);
const badge = document.getElementById(`badge-${freq}`);
badge.textContent = paused ? 'paused' : 'live';
badge.className = `pill ${paused ? 'paused' : 'live'}`;
const results = state[freq] || [];
if (!results.length) {
document.getElementById(`summary-${freq}`).innerHTML = '<div class="empty">No inference results yet.</div>';
document.getElementById(`images-${freq}`).innerHTML = '';
document.getElementById(`history-${freq}`).innerHTML = '';
return;
}
const active = results.find((item) => String(item.result_id) === String(selected[freq])) || results[results.length - 1];
selected[freq] = active.result_id;
renderSummary(freq, active);
renderImages(freq, active);
renderHistory(freq);
}
function renderAll() {
Object.keys(state).sort(freqSort).forEach(render);
}
function applySnapshot(series) {
const next = {};
for (const [freq, results] of Object.entries(series || {})) {
next[String(freq)] = Array.isArray(results) ? results.slice(-MAX_RESULTS) : [];
}
for (const freq of Object.keys(next)) {
state[freq] = next[freq];
if (state[freq].length) {
selected[freq] = state[freq][state[freq].length - 1].result_id;
}
}
renderAll();
}
function ingestResult(result) {
const freq = String(result.freq);
if (!state[freq]) state[freq] = [];
state[freq].push(result);
trimResults(freq);
selected[freq] = result.result_id;
render(freq);
}
async function loadHistory() {
const res = await fetch(`/inference/history?limit=${MAX_RESULTS}`);
const payload = await res.json();
applySnapshot(payload.series || {});
}
function setPaused(nextPaused) {
paused = nextPaused;
const btn = document.getElementById('pause-btn');
btn.textContent = paused ? 'Resume updates' : 'Pause updates';
btn.className = paused ? '' : 'primary';
renderAll();
}
function connectWs() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/inference/ws`);
ws.onopen = () => {
document.getElementById('ws-status').textContent = paused ? 'ws connected (paused)' : 'ws connected';
if (wsKeepalive) clearInterval(wsKeepalive);
wsKeepalive = setInterval(() => {
if (ws.readyState === 1) ws.send('ping');
}, 20000);
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'snapshot' && msg.data) {
if (!paused) applySnapshot(msg.data);
return;
}
if (msg.type !== 'inference_result' || paused) return;
ingestResult(msg.data);
};
ws.onclose = () => {
document.getElementById('ws-status').textContent = 'ws disconnected, retrying...';
if (wsKeepalive) clearInterval(wsKeepalive);
setTimeout(connectWs, 1500);
};
ws.onerror = () => {
document.getElementById('ws-status').textContent = 'ws error';
};
}
document.getElementById('pause-btn').addEventListener('click', async () => {
if (paused) {
setPaused(false);
document.getElementById('ws-status').textContent = 'syncing...';
try {
await loadHistory();
document.getElementById('ws-status').textContent = 'ws connected';
} catch (err) {
document.getElementById('ws-status').textContent = `history error: ${err}`;
}
} else {
setPaused(true);
document.getElementById('ws-status').textContent = 'ws connected (paused)';
}
});
loadHistory().then(connectWs).catch((err) => {
document.getElementById('ws-status').textContent = `init error: ${err}`;
connectWs();
});
</script>
</body>
</html>
"""
@app.get('/', response_class=HTMLResponse)
@app.get('/monitor', response_class=HTMLResponse)
async def monitor_page():
return HTMLResponse(content=MONITOR_HTML)
@app.get('/inference-viewer', response_class=HTMLResponse)
async def inference_viewer_page():
return HTMLResponse(content=INFERENCE_VIEWER_HTML)
@app.get('/inference/images/{filename}')
async def inference_image(filename: str):
safe_name = Path(filename).name
if safe_name != filename:
raise HTTPException(status_code=404, detail='image not found')
image_path = (INFERENCE_RESULT_DIR / safe_name).resolve()
if image_path.parent != INFERENCE_RESULT_DIR or not image_path.is_file():
raise HTTPException(status_code=404, detail='image not found')
return FileResponse(image_path)
if __name__ == '__main__':
import uvicorn
uvicorn.run(app, host=TELEMETRY_BIND_HOST, port=TELEMETRY_BIND_PORT)