Files
Behavision/web/src/views/Sites.jsx
Suriyakumarvijayanayagam 3f9fb33b24 Accounts people can create, and photos on a server with no bucket
A tenant had exactly the users somebody had created with a command on the
server. That is not a missing screen: a shop with an owner and four staff
either shared one password or raised a ticket per person, and a phone app
for the shop floor could not exist while there was one account to sign in
as.

Registration is by invitation, never open signup - the same line already
drawn around creating a company. The code carries the address and the role
and the request carries only a password, so a code that gets forwarded
cannot become somebody else's account, and a staff invitation cannot be
redeemed as an owner. Single use lives in the UPDATE and the account is
created in the same transaction.

Deactivating a member revokes their sessions in that transaction too. An
access token lives twelve hours, so without it "remove their access"
removed it sometime tomorrow. The session list and revoke that go with it
are the benefit of opaque tokens the product had been paying for and never
collecting: nothing could say what was signed in, let alone stop one.

Face images now work on a deployment with no object storage, which was
every local install and every self-hosted site - the arrivals feed said
"not storing customer photos" for every customer forever, on the screen
whose whole job is to show a face. Bounded to one row per visitor, so it
grows with the customer base and not with footfall; the bucket stays
primary wherever one exists.

Image.auth says whether a URL needs the session, because a browser img
cannot load one that does, a mobile image view can, and a webview can do
neither - the desktop client resolves those to a data URI in Go.

Found by running it, not by tests:

  * UPDATE ... RETURNING gives the value AFTER the update, so the prune
    read back empty keys, deleted nothing, and the table grew with
    footfall exactly as if it were not there. The fake agreed with either
    version; only the live Postgres test caught it.
  * Trusting only the auth flag broke every shop card, because Sites.jsx
    rebuilt a partial snapshot object and dropped it. A relative URL is
    now sufficient on its own.
  * ago() renders a future time as "just now", so a code valid for a week
    read "expires just now".

Verified live against real Postgres: invite, preview, escalation refused,
register into a session, replay 404, staff forbidden, device revoked and
401 at once, last owner refused, and a 92,405-byte camera JPEG stored,
served to its owner, 401 with no session, 404 to another tenant, and
rendered in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-05 11:45:42 +05:30

285 lines
11 KiB
JavaScript

import { useState } from 'react'
import { api } from '../api.js'
import { usePolled } from '../hooks.js'
import Shot from './Shot.jsx'
import SiteCheck from './SiteCheck.jsx'
// The estate at a glance.
//
// The question this answers is not "how busy were we" — it is "is any of this
// actually working". A shop whose PC has been unplugged for a week and a shop
// with no customers produce the same row of zeroes everywhere else in the
// product, and only one of them is something to act on.
//
// Laid out like the camera screen, and for the same reason: the first version
// was a definition list of cameras_up, fraction_below_gate and last_heartbeat,
// which is the view a developer wants. An owner opening this wants to SEE their
// shops. So the shop's own camera view is the card, the numbers sit under it,
// and one line says what to do — with the technical detail one click away
// rather than on the surface.
export default function Sites() {
const { data, error, loading } = usePolled(() => api.sites(), 20000, [])
// Cameras come from a second call and are joined here rather than server-side:
// the picture is decoration on this screen, so it must never be able to make
// the health list fail. If this errors the cards simply have no photograph.
const { data: cams } = usePolled(() => api.cameras(), 60000, [])
const [checking, setChecking] = useState(null)
const sites = data || []
if (loading && !data) return <Loading />
if (error) return <Problem error={error} />
if (!sites.length) return <Empty />
// Counted from the same verdicts the cards show. Summarising with a second,
// simpler rule up here is how a header ends up reading "all working" over a
// grid containing a red card.
const rows = sites.map(s => {
const own = (cams || []).filter(c => c.site_id === s.site_id)
return { site: s, cams: own, verdict: verdictFor(s, own) }
})
const tally = t => rows.filter(r => r.verdict.tone === t).length
const broken = tally('bad'), watch = tally('warn'), fresh = tally('idle')
return (
<>
<header className="head">
<h1>Shops</h1>
<p className="sub">
{sites.length} {sites.length === 1 ? 'shop' : 'shops'}
{broken > 0 && <> · <b className="bad">{broken} not working</b></>}
{watch > 0 && <> · <b className="warn">{watch} needing attention</b></>}
{fresh > 0 && <> · {fresh} not set up yet</>}
{!broken && !watch && !fresh && <> · <b className="ok">all working</b></>}
</p>
</header>
<div className="grid sites">
{rows.map(r => (
<SiteCard key={r.site.site_id} site={r.site} cams={r.cams}
verdict={r.verdict} onCheck={() => setChecking(r.site)} />
))}
</div>
{/* Reachable per shop, at last. The smoke test used to hang off a single
button on the camera screen that always checked sites[0], so with two
shops the second could not be checked at all. */}
{checking && <SiteCheck site={checking} onClose={() => setChecking(null)} />}
</>
)
}
// The one line the card leads with, in severity order. Only the first is shown:
// a shop that is offline AND has a bad camera needs its PC turned on first, and
// listing both invites someone to start with the wrong one.
//
// This is also the ONLY place a shop's health is decided. The pill over the
// picture, the stripe down the edge and this line all read from it, because the
// first version computed the pill separately from online + gate and a shop with
// two dead cameras came out labelled "Working" directly above the words "2 of 3
// cameras not connecting". Two surfaces disagreeing about one fact is worse
// than either being wrong on its own.
function verdictFor(site, cams) {
const gate = site.fraction_below_gate
const total = site.cameras_total || cams.length
if (!site.online) {
return { tone: 'bad', mark: '✕', headline: 'Offline',
words: `Offline — last heard from ${ago(site.last_heartbeat_at)}` }
}
// Footfall this shop saw and can never report. Stored with GREATEST()
// server-side so a restarted agent cannot make it quietly disappear.
if (site.dropped > 0) {
return { tone: 'bad', mark: '✕',
words: `${site.dropped} visits lost and unrecoverable` }
}
if (total === 0) {
return { tone: 'idle', mark: '+', headline: 'Not set up',
words: 'No cameras set up yet' }
}
if (site.cameras_up === 0) {
return { tone: 'bad', mark: '✕', words: 'No cameras connected' }
}
if (site.cameras_up < total) {
return { tone: 'warn', mark: '!',
words: `${total - site.cameras_up} of ${total} cameras not connecting` }
}
// The Office1 case: online, connected, and recognising almost nobody. Over
// half is a failure and not a warning — those visitors are gone.
if (gate > 0.5) {
return { tone: 'bad', mark: '✕',
words: `${pct(gate)} of faces too poor to recognise` }
}
if (gate > 0.2) {
return { tone: 'warn', mark: '!',
words: `${pct(gate)} of faces too poor to recognise` }
}
if (site.queued > 0) {
return { tone: 'warn', mark: '!', words: `${site.queued} visits waiting to upload` }
}
return { tone: 'ok', mark: '✓',
words: `Working — ${total} ${total === 1 ? 'camera' : 'cameras'} connected` }
}
const HEADLINES = { ok: 'Working', warn: 'Needs attention',
bad: 'Not working', idle: 'Not set up' }
function SiteCard({ site, cams, verdict, onCheck }) {
const gate = site.fraction_below_gate
const health = verdict.tone
const headline = verdict.headline || HEADLINES[health]
const view = bestView(cams)
const total = site.cameras_total || cams.length
return (
<article className={'card site state-' + health} onClick={onCheck} role="button"
tabIndex={0} onKeyDown={e => e.key === 'Enter' && onCheck()}>
<div className="shot">
{view.url
? <Shot image={view} alt={`View inside ${site.name}`} />
: <div className="noshot">
<ShopMark />
{view.reason && <span>{view.reason}</span>}
</div>}
<div className="shot-over">
<div className="shot-name">
<b>{site.name}</b>
<span>{total > 0
? `${total} ${total === 1 ? 'camera' : 'cameras'}`
: 'No cameras yet'}</span>
</div>
<span className={'status ' + health}>
<i aria-hidden="true" />{headline}
</span>
</div>
{view.at && <span className="shot-age">{ago(view.at)}</span>}
</div>
{/* Three numbers, and each one is a different question: is the hardware
up, can it see faces well enough to recognise them, and is anyone
home. The rest moved behind the check. */}
<div className="metrics">
<Metric label="Cameras"
value={total ? `${site.cameras_up}/${total}` : '—'}
tone={!total ? 'idle' : site.cameras_up < total ? 'bad' : 'ok'} />
<Metric label="Faces usable"
value={gate > 0 ? pct(1 - gate) : '—'}
tone={!gate ? 'idle' : gate > 0.5 ? 'bad' : gate > 0.2 ? 'warn' : 'ok'} />
<Metric label="Last seen" value={ago(site.last_heartbeat_at)}
tone={site.online ? 'ok' : 'bad'} />
</div>
<div className={'verdict ' + verdict.tone}>
<span className="mark" aria-hidden="true">{verdict.mark}</span>
<span className="words">{verdict.words}</span>
<span className="go" aria-hidden="true">→</span>
</div>
</article>
)
}
function Metric({ label, value, tone }) {
return (
<div className="metric">
<b className={tone}>{value}</b>
<span>{label}</span>
</div>
)
}
// The freshest picture any of this shop's cameras has sent.
//
// A missing picture is a normal state, not an error — images are off by default
// across the product — so the empty tile explains itself rather than showing a
// black hole with an apology in it. The three absences need different words:
// "nobody has set a camera up", "the PC has not reported yet", and "this system
// stores no photographs" are three different next actions.
function bestView(cams) {
// Nothing written here for a shop with no cameras: the verdict line already
// says exactly that, and three phrasings of one fact on one card reads as a
// fault rather than a state.
if (!cams.length) return {}
let best = null
for (const c of cams) {
if (!c.snapshot?.available || !c.snapshot.url) continue
if (!best || (c.snapshot_at || '') > (best.snapshot_at || '')) best = c
}
// The WHOLE snapshot object, not just its url. It carries `auth`, which says
// whether the picture has to be fetched with the session or can be handed
// straight to an <img> - and rebuilding a partial copy here is how that flag
// gets silently dropped on one screen and not another.
if (best) return { ...best.snapshot, at: best.snapshot_at }
const reason = cams.map(c => c.snapshot?.reason).find(Boolean)
return { reason: reason || 'No picture from this shop yet.' }
}
// Drawn, not an emoji or an icon font: an empty tile that still reads as a
// shop, in the same spirit as the camera screen's lens.
function ShopMark() {
return (
<svg className="shopmark" viewBox="0 0 40 32" aria-hidden="true">
<path d="M4 12h32v18H4z" />
<path d="M2 12l4-8h28l4 8" />
<path d="M15 30v-9h10v9" />
</svg>
)
}
function pct(f) { return `${Math.round(f * 100)}%` }
export function ago(iso) {
if (!iso) return 'never'
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return '—'
const secs = Math.max(0, (Date.now() - then) / 1000)
if (secs < 90) return 'just now'
const mins = Math.round(secs / 60)
if (mins < 60) return `${mins} min ago`
const hrs = Math.round(mins / 60)
if (hrs < 48) return `${hrs} h ago`
return `${Math.round(hrs / 24)} days ago`
}
// How long until a moment in the future.
//
// `ago` clamps at zero and reads a future timestamp as "just now", which is
// right for a heartbeat whose clock is a little ahead and completely wrong for
// an expiry: a code valid for a week rendered as "expires just now", which
// tells the operator not to bother handing it over.
export function until(iso) {
if (!iso) return 'never'
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return '—'
const secs = (then - Date.now()) / 1000
if (secs <= 0) return 'expired'
const mins = Math.round(secs / 60)
if (mins < 60) return `in ${mins} min`
const hrs = Math.round(mins / 60)
if (hrs < 48) return `in ${hrs} h`
return `in ${Math.round(hrs / 24)} days`
}
export function Loading() {
return <div className="state"><span className="spinner" aria-hidden="true" />Loading…</div>
}
export function Problem({ error }) {
return (
<div className="state">
<p className="error" role="alert">{error.message}</p>
</div>
)
}
function Empty() {
return (
<div className="state">
<h2>No shops yet</h2>
<p className="sub">
A shop appears here once its PC has been claimed with an enrolment code.
</p>
</div>
)
}