|
|
|
@ -36,6 +36,9 @@ class TelemetryPoint(BaseModel):
|
|
|
|
alarm: bool = False
|
|
|
|
alarm: bool = False
|
|
|
|
channel_idx: int = 0
|
|
|
|
channel_idx: int = 0
|
|
|
|
channels_total: int = 1
|
|
|
|
channels_total: int = 1
|
|
|
|
|
|
|
|
channel_values: Optional[List[float]] = None
|
|
|
|
|
|
|
|
channel_thresholds: Optional[List[Optional[float]]] = None
|
|
|
|
|
|
|
|
alarm_channels: Optional[List[int]] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _prune_freq_locked(freq: str, now_ts: float) -> None:
|
|
|
|
def _prune_freq_locked(freq: str, now_ts: float) -> None:
|
|
|
|
@ -136,15 +139,25 @@ MONITOR_HTML = """
|
|
|
|
--text: #1c232e;
|
|
|
|
--text: #1c232e;
|
|
|
|
--green: #12b76a;
|
|
|
|
--green: #12b76a;
|
|
|
|
--red: #ef4444;
|
|
|
|
--red: #ef4444;
|
|
|
|
|
|
|
|
--muted: #5b6574;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
body { margin: 0; background: var(--bg); color: var(--text); font-family: system-ui, -apple-system, Segoe UI, sans-serif; }
|
|
|
|
body { margin: 0; background: var(--bg); color: var(--text); font-family: system-ui, -apple-system, Segoe UI, sans-serif; }
|
|
|
|
.wrap { max-width: 1400px; margin: 0 auto; padding: 16px; }
|
|
|
|
.wrap { max-width: 1800px; margin: 0 auto; padding: 14px; }
|
|
|
|
.head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
|
|
|
.head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
|
|
|
.meta { font-size: 13px; color: #5b6574; }
|
|
|
|
.meta { font-size: 13px; color: var(--muted); }
|
|
|
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); gap: 12px; }
|
|
|
|
.grid { display: flex; flex-direction: column; gap: 10px; }
|
|
|
|
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 8px 8px 2px; }
|
|
|
|
.card { width: 100%; background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 8px 8px 8px; }
|
|
|
|
.title { font-size: 14px; font-weight: 600; margin: 6px 8px; }
|
|
|
|
.title-row { display: flex; justify-content: space-between; align-items: center; margin: 4px 8px; }
|
|
|
|
.plot { height: 280px; }
|
|
|
|
.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>
|
|
|
|
</style>
|
|
|
|
</head>
|
|
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<body>
|
|
|
|
@ -152,7 +165,7 @@ MONITOR_HTML = """
|
|
|
|
<div class=\"head\">
|
|
|
|
<div class=\"head\">
|
|
|
|
<div>
|
|
|
|
<div>
|
|
|
|
<h2 style=\"margin:0;\">DroneDetector Telemetry Monitor</h2>
|
|
|
|
<h2 style=\"margin:0;\">DroneDetector Telemetry Monitor</h2>
|
|
|
|
<div class=\"meta\">Green: dBFS current, Red: dynamic alarm threshold</div>
|
|
|
|
<div class=\"meta\">Green: dBFS current, Red: channel threshold, Red dots: alarm points</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class=\"meta\" id=\"status\">connecting...</div>
|
|
|
|
<div class=\"meta\" id=\"status\">connecting...</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
@ -162,17 +175,77 @@ MONITOR_HTML = """
|
|
|
|
<script>
|
|
|
|
<script>
|
|
|
|
const windowSec = 300;
|
|
|
|
const windowSec = 300;
|
|
|
|
const state = {}; // freq -> points[]
|
|
|
|
const state = {}; // freq -> points[]
|
|
|
|
|
|
|
|
const selectedChannel = {}; // freq -> 'max' | channel index as string
|
|
|
|
|
|
|
|
|
|
|
|
function numericSortFreq(a, b) {
|
|
|
|
function numericSortFreq(a, b) {
|
|
|
|
return Number(a) - Number(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) {
|
|
|
|
function ensurePlot(freq) {
|
|
|
|
if (document.getElementById(`plot-${freq}`)) return;
|
|
|
|
if (document.getElementById(`plot-${freq}`)) return;
|
|
|
|
|
|
|
|
|
|
|
|
const card = document.createElement('div');
|
|
|
|
const card = document.createElement('div');
|
|
|
|
card.className = 'card';
|
|
|
|
card.className = 'card';
|
|
|
|
card.innerHTML = `<div class=\"title\">${freq} MHz</div><div class=\"plot\" id=\"plot-${freq}\"></div>`;
|
|
|
|
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);
|
|
|
|
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) {
|
|
|
|
function trimPoints(freq) {
|
|
|
|
@ -181,47 +254,134 @@ function trimPoints(freq) {
|
|
|
|
state[freq] = arr.filter(p => Number(p.ts) >= cutoff);
|
|
|
|
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) {
|
|
|
|
function render(freq) {
|
|
|
|
ensurePlot(freq);
|
|
|
|
ensurePlot(freq);
|
|
|
|
trimPoints(freq);
|
|
|
|
trimPoints(freq);
|
|
|
|
|
|
|
|
updateChannelSelector(freq);
|
|
|
|
|
|
|
|
|
|
|
|
const pts = state[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 x = pts.map(p => new Date(Number(p.ts) * 1000));
|
|
|
|
const ts = new Date(Number(p.ts) * 1000);
|
|
|
|
const y = pts.map(p => p.dbfs_current);
|
|
|
|
x.push(ts);
|
|
|
|
const thr = pts.map(p => p.dbfs_threshold);
|
|
|
|
y.push(metric.y);
|
|
|
|
const alarmPts = pts.filter(p => p.alarm === true);
|
|
|
|
thr.push(metric.threshold);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (isAlarmForSelection(p, sel)) {
|
|
|
|
|
|
|
|
alarmX.push(ts);
|
|
|
|
|
|
|
|
alarmY.push(metric.y);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const labelSuffix = sel === 'max' ? 'max' : `ch ${sel}`;
|
|
|
|
|
|
|
|
|
|
|
|
const traces = [
|
|
|
|
const traces = [
|
|
|
|
{
|
|
|
|
{
|
|
|
|
x,
|
|
|
|
x,
|
|
|
|
y,
|
|
|
|
y,
|
|
|
|
mode: 'lines',
|
|
|
|
mode: 'lines',
|
|
|
|
name: 'dBFS',
|
|
|
|
name: `dBFS (${labelSuffix})`,
|
|
|
|
line: {color: '#12b76a', width: 2}
|
|
|
|
line: {color: '#12b76a', width: 2},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
{
|
|
|
|
x,
|
|
|
|
x,
|
|
|
|
y: thr,
|
|
|
|
y: thr,
|
|
|
|
mode: 'lines',
|
|
|
|
mode: 'lines',
|
|
|
|
name: 'Threshold',
|
|
|
|
name: `Threshold (${labelSuffix})`,
|
|
|
|
line: {color: '#ef4444', width: 2, dash: 'dash'}
|
|
|
|
line: {color: '#ef4444', width: 2, dash: 'dash'},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
{
|
|
|
|
x: alarmPts.map(p => new Date(Number(p.ts) * 1000)),
|
|
|
|
x: alarmX,
|
|
|
|
y: alarmPts.map(p => p.dbfs_current),
|
|
|
|
y: alarmY,
|
|
|
|
mode: 'markers',
|
|
|
|
mode: 'markers',
|
|
|
|
name: 'Alarm',
|
|
|
|
name: 'Alarm',
|
|
|
|
marker: {color: '#ef4444', size: 6, symbol: 'circle'}
|
|
|
|
marker: {color: '#ef4444', size: 6, symbol: 'circle'},
|
|
|
|
}
|
|
|
|
},
|
|
|
|
];
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
Plotly.react(`plot-${freq}`, traces, {
|
|
|
|
Plotly.react(`plot-${freq}`, traces, {
|
|
|
|
margin: {l: 40, r: 12, t: 12, b: 32},
|
|
|
|
margin: {l: 40, r: 12, t: 12, b: 32},
|
|
|
|
showlegend: true,
|
|
|
|
showlegend: true,
|
|
|
|
legend: {orientation: 'h', y: 1.16},
|
|
|
|
legend: {orientation: 'h', y: 1.16},
|
|
|
|
xaxis: {title: 'time'},
|
|
|
|
xaxis: {
|
|
|
|
yaxis: {title: 'dBFS'}
|
|
|
|
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});
|
|
|
|
}, {displayModeBar: false, responsive: true});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
renderAlarmEvents(freq, pts);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderAll() {
|
|
|
|
function renderAll() {
|
|
|
|
@ -259,7 +419,9 @@ function connectWs() {
|
|
|
|
renderAll();
|
|
|
|
renderAll();
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (msg.type !== 'point') return;
|
|
|
|
if (msg.type !== 'point') return;
|
|
|
|
|
|
|
|
|
|
|
|
const p = msg.data;
|
|
|
|
const p = msg.data;
|
|
|
|
const freq = String(p.freq);
|
|
|
|
const freq = String(p.freq);
|
|
|
|
if (!state[freq]) state[freq] = [];
|
|
|
|
if (!state[freq]) state[freq] = [];
|
|
|
|
@ -277,6 +439,10 @@ function connectWs() {
|
|
|
|
};
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
setInterval(() => {
|
|
|
|
|
|
|
|
renderAll();
|
|
|
|
|
|
|
|
}, 1000);
|
|
|
|
|
|
|
|
|
|
|
|
loadInitial().then(connectWs).catch((e) => {
|
|
|
|
loadInitial().then(connectWs).catch((e) => {
|
|
|
|
document.getElementById('status').textContent = `init error: ${e}`;
|
|
|
|
document.getElementById('status').textContent = `init error: ${e}`;
|
|
|
|
connectWs();
|
|
|
|
connectWs();
|
|
|
|
|