diff --git a/app/src/main/java/com/grigowashere/loratester/SettingsRepository.java b/app/src/main/java/com/grigowashere/loratester/SettingsRepository.java index 9872e7e..232ee28 100644 --- a/app/src/main/java/com/grigowashere/loratester/SettingsRepository.java +++ b/app/src/main/java/com/grigowashere/loratester/SettingsRepository.java @@ -13,6 +13,7 @@ public class SettingsRepository { private static final String KEY_RANGE_REGEX = "range_regex"; private static final String KEY_TELNET_ENABLED = "telnet_enabled"; private static final String KEY_DEVICE_ID = "device_id"; + private static final String KEY_DEVICE_LABEL = "device_label"; public static final String DEFAULT_SERVER = "https://lora.grigowashere.ru"; private static final String LEGACY_SERVER_HTTP = "http://grigowashere.ru:7634"; @@ -106,4 +107,16 @@ public class SettingsRepository { } return id; } + + public String getDeviceLabel() { + return prefs.getString(KEY_DEVICE_LABEL, null); + } + + public void setDeviceLabel(String label) { + if (label == null) { + prefs.edit().remove(KEY_DEVICE_LABEL).apply(); + } else { + prefs.edit().putString(KEY_DEVICE_LABEL, label.trim()).apply(); + } + } } diff --git a/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java b/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java index 675e92b..34092cb 100644 --- a/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java +++ b/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java @@ -317,7 +317,11 @@ public class TelemetryUploader implements TelnetClient.Listener { }); } - private static String phoneLabel() { + private String phoneLabel() { + String custom = settings.getDeviceLabel(); + if (custom != null && !custom.isBlank()) { + return custom.trim(); + } String manufacturer = Build.MANUFACTURER != null ? Build.MANUFACTURER : ""; String model = Build.MODEL != null ? Build.MODEL : ""; String label = (manufacturer + " " + model).trim(); diff --git a/app/src/main/java/com/grigowashere/loratester/ui/SettingsFragment.java b/app/src/main/java/com/grigowashere/loratester/ui/SettingsFragment.java index 0d4b390..0f56ade 100644 --- a/app/src/main/java/com/grigowashere/loratester/ui/SettingsFragment.java +++ b/app/src/main/java/com/grigowashere/loratester/ui/SettingsFragment.java @@ -42,6 +42,7 @@ public class SettingsFragment extends Fragment { TextInputEditText editPort = view.findViewById(R.id.editTelnetPort); TextInputEditText editRssi = view.findViewById(R.id.editRssiRegex); TextInputEditText editRange = view.findViewById(R.id.editRangeRegex); + TextInputEditText editDeviceLabel = view.findViewById(R.id.editDeviceLabel); SwitchMaterial switchTelnet = view.findViewById(R.id.switchTelnet); TextView deviceIdLabel = view.findViewById(R.id.deviceIdLabel); Button save = view.findViewById(R.id.btnSaveSettings); @@ -51,6 +52,10 @@ public class SettingsFragment extends Fragment { editPort.setText(String.valueOf(settings.getTelnetPort())); editRssi.setText(settings.getRssiRegex()); editRange.setText(settings.getRangeRegex()); + String savedLabel = settings.getDeviceLabel(); + if (savedLabel != null) { + editDeviceLabel.setText(savedLabel); + } switchTelnet.setChecked(settings.isTelnetEnabled()); deviceIdLabel.setText(getString(R.string.device_id_label, settings.getOrCreateDeviceId())); @@ -64,8 +69,10 @@ public class SettingsFragment extends Fragment { } settings.setRssiRegex(textOf(editRssi, SettingsRepository.DEFAULT_RSSI_REGEX)); settings.setRangeRegex(textOf(editRange, SettingsRepository.DEFAULT_RANGE_REGEX)); + settings.setDeviceLabel(textOf(editDeviceLabel, "")); settings.setTelnetEnabled(switchTelnet.isChecked()); uploader.refreshApi(); + uploader.registerPresence(); if (switchTelnet.isChecked()) { uploader.startTelnet(); } else { diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml index 4bcf845..9cfd007 100644 --- a/app/src/main/res/layout/fragment_settings.xml +++ b/app/src/main/res/layout/fragment_settings.xml @@ -73,6 +73,19 @@ android:inputType="text" /> + + + + + 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 = [];