Range regex
Подключить telnet
ID устройства: %1$s
+ Имя на карте (realme, OPPO…)
Сохранить
Сохранено
Сообщение…
diff --git a/server/core/__pycache__/storage.cpython-313.pyc b/server/core/__pycache__/storage.cpython-313.pyc
index 0decb08..ee3bfb7 100644
Binary files a/server/core/__pycache__/storage.cpython-313.pyc and b/server/core/__pycache__/storage.cpython-313.pyc differ
diff --git a/server/core/storage.py b/server/core/storage.py
index 1646979..0c0645d 100644
--- a/server/core/storage.py
+++ b/server/core/storage.py
@@ -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(
diff --git a/server/fastapi_app.py b/server/fastapi_app.py
index ff731fb..78609b7 100644
--- a/server/fastapi_app.py
+++ b/server/fastapi_app.py
@@ -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(),
}
diff --git a/server/static/index.html b/server/static/index.html
index c8700bd..e793217 100644
--- a/server/static/index.html
+++ b/server/static/index.html
@@ -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 = `${new Date(t * 1000).toLocaleTimeString()}
`;
+ let html = `${formatTimelineClock(cursor)}
`;
html += `Расстояние: ${dist.toFixed(0)} m (GPS)
`;
html += `TX ${txPos.lat.toFixed(5)}, ${txPos.lon.toFixed(5)}
`;
- 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 = `${new Date(t * 1000).toLocaleTimeString()}
`;
+ let html = `${formatTimelineClock(cursor)}
`;
html += `${pos.lat.toFixed(5)}, ${pos.lon.toFixed(5)}
`;
- 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 += '
' + 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 += `${escapeHtml(deviceDisplayName(d))}
Range: ${d.range_m ?? '—'} m
`;
+ html += `${escapeHtml(deviceDisplayName(d))}`;
+ html += `${escapeHtml(d.device_id)}
`;
+ html += ``;
+ html += ``;
+ html += ``;
+ html += `
`;
+ html += `
Range: ${d.range_m ?? '—'} m
`;
if (d.lat != null && d.lon != null && !isNullIsland(d.lat, d.lon)) {
html += `GPS: ${d.lat.toFixed(5)}, ${d.lon.toFixed(5)}
`;
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 = [];