2 Commits

Author SHA1 Message Date
48a30d97db Live camera view in the app, and the green light that was lying about it
Two changes, and the second was found by verifying the first.

## Watching a camera from the app, in another building

Snapshots answer "is that camera working". They do not answer "what is
happening in my shop right now", which is what somebody who opens the app away
from the counter is asking. Head office's browser already had that answer -
LiveHub plus cameras.Live, where the shop PC asks outbound whether anybody is
watching and pushes JPEG frames for as long as somebody is - and the app could
not reach it.

cloud.CameraLive opens that feed and the app's own loopback relay re-emits it
as multipart MJPEG. That is the trick: frames arrive base64 over SSE, an <img>
cannot render that, and an <img> renders MJPEG natively - so a tile is an
ordinary <img> pointed at loopback whether the camera is in this room or
another city.

- Reconnecting happens in the relay, not the page. The server caps one push at
  five minutes, so doing it here means the <img> never sees the stream end.
- The headers are flushed before the first frame. Go writes them on the first
  body write, so without that the whole response waits for the shop PC to
  start pushing. Measured against production: 30 seconds and not even a
  Content-Type, which surfaces as the request timing out.
- One camera at a time. Watching makes a shop PC upload, so a grid that went
  live at once would put an estate's worth of cameras on the wire because
  somebody opened a page.
- live.mjpeg is behind the same per-run token as the engine routes, and a
  wrong token is a 404 that never reaches head office at all.
- CameraLive uses its own HTTP client: the shared one's 30s timeout covers the
  whole response and would sever a working view every thirty seconds - the
  trap that made the server set WriteTimeout to zero for its own SSE endpoint.

## A camera read "Connected" for 34 minutes after the shop PC went blind

Which is why the verification above looked like a failure: head office
registered the viewer and no frame ever came.

reportWith returns early when the engine is unreachable - correctly, it has
nothing to say - so the last state it sent stays in the database looking
current. Measured live: cam2 and entrance both reading Connected, in green,
with last_seen_at 34 minutes old, while the heartbeat from the same PC said
cameras_up 0 of 0. Two surfaces reading two stored fields and disagreeing.

false could not be the answer. It means "this camera is not connecting", which
sends an installer to check cabling on a camera that was working perfectly the
last time anybody could ask it. So there are four states and one function:

  connected       reported recently, and working
  not_connecting  reported recently, and the stream will not open
  waiting         no shop PC has ever reported this camera
  stale           reported once, and not lately

- Connected is CLEARED when stale or waiting. A stale true left in place stays
  available to every client reading the field directly, and leaves two fields
  on one object disagreeing - how the shops screen once came out labelled
  Working, in green, above "2 of 3 cameras not connecting".
- Computed in scanCamera, so every camera anybody reads passes through it. A
  state computed per handler is one a handler forgets, and this had already
  reached three screens.
- CameraStaleAfter is 5 minutes: five missed reports, not one. Same reasoning
  as three missed heartbeats - an indicator that cries wolf gets ignored.
- An unparseable last_seen_at is stale. It should be impossible, which is why
  it must not fall through to the state that says everything is fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 16:48:43 +05:30
ecc8bbba6f The app on a laptop reported an engine that was never meant to be there
Signing in on a second Mac showed "engine not reachable at
http://127.0.0.1:8010" and 0 of 0 cameras, on an account whose shops were
running and recognising people the whole time. Nothing was broken: Live() and
Cameras() read only the engine on loopback, so the app answered as though the
person had never signed in - and camera sync goes through the engine, which is
why the count was zero rather than stale.

Having no engine is a normal state. A shop PC watches cameras; an owner's
laptop, a manager's machine and a second till being set up do not, and all
three are signed in to the same estate. Both methods now fall back to head
office when loopback fails and somebody is signed in. Loopback is still tried
first: a real shop PC must never be shown a minute-old summary when the engine
two milliseconds away has the live one.

Decisions worth keeping:

- Viewing is on the snapshot, not inferred per screen. Three surfaces read it,
  and a screen that computed it separately is how the shops screen once came
  out labelled Working, in green, above "2 of 3 cameras not connecting".
- fraction_below_gate takes the WORST shop, never an average. 0.10 against
  0.73 averages to 0.42 and hides the only shop anyone needs to visit.
- A remote camera is flagged, and Edit, Remove and Check placement are
  withheld. They talk to a camera on a LAN this computer cannot reach, and a
  button that cannot work is worse than one that is absent.
- connected is three states. null is "no shop computer has reported yet" and
  reads as waiting; false is "Not connecting". A bare false sends somebody to
  check cabling on a camera nobody has tried to reach.
- Snapshots are fetched in Go as data: URIs and cached by snapshot_at. A
  webview <img> resolves a relative src against wails:// and cannot send the
  bearer - the problem VisitorImage already solved - and this screen polls
  every 8 seconds at ~90 KB a camera.
- With no engine AND no session, the engine error is still the answer. The
  person is most likely setting this PC up.

The picture is the last snapshot and the banner says so: there is no live
video from here, because the engine's MJPEG stream is on the shop PC's
loopback behind a router with no inbound route. The LiveHub relay head office
uses is the answer to that and is a further step for this client.

Verified against production: five arrivals and two cameras parsed from the
real API. viewing_test.go covers the fallback, the worst-shop rule, the
withheld credentials and that an unchanged snapshot is fetched once across
two polls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 16:33:32 +05:30
22 changed files with 1527 additions and 98 deletions

129
CLAUDE.md
View File

@@ -3307,3 +3307,132 @@ Against real Postgres, on the demo tenant:
- HTML, PDF, GIF and empty bodies are all refused as face images: the check is
on the magic bytes, never the `Content-Type` header, because this endpoint
stores what it is handed and serves it back to a browser.
## Viewer mode: the app on a computer that is not watching anything
Signing in on a second Mac showed `engine not reachable at
http://127.0.0.1:8010` and **0 of 0 cameras**, on an account whose shops were
running and recognising people the whole time. Nothing was broken. `App.Live()`
and `App.Cameras()` read **only** `a.local`, so the app answered as though the
person had never signed in — and camera sync goes *through* the engine, which
is why the count was zero rather than merely stale.
That is the wrong model of what this application is. A shop PC watches
cameras; an owner's laptop, a manager's machine, a second till being set up do
not, and all three are signed in to the same estate. **Having no engine is a
normal state, not a failure**, and the app now says what it can see from where
it is standing instead of reporting the absence of something it does not need.
Both methods try loopback first and fall back to head office when it fails and
somebody is signed in. The order matters: a real shop PC must never be shown
head office's minute-old summary when the engine two milliseconds away has the
live one.
- **`Viewing` is on the snapshot, not inferred in the browser.** Three surfaces
read it — the banner, the camera tally, the getting-started panel — and a
screen that computed it separately is how the shops screen once came out
labelled **Working**, in green, directly above *"2 of 3 cameras not
connecting"*. One fact, one place, the same rule as the tray being a client
of `EngineStatus()`.
- **`fraction_below_gate` is the WORST shop, never an average.** 0.10 against
0.73 averages to 0.42 and hides the only shop anyone needs to visit. Same
rule the heartbeat already follows with `worst_site`.
- **A remote camera is flagged `remote: true`, and the screen withholds Edit,
Remove and Check placement.** Those talk to a camera on a LAN this computer
cannot reach, and an Edit button that cannot work is worse than one that is
absent. The tenant response structurally cannot carry `host`, `username` or
`has_password`, so nothing here can invent them either — a test asserts that.
- **`connected` is three states.** `null` is "no shop computer has reported on
this yet" and reads as *waiting*; `false` is *"Not connecting"*. A bare false
sends somebody to check cabling on a camera nobody has tried to reach.
- **The picture is the last snapshot, and it says so.** There is no live video
here: the engine's MJPEG stream is on the shop PC's loopback behind a router
with no inbound route. Head office's `LiveHub` relay is the answer to that
and is a further step for this client; the banner does not imply otherwise.
- **Snapshots are fetched in Go and passed as `data:` URIs, cached by
`snapshot_at`.** A webview `<img>` resolves a relative src against `wails://`
and cannot send the session's bearer — the same problem `VisitorImage`
already solved — and this screen polls every 8 seconds at ~90 KB a camera, so
re-fetching an unchanged frame is megabytes an hour to redraw the same
picture. Keyed on the server's `snapshot_at`, because a new timestamp is the
only thing that means a new photograph.
- **With no engine AND nobody signed in, the engine error is still the answer.**
There is nothing else to show and the person is most likely setting this PC
up; naming head office there points them at a step they have not reached.
## Watching a camera from the app, in another building
Snapshots answer *"is that camera working"*. They do not answer *"what is
happening in my shop right now"*, which is what somebody who opens the app
away from the counter is asking. Head office's browser already had the answer
— `LiveHub` plus `cameras.Live`, where the shop PC asks outbound whether
anybody is watching and pushes JPEG frames up for exactly as long as somebody
is — and the app could not reach it.
`cloud.CameraLive` opens that feed and the app's own loopback relay re-emits
it as **multipart MJPEG**, which is the whole trick: frames arrive base64 over
SSE, an `<img>` cannot render that, and an `<img>` renders MJPEG natively. So a
tile is an ordinary `<img>` pointed at loopback whether the camera is in this
room or another city, and no screen has to know which.
- **Reconnecting happens in the relay, not the page.** The server caps one push
at five minutes so a tab left open for a week cannot leave a shop uploading
for a week. Doing it here means the `<img>` never sees the stream end.
- **The headers are flushed before the first frame.** Go writes them on the
first body write, so without that the whole response — status line included —
waits for the shop PC to start pushing. Measured against production: thirty
seconds and not even a `Content-Type`, which surfaces as the *request* timing
out rather than a stream that has not painted yet.
- **One camera at a time.** Watching makes a shop PC upload, so a grid that
went live at once would put an estate's worth of cameras on the wire because
somebody opened a page. `Watch live` is per tile and toggles the previous one
off.
- **`live.mjpeg` is behind the same per-run token as the engine routes**, and a
wrong token is a 404 that never reaches head office at all. It is a live view
of a shop floor; the relay being on loopback is not on its own a control.
- **`CameraLive` uses its own HTTP client.** The shared one has a 30-second
timeout that covers the whole response and would therefore sever a working
live view every thirty seconds — the same trap that made the server set
`WriteTimeout` to zero for its own SSE endpoint.
## A camera read "Connected" for 34 minutes after the shop PC went blind
Found while verifying the live view against production, and it is the reason
that verification looked like a failure: head office registered the viewer and
no frame ever came.
`reportWith` returns early when the engine is unreachable — correctly, because
it has nothing to say — so the last state it sent **stays in the database
looking current**. Measured on the live estate: `cam2` and `entrance` both
reading **Connected**, in green, with `last_seen_at` thirty-four minutes old,
while the heartbeat from the same PC said `cameras_up: 0, cameras_total: 0`.
Two surfaces reading two stored fields and disagreeing about one fact.
`false` could not be the answer. It means *"this camera is not connecting"*,
which sends an installer to check cabling on a camera that was working
perfectly the last time anybody could ask it. So there are four states, not
three, and `api.CameraState` is the one function that decides them:
| state | meaning | what to do |
|---|---|---|
| `connected` | reported within `CameraStaleAfter`, and working | — |
| `not_connecting` | reported recently, and the stream will not open | check the address, password, cabling |
| `waiting` | no shop PC has ever reported this camera | it has not reached the PC yet |
| `stale` | reported once, and not lately | check the PC is on and Behavision is running |
- **`Connected` is CLEARED when the state is `stale` or `waiting`.** Leaving a
stale `true` in place keeps the lie available to every client that reads the
field directly — a mobile app, a script, an older desktop build — and leaves
two fields on one object disagreeing, which is exactly how the shops screen
once came out labelled **Working**, in green, above *"2 of 3 cameras not
connecting"*.
- **It is computed in `scanCamera`**, so every camera anybody reads passes
through it. A state computed per handler is a state one handler forgets, and
this one had already reached three screens.
- **`CameraStaleAfter` is 5 minutes — five missed reports, not one.** The agent
reports on a 60-second tick, so one miss is a dropped packet. Same reasoning
as a site being offline after three missed heartbeats: an indicator that
cries wolf is one people learn to ignore.
- **An unparseable `last_seen_at` is stale**, not connected. It should be
impossible, which is precisely why it must not fall through to the state that
says everything is fine.

View File

@@ -82,6 +82,14 @@ func (a *App) startup(ctx context.Context) {
if err := a.proxy.start(a.local.Base, a.local.User, a.local.Password); err != nil {
log.Printf("camera relay unavailable, tiles will not load: %v", err)
}
// And the other direction: watching a camera in another building, through
// head office's relay. Enabled unconditionally rather than only when a
// session already exists, because signing in is a thing that happens
// while the app is open - and CameraLive refuses without a session
// anyway, so there is nothing to gate.
if err := a.proxy.watchRemote(a.cloud.CameraLive); err != nil {
log.Printf("remote camera view unavailable: %v", err)
}
// A saved session means a shop PC that rebooted overnight comes back
// working instead of waiting for someone to log in.
@@ -616,10 +624,44 @@ func (a *App) StopEngine() EngineStatus {
// ---------------------------------------------------------------- cameras --
// Cameras lists this PC's cameras, or the company's if this PC has none of
// its own.
//
// The distinction is load-bearing and the UI is told which it got. A camera
// from the local engine is one THIS machine can reach, edit and stream. One
// from head office is a camera at a shop somewhere else: it has a snapshot
// and a connection state, and it cannot be edited from here because the shop
// PC on that LAN is the only thing that can reach it. Offering an Edit button
// that could not work would be worse than not showing the camera at all.
func (a *App) Cameras() ([]map[string]any, error) {
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
defer cancel()
return a.local.Cameras(ctx)
cams, err := a.local.Cameras(ctx)
if err == nil {
return cams, nil
}
if !a.cloud.LoggedIn() {
return nil, err
}
remote, rerr := a.cloud.RemoteCameras(ctx)
if rerr != nil {
return nil, err // the local failure is the one worth reporting
}
out := make([]map[string]any, 0, len(remote))
for _, c := range remote {
out = append(out, map[string]any{
"id": c.ID, "camera_id": c.CameraID, "label": c.Label,
"site": c.Site, "enabled": c.Enabled,
"connected": c.Connected, "last_seen_at": c.LastSeenAt,
"state": c.State, "state_note": c.StateNote,
"snapshot": c.Snapshot, "snapshot_at": c.SnapshotAt,
// What the screen keys off to hide Edit, Test and Check: this
// camera is on a network this PC cannot reach.
"remote": true,
})
}
return out, nil
}
// DiscoverCameras lists the cameras on this PC's network, so the add-camera
@@ -686,25 +728,115 @@ func (a *App) StreamURL(cameraID string) string {
return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID)
}
// RemoteStreamURL is the live view of a camera in another building.
//
// The picture comes from head office's relay - the shop PC pushes frames
// outbound because nothing can reach in - and this app re-emits them as MJPEG
// on its own loopback, so a tile is an ordinary <img> either way. A screen
// therefore never has to know which building it is looking at.
//
// Empty when the relay is not running, and the caller shows the last snapshot
// instead. There is no useful fallback URL: the head-office endpoint needs
// this session's bearer, which an <img> cannot send.
func (a *App) RemoteStreamURL(cameraID string) string {
if !a.cloud.LoggedIn() {
return ""
}
return a.proxy.urlFor(cameraID, "live.mjpeg")
}
// ------------------------------------------------------------------- live --
type LiveSnapshot struct {
Stats map[string]any `json:"stats"`
Events []map[string]any `json:"events"`
// Viewing is true when none of this came from an engine on THIS PC. The
// screen must say so: the numbers are the company's, not this machine's,
// and a laptop in a hotel showing "2 cameras live" without that word
// would be claiming to be watching a shop it cannot see.
Viewing bool `json:"viewing"`
}
// Live is what the shop PC sees, and falls back to what HEAD OFFICE sees.
//
// A PC with no engine is not necessarily broken - it is somebody signed in on
// a laptop away from the shop, which is the ordinary way an owner looks at
// their estate. Until now that produced "engine not reachable at
// 127.0.0.1:8010", an accurate sentence and a useless one when the reader was
// never expecting an engine on that machine.
//
// The local engine always wins when it is there: it is this shop's own
// ground truth and it is live rather than a heartbeat old.
func (a *App) Live() (LiveSnapshot, error) {
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
defer cancel()
stats, err := a.local.Stats(ctx)
if err == nil {
events, eerr := a.local.Events(ctx, 40)
if eerr == nil {
return LiveSnapshot{Stats: stats, Events: events}, nil
}
}
// No engine here. If nobody is signed in either, the honest answer is
// still the local error - there is nothing else to show and the person
// is most likely setting this PC up.
if !a.cloud.LoggedIn() {
return LiveSnapshot{}, err
}
return a.liveFromCloud(ctx)
}
// liveFromCloud builds the same shape the Live screen already renders, out of
// the estate's own feed, so the view needs no second code path.
func (a *App) liveFromCloud(ctx context.Context) (LiveSnapshot, error) {
sites, err := a.cloud.Sites(ctx)
if err != nil {
return LiveSnapshot{}, err
}
events, err := a.local.Events(ctx, 40)
arrivals, err := a.cloud.Arrivals(ctx, 40)
if err != nil {
return LiveSnapshot{}, err
}
return LiveSnapshot{Stats: stats, Events: events}, nil
// The counters are summed across the estate, and fraction_below_gate
// takes the WORST site rather than an average - one badly placed camera
// is a hole in the numbers, and averaging it against three good ones
// hides the only site anyone needs to visit. Same rule the heartbeat
// already follows.
var up, total int
worst := 0.0
people := map[string]struct{}{}
for _, s := range sites {
up, total = up+s.CamerasUp, total+s.CamerasTotal
if s.FractionBelowGate > worst {
worst = s.FractionBelowGate
}
}
events := make([]map[string]any, 0, len(arrivals))
for _, v := range arrivals {
if v.VisitorID != "" {
people[v.VisitorID] = struct{}{}
}
events = append(events, map[string]any{
"type": map[bool]string{true: "person.new", false: "person.seen"}[v.IsNew],
"ts": v.OccurredAt, "camera_id": v.CameraID,
"data": map[string]any{
"label": v.Label, "ref": v.Ref, "site": v.Site,
"similarity": v.Similarity, "attributes": v.Attributes,
},
})
}
return LiveSnapshot{
Viewing: true,
Events: events,
Stats: map[string]any{
"cameras": []map[string]any{},
"gallery": map[string]any{"identities": len(people), "sightings": len(arrivals)},
"cameras_up": up, "cameras_total": total,
"fraction_below_gate": worst,
},
}, nil
}
// ---------------------------------------------------------------- reports --

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Behavision</title>
<script type="module" crossorigin src="./assets/index-Be_Iv2Nz.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-lhDNZRcC.css">
<script type="module" crossorigin src="./assets/index-6LYbNlbD.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DOJ2bRrM.css">
</head>
<body>
<div id="root"></div>

View File

@@ -38,6 +38,9 @@ export const api = {
startPlacement: (id, seconds) => call('StartPlacementCheck', id, seconds),
placementResult: (id) => call('PlacementResult', id),
streamURL: (id) => call('StreamURL', id),
// The live view of a camera in another building, relayed through head
// office. Empty when nobody is signed in.
remoteStreamURL: (id) => call('RemoteStreamURL', id),
live: () => call('Live'),
pipelineStatus: () => call('PipelineStatus'),

View File

@@ -634,3 +634,32 @@ tr.click { cursor: pointer; } tr.click:hover td { background: var(--s2); }
.starter .note { padding: var(--sp-3) var(--sp-4); font-size: 12px; }
.btn.ghost { background: none; border-color: transparent; color: var(--ink-3); }
.btn.ghost:hover { color: var(--ink); }
/* Viewer mode: this PC has no engine, so the screens show the company's own
data from head office. Informational, not an error - it is the ordinary
state of a laptop away from a shop, and styling it red would train people
to ignore the red that means something. */
.viewing {
display: flex; gap: 10px; align-items: flex-start;
padding: 12px 14px; margin-bottom: 14px;
border: 1px solid var(--line); border-radius: 10px;
background: color-mix(in srgb, var(--accent) 7%, transparent);
color: var(--ink-2); font-size: 13px; line-height: 1.5;
}
.viewing b { color: var(--ink); font-weight: 600; }
.viewing svg { flex: none; margin-top: 2px; color: var(--accent); }
/* Watch live sits over the picture, opposite the connection pill. It is on
the tile rather than in the button row because it is about the picture, and
because the row it would otherwise join is hidden on a remote camera. */
.camview .btn.watch {
position: absolute;
right: 10px;
bottom: 10px;
background: rgba(0, 0, 0, .55);
border-color: rgba(255, 255, 255, .25);
color: #fff;
backdrop-filter: blur(6px);
}
.camview .btn.watch:hover { background: rgba(0, 0, 0, .72); }
.camview .btn.watch.on { background: var(--accent); border-color: var(--accent); color: #fff; }

View File

@@ -19,7 +19,27 @@ export default function Cameras() {
const [editing, setEditing] = useState(null)
const [check, setCheck] = useState(null)
const cams = data ?? []
const streams = useStreamURLs(cams)
// Every camera is remote or none is: this list comes from the engine on
// loopback, and when that is unreachable the whole list comes from head
// office instead. A remote camera is on a network this computer cannot
// reach, so the picture is the shop PC's last snapshot and the buttons that
// would talk to the camera are not offered - one that cannot work is worse
// than one that is absent.
const remote = cams.some(c => c.remote)
const streams = useStreamURLs(remote ? [] : cams)
// ONE camera at a time, and that is a cost decision rather than a layout
// one. A remote view makes the shop computer upload frames for as long as
// somebody is watching, so a grid that all went live at once would put an
// estate's worth of cameras on the wire because somebody opened a page.
const [watching, setWatching] = useState(null)
const [watchURL, setWatchURL] = useState('')
useEffect(() => {
let alive = true
if (!watching) { setWatchURL(''); return }
api.remoteStreamURL(watching).then(u => { if (alive) setWatchURL(u || '') })
.catch(() => { if (alive) setWatchURL('') })
return () => { alive = false }
}, [watching])
async function remove(cam) {
if (!confirm(`Remove ${cam.id}? Recognition from it stops immediately.`)) return
@@ -31,13 +51,22 @@ export default function Cameras() {
<header className="pagehead">
<div>
<h2>Cameras</h2>
<p>Add a camera, then prove it can see faces with a walk-past. Only then is it working.</p>
<p>{remote
? 'The cameras across your shops, as the shop computers last reported them.'
: 'Add a camera, then prove it can see faces with a walk-past. Only then is it working.'}</p>
</div>
<button className="btn primary" onClick={() => setEditing({ ...BLANK })}>
{!remote && <button className="btn primary" onClick={() => setEditing({ ...BLANK })}>
<Icon.Plus size={15} />Add camera
</button>
</button>}
</header>
{remote && <div className="viewing">
<b>Viewing your shops from here.</b> These cameras are wired to the shop
computers, so they are set up and checked there. Each tile shows that
camera's most recent frame; <b>Watch live</b> asks the shop computer to
send video for as long as you are looking.
</div>}
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
{cams.length === 0
@@ -51,7 +80,10 @@ export default function Cameras() {
</div>
: <div className="camgrid">
{cams.map(c => (
<CameraCard key={c.id} cam={c} stream={streams[c.id]}
<CameraCard key={c.id} cam={c}
stream={watching === c.id ? watchURL : streams[c.id]}
watching={watching === c.id}
onWatch={() => setWatching(watching === c.id ? null : c.id)}
onEdit={() => setEditing(c)} onCheck={() => setCheck(c.id)} onRemove={() => remove(c)} />
))}
</div>}
@@ -63,41 +95,74 @@ export default function Cameras() {
)
}
function CameraCard({ cam, stream, onEdit, onCheck, onRemove }) {
const conn = cam.connected === undefined ? { tone: 'idle', label: 'Engine stopped' }
: cam.connected ? { tone: 'ok', label: 'Connected' } : { tone: 'bad', label: 'Not connecting' }
function CameraCard({ cam, stream, watching, onWatch, onEdit, onCheck, onRemove }) {
// Three states, not two, and the third is why `connected` is a pointer on
// the wire: null means no shop computer has reported on this camera yet,
// which reads as waiting rather than as a fault to go and investigate.
const conn = cam.remote
// Four states, decided once by the server. `stale` is the one that was
// missing: the shop computer reports nothing when it cannot reach its own
// engine, so its last report used to sit there reading Connected -
// measured at 34 minutes on the live estate.
? ({ connected: { tone: 'ok', label: 'Connected' },
not_connecting: { tone: 'bad', label: 'Not connecting' },
stale: { tone: 'warn', label: 'Not reporting' } }[cam.state]
|| { tone: 'idle', label: 'Waiting for the shop computer' })
: cam.connected === undefined || cam.connected === null
? { tone: 'idle', label: 'Engine stopped' }
: cam.connected ? { tone: 'ok', label: 'Connected' }
: { tone: 'bad', label: 'Not connecting' }
// The last placement verdict, so "proven" survives closing the sheet. Only
// `good` is a pass: marginal means half the visitors are silently discarded.
const { data: last } = usePolled(() => api.placementResult(cam.id), 15000, [cam.id])
// Never asked for a remote camera: that answer lives on the shop computer,
// and polling loopback for it here only produces an error every 15 seconds.
const { data: last } = usePolled(
() => cam.remote ? Promise.resolve(null) : api.placementResult(cam.id), 15000, [cam.id])
const proof = !last || last.running || !last.verdict || last.verdict === 'starting'
? { tone: 'miss', label: 'Not yet proven', text: 'Walk past it once and Behavision will tell you if the placement works.' }
: last.verdict === 'good'
? { tone: 'seen', label: 'Proven', text: last.headline || 'Faces recognised on a walk-past.' }
: { tone: 'miss', label: 'Not proven', text: last.headline || 'Move the camera and check again.' }
const shot = cam.snapshot?.available ? cam.snapshot.url : ''
return (
<article className="camcard">
<div className="camview">
{stream
? <img src={stream} alt={cam.id} />
{stream || shot
? <img src={stream || shot} alt={cam.id} />
: <div className="placeholder"><Icon.NoCamera size={34} /></div>}
<span className={`pill ${conn.tone === 'idle' ? '' : conn.tone} over`}><i className={`dot ${conn.tone}`} />{conn.label}</span>
{cam.remote && <button className={`btn sm watch ${watching ? 'on' : ''}`} onClick={onWatch}>
<Icon.Play size={13} />{watching ? 'Stop watching' : 'Watch live'}
</button>}
</div>
<div className="cambody">
<div className="camtitle">
<div>
<h3>{cam.id}</h3>
<span className="mono note">{cam.host || cam.url}{cam.path ? ` · ${cam.path}` : ''}</span>
<h3>{cam.label || cam.camera_id || cam.id}</h3>
<span className="mono note">{cam.remote
? cam.site || cam.site_slug || ''
: `${cam.host || cam.url}${cam.path ? ` · ${cam.path}` : ''}`}</span>
</div>
<div className="camactions">
{!cam.remote && <div className="camactions">
<button className="btn sm" onClick={onEdit}>Edit</button>
<button className="btn sm danger" onClick={onRemove}>Remove</button>
</div>
</div>
<div className="camproof">
<span className={`tag ${proof.tone}`}>{proof.label}</span>
<span className="note">{proof.text}</span>
<button className={`btn sm ${proof.tone === 'seen' ? '' : 'primary'}`} onClick={onCheck}><Icon.Play size={13} />{proof.tone === 'seen' ? 'Check again' : 'Check placement'}</button>
</div>}
</div>
{cam.remote
? <div className="camproof">
<span className="note">{cam.state_note
? cam.state_note
: watching
? 'Live from the shop computer. It uploads only while you watch.'
: cam.snapshot?.available
? 'Last picture from the shop computer. Watch live to see it now.'
: cam.snapshot?.reason || 'No picture yet from the shop computer.'}</span>
</div>
: <div className="camproof">
<span className={`tag ${proof.tone}`}>{proof.label}</span>
<span className="note">{proof.text}</span>
<button className={`btn sm ${proof.tone === 'seen' ? '' : 'primary'}`} onClick={onCheck}><Icon.Play size={13} />{proof.tone === 'seen' ? 'Check again' : 'Check placement'}</button>
</div>}
</div>
</article>
)

View File

@@ -23,15 +23,22 @@ export default function Live({ onNavigate }) {
const cameras = data?.stats?.cameras ?? []
const gallery = data?.stats?.gallery ?? {}
const events = data?.events ?? []
// No engine on THIS PC, so everything below came from head office. It has to
// be said rather than implied: a laptop in a hotel showing "2 cameras live"
// without this line is claiming to watch a shop it cannot see.
const viewing = data?.viewing === true
// 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 worst = viewing
? (data?.stats?.fraction_below_gate ?? 0)
: cameras.reduce((acc, c) => {
const f = c?.pipeline?.best_quality?.fraction_below_gate
return typeof f === 'number' && f > acc ? f : acc
}, 0)
// Viewing: the server already summed these across the estate.
const up = viewing ? (data?.stats?.cameras_up ?? 0) : cameras.filter(c => c.connected).length
const arrivals = events.filter(e => e.type === 'person.new' || e.type === 'person.seen')
const freshest = useFreshest(arrivals[0])
@@ -40,14 +47,29 @@ export default function Live({ onNavigate }) {
<div className="page">
<header>
<h2>Live</h2>
<p>Who is in the shop, and whether it is reaching head office.</p>
<p>{viewing
? 'Your shops, as head office sees them.'
: 'Who is in the shop, and whether it is reaching head office.'}</p>
</header>
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
{viewing && (
<div className="viewing">
<Icon.Cloud size={15} />
<span><b>Viewing your shops from here.</b> This computer is not watching
any cameras itself — everything below is what your shop PCs reported.
To recognise people on this machine, it has to be on the same network
as a camera.</span>
</div>
)}
<PipelineStrip pipe={pipe} cameras={cameras} up={up} />
<GettingStarted cameras={cameras} arrivals={arrivals} onNavigate={onNavigate} />
{/* Getting Started walks somebody through setting up a camera on THIS
PC — not what a viewer is doing, and not something they could finish
from here. */}
{!viewing && <GettingStarted cameras={cameras} arrivals={arrivals} onNavigate={onNavigate} />}
<div className="panel arrivals-panel">
<div className="panelhead">

View File

@@ -14,6 +14,7 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
@@ -39,6 +40,19 @@ type Client struct {
// single-use refresh token.
refreshMu sync.Mutex
onRefresh func(Session)
// Camera snapshots already fetched, keyed by camera id. The Cameras screen
// polls every 8 seconds and a snapshot is ~90 KB, so re-fetching one that
// has not changed would put megabytes an hour on the wire to redraw the
// same picture - the same trap the web app's useAuthedImage avoids by
// keying on the url rather than the object around it.
shotMu sync.Mutex
shots map[string]cachedShot
}
type cachedShot struct {
at string // the server's snapshot_at; a new one is a new picture
uri string
}
type User struct {
@@ -629,3 +643,182 @@ func (c *Client) RecordPurchase(ctx context.Context, visitorID string,
"items": items, "source": "manual", "notes": notes,
}, nil)
}
// ---------------------------------------------------------------- viewing --
//
// A PC with no engine of its own is not broken, it is a VIEWER: somebody
// signed in on a laptop away from the shop. Everything below reads head
// office so those screens have something true to show instead of "engine not
// reachable", which is an accurate sentence and a useless one when the reader
// was never expecting an engine on that machine.
// Arrival is one visit as the estate's feed reports it, across every shop -
// not just this PC's. `GET /api/visits`.
type Arrival struct {
VisitID string `json:"visit_id"`
VisitRef string `json:"visit_ref"`
OccurredAt string `json:"occurred_at"`
Site string `json:"site"`
SiteSlug string `json:"site_slug"`
CameraID string `json:"camera_id"`
VisitorID string `json:"visitor_id"`
Ref string `json:"ref"`
Label string `json:"label"`
IsNew bool `json:"is_new_visitor"`
Similarity float64 `json:"similarity"`
Attributes map[string]any `json:"attributes"`
Image Photo `json:"image"`
}
// RemoteCamera is a camera as HEAD OFFICE knows it. Deliberately not the same
// type the local engine returns: this one can never be edited from here (the
// shop PC on that LAN is the only thing that can reach it) and it carries a
// snapshot rather than a stream.
type RemoteCamera struct {
ID string `json:"id"`
CameraID string `json:"camera_id"`
Label string `json:"label"`
Site string `json:"site"`
SiteSlug string `json:"site_slug"`
Enabled bool `json:"enabled"`
Connected *bool `json:"connected"`
LastSeenAt string `json:"last_seen_at"`
// State is the server's single answer - connected / not_connecting /
// waiting / stale - and the screen renders that rather than deciding
// again from Connected. Two places deciding one fact is how a shop came
// out labelled Working, in green, above "2 of 3 cameras not connecting".
State string `json:"state"`
StateNote string `json:"state_note"`
Snapshot Photo `json:"snapshot"`
SnapshotAt string `json:"snapshot_at"`
}
// Arrivals reads the estate's recent visits, newest last.
func (c *Client) Arrivals(ctx context.Context, limit int) ([]Arrival, error) {
var out struct {
Arrivals []Arrival `json:"arrivals"`
}
if err := c.send(ctx, http.MethodGet,
fmt.Sprintf("/api/visits?limit=%d", limit), nil, &out); err != nil {
return nil, err
}
return out.Arrivals, nil
}
// RemoteCameras lists every camera head office knows about for this company.
func (c *Client) RemoteCameras(ctx context.Context) ([]RemoteCamera, error) {
var out []RemoteCamera
if err := c.send(ctx, http.MethodGet, "/api/cameras", nil, &out); err != nil {
return nil, err
}
for i := range out {
out[i].Snapshot = c.resolveShot(ctx, out[i].ID, out[i].SnapshotAt, out[i].Snapshot)
}
return out, nil
}
// resolveShot turns a camera snapshot into something the window can render.
//
// Same problem VisitorImage has and the same answer: a deployment with no
// object storage serves the picture from the API itself, so the url is
// relative and needs this session's bearer. A webview <img> can supply
// neither - it resolves a relative src against wails:// and cannot set a
// header - so the bytes are fetched here and passed as a data: URI.
//
// A failure is an absence with a reason, never an error. Whether the camera is
// CONNECTED is the answer this screen exists to give; the photograph is
// decoration, and blanking the card because a picture would not load would
// hide the part that matters.
func (c *Client) resolveShot(ctx context.Context, camID, at string, p Photo) Photo {
if !p.Available || !p.Auth || p.URL == "" {
return p
}
c.shotMu.Lock()
hit, ok := c.shots[camID]
c.shotMu.Unlock()
if ok && hit.at == at && at != "" {
p.URL, p.Auth = hit.uri, false
return p
}
uri, err := c.fetchImage(ctx, p.URL)
if err != nil {
return Photo{Reason: "That camera's picture could not be loaded."}
}
c.shotMu.Lock()
if c.shots == nil {
c.shots = map[string]cachedShot{}
}
c.shots[camID] = cachedShot{at: at, uri: uri}
c.shotMu.Unlock()
p.URL, p.Auth = uri, false
return p
}
// CameraLive opens head office's live relay for one camera and returns the
// live SSE response for the caller to read and close.
//
// A response rather than frames, because the consumer is the app's own
// loopback relay: it re-emits these frames as MJPEG so an <img> can show them,
// and buffering the stream through a channel here would only add a place for
// frames to queue. A stale frame is worthless - the only one worth having is
// the newest - which is the whole reason LiveHub drops rather than queues.
//
// There is no client timeout on this request. A live view is endless by
// design and any deadline would cut the picture off mid-shift; the context is
// what ends it, when the viewer navigates away.
func (c *Client) CameraLive(ctx context.Context, cameraID string) (*http.Response, error) {
resp, err := c.liveOnce(ctx, cameraID)
if errors.Is(err, errTokenExpired) {
if rerr := c.Refresh(ctx); rerr != nil {
return nil, rerr
}
resp, err = c.liveOnce(ctx, cameraID)
}
return resp, err
}
func (c *Client) liveOnce(ctx context.Context, cameraID string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
c.Base+"/api/cameras/"+url.PathEscape(cameraID)+"/live", nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "text/event-stream")
c.mu.RLock()
tok := c.token
c.mu.RUnlock()
if tok == "" {
return nil, ErrUnauthorized
}
req.Header.Set("Authorization", "Bearer "+tok)
// c.http has a 30 s timeout, which covers the whole response and would
// therefore sever a working live view every thirty seconds - the same
// trap that made the server set WriteTimeout to zero for its own SSE
// endpoint. A dedicated client, with the dial bounded instead.
hc := &http.Client{Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
}}
resp, err := hc.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot reach %s: %w", c.Base, err)
}
if resp.StatusCode == http.StatusUnauthorized {
var e struct {
Error string `json:"error"`
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
resp.Body.Close()
_ = json.Unmarshal(body, &e)
if e.Error == "token_expired" {
return nil, errTokenExpired
}
return nil, ErrUnauthorized
}
if resp.StatusCode >= 400 {
resp.Body.Close()
return nil, fmt.Errorf("live view: %s", resp.Status)
}
return resp, nil
}

View File

@@ -21,6 +21,7 @@ package main
// session and the bytes are fetched and handed over as an object URL.
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
@@ -64,6 +65,12 @@ type streamProxy struct {
target string // engine origin, e.g. http://127.0.0.1:8010
user string
pass string
// Opens head office's live relay for one camera. Set on a computer that
// is signed in, whether or not an engine runs here - which is the whole
// point: watching a camera in another building is precisely the case
// where there is no engine on this machine to ask.
live func(ctx context.Context, cameraID string) (*http.Response, error)
}
func newStreamProxy() *streamProxy { return &streamProxy{} }
@@ -71,18 +78,49 @@ func newStreamProxy() *streamProxy { return &streamProxy{} }
// start binds a loopback listener and begins relaying. Calling it again while
// running is a no-op, so a restarted engine cannot leave two listeners behind.
func (p *streamProxy) start(base, user, pass string) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.srv != nil {
return nil
}
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
base = "http://" + base
}
if _, err := url.Parse(base); err != nil {
return fmt.Errorf("engine base %q: %w", base, err)
}
if err := p.bind(); err != nil {
return err
}
p.mu.Lock()
defer p.mu.Unlock()
p.target = strings.TrimRight(base, "/")
p.user, p.pass = user, pass
return nil
}
// watchRemote makes the relay able to serve head office's live view, and
// binds it if nothing else has.
//
// Separate from start() because the two are independent: a shop PC has both
// an engine and a session, an owner's laptop has only a session, and a PC
// still being set up has only an engine. Folding them together would mean a
// computer with no engine could not watch a camera at all - which is the one
// computer most likely to be trying to.
func (p *streamProxy) watchRemote(fn func(context.Context, string) (*http.Response, error)) error {
if err := p.bind(); err != nil {
return err
}
p.mu.Lock()
defer p.mu.Unlock()
p.live = fn
return nil
}
// bind starts the loopback listener once. Calling it again while running is a
// no-op, so neither a restarted engine nor a second sign-in can leave two
// listeners behind.
func (p *streamProxy) bind() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.srv != nil {
return nil
}
// The engine's own credential exists precisely so that the live face feed
// is never served open - CLAUDE.md is explicit that an unauthenticated
@@ -105,8 +143,6 @@ func (p *streamProxy) start(base, user, pass string) error {
p.ln = ln
p.token = hex.EncodeToString(raw)
p.target = strings.TrimRight(base, "/")
p.user, p.pass = user, pass
// No client timeout: an MJPEG stream is endless by design and any deadline
// would cut the picture off mid-shift. The request context ends it when
// the webview navigates away or the tile is replaced.
@@ -130,7 +166,7 @@ func (p *streamProxy) start(base, user, pass string) error {
func (p *streamProxy) stop() {
p.mu.Lock()
srv, ln := p.srv, p.ln
p.srv, p.ln, p.token = nil, nil, ""
p.srv, p.ln, p.token, p.live = nil, nil, "", nil
p.mu.Unlock()
if srv != nil {
_ = srv.Close()
@@ -155,6 +191,7 @@ func (p *streamProxy) urlFor(cameraID, file string) string {
func (p *streamProxy) handle(w http.ResponseWriter, r *http.Request) {
p.mu.RLock()
token, target, user, pass, client := p.token, p.target, p.user, p.pass, p.client
liveFn := p.live
p.mu.RUnlock()
if token == "" || client == nil {
http.NotFound(w, r)
@@ -190,6 +227,17 @@ func (p *streamProxy) handle(w http.ResponseWriter, r *http.Request) {
// calls; it is here so that adding a still later is a change to a screen
// rather than a change to the one file where a mistake is a credentialed
// proxy onto the biometric API.
// Head office's relay, not the engine. The two are different machines and
// different credentials, so this returns rather than falling through.
if parts[3] == "live.mjpeg" {
if liveFn == nil {
http.Error(w, "not signed in to head office", http.StatusBadGateway)
return
}
p.relayRemote(w, r, cameraID, liveFn)
return
}
var enginePath string
switch parts[3] {
case "stream.mjpeg":

149
desktop/stream_remote.go Normal file
View File

@@ -0,0 +1,149 @@
package main
// Watching a camera in another building, from the app.
//
// The shop PC sits behind a router with no inbound route, so nothing here can
// pull its MJPEG stream - that stream is served on the shop PC's own loopback
// and always will be. Head office's LiveHub is the way round it: the agent
// asks outbound whether anyone is watching and pushes JPEG frames up for
// exactly as long as somebody is. The head-office web app already consumes
// that; this is the same feed, for the app.
//
// It arrives as base64 frames over SSE, which an <img> cannot render, so this
// re-emits them as multipart MJPEG - which an <img> renders natively, through
// the relay that already exists for the local engine. That is what keeps ONE
// code path in the screens: a tile points at a loopback URL and does not know
// or care which building the picture came from.
import (
"bufio"
"context"
"encoding/base64"
"fmt"
"net/http"
"strings"
"time"
)
// The boundary is ours to choose; it only has to be a string the JPEG bytes
// cannot contain, and a marker line never appears inside JPEG data.
const mjpegBoundary = "behavisionframe"
// A frame is base64, so ~1.33 bytes on the wire per byte of picture. The
// engine re-encodes to 640 px for the relay and those measure ~20 KB, so this
// is roughly a hundredfold headroom - large enough never to clip a real frame
// and small enough that a broken or hostile stream cannot grow this process's
// memory without bound.
const maxFrameLine = 8 << 20
func (p *streamProxy) relayRemote(w http.ResponseWriter, r *http.Request,
cameraID string, open func(context.Context, string) (*http.Response, error)) {
w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary="+mjpegBoundary)
w.Header().Set("Cache-Control", "no-store")
flusher, _ := w.(http.Flusher)
// Send the headers NOW, before any frame exists. Go writes them on the
// first body write, so without this the whole response - status line
// included - waits for the shop computer to start pushing, and a viewer
// whose camera is slow to answer sees the REQUEST time out rather than a
// stream that has not painted yet. Measured against production: 30
// seconds and not even a Content-Type.
if flusher != nil {
flusher.Flush()
}
// Reconnecting is normal, not an error. The server caps one push at five
// minutes so that a tab left open for a week cannot leave a shop
// uploading for a week - so a viewer who IS still there simply asks
// again. Doing it here rather than in the page is what lets the <img>
// survive the cap: it never sees the stream end.
sent := 0
for {
if r.Context().Err() != nil {
return
}
n, err := p.pumpRemote(w, flusher, r.Context(), cameraID, open)
sent += n
if r.Context().Err() != nil {
return
}
// Nothing was written and the attempt failed. Writing an error body
// now would be writing it into a multipart stream the <img> is
// already parsing, so the picture simply stays on whatever it last
// showed and the screen's own "not connecting" state is the report.
if err != nil && sent == 0 {
return
}
select {
case <-r.Context().Done():
return
case <-time.After(1500 * time.Millisecond):
}
}
}
// pumpRemote runs one SSE connection to exhaustion and returns how many
// frames it forwarded.
func (p *streamProxy) pumpRemote(w http.ResponseWriter, flusher http.Flusher,
ctx context.Context, cameraID string,
open func(context.Context, string) (*http.Response, error)) (int, error) {
resp, err := open(ctx, cameraID)
if err != nil {
return 0, err
}
defer resp.Body.Close()
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), maxFrameLine)
var event, data string
frames := 0
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimSpace(line[7:])
case strings.HasPrefix(line, "data: "):
data = line[6:]
case line == "":
// End of one SSE event. `waiting` means head office has us
// registered and the shop PC has not started pushing yet - a real
// second or two while the agent is asked, and nothing to draw.
if event == "frame" && data != "" {
if err := writeMJPEGFrame(w, flusher, data); err != nil {
return frames, err // the webview went away
}
frames++
}
event, data = "", ""
}
}
return frames, sc.Err()
}
func writeMJPEGFrame(w http.ResponseWriter, flusher http.Flusher, b64 string) error {
jpg, err := base64.StdEncoding.DecodeString(b64)
if err != nil || len(jpg) == 0 {
// One malformed frame is not a reason to tear down a working view.
return nil
}
if _, err := fmt.Fprintf(w,
"--%s\r\nContent-Type: image/jpeg\r\nContent-Length: %d\r\n\r\n",
mjpegBoundary, len(jpg)); err != nil {
return err
}
if _, err := w.Write(jpg); err != nil {
return err
}
if _, err := w.Write([]byte("\r\n")); err != nil {
return err
}
// Flushed per frame. Anything held waiting for a full buffer is a tile
// that stays blank, which is indistinguishable from the view not working.
if flusher != nil {
flusher.Flush()
}
return nil
}

View File

@@ -0,0 +1,103 @@
package main
import (
"bytes"
"context"
"net/http"
"os"
"testing"
"time"
"github.com/loyaly/behavision-desktop/internal/cloud"
)
// The whole chain against the real head office and a real shop computer:
//
// TEST_CLOUD_EMAIL=... TEST_CLOUD_PASSWORD=... \
// go test ./desktop/ -run RemoteLive -v
//
// Everything in stream_remote_test.go proves the relay against a fake that
// agrees with me. Only this proves the part that cannot be faked: that a shop
// computer behind a router with no inbound route actually pushes frames when
// asked, that they survive base64 and SSE, and that what comes out of the
// loopback relay is a multipart stream an <img> will paint.
//
// It also costs something to run, which is why it is opt-in: watching makes
// the shop computer upload for as long as the test reads.
func TestRemoteLiveFromProduction(t *testing.T) {
email, pass := os.Getenv("TEST_CLOUD_EMAIL"), os.Getenv("TEST_CLOUD_PASSWORD")
if email == "" || pass == "" {
t.Skip("set TEST_CLOUD_EMAIL and TEST_CLOUD_PASSWORD to run against production")
}
base := os.Getenv("TEST_CLOUD_URL")
if base == "" {
base = "https://mcp.loyaly.ai"
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
c := cloud.New(base)
if _, err := c.Login(ctx, email, pass); err != nil {
t.Fatalf("login: %v", err)
}
cams, err := c.RemoteCameras(ctx)
if err != nil {
t.Fatalf("cameras: %v", err)
}
t.Logf("%d cameras", len(cams))
target := os.Getenv("TEST_CLOUD_CAMERA")
for _, cam := range cams {
conn := "waiting"
if cam.Connected != nil {
conn = map[bool]string{true: "connected", false: "not connecting"}[*cam.Connected]
}
t.Logf(" %-10s %-16s %-15s snapshot=%v", cam.CameraID, cam.Site, conn, cam.Snapshot.Available)
if target == "" && cam.Connected != nil && *cam.Connected {
target = cam.CameraID
}
}
if target == "" {
t.Skip("no connected camera to watch")
}
p := newStreamProxy()
if err := p.watchRemote(c.CameraLive); err != nil {
t.Fatalf("watchRemote: %v", err)
}
defer p.stop()
rctx, rcancel := context.WithTimeout(ctx, 30*time.Second)
defer rcancel()
req, _ := http.NewRequestWithContext(rctx, http.MethodGet, p.urlFor(target, "live.mjpeg"), nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET relay: %v", err)
}
defer resp.Body.Close()
start := time.Now()
acc, buf, frames := make([]byte, 0, 1<<20), make([]byte, 32*1024), 0
for frames < 10 {
n, rerr := resp.Body.Read(buf)
acc = append(acc, buf[:n]...)
frames = bytes.Count(acc, []byte("--"+mjpegBoundary))
if rerr != nil {
break
}
}
el := time.Since(start)
t.Logf("watching %q: %d frames, %d bytes, %.1fs (%.1f fps, %.0f KB/s)",
target, frames, len(acc), el.Seconds(),
float64(frames)/el.Seconds(), float64(len(acc))/el.Seconds()/1024)
if frames < 3 {
t.Fatalf("got %d frames from a connected camera - the shop computer is "+
"not answering head office's request to push", frames)
}
// Bytes that are actually a picture, not a framing header that happens to
// be well formed. A JPEG begins FFD8.
if !bytes.Contains(acc, []byte{0xFF, 0xD8, 0xFF}) {
t.Error("no JPEG start marker anywhere in the stream")
}
}

View File

@@ -0,0 +1,209 @@
package main
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// jpg is a byte sequence that is not valid JPEG and does not need to be: what
// is under test is that the bytes arrive intact and framed, not that a decoder
// likes them.
var jpg = []byte{0xFF, 0xD8, 'h', 'e', 'l', 'l', 'o', 0xFF, 0xD9}
// sseServer answers head office's live endpoint with `pushes` frames and then
// ends the response, which is what the server's five-minute cap does.
func sseServer(t *testing.T, frames int, hits *int32) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(hits, 1)
w.Header().Set("Content-Type", "text/event-stream")
fl, _ := w.(http.Flusher)
// Registered, nothing being pushed yet. Nothing may be drawn for it.
fmt.Fprint(w, "event: waiting\ndata: \n\n")
if fl != nil {
fl.Flush()
}
for i := 0; i < frames; i++ {
fmt.Fprintf(w, "event: frame\ndata: %s\n\n",
base64.StdEncoding.EncodeToString(jpg))
if fl != nil {
fl.Flush()
}
}
}))
}
func openerFor(srv *httptest.Server) func(context.Context, string) (*http.Response, error) {
return func(ctx context.Context, cam string) (*http.Response, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/live/"+cam, nil)
return http.DefaultClient.Do(req)
}
}
// The whole point: base64 frames over SSE are not something an <img> can show,
// and a multipart MJPEG stream is. Without this the app could only ever show a
// still, on exactly the computers that cannot reach the camera any other way.
func TestRemoteFramesReachTheWebviewAsMJPEG(t *testing.T) {
var hits int32
srv := sseServer(t, 3, &hits)
defer srv.Close()
p := newStreamProxy()
if err := p.watchRemote(openerFor(srv)); err != nil {
t.Fatalf("watchRemote: %v", err)
}
defer p.stop()
u := p.urlFor("cam2", "live.mjpeg")
if u == "" {
t.Fatal("no relay url; the proxy did not bind")
}
// The relay reconnects for as long as the viewer is there, so the read is
// bounded by us rather than by the stream ending - exactly as an <img>
// would behave.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET relay: %v", err)
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "multipart/x-mixed-replace") {
t.Fatalf("Content-Type = %q, an <img> will not treat that as a stream", ct)
}
// Read the first three frames' worth and stop; the relay would otherwise
// go on reconnecting forever, which is the behaviour being relied on.
want := append([]byte(fmt.Sprintf("--%s\r\nContent-Type: image/jpeg\r\nContent-Length: %d\r\n\r\n",
mjpegBoundary, len(jpg))), jpg...)
got := make([]byte, 0, 4096)
buf := make([]byte, 512)
for len(got) < 3*len(want) {
n, rerr := resp.Body.Read(buf)
got = append(got, buf[:n]...)
if rerr != nil {
break
}
}
if n := bytes.Count(got, []byte("--"+mjpegBoundary)); n < 3 {
t.Fatalf("got %d frames in %d bytes, want at least 3", n, len(got))
}
if !bytes.Contains(got, want) {
t.Errorf("a frame was not framed as expected:\n%q", got[:min(len(got), 300)])
}
// `waiting` is a real state - head office has us registered and the shop
// computer has not started pushing - and there is nothing to draw for it.
// Emitting an empty part would blank a tile that already had a picture.
if bytes.Contains(got, []byte("Content-Length: 0")) {
t.Error("an empty frame was written for a waiting event")
}
}
// The server caps one push at five minutes so a tab left open for a week
// cannot leave a shop uploading for a week. Reconnecting is therefore a normal
// event, and doing it here rather than in the page is what lets the <img>
// survive the cap - it never sees the stream end.
func TestTheRelayReconnectsWhenHeadOfficeEndsAPush(t *testing.T) {
var hits int32
srv := sseServer(t, 1, &hits)
defer srv.Close()
p := newStreamProxy()
if err := p.watchRemote(openerFor(srv)); err != nil {
t.Fatalf("watchRemote: %v", err)
}
defer p.stop()
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, p.urlFor("cam2", "live.mjpeg"), nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET relay: %v", err)
}
defer resp.Body.Close()
// Two frames means two pushes, because each push carries exactly one.
seen, buf := 0, make([]byte, 256)
acc := make([]byte, 0, 2048)
for seen < 2 {
n, rerr := resp.Body.Read(buf)
acc = append(acc, buf[:n]...)
seen = bytes.Count(acc, []byte("--"+mjpegBoundary))
if rerr != nil {
break
}
}
if seen < 2 {
t.Fatalf("got %d frames across reconnects, want 2", seen)
}
if got := atomic.LoadInt32(&hits); got < 2 {
t.Errorf("head office was asked %d times, want at least 2", got)
}
}
// Signed out, the relay must not pretend. There is no fallback URL to offer
// either: the head-office endpoint needs this session's bearer, which an <img>
// cannot send - so a tile that silently failed would be the only alternative.
func TestTheRelayRefusesWhenNobodyIsSignedIn(t *testing.T) {
p := newStreamProxy()
if err := p.watchRemote(nil); err != nil {
t.Fatalf("watchRemote: %v", err)
}
defer p.stop()
resp, err := http.Get(p.urlFor("cam2", "live.mjpeg"))
if err != nil {
t.Fatalf("GET relay: %v", err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
if resp.StatusCode != http.StatusBadGateway {
t.Errorf("status = %d, want 502", resp.StatusCode)
}
}
// The relay is credentialed - it is a path to a live view of a shop floor -
// and the token is the only thing standing between another local process and
// it. live.mjpeg must be behind exactly the same door as the engine routes.
func TestTheRemoteRouteIsBehindTheSameToken(t *testing.T) {
var hits int32
srv := sseServer(t, 1, &hits)
defer srv.Close()
p := newStreamProxy()
if err := p.watchRemote(openerFor(srv)); err != nil {
t.Fatalf("watchRemote: %v", err)
}
defer p.stop()
// The right shape, the wrong value.
parts := strings.Split(p.urlFor("cam2", "live.mjpeg"), "/")
parts[4] = strings.Repeat("0", len(parts[4]))
bad := strings.Join(parts, "/")
resp, err := http.Get(bad)
if err != nil {
t.Fatalf("GET relay: %v", err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status = %d, want 404 - and 404 rather than 403, because there is nothing here to tell an unwelcome caller they found the right door", resp.StatusCode)
}
if atomic.LoadInt32(&hits) != 0 {
t.Error("a request with the wrong token still made the shop computer upload")
}
}

166
desktop/viewing_test.go Normal file
View File

@@ -0,0 +1,166 @@
package main
import (
"context"
"encoding/base64"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/loyaly/behavision-desktop/internal/cloud"
"github.com/loyaly/behavision-desktop/internal/local"
)
// Viewer mode: what the app shows on a computer that is signed in and is not
// itself watching any cameras.
//
// This is the friend's-Mac case, and before it existed the app was honest and
// useless: Live() and Cameras() read ONLY the engine on 127.0.0.1, so a laptop
// with no engine got "engine not reachable at http://127.0.0.1:8010" and
// "0 of 0 cameras" - on an account whose shops were running and recognising
// people the whole time. Signing in is what the person did; the app answered
// as if they had not.
//
// The engine here is a port nothing listens on, which is precisely what a PC
// with no engine is.
const noEngine = "http://127.0.0.1:1" // reserved, refuses immediately
func viewerApp(t *testing.T, srv *httptest.Server) *App {
t.Helper()
c := cloud.New(srv.URL)
c.SetSession(cloud.Session{Token: "test-token"})
return &App{
ctx: context.Background(),
cloud: c,
local: local.New(noEngine, "", ""),
}
}
func TestLiveFallsBackToHeadOfficeWhenThereIsNoEngine(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/api/sites":
// Two shops. One is fine, one is the Office1 case.
w.Write([]byte(`[
{"slug":"a","name":"A","cameras_up":2,"cameras_total":2,"fraction_below_gate":0.10},
{"slug":"b","name":"B","cameras_up":1,"cameras_total":3,"fraction_below_gate":0.73}
]`))
case strings.HasPrefix(r.URL.Path, "/api/visits"):
w.Write([]byte(`{"arrivals":[
{"visit_id":"v1","visitor_id":"p1","ref":"V-1","label":"Visitor 1","camera_id":"cam2","is_new_visitor":true},
{"visit_id":"v2","visitor_id":"p1","ref":"V-1","label":"Visitor 1","camera_id":"cam2"},
{"visit_id":"v3","camera_id":"entrance"}
]}`))
default:
t.Errorf("unexpected request %s", r.URL.Path)
}
}))
defer srv.Close()
snap, err := viewerApp(t, srv).Live()
if err != nil {
t.Fatalf("Live: %v", err)
}
if !snap.Viewing {
t.Fatal("the snapshot did not say it was a view of somewhere else")
}
if got := snap.Stats["cameras_up"]; got != 3 {
t.Errorf("cameras_up = %v, want 3 summed across both shops", got)
}
if got := snap.Stats["cameras_total"]; got != 5 {
t.Errorf("cameras_total = %v, want 5", got)
}
// The WORST site, never an average. Averaging 0.10 against 0.73 reports
// 0.42 and hides the only shop anyone needs to go and fix - the same rule
// the heartbeat already follows with worst_site.
if got := snap.Stats["fraction_below_gate"]; got != 0.73 {
t.Errorf("fraction_below_gate = %v, want the worst shop's 0.73", got)
}
// Three arrivals, two of them the same person, one unidentified. A visit
// with no visitor_id is real footfall and an unknown person, so it counts
// as a sighting and not as somebody known.
g := snap.Stats["gallery"].(map[string]any)
if g["identities"] != 1 || g["sightings"] != 3 {
t.Errorf("gallery = %v, want 1 identity over 3 sightings", g)
}
if len(snap.Events) != 3 {
t.Fatalf("got %d events, want 3", len(snap.Events))
}
if snap.Events[0]["type"] != "person.new" || snap.Events[1]["type"] != "person.seen" {
t.Errorf("arrival types wrong: %v", snap.Events)
}
}
// Nobody signed in: the local failure is the honest answer. There is nothing
// else to show, and the person is most likely setting this PC up - telling
// them about head office would be telling them about something they have not
// got to yet.
func TestLiveWithNoEngineAndNoSessionReportsTheEngine(t *testing.T) {
a := &App{ctx: context.Background(), cloud: cloud.New("https://example.invalid"),
local: local.New(noEngine, "", "")}
if _, err := a.Live(); err == nil {
t.Fatal("want the engine error, got nil")
}
}
// A remote camera is flagged, because the screen has to withhold every button
// that would talk to a camera on a network this computer cannot reach. An Edit
// button that cannot work is worse than one that is absent.
func TestRemoteCamerasAreFlaggedAndCarryNoCredentials(t *testing.T) {
jpeg := base64.StdEncoding.EncodeToString([]byte{0xFF, 0xD8, 0xFF, 0xD9})
var shots int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/camera-snapshots/") {
shots++
w.Header().Set("Content-Type", "image/jpeg")
b, _ := base64.StdEncoding.DecodeString(jpeg)
w.Write(b)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[
{"id":"c1","camera_id":"cam2","label":"Open office","site":"Coimbatore",
"connected":true,"snapshot_at":"2026-09-30T10:00:00Z",
"snapshot":{"available":true,"url":"/api/camera-snapshots/c1.jpg","auth":true}}
]`))
}))
defer srv.Close()
a := viewerApp(t, srv)
cams, err := a.Cameras()
if err != nil {
t.Fatalf("Cameras: %v", err)
}
if len(cams) != 1 {
t.Fatalf("got %d cameras, want 1", len(cams))
}
if cams[0]["remote"] != true {
t.Error("the camera was not flagged remote")
}
// The RTSP details are a live path into the camera itself and the server
// does not send them to a tenant at all. Nothing here may invent them.
for _, k := range []string{"host", "port", "path", "username", "password"} {
if _, ok := cams[0][k]; ok {
t.Errorf("a remote camera carried %q", k)
}
}
// The picture has to be fetched here: a webview <img> resolves a relative
// src against wails:// and cannot send the session's bearer.
shot := cams[0]["snapshot"].(cloud.Photo)
if !strings.HasPrefix(shot.URL, "data:image/jpeg;base64,") || shot.Auth {
t.Errorf("snapshot url = %q auth=%v, want an inline data URI", shot.URL, shot.Auth)
}
// And fetched ONCE. This screen polls every 8 seconds and a real snapshot
// is ~90 KB, so re-fetching an unchanged picture is megabytes an hour to
// redraw the same frame.
if _, err := a.Cameras(); err != nil {
t.Fatalf("second poll: %v", err)
}
if shots != 1 {
t.Errorf("fetched the same snapshot %d times across two polls", shots)
}
}

View File

@@ -0,0 +1,96 @@
package api
import (
"testing"
"time"
)
func ptr(b bool) *bool { return &b }
// The state this was written for. Two cameras read "Connected", in green, on
// the live estate thirty-four minutes after the shop computer had stopped
// being able to see either of them - because the agent correctly reports
// nothing when it cannot reach the engine, and the last value it sent stays
// in the database looking current.
func TestAStaleReportIsNotAConnectedCamera(t *testing.T) {
now := time.Date(2026, 9, 30, 11, 13, 0, 0, time.UTC)
c := Camera{Connected: ptr(true),
LastSeenAt: now.Add(-34 * time.Minute).Format(time.RFC3339)}
c.CameraState(now)
if c.State != CameraStale {
t.Errorf("state = %q, want %q", c.State, CameraStale)
}
// Cleared, not merely overruled. A stale true left in place stays
// available to every client that reads the field directly, and leaves two
// fields on one object disagreeing.
if c.Connected != nil {
t.Errorf("connected = %v, want null - nobody currently knows", *c.Connected)
}
if c.StateNote == "" {
t.Error("a stale camera said nothing about what to do")
}
}
// One missed report is a dropped packet. Warning on it would put an alarm on a
// healthy estate every few minutes, and an indicator that cries wolf is one
// people learn to ignore.
func TestOneMissedReportIsStillConnected(t *testing.T) {
now := time.Now()
c := Camera{Connected: ptr(true),
LastSeenAt: now.Add(-90 * time.Second).Format(time.RFC3339)}
c.CameraState(now)
if c.State != CameraConnected {
t.Errorf("state = %q after 90s, want %q", c.State, CameraConnected)
}
if c.Connected == nil || !*c.Connected {
t.Error("a fresh report lost its connected flag")
}
}
// "Nobody has ever told us" and "nobody has told us lately" need different
// sentences: the first is a camera head office added a minute ago and the
// shop computer has not picked up, the second is a shop computer that has
// stopped. Sending an installer to the wrong one wastes a journey.
func TestNeverReportedIsNotTheSameAsStopped(t *testing.T) {
now := time.Now()
var never Camera
never.CameraState(now)
if never.State != CameraWaiting {
t.Errorf("state = %q, want %q", never.State, CameraWaiting)
}
stopped := Camera{LastSeenAt: now.Add(-time.Hour).Format(time.RFC3339)}
stopped.CameraState(now)
if stopped.State == never.State {
t.Fatal("a camera nobody has reported and one that stopped read the same")
}
if stopped.StateNote == never.StateNote {
t.Error("two states that need different actions gave the same advice")
}
}
// A camera the shop computer CAN see and cannot open is the one case where
// "check the cabling" is the right advice, and it must stay distinguishable
// from the three where it is not.
func TestAFreshFailureSaysCheckTheCamera(t *testing.T) {
now := time.Now()
c := Camera{Connected: ptr(false), LastSeenAt: now.Format(time.RFC3339)}
c.CameraState(now)
if c.State != CameraNotConnect {
t.Errorf("state = %q, want %q", c.State, CameraNotConnect)
}
if c.Connected == nil || *c.Connected {
t.Error("a reported failure must stay false, not become unknown")
}
}
// An unparseable timestamp is not a working camera. It should not be possible,
// which is exactly why it must not fall through to "connected".
func TestAnUnreadableTimestampIsStale(t *testing.T) {
c := Camera{Connected: ptr(true), LastSeenAt: "not a time"}
c.CameraState(time.Now())
if c.State != CameraStale || c.Connected != nil {
t.Errorf("state = %q connected = %v, want stale and unknown", c.State, c.Connected)
}
}

View File

@@ -655,6 +655,11 @@ type Camera struct {
Snapshot Image `json:"snapshot"`
SnapshotAt string `json:"snapshot_at,omitempty"`
// State is the ONE answer a screen should render, because there are four
// of them and only three were ever expressed. See CameraState.
State string `json:"state"`
StateNote string `json:"state_note,omitempty"`
// Check is the last attempt to prove this camera works. Always present so
// a client can tell "never checked" from "checked and failed" without
// guessing from an absent field.
@@ -962,3 +967,70 @@ type DeviceSession struct {
// hand.
Current bool `json:"current"`
}
// Camera states, and why a fourth one had to exist.
//
// A shop computer reports each camera's state about once a minute. When it
// cannot reach the recognition engine it reports NOTHING - correctly, because
// it has nothing to say - and the last value it sent stays in the database
// unchanged. Measured on the live estate: two cameras reading **Connected**,
// in green, thirty-four minutes after the shop computer had stopped being
// able to see either of them, while the heartbeat from the same PC said
// 0 of 0 cameras. Both surfaces were reading stored fields and disagreeing.
//
// `false` could not be the answer. It means "this camera is not connecting",
// which sends an installer to check cabling on a camera that was working
// perfectly the last time anybody could ask it. The honest statement is that
// nobody currently knows - and that is a different sentence from "nobody has
// ever told us", which is what an unreported camera needs. Two states that
// need different actions must never share a word; the same rule that keeps
// `artifact` apart from `no_faces` in the commissioning verdicts.
const (
CameraConnected = "connected" // reported recently, and working
CameraNotConnect = "not_connecting" // reported recently, and not
CameraWaiting = "waiting" // no shop computer has ever reported
CameraStale = "stale" // reported once, and not lately
)
// CameraStaleAfter is five missed reports, not one.
//
// The agent reports on a 60 s tick, so one miss is a dropped packet or a slow
// upload. Calling that stale would put a warning on a healthy estate every few
// minutes, and an indicator that cries wolf is one people learn to ignore -
// which is the same reasoning that makes a site offline after three missed
// heartbeats rather than one.
const CameraStaleAfter = 5 * time.Minute
// CameraState decides the four states, and nulls Connected when it is not
// entitled to an opinion.
//
// Connected is CLEARED rather than left alone on purpose. Leaving a stale true
// in place would keep the lie available to every client that reads the field
// directly - a mobile app, a script, an older build of our own desktop app -
// and would leave two fields on one object disagreeing, which is precisely how
// the shops screen once came out labelled Working in green above "2 of 3
// cameras not connecting".
func (c *Camera) CameraState(now time.Time) {
switch {
case c.LastSeenAt == "":
c.State = CameraWaiting
c.StateNote = "No shop computer has reported on this camera yet."
c.Connected = nil
return
}
seen, err := time.Parse(time.RFC3339, c.LastSeenAt)
if err != nil || now.Sub(seen) > CameraStaleAfter {
c.State = CameraStale
c.StateNote = "The shop computer has stopped reporting this camera. " +
"Check that the computer is on and Behavision is running on it."
c.Connected = nil
return
}
if c.Connected != nil && *c.Connected {
c.State = CameraConnected
return
}
c.State = CameraNotConnect
c.StateNote = "The shop computer cannot open this camera's stream. " +
"Check the address, the password and the cabling."
}

View File

@@ -59,6 +59,11 @@ func scanCamera(row pgx.Row) (api.Camera, error) {
// The KEY travels in ImageKey, which is json:"-", and the handler swaps it
// for a signed link. Same rule as an arrival's face.
c.Snapshot.Key = snapKey
// Here rather than in a handler, because every camera anybody reads comes
// through this function and a state computed per caller is a state one
// caller forgets - which is how a stale `connected` reached three screens
// at once.
c.CameraState(time.Now())
return c, nil
}

File diff suppressed because one or more lines are too long

View File

@@ -6,7 +6,7 @@
<meta name="color-scheme" content="dark" />
<link rel="icon" type="image/png" href="/favicon.png" />
<title>Behavision</title>
<script type="module" crossorigin src="/assets/index-Ckr5hGZd.js"></script>
<script type="module" crossorigin src="/assets/index-BuVkBNdU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D4KGRSVS.css">
</head>
<body>

View File

@@ -47,9 +47,13 @@ export default function Cameras({ user }) {
const canEdit = ['admin', 'owner', 'manager'].includes(user.role)
const list = cams || []
const up = list.filter(c => c.connected).length
const down = list.filter(c => c.connected === false).length
const waiting = list.filter(c => c.connected == null).length
// Counted off `state`, the one field the server computes, never off
// `connected`. Two places deciding the same fact is how a shop came out
// labelled Working, in green, above "2 of 3 cameras not connecting".
const up = list.filter(c => c.state === 'connected').length
const down = list.filter(c => c.state === 'not_connecting').length
const waiting = list.filter(c => c.state === 'waiting').length
const stale = list.filter(c => c.state === 'stale').length
return (
<>
@@ -58,6 +62,7 @@ export default function Cameras({ user }) {
<p className="sub">
{list.length} {list.length === 1 ? 'camera' : 'cameras'}
{up > 0 && <> · <b className="ok">{up} connected</b></>}
{stale > 0 && <> · <b className="warn">{stale} not reporting</b></>}
{down > 0 && <> · <b className="bad">{down} down</b></>}
{waiting > 0 && <> · {waiting} waiting for the shop PC</>}
</p>
@@ -108,10 +113,13 @@ function CameraCard({ cam, canEdit, onEdit, onWatch }) {
// Three states, not two. A camera nobody has tried yet is not a camera that
// is down, and telling an operator to check the cabling on a camera the shop
// PC has not even seen sends them to the wrong building.
const state = cam.connected == null ? 'idle'
: cam.connected ? 'ok' : 'bad'
const words = cam.connected == null ? 'Waiting for the shop PC'
: cam.connected ? 'Connected' : 'Not connecting'
// Four states, and the fourth is the one that was missing: a camera whose
// shop PC has stopped reporting it. The agent correctly says nothing when it
// cannot reach the engine, so the last value it sent used to sit in the
// database reading Connected - measured at 34 minutes on the live estate.
const state = { connected: 'ok', not_connecting: 'bad', stale: 'warn' }[cam.state] || 'idle'
const words = { connected: 'Connected', not_connecting: 'Not connecting',
stale: 'Not reporting' }[cam.state] || 'Waiting for the shop PC'
const verified = verification(cam)
return (