From 700f56ef51f4b7b5f50fb7d4d2d1e0034bcf46e3 Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Fri, 28 Aug 2026 20:04:53 -0400 Subject: [PATCH] fix: hydrate camera popup from GET /api/cameras/{id} List payload is slim (no URLs). YouTube thumbs need source_url from the detail endpoint, so the popup fetches the full row before rendering. --- app/static/index.html | 50 +++++++++++++++++++++-------- tests/test_camera_popup_contract.py | 15 +++++++++ 2 files changed, 51 insertions(+), 14 deletions(-) create mode 100644 tests/test_camera_popup_contract.py diff --git a/app/static/index.html b/app/static/index.html index db4fb01..0226c13 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -1905,9 +1905,7 @@ async function initMap() { L.control.zoom({ position: 'topright' }).addTo(map); map.attributionControl.setPrefix(''); let activeHls = null; - map.on('popupopen', (e) => { - if (activeHls) { try { activeHls.destroy(); } catch (_) {} activeHls = null; } - const root = e.popup.getElement(); + const startHlsFrom = (root) => { const v = root && root.querySelector('video[data-hls]'); if (!v) return; const fallback = () => { @@ -1935,6 +1933,25 @@ async function initMap() { }; if (window.Hls) startHls(); else loadHls().then(startHls).catch(fallback); + }; + map.on('popupopen', (e) => { + if (activeHls) { try { activeHls.destroy(); } catch (_) {} activeHls = null; } + const root = e.popup.getElement(); + const box = root && root.querySelector('.cam-pop[data-cam-id]'); + if (box && box.dataset.loaded !== '1') { + fetch(`${API}/api/cameras/${box.dataset.camId}`) + .then(r => { if (!r.ok) throw new Error('camera detail'); return r.json(); }) + .then(c => { + box.outerHTML = camPopupHtml(c); + startHlsFrom(e.popup.getElement()); + }) + .catch(() => { + const ph = box.querySelector('.thumb.placeholder'); + if (ph) ph.textContent = 'preview unavailable'; + }); + return; + } + startHlsFrom(root); }); map.on('popupclose', () => { if (activeHls) { try { activeHls.destroy(); } catch (_) {} activeHls = null; } @@ -2305,6 +2322,20 @@ function camThumb(c) { ); return `camera preview`; } +function camPopupHtml(c) { + return `
` + + `${esc(c.location_name || 'Open camera')}` + + `${c.id ? camThumb(c) : '
no snapshot
'}` + + `` + + `` + + `` + + `` + + `` + + `` + + `
Vendor${esc(c.vendor || '?')} ${esc(c.device_type || '')}
Source${esc(c.discovery_source || '?')}
First seen${esc((c.first_seen||'').slice(0,16).replace('T',' '))}
Last seen${esc((c.last_seen||'').slice(0,16).replace('T',' '))}
Coords${(c.lat!=null&&c.lon!=null) ? c.lat.toFixed(3)+', '+c.lon.toFixed(3) : 'unknown'}
` + + camSourceLink(c) + + `
`; +} async function loadCams() { if (!map) return; if (tooZoomedOut()) { @@ -2340,18 +2371,9 @@ async function loadCams() { iconSize: [12, 12], iconAnchor: [6, 6], }); camsGroup.addLayer(L.marker([c.lat, c.lon], { icon }) - .bindPopup(() => `
` + + .bindPopup(() => `
` + `${esc(c.location_name || 'Open camera')}` + - `${c.id ? camThumb(c) : '
no snapshot
'}` + - `` + - `` + - `` + - `` + - `` + - `` + - `
Vendor${esc(c.vendor || '?')} ${esc(c.device_type || '')}
Source${esc(c.discovery_source || '?')}
First seen${esc((c.first_seen||'').slice(0,16).replace('T',' '))}
Last seen${esc((c.last_seen||'').slice(0,16).replace('T',' '))}
Coords${(c.lat!=null&&c.lon!=null) ? c.lat.toFixed(3)+', '+c.lon.toFixed(3) : 'unknown'}
` + - camSourceLink(c) + - `
`)); + `
loading feed…
`)); }); camsGroup.addTo(map); camsGroup.eachLayer(l => l.setOpacity && l.setOpacity(camsOpacity)); diff --git a/tests/test_camera_popup_contract.py b/tests/test_camera_popup_contract.py new file mode 100644 index 0000000..42f1bce --- /dev/null +++ b/tests/test_camera_popup_contract.py @@ -0,0 +1,15 @@ +"""Camera list is slim (no URLs). Popup must fetch GET /api/cameras/{id}.""" + +from pathlib import Path + +HTML = Path(__file__).resolve().parents[1] / "app/static/index.html" + + +def test_popupopen_fetches_camera_detail_row(): + html = HTML.read_text() + start = html.index("map.on('popupopen'") + end = html.index("map.on('popupclose'") + block = html[start:end] + assert "fetch(" in block + assert "/api/cameras/" in block + assert "camPopupHtml(" in block