The first launch was a code box with a link under it, then an empty Live screen with 'No cameras' in amber in a far corner, then a form asking for an IP address, and for the first few minutes of all of it the engine silently downloading 275 MB with nothing on screen but a stopped-looking status. Walked in a browser with the new mock; nobody who was not an installer would have got through it. Now: a welcome that asks the one question a shop owner can answer - managed from a head office, or on this PC only - with each path in a sentence; a Getting Started checklist on Live that reads its three steps from the engine and ticks them itself (recognition ready, camera added and connected, camera proven by a walk-past), with the one button for the next step, and that disappears the moment somebody is recognised; and the model download reported as a percentage in the tray, the sidebar and the checklist, parsed by the supervisor from the engine's own progress lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
254 lines
11 KiB
JavaScript
254 lines
11 KiB
JavaScript
import { useEffect, useRef, useState } from 'react'
|
|
import { api } from '../bridge.js'
|
|
import { usePolled, fmtTime } from '../hooks.js'
|
|
import * as Icon from '../ui/icons.jsx'
|
|
|
|
// The shop floor screen.
|
|
//
|
|
// Rebuilt around what somebody standing at the counter is actually here for:
|
|
// WHO JUST WALKED IN. The previous version led with four large stat boxes and
|
|
// left arrivals as a thin list of "person.seen" rows in the corner — the least
|
|
// actionable content taking the most space, and the product's whole reason for
|
|
// existing rendered as a log.
|
|
//
|
|
// Now: a status strip that answers "is this working" in one line, and arrivals
|
|
// as cards big enough to recognise a customer from while looking up at them.
|
|
// No camera picture here - the person at the counter is not watching CCTV,
|
|
// and a live video tile costs CPU the recognition pipeline needs. The picture
|
|
// lives on the Cameras screen, where it is a setup tool.
|
|
export default function Live({ onNavigate }) {
|
|
const { data, error } = usePolled(() => api.live(), 3000)
|
|
const { data: pipe } = usePolled(() => api.pipelineStatus(), 5000)
|
|
|
|
const cameras = data?.stats?.cameras ?? []
|
|
const gallery = data?.stats?.gallery ?? {}
|
|
const events = data?.events ?? []
|
|
|
|
// fraction_below_gate is the number that decides a site: what share of the
|
|
// faces this camera saw were too poor to enrol. Surfaced rather than buried,
|
|
// because a high value looks exactly like "a quiet day".
|
|
const worst = cameras.reduce((acc, c) => {
|
|
const f = c?.pipeline?.best_quality?.fraction_below_gate
|
|
return typeof f === 'number' && f > acc ? f : acc
|
|
}, 0)
|
|
const up = cameras.filter(c => c.connected).length
|
|
|
|
const arrivals = events.filter(e => e.type === 'person.new' || e.type === 'person.seen')
|
|
const freshest = useFreshest(arrivals[0])
|
|
|
|
return (
|
|
<div className="page">
|
|
<header>
|
|
<h2>Live</h2>
|
|
<p>Who is in the shop, and whether it is reaching head office.</p>
|
|
</header>
|
|
|
|
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
|
|
|
<PipelineStrip pipe={pipe} cameras={cameras} up={up} />
|
|
|
|
<GettingStarted cameras={cameras} arrivals={arrivals} onNavigate={onNavigate} />
|
|
|
|
<div className="panel arrivals-panel">
|
|
<div className="panelhead">
|
|
<h3>Who just walked in</h3>
|
|
{arrivals.length > 0 && <span className="note">{arrivals.length} today</span>}
|
|
</div>
|
|
<div className="panelbody flush">
|
|
{arrivals.length === 0
|
|
? <div className="empty">
|
|
<Icon.NoFaces size={34} />
|
|
<b>Nobody yet</b>
|
|
<p>Customers appear here the moment a camera recognises a face.</p>
|
|
</div>
|
|
: <div className="arrivals">
|
|
{arrivals.slice(0, 30).map((e, i) => (
|
|
<Arrival key={`${e.ts}-${i}`} e={e} fresh={i === 0 && freshest} />
|
|
))}
|
|
</div>}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="metrics" style={{ marginTop: 'var(--sp-4)' }}>
|
|
<Metric k="People known" v={gallery.identities ?? '—'} />
|
|
<Metric k="Sightings" v={gallery.sightings ?? '—'} />
|
|
<Metric k="Cameras live" v={cameras.length ? `${up}/${cameras.length}` : '—'}
|
|
tone={!cameras.length ? null : up === 0 ? 'bad' : up < cameras.length ? 'warn' : 'ok'} />
|
|
<Metric k="Faces too poor to use"
|
|
v={cameras.length ? `${Math.round(worst * 100)}%` : '—'}
|
|
tone={worst > 0.5 ? 'bad' : worst > 0.2 ? 'warn' : 'ok'}
|
|
s={worst > 0.5 ? 'Most visitors are being missed — move the camera'
|
|
: 'Share of faces below the enrolment gate'} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// The first five minutes, as a checklist that ticks itself.
|
|
//
|
|
// Before this the Live screen after install was "Nobody yet" over a row of
|
|
// dashes, with an amber "No cameras" in the far corner. Nothing said what to
|
|
// do next. This says the three things, in order, and each step reads its own
|
|
// state from the engine: recognition ready, a camera added, the camera
|
|
// proven by a walk-past. It disappears on its own once someone has actually
|
|
// been recognised, because at that point the product has explained itself.
|
|
function GettingStarted({ cameras, arrivals, onNavigate }) {
|
|
const { data: eng } = usePolled(() => api.engineStatus(), 4000)
|
|
const [hidden, setHidden] = useState(() => { try { return localStorage.getItem('bv.gettingStarted') === 'done' } catch { return false } })
|
|
const hasCamera = cameras.length > 0
|
|
const anyUp = cameras.some(c => c.connected)
|
|
const { data: checks } = usePolled(async () => {
|
|
const out = {}
|
|
for (const c of cameras) { try { out[c.camera_id] = await api.placementResult(c.camera_id) } catch { /* not yet */ } }
|
|
return out
|
|
}, 10000, [cameras.map(c => c.camera_id).join('|')])
|
|
const proven = Object.values(checks ?? {}).some(r => r && !r.running && r.verdict === 'good')
|
|
const recognised = arrivals.length > 0
|
|
|
|
if (hidden || recognised) return null
|
|
const engineReady = Boolean(eng?.reachable && eng?.recognition_model)
|
|
const progress = eng?.progress
|
|
|
|
const steps = [
|
|
{ done: engineReady, now: !engineReady,
|
|
title: engineReady ? `Recognition ready (${eng.recognition_model})` : progress ? `Downloading ${progress.what}… ${progress.percent}%` : 'Starting recognition…',
|
|
text: engineReady ? null : 'First start downloads about 275 MB of recognition models. A few minutes on a normal connection; nothing to do meanwhile.' },
|
|
{ done: hasCamera && anyUp, now: engineReady && !(hasCamera && anyUp),
|
|
title: hasCamera ? (anyUp ? 'Camera connected' : 'Camera added — not connecting yet') : 'Add your camera',
|
|
text: hasCamera ? (anyUp ? null : 'Check its password and stream path under Cameras → Edit.') : 'Behavision can find it on the network; you type only its password.',
|
|
action: hasCamera ? null : { label: 'Add camera', go: 'cameras' } },
|
|
{ done: proven, now: hasCamera && anyUp && !proven,
|
|
title: proven ? 'Camera proven — it can recognise faces' : 'Walk past the camera',
|
|
text: proven ? null : 'Run Check placement and walk past like a customer for 25 seconds. Only a “good” verdict means it will recognise people.',
|
|
action: hasCamera && anyUp && !proven ? { label: 'Check placement', go: 'cameras' } : null },
|
|
]
|
|
|
|
return (
|
|
<section className="panel starter">
|
|
<div className="panelhead">
|
|
<h3>Getting started</h3>
|
|
<button className="btn sm ghost" onClick={() => { try { localStorage.setItem('bv.gettingStarted', 'done') } catch {} ; setHidden(true) }}>Hide</button>
|
|
</div>
|
|
<ol className="steps compact">
|
|
{steps.map((st, i) => (
|
|
<li key={i} className={st.done ? 'done' : st.now ? 'now' : ''}>
|
|
<b>{st.title}</b>
|
|
{st.text && <span>{st.text}</span>}
|
|
{st.action && <button className="btn sm primary" onClick={() => onNavigate?.(st.action.go)}>{st.action.label}</button>}
|
|
</li>
|
|
))}
|
|
</ol>
|
|
<p className="note">The moment a customer is recognised, this list goes away.</p>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// One customer, big enough to match against the person in front of you.
|
|
function Arrival({ e, fresh }) {
|
|
const isNew = e.type === 'person.new'
|
|
const name = e.data?.label || 'Unrecognised'
|
|
const bits = [e.data?.gender, e.data?.age ?? e.data?.age_range, e.data?.emotion].filter(Boolean)
|
|
const sim = typeof e.data?.similarity === 'number' ? e.data.similarity : null
|
|
|
|
return (
|
|
<article className={`arrival ${isNew ? 'is-new' : 'is-seen'} ${fresh ? 'fresh' : ''}`}>
|
|
<div className="avatar">{avatarText(name)}</div>
|
|
<div className="who">
|
|
<div className="name">{name}</div>
|
|
<div className="meta">
|
|
{e.camera_id}
|
|
{bits.length > 0 && <> · {bits.join(', ')}</>}
|
|
{sim !== null && !isNew && <> · match {sim.toFixed(2)}</>}
|
|
</div>
|
|
</div>
|
|
<div className="right">
|
|
<span className={`tag ${isNew ? 'new' : 'seen'}`}>{isNew ? 'new' : 'returning'}</span>
|
|
<span className="when">{fmtTime(e.ts)}</span>
|
|
</div>
|
|
</article>
|
|
)
|
|
}
|
|
|
|
// "Visitor 13" must show 13, not V1 — initials() would give the same two
|
|
// characters to Visitor 10, 13 and 15, and read as the reference V-1 for a
|
|
// fourth person. Found by looking at the screen, not by a test.
|
|
function avatarText(name) {
|
|
const auto = /^Visitor (\d+)$/.exec(String(name).trim())
|
|
if (auto) return auto[1]
|
|
const words = String(name).trim().split(/\s+/).filter(Boolean)
|
|
if (!words.length) return '?'
|
|
return (words[0][0] + (words[1]?.[0] ?? '')).toUpperCase()
|
|
}
|
|
|
|
// One line, above everything, answering the question every other screen is a
|
|
// detail of: is this shop working, and is anything leaving it.
|
|
function PipelineStrip({ pipe, cameras, up }) {
|
|
if (!pipe) return null
|
|
|
|
// A PC set up on its own is not "not linked yet" — nothing is coming, and an
|
|
// idle dot beside a count of zero reads as a fault.
|
|
if (pipe.standalone) {
|
|
return (
|
|
<div className="statusbar">
|
|
<span className="item"><i className="dot ok" /><b>Running on this PC only</b></span>
|
|
<span className="sep" />
|
|
<span className="item note">Recognition and customers stay here.</span>
|
|
<span className="grow" />
|
|
<span className="item note"><Icon.Signal size={14} />{up} of {cameras.length} cameras</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const stuck = pipe.claimed && !pipe.broker_up
|
|
const tone = !pipe.claimed ? 'idle' : pipe.broker_up ? 'ok' : 'bad'
|
|
const text = !pipe.claimed ? 'Not linked to head office'
|
|
: pipe.broker_up ? 'Sending to head office' : 'Offline — saving locally'
|
|
|
|
return (
|
|
<div className="statusbar">
|
|
<span className="item">
|
|
{pipe.broker_up ? <Icon.Cloud size={15} /> : <Icon.CloudOff size={15} />}
|
|
<i className={`dot ${tone}`} /><b>{text}</b>
|
|
</span>
|
|
<span className="sep" />
|
|
<span className="item note">{pipe.accepted} recorded today</span>
|
|
{pipe.queued > 0 && (
|
|
<span className={`item note ${stuck ? 'warn' : ''}`}>{pipe.queued} waiting to send</span>
|
|
)}
|
|
{pipe.dropped > 0 && (
|
|
<span className="item note bad"><Icon.Warning size={14} />{pipe.dropped} lost — this PC was offline too long</span>
|
|
)}
|
|
<span className="grow" />
|
|
<span className="item note"><Icon.Signal size={14} />{up} of {cameras.length} cameras</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Metric({ k, v, s, tone }) {
|
|
return (
|
|
<div className={`metric ${tone ?? ''}`}>
|
|
<div className="metric-k">{k}</div>
|
|
<div className="metric-v">{v}</div>
|
|
{s && <div className="metric-s">{s}</div>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// True for a few seconds after a genuinely new arrival, so the top card can
|
|
// announce itself once. Keyed on the timestamp rather than the array, which
|
|
// changes identity on every poll.
|
|
function useFreshest(top) {
|
|
const [fresh, setFresh] = useState(false)
|
|
const seen = useRef(null)
|
|
useEffect(() => {
|
|
if (!top || top.ts === seen.current) return
|
|
const first = seen.current === null
|
|
seen.current = top.ts
|
|
if (first) return // do not flash the whole list on mount
|
|
setFresh(true)
|
|
const id = setTimeout(() => setFresh(false), 1200)
|
|
return () => clearTimeout(id)
|
|
}, [top?.ts])
|
|
return fresh
|
|
}
|