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.
896 lines
29 KiB
Python
896 lines
29 KiB
Python
import asyncio
|
|
import os
|
|
import re
|
|
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()
|
|
INFERENCE_IMAGE_RE = re.compile(r"_inference_(\d+)_")
|
|
|
|
|
|
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
|
|
|
|
|
|
def _resolve_latest_images_for_model(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
model_name = str(payload.get('model', ''))
|
|
if not model_name or not INFERENCE_RESULT_DIR.is_dir():
|
|
payload['images'] = _sanitize_image_names(payload.get('images', []))
|
|
return payload
|
|
|
|
model_suffix = f"_{model_name}.png"
|
|
grouped: Dict[int, List[str]] = {}
|
|
for path in INFERENCE_RESULT_DIR.iterdir():
|
|
if not path.is_file():
|
|
continue
|
|
name = path.name
|
|
if not name.endswith(model_suffix):
|
|
continue
|
|
match = INFERENCE_IMAGE_RE.search(name)
|
|
if match is None:
|
|
continue
|
|
grouped.setdefault(int(match.group(1)), []).append(name)
|
|
|
|
if not grouped:
|
|
payload['images'] = _sanitize_image_names(payload.get('images', []))
|
|
return payload
|
|
|
|
current_id = int(payload.get('result_id', 0) or 0)
|
|
resolved_id = current_id if current_id in grouped else max(grouped)
|
|
payload['result_id'] = resolved_id
|
|
payload['images'] = sorted(grouped[resolved_id])
|
|
return payload
|
|
|
|
|
|
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 = _resolve_latest_images_for_model(payload)
|
|
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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
|
}
|
|
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)
|