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
183 lines
7.9 KiB
JavaScript
183 lines
7.9 KiB
JavaScript
import { useState } from 'react'
|
||
import { api } from '../api.js'
|
||
import { usePolled } from '../hooks.js'
|
||
import Shot from './Shot.jsx'
|
||
import CameraLive from './CameraLive.jsx'
|
||
import { ago, Loading, Problem } from './Sites.jsx'
|
||
import CameraSetup from './CameraSetup.jsx'
|
||
|
||
// Cameras, onboarded from head office.
|
||
//
|
||
// The shop PC is still the thing that CONNECTS — it is the only machine on the
|
||
// camera's network — so everything here is desired state that its agent pulls
|
||
// and applies. That is why a new camera reads "waiting for the shop PC" rather
|
||
// than "connected": saying it is set up would be claiming something nobody has
|
||
// verified yet.
|
||
// verification is the second claim a camera card makes, and the one that
|
||
// matters: connected says the stream opens, verified says a person walking past
|
||
// can actually be recognised. Office1 was the first for weeks without ever
|
||
// being the second.
|
||
function verification(cam) {
|
||
const c = cam.check || {}
|
||
if (c.state === 'requested' || c.state === 'running') {
|
||
return { tone: 'idle', mark: '…', words: 'Checking now' }
|
||
}
|
||
if (c.state !== 'done') {
|
||
return { tone: 'idle', mark: '?', words: 'Not checked yet' }
|
||
}
|
||
if (c.kind === 'placement') {
|
||
return c.ok
|
||
? { tone: 'ok', mark: '✓', words: 'Recognises faces here' }
|
||
: { tone: 'bad', mark: '✕', words: c.headline || 'Cannot recognise faces here' }
|
||
}
|
||
return c.ok
|
||
? { tone: 'warn', mark: '!', words: 'Stream works — faces not checked yet' }
|
||
: { tone: 'bad', mark: '✕', words: c.headline || 'Could not reach the camera' }
|
||
}
|
||
|
||
export default function Cameras({ user }) {
|
||
const { data: cams, error, loading, reload } =
|
||
usePolled(() => api.cameras(), 20000, [])
|
||
const { data: sites } = usePolled(() => api.sites(), 0, [])
|
||
const [editing, setEditing] = useState(null)
|
||
// Only one camera streams at a time, on purpose. Every open view makes a
|
||
// shop PC upload, so a grid that went live all at once would put an estate's
|
||
// worth of cameras on the wire because somebody opened a page.
|
||
const [watching, setWatching] = useState(null)
|
||
|
||
const canEdit = ['admin', 'owner', 'manager'].includes(user.role)
|
||
const list = cams || []
|
||
// 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 (
|
||
<>
|
||
<header className="head">
|
||
<h1>Cameras</h1>
|
||
<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>
|
||
{canEdit && (
|
||
<button className="primary" onClick={() => setEditing({})}>
|
||
Set up a camera
|
||
</button>
|
||
)}
|
||
</header>
|
||
|
||
{loading && !cams ? <Loading /> : error ? <Problem error={error} /> : (
|
||
list.length === 0 ? (
|
||
<div className="state">
|
||
<h2>No cameras yet</h2>
|
||
<p className="sub">
|
||
Add one here and the shop’s PC will pick it up within a couple of
|
||
minutes. Cameras already set up on a shop PC appear here on their own.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="grid cams">
|
||
{list.map(c => (
|
||
<CameraCard key={c.id} cam={c} canEdit={canEdit}
|
||
onEdit={() => setEditing(c)}
|
||
onWatch={() => setWatching(c)} />
|
||
))}
|
||
</div>
|
||
)
|
||
)}
|
||
|
||
{editing && (
|
||
<CameraSetup
|
||
existing={editing.id ? editing : null}
|
||
sites={sites || []}
|
||
onClose={() => { setEditing(null); reload() }}
|
||
onSaved={(_, opts) => { if (!opts?.keepOpen) setEditing(null); reload() }}
|
||
/>
|
||
)}
|
||
|
||
{watching && (
|
||
<CameraLive camera={watching} onClose={() => setWatching(null)} />
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
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.
|
||
// 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 (
|
||
// The picture IS the card. A camera is a thing you look at, and the
|
||
// previous layout made it a black rectangle sitting above a table of
|
||
// connection settings — which is the view a developer wants, not a shop
|
||
// owner. The technical detail moves behind Edit, where it is needed only
|
||
// when something is being changed.
|
||
<article className={'card cam state-' + state} onClick={canEdit ? onEdit : undefined}
|
||
role={canEdit ? 'button' : undefined} tabIndex={canEdit ? 0 : undefined}
|
||
onKeyDown={e => canEdit && e.key === 'Enter' && onEdit()}>
|
||
<div className="shot">
|
||
{cam.snapshot?.available
|
||
? <Shot image={cam.snapshot} alt={`View from ${cam.label}`} />
|
||
: <div className="noshot">
|
||
<span className="lens" aria-hidden="true" />
|
||
{cam.snapshot?.reason || 'No picture yet.'}
|
||
</div>}
|
||
|
||
{/* Over the picture, where a camera label belongs, rather than in a
|
||
caption underneath it. */}
|
||
<div className="shot-over">
|
||
<div className="shot-name">
|
||
<b>{cam.label || cam.camera_id}</b>
|
||
<span>{cam.site}</span>
|
||
</div>
|
||
<span className={'status ' + state}>
|
||
<i aria-hidden="true" />{words}
|
||
</span>
|
||
</div>
|
||
|
||
{cam.snapshot_at && (
|
||
<span className="shot-age">{ago(cam.snapshot_at)}</span>
|
||
)}
|
||
|
||
{/* Always offered, including when this card says the camera is down.
|
||
`connected` is head office's LAST REPORT and can be two minutes
|
||
stale, so gating on it hid the button during every reconnect - and
|
||
"is that camera really down?" is precisely the moment somebody wants
|
||
to look. A hidden control says "you cannot" when the honest answer
|
||
is "here is why", which the live view itself can give.
|
||
stopPropagation because the card itself opens Edit. */}
|
||
<button className="watch" title="Watch this camera now"
|
||
onClick={e => { e.stopPropagation(); onWatch() }}>
|
||
<i aria-hidden="true" />Live
|
||
</button>
|
||
</div>
|
||
|
||
{/* Connected and verified are different claims, and the gap between them
|
||
is where a site gets signed off broken: a camera can be streaming
|
||
perfectly and still produce views nothing can recognise. This is the
|
||
one fact worth carrying on the front of the card. */}
|
||
<div className={'verdict ' + verified.tone}>
|
||
<span className="mark" aria-hidden="true">{verified.mark}</span>
|
||
<span className="words">{verified.words}</span>
|
||
{canEdit && <span className="go" aria-hidden="true">→</span>}
|
||
</div>
|
||
</article>
|
||
)
|
||
}
|