The camera used an MJPEG stream through the proxy and the engine's stats under names it does not use (frames/faces rather than frames_processed/faces_seen), so the picture was blank and the counter read zero. The engine re-serves its latest frame until the pipeline produces a new one, so a polled still is the same picture with none of the multipart fragility - which matters when the audience is in the room. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
259 lines
14 KiB
HTML
259 lines
14 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Behavision — live demo</title>
|
|
<style>
|
|
:root{
|
|
--bg:#0A0E12; --s1:#11171C; --s2:#161D24; --s3:#1D262E;
|
|
--line:#24303A; --line2:#1B242C;
|
|
--ink:#E8EEF3; --ink2:#9FB0BD; --ink3:#6B7E8C;
|
|
--accent:#3DD0C4; --accent-dim:#123039;
|
|
--ok:#3FBF7F; --warn:#E0A33A; --bad:#E15B4C;
|
|
--mono:'SF Mono',ui-monospace,Menlo,monospace;
|
|
--font:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
|
|
}
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{background:var(--bg);color:var(--ink);font-family:var(--font);font-size:14px;line-height:1.5;
|
|
-webkit-font-smoothing:antialiased;padding:18px;min-height:100vh}
|
|
.top{display:flex;align-items:center;gap:14px;margin-bottom:16px;flex-wrap:wrap}
|
|
.brand{display:flex;align-items:center;gap:10px;margin-right:auto}
|
|
.brand img{width:26px;height:26px;object-fit:contain}
|
|
.brand b{font-size:16px;letter-spacing:-.01em}
|
|
.brand span{color:var(--ink3);font-size:12px}
|
|
.pill{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:99px;
|
|
border:1px solid var(--line);background:var(--s1);font-size:12px;color:var(--ink2)}
|
|
.dot{width:7px;height:7px;border-radius:99px;background:var(--ink3);flex:none}
|
|
.dot.ok{background:var(--ok);box-shadow:0 0 0 3px rgba(63,191,127,.16)}
|
|
.dot.bad{background:var(--bad);box-shadow:0 0 0 3px rgba(225,91,76,.16)}
|
|
.dot.warn{background:var(--warn);box-shadow:0 0 0 3px rgba(224,163,58,.16)}
|
|
|
|
.grid{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);gap:14px;align-items:start}
|
|
@media(max-width:1100px){.grid{grid-template-columns:minmax(0,1fr)}}
|
|
.card{background:var(--s1);border:1px solid var(--line);border-radius:12px;overflow:hidden}
|
|
.card h2{font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--ink3);
|
|
padding:12px 16px;border-bottom:1px solid var(--line2);display:flex;align-items:center;gap:10px}
|
|
.card h2 .grow{margin-left:auto;font-weight:500;letter-spacing:0;text-transform:none;font-size:12px;color:var(--ink3)}
|
|
.pad{padding:16px}
|
|
|
|
.cam{position:relative;aspect-ratio:16/9;background:#05090C}
|
|
.cam img{width:100%;height:100%;object-fit:cover;display:block}
|
|
.cam .none{position:absolute;inset:0;display:grid;place-items:center;color:var(--ink3);font-size:13px;text-align:center;padding:20px}
|
|
.cam .tag{position:absolute;top:10px;left:10px;background:rgba(10,14,18,.78);backdrop-filter:blur(8px);
|
|
border:1px solid var(--line);border-radius:8px;padding:5px 10px;font-size:11.5px;font-family:var(--mono)}
|
|
|
|
/* the chain */
|
|
.chain{display:flex;flex-direction:column;gap:0}
|
|
.step{display:grid;grid-template-columns:26px 1fr auto;gap:12px;align-items:start;padding:11px 16px;
|
|
border-bottom:1px solid var(--line2);opacity:.38;transition:opacity .25s}
|
|
.step:last-child{border-bottom:0}
|
|
.step.on{opacity:1}
|
|
.step .n{width:22px;height:22px;border-radius:99px;display:grid;place-items:center;font-size:11px;font-weight:600;
|
|
background:var(--s3);color:var(--ink3);border:1px solid var(--line);margin-top:1px}
|
|
.step.on .n{background:var(--accent);color:#04161B;border-color:transparent}
|
|
.step b{font-size:13.5px;font-weight:550;display:block}
|
|
.step small{color:var(--ink2);font-size:12px;display:block;margin-top:1px;font-family:var(--mono)}
|
|
.step .ms{font-family:var(--mono);font-size:11.5px;color:var(--accent);white-space:nowrap;margin-top:2px}
|
|
|
|
.empty{padding:34px 16px;text-align:center;color:var(--ink3);font-size:13px;line-height:1.6}
|
|
.empty b{display:block;color:var(--ink2);font-size:14px;margin-bottom:5px}
|
|
|
|
/* customer */
|
|
.who{display:flex;gap:13px;align-items:center;padding:16px;border-bottom:1px solid var(--line2)}
|
|
.av{width:50px;height:50px;border-radius:10px;background:var(--s3);border:1px solid var(--line);
|
|
display:grid;place-items:center;font-weight:600;font-size:17px;color:var(--ink2);flex:none;overflow:hidden}
|
|
.av img{width:100%;height:100%;object-fit:cover}
|
|
.who .n{font-size:16px;font-weight:600;letter-spacing:-.01em}
|
|
.who .m{color:var(--ink3);font-size:12.5px;margin-top:2px}
|
|
.badge{display:inline-block;padding:2px 8px;border-radius:99px;font-size:10.5px;font-weight:600;
|
|
letter-spacing:.04em;text-transform:uppercase}
|
|
.badge.new{background:var(--accent-dim);color:var(--accent)}
|
|
.badge.seen{background:rgba(63,191,127,.14);color:var(--ok)}
|
|
label{display:block;font-size:11.5px;font-weight:550;color:var(--ink2);margin-bottom:5px}
|
|
input{width:100%;background:var(--s2);border:1px solid var(--line);border-radius:7px;padding:9px 11px;
|
|
color:var(--ink);font:inherit;font-size:13.5px}
|
|
input:focus{outline:none;border-color:var(--accent)}
|
|
.row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px}
|
|
button{background:var(--accent);color:#04161B;border:0;border-radius:7px;padding:9px 16px;
|
|
font:inherit;font-size:13px;font-weight:600;cursor:pointer}
|
|
button:disabled{opacity:.45;cursor:default}
|
|
button.sec{background:var(--s3);color:var(--ink);border:1px solid var(--line)}
|
|
.saved{color:var(--ok);font-size:12.5px;margin-top:9px;display:flex;align-items:center;gap:6px}
|
|
|
|
/* raw json */
|
|
pre{font-family:var(--mono);font-size:11px;line-height:1.55;color:var(--ink2);
|
|
background:#080C10;border-top:1px solid var(--line2);padding:13px 16px;margin:0;
|
|
max-height:230px;overflow:auto;white-space:pre-wrap;word-break:break-word}
|
|
.req{font-family:var(--mono);font-size:11.5px;color:var(--accent);padding:10px 16px;background:var(--s2)}
|
|
.req .st{float:right;color:var(--ink3)}
|
|
.hint{color:var(--ink3);font-size:12px;padding:10px 16px 14px;line-height:1.55}
|
|
.stack{display:flex;flex-direction:column;gap:14px}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="top">
|
|
<div class="brand">
|
|
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDIxcy04LTQuNS04LTEwYTQuNSA0LjUgMCAwIDEgOC0yLjggNC41IDQuNSAwIDAgMSA4IDIuOGMwIDUuNS04IDEwLTggMTB6IiBmaWxsPSIjRjJDMTFGIi8+PC9zdmc+" alt="">
|
|
<div><b>Behavision</b> <span id="site">— live demo</span></div>
|
|
</div>
|
|
<span class="pill"><i class="dot" id="d-eng"></i><span id="t-eng">engine…</span></span>
|
|
<span class="pill"><i class="dot" id="d-cam"></i><span id="t-cam">camera…</span></span>
|
|
<span class="pill"><i class="dot" id="d-cloud"></i><span id="t-cloud">cloud…</span></span>
|
|
</div>
|
|
|
|
<div class="grid">
|
|
<div class="stack">
|
|
<div class="card">
|
|
<h2>The camera <span class="grow" id="camname"></span></h2>
|
|
<div class="cam">
|
|
<img id="feed" alt="" style="display:none">
|
|
<div class="none" id="feednone">waiting for the camera…</div>
|
|
<div class="tag" id="camtag" style="display:none"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>The customer <span class="grow">type a name, then walk past again</span></h2>
|
|
<div id="cust">
|
|
<div class="empty"><b>Nobody yet</b>Walk in front of the camera.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stack">
|
|
<div class="card">
|
|
<h2>What just happened <span class="grow" id="lat"></span></h2>
|
|
<div id="chain"><div class="empty"><b>Waiting</b>Every step below lights up as it really happens.</div></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>What the mobile app receives</h2>
|
|
<div class="req" id="m-req">GET /api/visits<span class="st" id="m-st"></span></div>
|
|
<pre id="m-body">…</pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>What the dashboard receives</h2>
|
|
<div class="req" id="d-req">GET /api/reports/footfall<span class="st" id="d-st"></span></div>
|
|
<pre id="d-body">…</pre>
|
|
<div class="hint">Both of these are the real production API at mcp.loyaly.ai, called from this
|
|
machine with a staff login — not a mock, and not the local engine.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const $ = s => document.querySelector(s);
|
|
let camStarted = null, current = null, savedFor = null;
|
|
|
|
function setPill(dot, text, tone, label){
|
|
$(dot).className = 'dot' + (tone ? ' ' + tone : '');
|
|
$(text).textContent = label;
|
|
}
|
|
|
|
function initials(name, ref){
|
|
const m = /^Visitor (\d+)$/.exec((name||'').trim());
|
|
if (m) return m[1];
|
|
const w = (name||'').trim().split(/\s+/).filter(Boolean);
|
|
if (!w.length) return '?';
|
|
return (w[0][0] + (w[1]?.[0] ?? '')).toUpperCase();
|
|
}
|
|
|
|
function drawChain(e){
|
|
if (!e){ $('#chain').innerHTML = '<div class="empty"><b>Waiting</b>Every step below lights up as it really happens.</div>'; $('#lat').textContent=''; return; }
|
|
const g = e.engine, c = e.cloud;
|
|
const steps = [
|
|
[true, 'Camera saw a face', g.quality != null ? `quality ${(+g.quality).toFixed(2)} · camera ${g.camera}` : `camera ${g.camera}`, ''],
|
|
[true, e.kind === 'new' ? 'Engine: nobody it knows → enrolled' : 'Engine: matched a returning customer',
|
|
(g.label || '') + (g.similarity != null && g.similarity >= 0 ? ` · similarity ${(+g.similarity).toFixed(2)}` : '') , ''],
|
|
[true, 'Agent queued the visit', 'durable on this disk until the broker confirms', ''],
|
|
[!!c, 'Broker delivered it', 'MQTT over TLS to mcp.loyaly.ai', ''],
|
|
[!!c, 'Server recorded it', c ? `${c.site} · ${c.is_new ? 'new customer' : 'returning'}` : 'waiting…', ''],
|
|
[!!c, 'Mobile + dashboard can see it', c ? `visit ${String(c.visit_id).slice(0,8)}` : 'waiting…',
|
|
e.latency != null ? `+${e.latency}s` : ''],
|
|
];
|
|
$('#chain').innerHTML = steps.map(([on,title,sub,ms],i)=>
|
|
`<div class="step ${on?'on':''}"><div class="n">${i+1}</div><div><b>${title}</b><small>${sub}</small></div><div class="ms">${ms}</div></div>`
|
|
).join('');
|
|
$('#lat').textContent = e.latency != null ? `camera → cloud in ${e.latency}s` : '';
|
|
}
|
|
|
|
function drawCustomer(e){
|
|
const c = e && e.cloud;
|
|
if (!c){ if(!current) $('#cust').innerHTML = '<div class="empty"><b>Nobody yet</b>Walk in front of the camera.</div>'; return; }
|
|
const changed = !current || current.visitor_id !== c.visitor_id || current.visit_id !== c.visit_id;
|
|
if (!changed) return;
|
|
current = c;
|
|
const name = c.label || 'Unrecognised';
|
|
const img = c.image && c.image.available && c.image.url;
|
|
$('#cust').innerHTML = `
|
|
<div class="who">
|
|
<div class="av">${img ? `<img src="${img}">` : initials(name)}</div>
|
|
<div style="flex:1;min-width:0">
|
|
<div class="n">${name}</div>
|
|
<div class="m">${c.ref ? c.ref + ' · ' : ''}${c.is_new ? 'first time here' : 'returning'}${c.similarity>0 ? ' · match ' + (+c.similarity).toFixed(2) : ''}</div>
|
|
</div>
|
|
<span class="badge ${c.is_new?'new':'seen'}">${c.is_new?'new':'returning'}</span>
|
|
</div>
|
|
<div class="pad">
|
|
<div class="row">
|
|
<div><label>Name</label><input id="f-name" placeholder="e.g. Suriya" value=""></div>
|
|
<div><label>Phone</label><input id="f-phone" placeholder="+91…" value=""></div>
|
|
</div>
|
|
<button id="save">Save to the customer record</button>
|
|
<div id="savedmsg"></div>
|
|
<div class="hint" style="padding:12px 0 0">This writes to the production API. Walk past again and
|
|
the name comes back through the cloud instead of “${name}”.</div>
|
|
</div>`;
|
|
$('#save').onclick = async () => {
|
|
const b = $('#save'); b.disabled = true; b.textContent = 'Saving…';
|
|
const r = await fetch('/api/profile', {method:'POST', headers:{'content-type':'application/json'},
|
|
body: JSON.stringify({id: c.visitor_id, full_name: $('#f-name').value, phone: $('#f-phone').value})});
|
|
const d = await r.json();
|
|
b.disabled = false; b.textContent = 'Save to the customer record';
|
|
$('#savedmsg').innerHTML = (d.status===200||d.status===204)
|
|
? '<div class="saved">✓ Saved — PUT /api/visitors/'+String(c.visitor_id).slice(0,8)+'…/profile → '+d.status+'</div>'
|
|
: '<div class="saved" style="color:var(--bad)">'+(d.body&&d.body.message||('HTTP '+d.status))+'</div>';
|
|
savedFor = c.visitor_id;
|
|
};
|
|
}
|
|
|
|
async function tick(){
|
|
let s;
|
|
try { s = await (await fetch('/api/snapshot')).json(); } catch { return; }
|
|
|
|
const eng = s.engine || {};
|
|
setPill('#d-eng','#t-eng', eng.up ? 'ok' : 'bad', eng.up ? ('engine · ' + (eng.model||'starting')) : 'engine starting…');
|
|
const cam = (eng.cameras||[])[0];
|
|
setPill('#d-cam','#t-cam', cam && cam.connected ? 'ok' : 'warn',
|
|
cam ? (cam.connected ? `camera live · ${cam.frames||0} frames` : 'camera connecting…') : 'no camera yet');
|
|
setPill('#d-cloud','#t-cloud', s.cloud_ok ? 'ok' : 'bad', s.cloud_ok ? 'cloud connected' : 'cloud unreachable');
|
|
$('#camname').textContent = cam ? cam.id : '';
|
|
|
|
if (cam && cam.connected){
|
|
if (camStarted !== cam.id){
|
|
camStarted = cam.id;
|
|
$('#feed').style.display = 'block'; $('#feednone').style.display = 'none';
|
|
$('#camtag').style.display = 'block';
|
|
// A polled still rather than an MJPEG stream: the engine re-serves its
|
|
// latest frame anyway, and a multipart stream through a proxy is one
|
|
// more thing to fail in front of an audience.
|
|
setInterval(() => { $('#feed').src = '/camera.jpg?id=' +
|
|
encodeURIComponent(camStarted) + '&t=' + Date.now(); }, 350);
|
|
}
|
|
$('#camtag').textContent = cam.id + (cam.tracks ? ` · ${cam.tracks} in frame` : '');
|
|
}
|
|
|
|
const e = (s.chain||[])[0];
|
|
drawChain(e); drawCustomer(e);
|
|
|
|
$('#m-st').textContent = s.mobile.status;
|
|
$('#m-body').textContent = JSON.stringify(s.mobile.body, null, 1).slice(0, 2600);
|
|
$('#d-st').textContent = s.dashboard.status;
|
|
$('#d-body').textContent = JSON.stringify(s.dashboard.body, null, 1).slice(0, 1800);
|
|
}
|
|
tick(); setInterval(tick, 1500);
|
|
</script>
|
|
</body>
|
|
</html>
|