Files
Behavision/behavision/static/dashboard.html
Suriyakumarvijayanayagam 4c62fc0ef3 The Loyaly mark everywhere a person sees the product
Brand assets in brand/ (the 512px mark, sizes for each surface, a
multi-size .ico). Windows executables carry it as a compiled-in
resource (rsrc_windows_amd64.syso from go-winres) so Explorer, the
taskbar and the installer show it; installer/build.ps1 therefore uses a
plain go build rather than wails build, which would add a second copy
and fail the link. The tray icon is the mark with a state dot over its
corner - a plain coloured circle read as a generic status light among
other icons - rendered from the embedded PNG at 32px so it survives
150% scaling. The desktop app's login, setup and sidebar marks, the
head-office web app's mark and favicon, and the engine dashboard's
favicon are the same file.

Also found while packaging: no wheel so far shipped static/, so the
engine's own dashboard at :8010 on a Windows source install would have
failed with a missing file. package-data now includes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 12:54:19 +05:30

554 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Behavision</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; margin: 0; }
body { font: 14px/1.5 system-ui, sans-serif; background: #101418;
color: #dde3ea; padding: 1.25rem; }
h1 { font-size: 1.15rem; margin-bottom: 1rem; letter-spacing: .02em; }
h1 small { color: #7b8794; font-weight: 400; margin-left: .5rem; }
.grid { display: grid; grid-template-columns: 2fr 1fr; gap: 1rem; }
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
.card { background: #171d24; border: 1px solid #232c36;
border-radius: 10px; padding: 1rem; }
.card h2 { font-size: .8rem; text-transform: uppercase; color: #7b8794;
letter-spacing: .08em; margin-bottom: .75rem; }
img.feed { width: 100%; border-radius: 6px; background: #000;
min-height: 240px; }
table { width: 100%; border-collapse: collapse; }
td, th { padding: .35rem .5rem; text-align: left;
border-bottom: 1px solid #232c36; }
th { color: #7b8794; font-weight: 500; font-size: .78rem; }
.muted { color: #7b8794; }
ul#events { list-style: none; max-height: 380px; overflow-y: auto; }
ul#events li { padding: .4rem 0; border-bottom: 1px solid #232c36; }
.tag { display: inline-block; padding: .05rem .45rem; border-radius: 99px;
font-size: .72rem; margin-right: .4rem; }
.tag.new { background: #2b4e77; } .tag.seen { background: #2e5c3a; }
.tag.cam { background: #5a4a2a; }
.tag.miss { background: #6b3030; }
.tag.merge { background: #4a3a6b; }
.bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden;
margin: .35rem 0 .5rem; background: #222; }
.bar i { display: block; }
.warn { color: #e0a33a; }
.pl-row { margin-bottom: .9rem; }
.pl-row b { font-weight: 600; }
.legend { font-size: .72rem; }
.legend span { margin-right: .7rem; white-space: nowrap; }
.btn { background: #2a3446; color: #cfd8e3; border: 1px solid #3a4658;
border-radius: 4px; padding: .25rem .6rem; cursor: pointer;
font: inherit; font-size: .78rem; }
.btn:hover { background: #35415a; }
.btn[disabled] { opacity: .5; cursor: default; }
.btn.primary { background: #2b4e77; border-color: #3a6291; }
.btn.danger { background: #4a2626; border-color: #6b3030; }
.card h2 .btn { float: right; margin-top: -.15rem; text-transform: none;
letter-spacing: 0; }
.cam-row { display: flex; align-items: center; gap: .5rem;
padding: .4rem 0; border-bottom: 1px solid #1e2430; }
.cam-row:last-child { border-bottom: 0; }
.cam-row .grow { flex: 1; min-width: 0; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
.pill { font-size: .7rem; padding: .05rem .45rem; border-radius: 99px; }
.pill.up { background: #2e5c3a; } .pill.down { background: #6b3030; }
.fields { display: grid; grid-template-columns: 1fr 1fr; gap: .5rem .75rem;
margin-bottom: .6rem; }
.fields label { display: block; font-size: .72rem; color: #7b8794;
margin-bottom: .15rem; }
.fields .wide { grid-column: 1 / -1; }
.fields input { width: 100%; background: #101418; color: #dde3ea;
border: 1px solid #2b3543; border-radius: 4px;
padding: .3rem .45rem; font: inherit; font-size: .82rem; }
.fields input:disabled { color: #7b8794; }
#cam-form { border-top: 1px solid #232c36; margin-top: .6rem;
padding-top: .75rem; }
#wizard { position: fixed; inset: 0; background: #000a;
display: flex; align-items: center; justify-content: center;
padding: 1rem; z-index: 20; }
/* An author `display` beats the UA stylesheet's `[hidden] { display: none }`,
so without this the placement wizard sits open over the dashboard on every
load - the modal is toggled by the `hidden` property, not by a class. */
#wizard[hidden] { display: none; }
#wizard .panel { background: #171d24; border: 1px solid #2b3543;
border-radius: 10px; padding: 1.25rem; max-width: 460px;
width: 100%; }
#wizard h3 { font-size: .95rem; margin-bottom: .5rem; }
#wizard .advice { margin: .6rem 0 .9rem; padding-left: 1.1rem;
font-size: .84rem; color: #b8c2ce; }
#wizard .advice li { margin-bottom: .3rem; }
.verdict { font-size: .95rem; font-weight: 600; margin: .4rem 0; }
.v-good { color: #4caf7d; } .v-marginal { color: #e0a33a; }
.v-poor, .v-artifact { color: #e05c5c; }
.v-no_faces, .v-inconclusive { color: #e0a33a; }
.progress { height: 6px; border-radius: 3px; background: #222;
overflow: hidden; margin: .6rem 0; }
.progress i { display: block; height: 100%; background: #3a6291; }
#cam-test { margin-top: .6rem; font-size: .82rem; }
#cam-test img { width: 100%; border-radius: 6px; margin-top: .4rem; }
.ok { color: #4caf7d; }
.err { color: #e05c5c; }
.dup { display: flex; align-items: center; gap: .5rem;
padding: .35rem 0; border-bottom: 1px solid #1e2430; }
.dup:last-child { border-bottom: 0; }
.dup .grow { flex: 1; min-width: 0; }
.dup button { background: #2a3446; color: #cfd8e3; border: 1px solid #3a4658;
border-radius: 4px; padding: .2rem .55rem; cursor: pointer;
font: inherit; font-size: .78rem; }
.dup button:hover { background: #35415a; }
.dup button[disabled] { opacity: .5; cursor: default; }
.sim { font-variant-numeric: tabular-nums; }
.dot { display: inline-block; width: .55rem; height: .55rem;
border-radius: 50%; margin-right: .25rem; vertical-align: middle; }
</style>
</head>
<body>
<h1>Behavision <small id="status">connecting…</small></h1>
<div class="grid">
<div>
<div class="card"><h2>Live</h2><div id="feeds"></div></div>
<div class="card" style="margin-top:1rem">
<h2>Cameras <button class="btn" id="cam-new">+ Add camera</button></h2>
<div id="cam-list" class="muted">none configured</div>
<div id="cam-form" hidden>
<div class="fields">
<div><label for="f-id">Camera id</label>
<input id="f-id" placeholder="entrance"></div>
<div><label for="f-host">Host / IP</label>
<input id="f-host" placeholder="192.168.0.138"></div>
<div><label for="f-port">Port</label>
<input id="f-port" type="number" value="554"></div>
<div><label for="f-path">Stream path</label>
<input id="f-path" placeholder="/ch0_0.264"></div>
<div><label for="f-username">Username</label>
<input id="f-username" placeholder="admin"></div>
<div><label for="f-password">Password</label>
<input id="f-password" type="password" autocomplete="new-password"></div>
<div><label for="f-max_width">Max width (px)</label>
<input id="f-max_width" type="number" value="1280"></div>
<div><label for="f-webcam">Webcam index (instead of RTSP)</label>
<input id="f-webcam" type="number" placeholder="0"></div>
<div class="wide"><label for="f-url">Full URL (overrides host/port/path)</label>
<input id="f-url" placeholder="rtsp://user:pass@host:554/stream"></div>
</div>
<button class="btn" id="cam-test-btn">Test connection</button>
<button class="btn primary" id="cam-save">Save</button>
<button class="btn" id="cam-cancel">Cancel</button>
<div id="cam-test"></div>
</div>
</div>
</div>
<div>
<div class="card"><h2>Recent events</h2><ul id="events"></ul></div>
<div class="card" style="margin-top:1rem"><h2>Recognition health</h2>
<div id="pipeline" class="muted">no tracks yet</div></div>
<div class="card" style="margin-top:1rem"><h2>Possible duplicates</h2>
<div id="dupes" class="muted">none found</div></div>
<div class="card" style="margin-top:1rem"><h2>People</h2>
<table><thead><tr><th>Label</th><th>Seen</th><th>Last</th></tr></thead>
<tbody id="people"></tbody></table>
</div>
</div>
</div>
<div id="wizard" hidden><div class="panel">
<h3 id="wz-title">Camera placement check</h3>
<div id="wz-body"></div>
<button class="btn" id="wz-close">Close</button>
<button class="btn primary" id="wz-again" hidden>Run again</button>
<button class="btn" id="wz-loosen" hidden>Use this camera's own gate</button>
</div></div>
<script>
const feeds = document.getElementById('feeds');
const fmtTime = ts => new Date(ts * 1000).toLocaleTimeString();
// Identity labels are user-supplied (PATCH /api/identities/{id}) and camera
// ids come from config, so every value interpolated into innerHTML below is
// escaped first. Without this a label like <img onerror=...> is stored XSS.
const esc = v => String(v ?? '').replace(/[&<>"']/g,
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// `camera_id` only exists on cameras whose worker is running - it comes from
// worker.stats(). A stored camera that failed to start has only `id`, and
// using the wrong one put the string "undefined" in the stream URL.
let feedKey = null;
function renderFeeds(cams) {
const key = cams.map(c => c.id).join('|');
// Never re-create a live <img>: assigning src restarts the MJPEG stream, so
// rebuilding on every 3s refresh would make every feed flicker forever.
if (key === feedKey) return;
feedKey = key;
feeds.innerHTML = cams.map(cam => `<figure><img class="feed"
src="/api/cameras/${encodeURIComponent(cam.id)}/stream.mjpeg"
alt="${esc(cam.id)}"><figcaption class="muted">${esc(cam.id)}</figcaption>
</figure>`).join('') || '<span class="muted">no cameras configured</span>';
}
async function boot() {
refresh();
setInterval(refresh, 3000);
}
// Outcome of every finished track. This panel exists because the pipeline
// was previously unfalsifiable from the UI: a camera rejecting every visitor
// on quality looked exactly like a camera nobody walked past.
const OUTCOMES = [
['recognized', '#2e5c3a', 'returning'],
['enrolled', '#2b4e77', 'new'],
['rejected_quality', '#8a3b3b', 'face too poor to enroll'],
['gave_up_ambiguous','#8a6a2a', 'never settled'],
['ended_ambiguous', '#6a5a3a', 'left while unsure'],
['too_brief', '#444', 'gone too fast'],
['no_embedding', '#333', 'never encodable'],
];
function renderPipeline(c) {
const p = c.pipeline;
if (!p || !p.tracks_ended) {
return `<div class="pl-row"><b>${esc(c.camera_id)}</b>
<span class="muted"> — no finished tracks yet</span></div>`;
}
const total = p.tracks_ended;
const bar = OUTCOMES.map(([key, color]) => {
const n = p.outcomes[key] || 0;
return n ? `<i style="width:${(n / total * 100).toFixed(1)}%;
background:${color}" title="${key}: ${n}"></i>` : '';
}).join('');
const legend = OUTCOMES.filter(([k]) => p.outcomes[k]).map(([k, color, human]) =>
`<span><i class="dot" style="background:${color}"></i>${esc(human)}
${esc(p.outcomes[k])}</span>`).join('');
const q = p.best_quality || {};
// The number that says the enrollment gate is wrong for this camera, as
// opposed to the camera being pointed somewhere nobody walks.
const below = q.fraction_below_gate;
const gateWarn = below >= 0.5
? `<div class="warn">⚠ ${(below * 100).toFixed(0)}% of faces are below the
enrollment quality gate — these visitors are seen and discarded.
Fix camera placement before touching thresholds.</div>` : '';
const spread = q.n
? `<div class="muted legend">face quality p05 ${esc(q.p05)} ·
median ${esc(q.p50)} · p95 ${esc(q.p95)}${
below != null ? ` · ${(below * 100).toFixed(0)}% under gate` : ''}</div>`
: '';
return `<div class="pl-row"><b>${esc(c.camera_id)}</b>
<span class="muted"> — ${esc(total)} finished tracks</span>
<div class="bar">${bar}</div>
<div class="muted legend">${legend}</div>${spread}${gateWarn}</div>`;
}
// One person enrolled twice. Merging is irreversible - nothing records which
// embedding came from which identity - so this only ever *suggests*, and the
// operator confirms. Pairs below enroll_threshold are not offered at all.
function renderDupes(pairs) {
if (!pairs.length) return '<span class="muted">none found</span>';
return pairs.map(p => {
// Merge the sparser record into the richer one, and a "Visitor N" into a
// named person, so the surviving identity is the one with more history.
const named = x => x.kind !== 'auto';
let [from, into] = named(p.a) && !named(p.b) ? [p.b, p.a]
: named(p.b) && !named(p.a) ? [p.a, p.b]
: p.a.sighting_count <= p.b.sighting_count ? [p.a, p.b] : [p.b, p.a];
return `<div class="dup">
<span class="grow">${esc(from.label)} <span class="muted">&rarr;</span>
${esc(into.label)}</span>
<span class="muted sim">${esc(p.similarity)}${p.confident ? '' : ' ?'}</span>
<button data-from="${esc(from.id)}" data-into="${esc(into.id)}"
data-desc="${esc(from.label)} into ${esc(into.label)}">Merge</button>
</div>`;
}).join('');
}
document.getElementById('dupes').addEventListener('click', async ev => {
const btn = ev.target.closest('button[data-from]');
if (!btn) return;
const {from, into, desc} = btn.dataset;
if (!confirm(`Merge ${desc}?\n\nThis cannot be undone.`)) return;
btn.disabled = true;
const send = force => fetch(`/api/identities/${encodeURIComponent(from)}/merge`,
{method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({into: Number(into), force})});
let r = await send(false);
if (r.status === 409) {
// The gallery's own numbers say these are different people. Show the
// measured similarity rather than a generic failure - overriding it is a
// decision, and the operator needs the number to make it.
const d = (await r.json()).detail || {};
if (!confirm(`${d.reason || 'Refused.'}\n\nMerge anyway?`)) {
btn.disabled = false; return;
}
r = await send(true);
}
if (!r.ok) alert('Merge failed.');
btn.disabled = false;
refresh();
});
// -- camera settings ------------------------------------------------------
// The API never returns a camera password - not masked, not empty-string-if-
// set, absent. So an edit sends `password` only when the user actually typed
// one; leaving it blank keeps whatever is stored.
const F = ['id', 'host', 'port', 'path', 'username', 'password', 'max_width',
'webcam', 'url'];
const fld = n => document.getElementById('f-' + n);
let editing = null; // camera id being edited, or null when adding
function renderCameras(cams) {
const el = document.getElementById('cam-list');
if (!cams.length) {
el.innerHTML = '<span class="muted">none configured</span>';
return;
}
el.innerHTML = cams.map(c => {
const live = c.connected === undefined ? null : !!c.connected;
const pill = live === null ? '<span class="pill muted">stopped</span>'
: `<span class="pill ${live ? 'up' : 'down'}">${live ? 'live' : 'offline'}</span>`;
return `<div class="cam-row">
<span class="grow"><b>${esc(c.id)}</b>
<span class="muted"> ${esc(c.url)}</span></span>
${pill}
<button class="btn" data-check="${esc(c.id)}">Check placement</button>
<button class="btn" data-edit="${esc(c.id)}">Edit</button>
<button class="btn danger" data-del="${esc(c.id)}">Delete</button>
</div>`;
}).join('');
}
function openForm(cam) {
editing = cam ? cam.id : null;
for (const n of F) fld(n).value = '';
fld('port').value = 554;
fld('max_width').value = 1280;
if (cam) {
for (const n of F) if (cam[n] !== null && cam[n] !== undefined) fld(n).value = cam[n];
fld('password').value = '';
fld('password').placeholder = cam.has_password ? '(unchanged)' : '';
} else {
fld('password').placeholder = '';
}
// The id is the store key and PATCH ignores it; showing it editable would
// imply a rename that silently does nothing.
fld('id').disabled = !!cam;
document.getElementById('cam-test').innerHTML = '';
document.getElementById('cam-form').hidden = false;
}
function closeForm() {
document.getElementById('cam-form').hidden = true;
editing = null;
}
function formBody() {
const body = {};
for (const n of F) {
const v = fld(n).value.trim();
if (v === '') continue; // blank = "leave alone", never "clear"
body[n] = (n === 'port' || n === 'max_width' || n === 'webcam')
? Number(v) : v;
}
return body;
}
async function testCamera() {
const btn = document.getElementById('cam-test-btn');
const out = document.getElementById('cam-test');
btn.disabled = true;
out.innerHTML = '<span class="muted">connecting…</span>';
try {
const r = await fetch('/api/cameras/test', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(formBody())});
const d = await r.json();
if (!d.ok) {
out.innerHTML = `<span class="err">${esc(d.error || 'failed')}</span>`;
} else {
const scaled = d.downscaled_to
? ` <span class="muted">(downscaled to ${esc(d.downscaled_to)}px)</span>` : '';
out.innerHTML = `<span class="ok">connected — ${esc(d.width)}×${esc(d.height)}</span>${scaled}`
+ (d.snapshot ? `<img src="data:image/jpeg;base64,${esc(d.snapshot)}" alt="snapshot">` : '');
}
} catch (e) {
out.innerHTML = '<span class="err">test request failed</span>';
}
btn.disabled = false;
}
async function saveCamera() {
const btn = document.getElementById('cam-save');
const out = document.getElementById('cam-test');
const body = formBody();
if (!editing && !body.id) {
out.innerHTML = '<span class="err">camera id is required</span>';
return;
}
btn.disabled = true;
const r = editing
? await fetch(`/api/cameras/${encodeURIComponent(editing)}`, {
method: 'PATCH', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)})
: await fetch('/api/cameras', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)});
btn.disabled = false;
if (!r.ok) {
let msg = `save failed (${r.status})`;
try { const d = await r.json(); msg = typeof d.detail === 'string' ? d.detail : msg; } catch (e) {}
out.innerHTML = `<span class="err">${esc(msg)}</span>`;
return;
}
closeForm();
feedKey = null; // a new or edited camera needs its feed rebuilt
refresh();
}
document.getElementById('cam-new').onclick = () => openForm(null);
document.getElementById('cam-cancel').onclick = closeForm;
document.getElementById('cam-test-btn').onclick = testCamera;
document.getElementById('cam-save').onclick = saveCamera;
document.getElementById('cam-list').addEventListener('click', async ev => {
const check = ev.target.closest('button[data-check]');
if (check) { wzStart(check.dataset.check); return; }
const edit = ev.target.closest('button[data-edit]');
if (edit) {
const cams = await (await fetch('/api/cameras')).json();
const cam = cams.find(c => c.id === edit.dataset.edit);
if (cam) openForm(cam);
return;
}
const del = ev.target.closest('button[data-del]');
if (!del) return;
const id = del.dataset.del;
if (!confirm(`Delete camera "${id}"? Recognition from it stops immediately.`)) return;
del.disabled = true;
await fetch(`/api/cameras/${encodeURIComponent(id)}`, {method: 'DELETE'});
if (editing === id) closeForm();
feedKey = null;
refresh();
});
// -- placement wizard -----------------------------------------------------
// The Office1 camera ran for weeks recognising almost nobody, and nothing
// looked broken. This makes that discoverable at install time instead of from
// a footfall report that was always zero.
const wz = document.getElementById('wizard');
let wzCamera = null, wzTimer = null, wzLast = null;
function wzShow(html) { document.getElementById('wz-body').innerHTML = html; }
function wzRender(d) {
wzLast = d;
const pct = Math.round(100 * (d.elapsed / d.seconds));
const q = d.quality || {};
const bar = `<div class="progress"><i style="width:${d.running ? pct : 100}%"></i></div>`;
const advice = (d.advice || []).map(a => `<li>${esc(a)}</li>`).join('');
const numbers = q.n ? `<div class="muted legend">faces ${esc(q.n)} ·
quality p05 ${esc(q.p05)} · median ${esc(q.p50)} · p95 ${esc(q.p95)} ·
gate ${esc(d.gate)} · below gate ${Math.round(100 * q.fraction_below_gate)}%</div>` : '';
wzShow(`<div class="verdict v-${esc(d.verdict)}">${esc(d.headline)}</div>
${bar}<ul class="advice">${advice}</ul>${numbers}`);
document.getElementById('wz-again').hidden = d.running;
// Loosening the gate is only ever offered for a marginal camera. For a poor
// one the answer is to move the camera: dropping the gate there converts a
// visible miss into an invisible wrong match, which is strictly worse.
document.getElementById('wz-loosen').hidden =
!(d.verdict === 'marginal' && q.p05 !== undefined);
}
async function wzPoll() {
const r = await fetch(`/api/cameras/${encodeURIComponent(wzCamera)}/commission`);
if (!r.ok) return;
const d = await r.json();
wzRender(d);
if (!d.running) { clearInterval(wzTimer); wzTimer = null; }
}
async function wzStart(id) {
wzCamera = id;
wz.hidden = false;
document.getElementById('wz-title').textContent = `Placement check — ${id}`;
wzShow('<span class="muted">starting…</span>');
const r = await fetch(`/api/cameras/${encodeURIComponent(id)}/commission`,
{method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({seconds: 25})});
if (!r.ok) { wzShow('<span class="err">could not start — is the camera running?</span>'); return; }
wzRender(await r.json());
if (wzTimer) clearInterval(wzTimer);
wzTimer = setInterval(wzPoll, 1000);
}
document.getElementById('wz-close').onclick = async () => {
if (wzTimer) { clearInterval(wzTimer); wzTimer = null; }
// Cancel rather than leave it running: a check still collecting after the
// installer walked away would keep changing the answer they just read.
if (wzCamera) await fetch(`/api/cameras/${encodeURIComponent(wzCamera)}/commission`,
{method: 'DELETE'});
wz.hidden = true; wzCamera = null;
};
document.getElementById('wz-again').onclick = () => wzStart(wzCamera);
document.getElementById('wz-loosen').onclick = async () => {
// Set this camera's gate just under the faces it actually sees. Per camera
// only - quality describes a view, and every camera writes into one gallery.
const gate = Math.max(0.1, Math.round((wzLast.quality.p05 - 0.02) * 100) / 100);
if (!confirm(`Set ${wzCamera} min_enroll_quality to ${gate}?\n\n` +
`Only this camera is affected.`)) return;
await fetch(`/api/cameras/${encodeURIComponent(wzCamera)}`, {
method: 'PATCH', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({tuning: {min_enroll_quality: gate}})});
wzStart(wzCamera);
};
async function refresh() {
try {
const [stats, events, people, dupes, camList] = await Promise.all([
(await fetch('/api/stats')).json(),
(await fetch('/api/events?limit=30')).json(),
(await fetch('/api/identities?limit=30')).json(),
(await fetch('/api/identities/duplicates?limit=10')).json(),
(await fetch('/api/cameras')).json(),
]);
renderFeeds(camList);
renderCameras(camList);
const cams = stats.cameras.map(c =>
`${c.camera_id}: ${c.connected ? 'live' : 'offline'}`).join(' · ');
// textContent, not innerHTML — no escaping needed here.
document.getElementById('status').textContent =
`${cams} · ${stats.gallery.identities} people · ${stats.gallery.sightings} sightings`;
document.getElementById('events').innerHTML = events.map(e => {
const cls = e.type === 'person.new' ? 'new'
: e.type === 'person.seen' ? 'seen'
: e.type === 'person.missed' ? 'miss'
: e.type === 'identity.merged' ? 'merge' : 'cam';
const who = e.data.label ? ` ${esc(e.data.label)}` : '';
// genderage.onnx reports an integer `age`; the Caffe fallback reports
// a bucketed `age_range`. Show whichever this backend produced.
const age = e.data.age ?? e.data.age_range;
const extra = e.data.gender ? ` · ${esc(e.data.gender)}${age != null ? ', ' + esc(age) : ''}${e.data.emotion ? ', ' + esc(e.data.emotion) : ''}` : '';
return `<li><span class="tag ${cls}">${esc(e.type)}</span>${who}
<span class="muted">${extra} · ${esc(e.camera_id)} · ${fmtTime(e.ts)}</span></li>`;
}).join('');
document.getElementById('pipeline').innerHTML =
stats.cameras.map(renderPipeline).join('');
document.getElementById('dupes').innerHTML = renderDupes(dupes);
document.getElementById('people').innerHTML = people.map(p =>
`<tr><td>${esc(p.label)}</td><td>${esc(p.sighting_count)}</td>
<td class="muted">${p.last_seen_at ? fmtTime(p.last_seen_at) : '—'}</td></tr>`
).join('');
} catch (err) {
document.getElementById('status').textContent = 'api unreachable';
}
}
boot();
</script>
</body>
</html>