This commit is contained in:
2026-06-16 11:24:21 +03:00
parent 3399e81447
commit 64607def4a
9 changed files with 346 additions and 72 deletions
Binary file not shown.
+19
View File
@@ -149,6 +149,25 @@ def _trim_telemetry(conn: sqlite3.Connection, device_id: str) -> None:
)
def update_device_label(device_id: str, label: str) -> dict[str, Any]:
if not is_valid_device_id(device_id):
raise ValueError(f"invalid device_id '{device_id}'")
clean = (label or "").strip()
if not clean:
raise ValueError("label required")
ts = time.time()
with _db() as conn:
conn.execute(
"""
INSERT INTO devices (device_id, label, last_seen)
VALUES (?, ?, ?)
ON CONFLICT(device_id) DO UPDATE SET label = excluded.label
""",
(device_id, clean, ts),
)
return {"ok": True, "device_id": device_id, "label": clean}
def list_devices() -> list[dict[str, Any]]:
with _db() as conn:
rows = conn.execute(
+14
View File
@@ -31,6 +31,7 @@ storage.init_db()
class TelemetryBody(BaseModel):
device_id: str
device_label: Optional[str] = None
lat: Optional[float] = None
lon: Optional[float] = None
rssi: Optional[float] = None
@@ -53,6 +54,10 @@ class TrackStartBody(BaseModel):
label: Optional[str] = None
class DeviceLabelBody(BaseModel):
label: str
class TrackPoint(BaseModel):
ts: Optional[float] = None
lat: float
@@ -120,6 +125,14 @@ def get_devices():
return storage.list_devices()
@app.patch("/api/devices/{device_id}/label")
def patch_device_label(device_id: str, body: DeviceLabelBody):
try:
return storage.update_device_label(device_id, body.label)
except ValueError as e:
raise HTTPException(400, detail=str(e)) from e
@app.get("/api/telemetry")
def get_telemetry_history(
device_id: Optional[str] = None,
@@ -366,6 +379,7 @@ def health():
return {
"ok": status["db_ok"],
"ts": time.time(),
"api_build": "2026-06-16c",
**status,
**elevation_status(),
}
+274 -71
View File
@@ -128,6 +128,14 @@
display: flex; gap: 4px; flex-wrap: wrap;
pointer-events: auto;
}
#mapWrap .leaflet-top.leaflet-right {
top: 46px;
right: 10px;
margin-top: 0;
}
#mapWrap .leaflet-control-layers-toggle {
width: 30px; height: 30px; line-height: 30px;
}
#mapCenterBar button {
padding: 5px 10px; font-size: 0.75rem; border: 1px solid #444; border-radius: 4px;
background: #16213ee6; color: #eee; cursor: pointer;
@@ -353,6 +361,8 @@
{ position: 'topright', collapsed: true }
).addTo(map);
const API_BUILD = '2026-06-16c';
const markers = {};
let selectedId = null;
let chatSince = 0;
@@ -392,6 +402,7 @@
let lastDevices = [];
const deviceLabelCache = {};
let timelineSpanMs = 1000;
let timelineUseProgress = false;
let elevProfileTx = null;
let elevProfileRx = null;
let elevProfileSingle = null;
@@ -458,9 +469,113 @@
function sliderTime() {
const ms = parseInt(document.getElementById('timeSlider').value || '0', 10);
if (timelineUseProgress) return ms / timelineSpanMs;
return overlapMin + ms / 1000;
}
function timelineCursor() {
const ms = parseInt(document.getElementById('timeSlider').value || '0', 10);
if (timelineUseProgress) return { progress: ms / timelineSpanMs };
const t = overlapMin + ms / 1000;
return { t };
}
function analyzeTrackTiming(points) {
if (!points || points.length < 2) {
return { useProgress: true, issues: ['мало точек'], withMeta: 0, total: points?.length || 0 };
}
const ts = points.map(p => Number(p.ts));
const unique = new Set(ts.map(v => Math.round(v * 1000))).size;
const span = Math.max(...ts) - Math.min(...ts);
const withMeta = points.filter(p => p.meta && String(p.meta).length > 2).length;
const issues = [];
if (!Number.isFinite(span) || span < 0.05) issues.push('одинаковое время у точек');
if (unique < 2) issues.push('нет различимых меток времени');
if (withMeta < Math.max(1, Math.floor(points.length * 0.15))) {
issues.push(`радио-meta только у ${withMeta}/${points.length} точек`);
}
return {
useProgress: issues.some(x => x.includes('время') || x.includes('меток')),
issues,
withMeta,
total: points.length,
span,
unique,
};
}
function positionAtProgress(points, progress) {
if (!points?.length) return null;
const p = Math.max(0, Math.min(1, progress));
if (points.length === 1) {
const one = points[0];
return { lat: Number(one.lat), lon: Number(one.lon), meta: one.meta, rssi: one.rssi };
}
const f = p * (points.length - 1);
const i = Math.floor(f);
const j = Math.min(i + 1, points.length - 1);
const frac = f - i;
const a = points[i];
const b = points[j];
if (frac <= 0 || i === j) {
return { lat: Number(a.lat), lon: Number(a.lon), meta: a.meta, rssi: a.rssi };
}
return {
lat: Number(a.lat) + (Number(b.lat) - Number(a.lat)) * frac,
lon: Number(a.lon) + (Number(b.lon) - Number(a.lon)) * frac,
meta: frac < 0.5 ? a.meta : b.meta,
rssi: frac < 0.5 ? a.rssi : b.rssi,
};
}
function positionAtCursor(points, cursor) {
if (timelineUseProgress) return positionAtProgress(points, cursor.progress);
return positionAt(points, cursor.t);
}
function trackDistanceAtCursor(track, cursor) {
if (timelineUseProgress) return trackDistanceAtProgress(track, cursor.progress);
return trackDistanceAtTime(track, cursor.t);
}
function trackDistanceAtProgress(track, progress) {
if (!track?.points?.length) return 0;
const pts = track.points;
if (pts.length === 1) return 0;
const f = Math.max(0, Math.min(1, progress)) * (pts.length - 1);
const idx = Math.floor(f);
const frac = f - idx;
let dist = 0;
for (let i = 1; i <= idx && i < pts.length; i++) {
dist += haversineM(pts[i - 1].lat, pts[i - 1].lon, pts[i].lat, pts[i].lon);
}
if (frac > 0 && idx + 1 < pts.length) {
const a = pts[idx];
const b = pts[idx + 1];
const lat = Number(a.lat) + (Number(b.lat) - Number(a.lat)) * frac;
const lon = Number(a.lon) + (Number(b.lon) - Number(a.lon)) * frac;
dist += haversineM(a.lat, a.lon, lat, lon);
}
return dist;
}
function snapAtCursor(track, telemetryRows, cursor, roleFallback) {
if (timelineUseProgress) {
const pos = positionAtProgress(track?.points, cursor.progress);
if (!pos) return null;
return { meta: pos.meta, role: roleFallback, rssi: pos.rssi, ts: cursor.progress };
}
return snapAtTime(track, telemetryRows, cursor.t, roleFallback);
}
function formatTimelineClock(cursor) {
if (timelineUseProgress) {
const pct = Math.round(cursor.progress * 100);
return `${pct}% пути`;
}
return new Date(cursor.t * 1000).toLocaleTimeString();
}
function normalizeTrack(track) {
if (!track?.points?.length) return track;
const points = track.points.map(p => ({
@@ -1554,11 +1669,11 @@
drawElevationChart();
requestAnimationFrame(() => drawElevationChart(
singleTrackActive
? { single: trackDistanceAtTime(loadedSingleTrack, sliderTime()) }
? { single: trackDistanceAtCursor(loadedSingleTrack, timelineCursor()) }
: dualTracksActive
? {
tx: trackDistanceAtTime(loadedTxTrack, sliderTime()),
rx: trackDistanceAtTime(loadedRxTrack, sliderTime())
tx: trackDistanceAtCursor(loadedTxTrack, timelineCursor()),
rx: trackDistanceAtCursor(loadedRxTrack, timelineCursor())
}
: null
));
@@ -1861,7 +1976,7 @@
layerList.push(seg);
}
pts.forEach(p => {
pts.forEach((p, pointIdx) => {
const q = rxQualityFromMeta(p.meta);
const ptColor = useQuality && q != null ? (qualityColor(q) || '#ff8800') : color;
const m = L.circleMarker([p.lat, p.lon], {
@@ -1869,24 +1984,29 @@
});
m.addTo(map);
m.on('click', () => {
const relMs = Math.max(0, Math.min(Math.round((p.ts - overlapMin) * 1000), timelineSpanMs));
document.getElementById('timeSlider').value = String(relMs);
if (timelineUseProgress) {
const relMs = Math.round((pointIdx / Math.max(1, pts.length - 1)) * timelineSpanMs);
document.getElementById('timeSlider').value = String(relMs);
updateTimelineAt(timelineCursor(), { openModal: true });
} else {
const relMs = Math.max(0, Math.min(Math.round((Number(p.ts) - overlapMin) * 1000), timelineSpanMs));
document.getElementById('timeSlider').value = String(relMs);
updateTimelineAt(timelineCursor(), { openModal: true });
}
modalMode = 'timeline';
updateTimelineAt(Number(p.ts), { openModal: true });
});
markerList.push(m);
});
}
function buildTimelineModalHtml(t, txPos, rxPos) {
function buildTimelineModalHtml(cursor, txPos, rxPos) {
if (!txPos || !rxPos) return '';
const dist = haversineM(txPos.lat, txPos.lon, rxPos.lat, rxPos.lon);
let html = `<b>${new Date(t * 1000).toLocaleTimeString()}</b><br>`;
let html = `<b>${formatTimelineClock(cursor)}</b><br>`;
html += `Расстояние: ${dist.toFixed(0)} m (GPS)<br><br>`;
html += `<span class="legend-tx">TX</span> ${txPos.lat.toFixed(5)}, ${txPos.lon.toFixed(5)}<br>`;
const { txTel, rxTel } = pairedTelemetryAtTime(
loadedTxTrack, loadedRxTrack, telemetryTx, telemetryRx, t
);
const txTel = snapAtCursor(loadedTxTrack, telemetryTx, cursor, 'TX');
const rxTel = snapAtCursor(loadedRxTrack, telemetryRx, cursor, 'RX');
html += renderTimelineCompare(
txTel || { meta: txPos.meta, role: 'TX', rssi: null },
rxTel || { meta: rxPos.meta, role: 'RX', rssi: null },
@@ -1901,16 +2021,19 @@
return { min: Number(points[0].ts), max: Number(points[points.length - 1].ts), mode: 'single' };
}
function updateTimelineAt(t, opts) {
function updateTimelineAt(tOrCursor, opts) {
const openModal = opts && opts.openModal;
const cursor = (tOrCursor && typeof tOrCursor === 'object')
? tOrCursor
: (timelineUseProgress ? { progress: Number(tOrCursor) } : { t: Number(tOrCursor) });
if (singleTrackActive && loadedSingleTrack) {
updateTimelineAtSingle(t, openModal);
updateTimelineAtSingle(cursor, openModal);
return;
}
if (!loadedTxTrack || !loadedRxTrack) return;
const txPos = positionAt(loadedTxTrack.points, t);
const rxPos = positionAt(loadedRxTrack.points, t);
document.getElementById('timeCurrent').textContent = new Date(t * 1000).toLocaleTimeString();
const txPos = positionAtCursor(loadedTxTrack.points, cursor);
const rxPos = positionAtCursor(loadedRxTrack.points, cursor);
document.getElementById('timeCurrent').textContent = formatTimelineClock(cursor);
if (ghostTx) map.removeLayer(ghostTx);
if (ghostRx) map.removeLayer(ghostRx);
@@ -1934,15 +2057,14 @@
[[txPos.lat, txPos.lon], [rxPos.lat, rxPos.lon]],
{ color: '#00ff88', weight: 3, dashArray: '6,6' }
).addTo(map);
const modalHtml = buildTimelineModalHtml(t, txPos, rxPos);
const modalHtml = buildTimelineModalHtml(cursor, txPos, rxPos);
if (openModal || (isModalOpen() && modalMode === 'timeline')) {
openMapModal(modalHtml, 'timeline');
}
}
const { txTel, rxTel } = pairedTelemetryAtTime(
loadedTxTrack, loadedRxTrack, telemetryTx, telemetryRx, t
);
const txTel = snapAtCursor(loadedTxTrack, telemetryTx, cursor, 'TX');
const rxTel = snapAtCursor(loadedRxTrack, telemetryRx, cursor, 'RX');
const timelineStatsEl = document.getElementById('timelineStats');
setPanelHtml(timelineStatsEl, renderTimelineCompare(
txTel,
@@ -1951,16 +2073,16 @@
deviceDisplayName(loadedRxTrack?.device_id)
));
drawElevationChart({
tx: trackDistanceAtTime(loadedTxTrack, t),
rx: trackDistanceAtTime(loadedRxTrack, t)
tx: trackDistanceAtCursor(loadedTxTrack, cursor),
rx: trackDistanceAtCursor(loadedRxTrack, cursor)
});
}
function updateTimelineAtSingle(t, openModal) {
function updateTimelineAtSingle(cursor, openModal) {
const track = loadedSingleTrack;
if (!track) return;
const pos = positionAt(track.points, t);
document.getElementById('timeCurrent').textContent = new Date(t * 1000).toLocaleTimeString();
const pos = positionAtCursor(track.points, cursor);
document.getElementById('timeCurrent').textContent = formatTimelineClock(cursor);
if (ghostTx) map.removeLayer(ghostTx);
if (ghostRx) map.removeLayer(ghostRx);
if (linkLine) map.removeLayer(linkLine);
@@ -1972,9 +2094,9 @@
ghostTx = L.circleMarker([pos.lat, pos.lon], {
radius: 10, color, fillColor: color, fillOpacity: 0.9, weight: 3
}).addTo(map);
let html = `<b>${new Date(t * 1000).toLocaleTimeString()}</b><br>`;
let html = `<b>${formatTimelineClock(cursor)}</b><br>`;
html += `${pos.lat.toFixed(5)}, ${pos.lon.toFixed(5)}<br>`;
const tel = snapAtTime(track, telemetrySingle, t, track.role);
const tel = snapAtCursor(track, telemetrySingle, cursor, track.role);
const snap = tel ? telemetryToSnap(tel) : RadioUI.parseRadioSnapshot(pos.meta);
html += RadioUI.formatRadioPanel(snap, new Set(), isRadioStaticOpen(mapModalBody));
if (tel) html += '<br>' + formatTelemetryRow(tel, new Set());
@@ -1982,7 +2104,7 @@
openMapModal(html, 'timeline');
}
}
const tel = snapAtTime(track, telemetrySingle, t, track.role);
const tel = snapAtCursor(track, telemetrySingle, cursor, track.role);
const snap = tel ? telemetryToSnap(tel) : RadioUI.parseRadioSnapshot(null);
const timelineStatsEl = document.getElementById('timelineStats');
setPanelHtml(
@@ -2008,8 +2130,31 @@
statsPanel.classList.toggle('timeline-single', single);
}
function applyProgressTimeline(diagnosis, extraNote) {
const note = document.getElementById('timelineNote');
timelineUseProgress = true;
overlapMin = 0;
overlapMax = 1;
timelineSpanMs = 1000;
const slider = document.getElementById('timeSlider');
slider.min = 0;
slider.max = String(timelineSpanMs);
slider.step = 10;
slider.value = '0';
document.getElementById('timeStart').textContent = '0%';
document.getElementById('timeEnd').textContent = '100%';
document.getElementById('timeCurrent').textContent = '0% пути';
const issues = (diagnosis?.issues || []).join('; ');
note.textContent = (extraNote ? extraNote + ' ' : '')
+ 'Старый/битый трек: время записи ненадёжно — шкала по прогрессу пути.'
+ (issues ? ` (${issues})` : '');
note.style.color = '#ffb74d';
}
function applyTimelineRange(range, noteText) {
const note = document.getElementById('timelineNote');
timelineUseProgress = false;
note.style.color = '#aaa';
overlapMin = range.min;
overlapMax = range.max;
const spanSec = Math.max(0.001, overlapMax - overlapMin);
@@ -2026,62 +2171,79 @@
}
function setupTimelineSingle() {
const range = singleTrackRange(loadedSingleTrack.points);
const diag = analyzeTrackTiming(loadedSingleTrack.points);
setTimelineMode(true);
if (!range) {
if (!loadedSingleTrack.points?.length) {
setTimelineVisible(false);
return;
}
applyTimelineRange(
range,
`Трек #${loadedSingleTrack.id} · ${deviceDisplayName(loadedSingleTrack.device_id)}`
);
if (diag.useProgress) {
applyProgressTimeline(diag, `Трек #${loadedSingleTrack.id} · ${deviceDisplayName(loadedSingleTrack)}`);
} else {
const range = singleTrackRange(loadedSingleTrack.points);
applyTimelineRange(
range,
`Трек #${loadedSingleTrack.id} · ${deviceDisplayName(loadedSingleTrack)}`
);
}
setTimelineVisible(true);
updateTimelineAtSingle(overlapMin);
updateTimelineAtSingle(timelineCursor());
loadElevationProfiles();
}
function setupTimeline() {
setTimelineMode(false);
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
if (!range) {
setTimelineVisible(false);
return;
const txDiag = analyzeTrackTiming(loadedTxTrack.points);
const rxDiag = analyzeTrackTiming(loadedRxTrack.points);
if (txDiag.useProgress || rxDiag.useProgress) {
applyProgressTimeline(
{ issues: [...new Set([...(txDiag.issues || []), ...(rxDiag.issues || [])])] },
'Сравнение TX/RX по прогрессу пути.'
);
} else {
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
if (!range) {
setTimelineVisible(false);
return;
}
let noteText = 'Общий интервал записи обоих треков.';
if (range.mode === 'union') {
noteText =
'Треки не пересекаются по времени — шкала на полном диапазоне; вне записи позиция удерживается на краю.';
}
applyTimelineRange(range, noteText);
}
let noteText = 'Общий интервал записи обоих треков.';
if (range.mode === 'union') {
noteText =
'Треки не пересекаются по времени — шкала на полном диапазоне; вне записи позиция удерживается на краю.';
}
applyTimelineRange(range, noteText);
setTimelineVisible(true);
updateTimelineAt(overlapMin);
updateTimelineAt(timelineCursor());
loadElevationProfiles();
}
async function refreshTimelineTelemetry() {
if (singleTrackActive && loadedSingleTrack) {
const range = singleTrackRange(loadedSingleTrack.points);
if (!range) return;
const res = await fetch(
`/api/telemetry?device_id=${encodeURIComponent(loadedSingleTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`,
{ cache: 'no-store' }
);
if (res.ok) telemetrySingle = await res.json();
const t = sliderTime();
updateTimelineAtSingle(t);
if (!timelineUseProgress) {
const range = singleTrackRange(loadedSingleTrack.points);
if (!range) return;
const res = await fetch(
`/api/telemetry?device_id=${encodeURIComponent(loadedSingleTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`,
{ cache: 'no-store' }
);
if (res.ok) telemetrySingle = await res.json();
}
updateTimelineAtSingle(timelineCursor());
return;
}
if (!dualTracksActive || !loadedTxTrack || !loadedRxTrack) return;
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
if (!range) return;
const [telTx, telRx] = await Promise.all([
fetch(`/api/telemetry?device_id=${encodeURIComponent(loadedTxTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`, { cache: 'no-store' }),
fetch(`/api/telemetry?device_id=${encodeURIComponent(loadedRxTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`, { cache: 'no-store' })
]);
if (telTx.ok) telemetryTx = await telTx.json();
if (telRx.ok) telemetryRx = await telRx.json();
updateTimelineAt(sliderTime());
if (!timelineUseProgress) {
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
if (!range) return;
const [telTx, telRx] = await Promise.all([
fetch(`/api/telemetry?device_id=${encodeURIComponent(loadedTxTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`, { cache: 'no-store' }),
fetch(`/api/telemetry?device_id=${encodeURIComponent(loadedRxTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`, { cache: 'no-store' })
]);
if (telTx.ok) telemetryTx = await telTx.json();
if (telRx.ok) telemetryRx = await telRx.json();
}
updateTimelineAt(timelineCursor());
}
function trackOptionLabel(t) {
@@ -2165,7 +2327,7 @@
{ cache: 'no-store' }
);
if (telRes.ok) telemetrySingle = await telRes.json();
updateTimelineAtSingle(overlapMin);
updateTimelineAtSingle(timelineCursor());
}
document.getElementById('trackInfo').textContent =
`Трек #${loadedSingleTrack.id} (${loadedSingleTrack.points.length} точек)`;
@@ -2227,8 +2389,7 @@
]);
if (telTx.ok) telemetryTx = await telTx.json();
if (telRx.ok) telemetryRx = await telRx.json();
const t = sliderTime();
updateTimelineAt(t);
updateTimelineAt(timelineCursor());
}
const modeHint = range && range.mode === 'union' ? ' · без пересечения по времени' : '';
@@ -2382,7 +2543,7 @@
const onSlider = () => {
if (!singleTrackActive && !dualTracksActive) return;
modalMode = 'timeline';
updateTimelineAt(sliderTime());
updateTimelineAt(timelineCursor());
};
slider.addEventListener('input', onSlider);
slider.addEventListener('change', onSlider);
@@ -2401,18 +2562,48 @@
let ms = parseInt(slider.value, 10) + step;
if (ms > timelineSpanMs) ms = 0;
slider.value = String(ms);
updateTimelineAt(sliderTime());
updateTimelineAt(timelineCursor());
}, Math.max(100, step));
};
}
async function saveDeviceLabel(deviceId) {
const input = document.getElementById('deviceLabelInput');
const label = input ? input.value.trim() : '';
if (!deviceId || !label) return;
const res = await fetch(`/api/devices/${encodeURIComponent(deviceId)}/label`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label })
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.detail || data.error || 'Не удалось сохранить имя');
return;
}
deviceLabelCache[deviceId] = label;
await fetchDevices();
}
document.getElementById('stats').addEventListener('click', e => {
if (e.target && e.target.id === 'btnSaveDeviceLabel' && selectedId) {
saveDeviceLabel(selectedId);
}
});
function buildDeviceStatsHtml(d) {
const snap = RadioUI.parseRadioSnapshot(d.meta, d.role, d.rssi);
const changed = RadioUI.diffSnapshots(prevDeviceSnap, snap);
prevDeviceSnap = snap;
const statsEl = document.getElementById('stats');
let html = RadioUI.formatRadioPanel(snap, changed, isRadioStaticOpen(statsEl));
html += `<b>${escapeHtml(deviceDisplayName(d))}</b><br>Range: ${d.range_m ?? '—'} m<br>`;
html += `<b>${escapeHtml(deviceDisplayName(d))}</b>`;
html += `<div class="muted" style="font-size:0.75rem">${escapeHtml(d.device_id)}</div>`;
html += `<div style="margin-top:6px;display:flex;gap:4px">`;
html += `<input id="deviceLabelInput" type="text" value="${escapeHtml(deviceDisplayName(d))}" placeholder="Имя устройства" style="flex:1;padding:4px 6px;border-radius:4px;border:1px solid #444;background:#0a0a14;color:#eee;font-size:0.8rem" />`;
html += `<button type="button" id="btnSaveDeviceLabel" style="padding:4px 8px;border-radius:4px;border:1px solid #444;background:#16213e;color:#eee;cursor:pointer;font-size:0.75rem">OK</button>`;
html += `</div>`;
html += `<br>Range: ${d.range_m ?? '—'} m<br>`;
if (d.lat != null && d.lon != null && !isNullIsland(d.lat, d.lon)) {
html += `GPS: ${d.lat.toFixed(5)}, ${d.lon.toFixed(5)}<br>`;
Object.keys(markers).forEach(id => {
@@ -2447,6 +2638,18 @@
devices.forEach(d => { if (d.role === 'TX') tx++; else if (d.role === 'RX') rx++; });
document.getElementById('status').textContent =
`${devices.length} устр. · TX:${tx} RX:${rx} · ${new Date().toLocaleTimeString()}`;
try {
const hres = await fetch('/api/health', { cache: 'no-store' });
if (hres.ok) {
const h = await hres.json();
if (h.api_build && h.api_build !== API_BUILD) {
document.getElementById('status').title =
`UI ${API_BUILD} · сервер ${h.api_build} — обновите образ Docker`;
} else if (h.api_build) {
document.getElementById('status').title = `build ${h.api_build}`;
}
}
} catch (e) {}
const list = document.getElementById('deviceList');
list.innerHTML = '';
const bounds = [];