Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c62fc0ef3 | |||
| effa4f3d62 | |||
| 6c210f792f | |||
| 8786a5b0b4 | |||
| c93fbff31f | |||
| 4c750cb2ac | |||
| 5f83a1077d | |||
| a74cb899b4 | |||
| 8c88aad06e | |||
| 50a843ce46 | |||
| 979aa77cda | |||
| 3d3775c8be | |||
| a1fe0942e2 | |||
| e262fc8482 | |||
| b59e667a68 | |||
| 70c447873d | |||
| 719ba2c7f5 | |||
| 5e1dcf7050 | |||
| 92573e9067 | |||
| 92b12bcb1c | |||
| c50a74de47 | |||
| 0a423ed8cc | |||
| 22196ab9ba | |||
| 5e544eee3d | |||
| 9521cb986b | |||
| 30e01765ae | |||
| ee9e8b80b7 | |||
| 3598d8e9c0 | |||
| ce0223006b | |||
| 08873f4a67 | |||
| 9182f70442 | |||
| 3f9fb33b24 | |||
| ffae7e45d5 | |||
| 18686cbceb | |||
| 2cd7a78ddc | |||
| e0ceb14589 |
14
.gitignore
vendored
@@ -52,3 +52,17 @@ node_modules/
|
||||
# in the tree `go build ./...` fails on a fresh checkout - on a machine that may
|
||||
# have no npm at all. They are ~200 KB and regenerating them is one command; a
|
||||
# repository that does not compile is the more expensive problem.
|
||||
|
||||
# Backups of .env made when editing camera credentials.
|
||||
/.env.bak-*
|
||||
|
||||
# Generated by the wails CLI on every dev run and build, not source.
|
||||
# NOT /desktop/build/ as a whole: appicon.png, darwin/ and windows/ under it
|
||||
# are the Wails project scaffolding (icon, Info.plist, manifest) that a
|
||||
# reproducible Windows build needs. Only the compiled output is ignored.
|
||||
/desktop/frontend/wailsjs/
|
||||
/desktop/frontend/package.json.md5
|
||||
|
||||
# Left behind by `pip install .` of the engine (setuptools metadata), not source.
|
||||
/behavision.egg-info/
|
||||
/.prod/
|
||||
|
||||
658
CLAUDE.md
@@ -152,7 +152,8 @@ own camera, same-person similarity p05 0.719 vs 0.620) → `arcface_int8.onnx`
|
||||
the chain. AdaFace slots are wired but empty: drop a converted
|
||||
`adaface_ir50.onnx` in and it is picked up, BGR channel order already
|
||||
handled (see `color_order_for`). The dev machine is
|
||||
memory-starved (16 GB, often < 1.5 GB free): the 260 MB model fails with
|
||||
memory-starved (**8 GB**, measured 0.45 GB free with a browser and Docker
|
||||
open): the 260 MB model fails with
|
||||
"bad allocation"; int8 quantization of it segfaulted (OOM). w600k_mbf comes
|
||||
from InsightFace buffalo_sc; genderage.onnx from buffalo_l. On failure,
|
||||
the encoder retries loading with `ORT_DISABLE_ALL` graph optimization.
|
||||
@@ -1695,20 +1696,41 @@ The enrol response gained `client_slug` and `topic_prefix`, both **derived from
|
||||
the broker username** rather than looked up separately, so the agent's topic
|
||||
prefix and the broker's ACL are equal by construction.
|
||||
|
||||
### Still a command: creating the shop itself
|
||||
### Opening a shop is an API call, and the broker learns of it in the same request
|
||||
|
||||
`provision site` prints a broker password that a human then has to add to
|
||||
Mosquitto. So a tenant cannot open their second shop without us, and that is the
|
||||
one remaining hole in self-service onboarding. Closing it needs a decision, not
|
||||
code:
|
||||
`POST /api/sites` (owner), and `provision site` behind the same code. This was
|
||||
the last piece of onboarding that needed a shell: `provision site` printed a
|
||||
broker password and a person typed it into Mosquitto's passwd file on the host
|
||||
— which turned out to be mounted read-only in the container, so the first
|
||||
attempt failed silently and the password had to be re-rolled. No tenant could
|
||||
open a second branch without us.
|
||||
|
||||
- **the server manages Mosquitto's `passwd`/`acl` and reloads it** — possible
|
||||
because they are co-located, and it couples the API to the broker's
|
||||
filesystem; or
|
||||
- **one broker user per CLIENT rather than per site** — then adding a shop needs
|
||||
no broker change at all. Cross-tenant isolation is unchanged; what is given up
|
||||
is that one of a customer's own PCs could publish as another of their sites.
|
||||
Every deployed site would need re-provisioning.
|
||||
Neither option recorded here before was taken. The server does not write the
|
||||
broker's files, and there is still one broker user per site. Mosquitto 2.0's
|
||||
**dynamic-security plugin** takes the same operations as commands on
|
||||
`$CONTROL/dynamic-security/v1`, from a client holding the `admin` role;
|
||||
`server/internal/broker` drives it over the server's own broker login.
|
||||
|
||||
- **A role per site, with literal topics.** The 2.0 plugin does **not**
|
||||
substitute `%u` in ACL topics (measured: the publish was denied), so
|
||||
`site.<client>.<site>` is created with the client and deleted with it.
|
||||
- **Idempotent.** Re-running `EnsureSite` on an existing login sets the password
|
||||
to the one the database holds and confirms the role. `addClientRole` on a
|
||||
client that already has the role answers "Internal error", so the role is
|
||||
checked with `getClient` rather than inferred from prose.
|
||||
- **The row and the login are created together, or not at all.** If the broker
|
||||
refuses, the just-created row is removed and the caller gets 502. A shop that
|
||||
exists in the database and not on the broker is one whose PC enrols fine and
|
||||
never delivers a visit — the silent-failure class this whole endpoint ends.
|
||||
- **Its own connection**, not the ingest client's: that one has
|
||||
`SetOrderMatters` and blocking handlers, and a provisioning call must neither
|
||||
wait behind a slow visit nor delay one.
|
||||
- **Cutover keeps every password.** `behavision-server broker-init` converts the
|
||||
passwd file into the plugin's store: `$7$` lines are PBKDF2-SHA512 with a
|
||||
salt and iteration count, which is exactly what the plugin stores, so no shop
|
||||
PC re-claims and no credential changes hands. Rehearsed locally against a
|
||||
file `mosquitto_passwd` wrote; `run-local.sh` now brings the broker up the
|
||||
same way as production.
|
||||
|
||||
## Running it against the real office camera: four dead wires
|
||||
|
||||
@@ -1953,6 +1975,182 @@ Verified live 2026-08-31, whole chain: enrol → upload-url → PUT → anonymou
|
||||
JPEG downloaded → erase → presigned GET **404**, 0 templates, 0 profiles, label
|
||||
`Erased`, visit row kept.
|
||||
|
||||
## Identifiers: a customer number people can say (migration 012)
|
||||
|
||||
Every id in the schema is a uuid and stays one. What was wrong was putting one
|
||||
in front of a person. `RecordVisit` named every new customer from theirs:
|
||||
|
||||
```sql
|
||||
UPDATE visitors SET label = 'Visitor ' || left(id::text, 8)
|
||||
```
|
||||
|
||||
So the name on the arrivals feed, on the shop PC, and in the mobile app was
|
||||
**"Visitor 3446ec35"** — the string a shop assistant reads out to a colleague,
|
||||
writes on a card, and types into a search box. Not a display problem to paper
|
||||
over in a front end either: `label` is a stored column staff can overwrite and
|
||||
`SearchVisitors` matches on, so it had to be fixed where it is written.
|
||||
|
||||
`visitors.number` is a **per-client** sequence and the label is now
|
||||
`Visitor 42`, referenced as **`V-42`**. Three properties, each ruling out an
|
||||
alternative:
|
||||
|
||||
- **Speakable.** The whole point.
|
||||
- **Per client, not global.** A global sequence tells any customer who signs up
|
||||
how many people the entire platform has ever seen, from their own first
|
||||
visitor number. Per tenant it reveals a tenant's own count to that tenant's
|
||||
own staff, who know it already.
|
||||
- **Not the primary key.** Ids are minted where nothing can ask a database for
|
||||
the next value, and eleven tables reference `visitors.id`. This is a public
|
||||
*reference* beside the key, which is the part humans needed.
|
||||
|
||||
The counter is `clients.visitor_seq`, taken with `UPDATE ... RETURNING` inside
|
||||
the visit transaction. That returns the value **after** the update — the same
|
||||
semantics that silently broke the face prune in 011 by handing back what it had
|
||||
just written, and here exactly what is wanted. It row-locks the client for the
|
||||
length of the insert, which serialises new-visitor creation per tenant and costs
|
||||
nothing: it runs only for a face nobody in the estate has ever seen.
|
||||
|
||||
The backfill numbers existing rows by `first_seen_at` and relabels **only** the
|
||||
eight-lowercase-hex pattern the old statement produced, so a name a human typed
|
||||
is never overwritten. Verified on the live database: 13 hex labels became
|
||||
Visitor 1-13 in first-seen order, two "Walk-in test" names were left alone, and
|
||||
`visitor_seq` landed on 15.
|
||||
|
||||
### Three of the four things already had a human name; the API refused it
|
||||
|
||||
That is the part worth keeping. Only visitors genuinely lacked a reference:
|
||||
|
||||
| thing | reference | since |
|
||||
|---|---|---|
|
||||
| shop | `slug` — "chennai" | 001 |
|
||||
| camera | `camera_id` — "Office1", and what `visits.camera_id` holds | 005 |
|
||||
| customer | `V-<number>` | 012 |
|
||||
| person | email | 002 |
|
||||
|
||||
`refs.go` accepts either form anywhere an id is taken. A uuid resolves with no
|
||||
lookup at all, so nothing that worked yesterday changes — including every URL a
|
||||
client has already stored. Only a non-uuid costs a query.
|
||||
|
||||
- **A camera id is unique per SITE, not per tenant.** Two shops may each have an
|
||||
`Office1`, so an ambiguous name resolves to **nothing** rather than to
|
||||
whichever row sorted first — acting on a guess would edit the wrong shop's
|
||||
camera.
|
||||
- **404 on a path, 400 on a query filter.** `/api/visits` exists and answered;
|
||||
what was wrong was the filter, and a 404 there reads as "the arrivals feed is
|
||||
gone". A path segment names the resource itself, so an unknown one *is* a 404.
|
||||
- **`site` and `site_id` are both accepted everywhere now.** Reports took one and
|
||||
the arrivals feed the other, and an unknown query parameter is silently
|
||||
ignored — so getting it the wrong way round returned the whole estate instead
|
||||
of an error, which is a wrong number nobody would question.
|
||||
- **The search matches the reference.** `V-13` is what the product now shows, so
|
||||
it is what gets pasted into the search box, and `label ILIKE '%V-13%'` finds
|
||||
nothing because the label says "Visitor 13". A search that comes back empty
|
||||
for the identifier you were just shown is worse than no search.
|
||||
|
||||
The **edge** engine has always numbered its identities from a SQLite rowid, so
|
||||
"Visitor 3" there and "Visitor 47" here are the same person under two numbers.
|
||||
Left alone deliberately: making them agree means the shop PC asking the server
|
||||
for a number, which cannot work offline — and the edge number appears only on
|
||||
the engine's own diagnostic dashboard.
|
||||
|
||||
### Two bugs, one from a real database and one from a real browser
|
||||
|
||||
- **`'Visitor ' || $2::text` next to `number = $2`.** Postgres deduces two types
|
||||
for one parameter and refuses the whole insert: *"inconsistent types deduced
|
||||
for parameter $2"*. It compiled, it passed every in-memory test, and it failed
|
||||
on the first real database — along with the existing face tests, which go
|
||||
through the same path. The label is formatted in Go now.
|
||||
- **The avatar said `V1` for three different people.** With no photograph the
|
||||
arrivals feed draws initials, and `initials("Visitor 13")` takes the first
|
||||
letter of each word — `V1`, which is also what "Visitor 10" and "Visitor 15"
|
||||
produce, and which reads as the `V-1` reference for a fourth person. It shows
|
||||
the number itself now. Found by opening the page: every test here passes a
|
||||
human name. The prop carrying it is `customerRef`, not `ref` — React reserves
|
||||
that name, so it would never have reached the component.
|
||||
|
||||
### Why the uuid stays, when the slug would do
|
||||
|
||||
Asked directly: `site_id` is 36 characters, why not a small number?
|
||||
|
||||
The honest answer is that **the length was never the problem — needing it was**,
|
||||
and that is already fixed: `?site=chennai` and `/api/sites/chennai/check` work,
|
||||
and the shop PC has always identified itself by slug (`agent.json` holds
|
||||
`"site_id": "chennai"`, never the uuid). The uuid in a *response* is the stable
|
||||
key for a client that wants to store one.
|
||||
|
||||
Two reasons not to replace it, and one reason that is NOT among them:
|
||||
|
||||
- **Enumeration.** `/api/sites/3/check` makes any future tenancy hole walkable
|
||||
by counting; a uuid makes it require a leak first. Every handler scopes by the
|
||||
session's client today, so this is defence in depth rather than the control —
|
||||
but this database holds biometric templates, and defence in depth is the point
|
||||
of a second layer.
|
||||
- **The payoff is now zero.** Eight tables carry a foreign key to `sites(id)`,
|
||||
against a live database, to make a field shorter that a client is already told
|
||||
not to use.
|
||||
- **NOT because ids must be minted offline.** Sites, visitors and visits are all
|
||||
created server-side with a database in hand. That argument holds for the
|
||||
agent's `event_id` — which is derived precisely so it needs no coordination —
|
||||
and it does not hold here; claiming it would be a defence of the status quo
|
||||
rather than a reason for it.
|
||||
|
||||
What DID need fixing is that the references were only stable by accident.
|
||||
Migration 013 makes `clients.slug`, `sites.slug`, `site_cameras.camera_id` and
|
||||
`visitors.number` immutable in the database, because 012 turned them from
|
||||
descriptive columns into identifiers other systems store:
|
||||
|
||||
- `clients.slug` is an MQTT topic segment the broker ACL is written against.
|
||||
Rename one and that tenant's whole estate is silently refused by the broker,
|
||||
with no way to tell the agents.
|
||||
- `sites.slug` is what a shop PC calls itself. A rename orphans the PC from the
|
||||
shop it is standing in.
|
||||
- `site_cameras.camera_id` lands in `visits.camera_id`, which is text and not a
|
||||
foreign key. A rename orphans every visit already attributed to the old name:
|
||||
the footfall is still there and no longer joins to a camera. This was
|
||||
half-enforced in `handleUpdateCamera` and nowhere else — the shape of a rule
|
||||
that holds until somebody adds a second write path.
|
||||
|
||||
A trigger rather than a CHECK, because a CHECK cannot see the old row and the
|
||||
rule is about the transition. **The display name is deliberately NOT frozen** —
|
||||
"TeNext Chennai", "Front door" — it is what a person reads, nothing keys on it,
|
||||
and a system that cannot fix a typo in a shop's name has confused the two.
|
||||
|
||||
### Three uuids on one arrival, three different answers
|
||||
|
||||
Asked of the row the feed actually returns, and they do not get the same reply:
|
||||
|
||||
- **`site_id`** had a reference all along and the feed was not sending it. A
|
||||
client could read the shop's *name* off an arrival and still had no way to ask
|
||||
for that shop except by uuid, which is the exact gap the scheme exists to
|
||||
close. `site_slug` now travels with it.
|
||||
- **`visit_id` stays a uuid, and needs no reference.** No route takes it; it is
|
||||
a key a client de-duplicates on, because delivery is at-least-once. Nobody
|
||||
says a visit id out loud.
|
||||
- **The uuid in a face URL must STAY random.** `visit_faces.id` is
|
||||
`gen_random_uuid()` and a derived or sequential one would let somebody walk a
|
||||
shop's customers by date — the same reason object keys in the bucket are
|
||||
random rather than derived from the event id. A readable identifier is right
|
||||
for a customer and wrong for the thing that points at their photograph.
|
||||
|
||||
And one field left with it: **`seq` is now `json:"-"`**. `visits.seq` is a plain
|
||||
bigserial, so it counts every visit on the *platform*, and shipping it put the
|
||||
total footfall of every customer we have on every row of every tenant's feed —
|
||||
the same German-tank estimate that decided `visitors.number` had to be per
|
||||
client. It was there as a convenience for *"have I fallen behind"*, nothing ever
|
||||
read it, and the cursor already answers that question without disclosing a
|
||||
number. The SSE event id was never the raw value; it has always been the opaque
|
||||
cursor.
|
||||
|
||||
The one test that broke was reading `seq` back off the wire to assert the cursor
|
||||
pointed at the last row of a burst. It asserts against the seeded position now —
|
||||
the property is unchanged, and the test can no longer see what a client cannot.
|
||||
|
||||
Fixture note: `embedding(seed)` fills every dimension with one value, so after
|
||||
L2 normalisation 0.31 and 0.62 are the **same direction** and the matcher
|
||||
correctly calls them one person. Tests that need several different people use
|
||||
`distinctFace(i)`, which is orthogonal per index.
|
||||
|
||||
|
||||
## Setting up on a new machine
|
||||
|
||||
1. Copy the `Behavision` folder **including `.env`** (gitignored, holds
|
||||
@@ -2171,3 +2369,437 @@ Two bugs, both found by running it after a reboot rather than by reading it.
|
||||
longer discarded, and the broker is waited for and reported on if it will not
|
||||
stay up. A failure there means the server cannot authenticate to its own
|
||||
broker, which is exactly what this script exists to surface early.
|
||||
|
||||
## Live view at head office, and what it honestly is
|
||||
|
||||
Live video exists and always has — on the shop PC, where the camera is:
|
||||
|
||||
- `GET /api/cameras/{id}/stream.mjpeg` on the engine's loopback API. Measured
|
||||
on the office camera: 1280×720, ~850 KB/s.
|
||||
- The desktop app's Live screen and the engine's own dashboard both render it.
|
||||
|
||||
Head office has it too, and this is the part that was got wrong first: the
|
||||
original answer here was "cannot cheaply", which conflated **true video** with
|
||||
**seeing the camera now**. Only the first needs infrastructure this does not
|
||||
have.
|
||||
|
||||
The shop PC is behind a router with no inbound route, so head office cannot
|
||||
pull that stream. What it CAN do is answer the agent's outbound requests, which
|
||||
is the shape of everything else in this system — so `LiveHub` + `cameras.Live`
|
||||
relay frames the other way: head office holds a poll open, the agent asks "is
|
||||
anyone watching?", and pushes JPEGs up for exactly as long as somebody is.
|
||||
|
||||
**It is ~13 frames a second of 640 px JPEG.** Measured end to end on the office
|
||||
camera: 131 frames in 10 s, 20.3 KB each, **259 KB/s**, and zero duplicates.
|
||||
|
||||
The rate is not a guess. The engine re-serves its latest frame until the
|
||||
pipeline produces a new one, so polling faster than it encodes returns the same
|
||||
picture: 93 polls in 6 s yielded 72 distinct frames. So the relay polls a little
|
||||
ahead of the engine and **drops frames identical to the last one by hash** —
|
||||
which lets the rate follow the camera rather than a constant, and means every
|
||||
byte on the wire is a picture the viewer has not seen.
|
||||
|
||||
### Why this is MJPEG and not the camera's own H.264
|
||||
|
||||
The obviously better design is passthrough: every CCTV camera already produces
|
||||
compressed video, and its **sub-stream** is exactly the right size for a live
|
||||
view. Probed on the office camera: main `/ch0_0.264` is 2304×1296 @ 15 fps, sub
|
||||
`/ch0_1.264` is **800×448 @ 15 fps**. Relaying that untouched would be smoother
|
||||
than this, cost less bandwidth, and use no CPU at all — no decode, no encode.
|
||||
|
||||
**It cannot be done on this camera, and the reason is worth recording: both
|
||||
streams are H.265.** The file names end in `.264`; the codec is HEVC. A browser
|
||||
plays H.264 everywhere and H.265 only on some platforms, so a passthrough relay
|
||||
cannot rely on it — and transcoding HEVC→H.264 on the shop PC would put a video
|
||||
encoder on the machine that is already doing the recognition.
|
||||
|
||||
So the choice is not MJPEG-versus-video in the abstract. It is: **re-encode
|
||||
frames and work on every camera, or pass through and work only on H.264
|
||||
cameras.** This does the first. Passthrough (RTSP → fMP4 → Media Source
|
||||
Extensions, no re-encode) is a well-understood build on top of the same relay
|
||||
and is the right upgrade for an estate of H.264 cameras — including this one, if
|
||||
its sub-stream is switched to H.264 in the camera's own settings.
|
||||
|
||||
`probe_source` therefore reports `codec`, because it decides what is possible
|
||||
and an installer can usually change it. Otherwise the only way to learn it is to
|
||||
read RTSP by hand, which is how this was found.
|
||||
|
||||
True sub-second video with no re-encode at any codec is WebRTC. Worth noting
|
||||
that the earlier claim here — that it needs a TURN server — is wrong: the server
|
||||
has a public address, so a shop PC behind NAT connects to it directly and TURN
|
||||
is only needed when *neither* side is reachable.
|
||||
|
||||
The browser cannot be pointed straight at the shop PC even on one LAN: the
|
||||
engine's API is Basic-authenticated with a credential it generates locally and
|
||||
never sends anywhere, and shipping that to the cloud so a web page could use it
|
||||
would put the key to the biometric API and the live face feed in the server's
|
||||
database.
|
||||
|
||||
**Nothing is uploaded when nobody is looking**, and that is the entire cost
|
||||
argument:
|
||||
|
||||
- `Publish` returns false once the last viewer has gone, which is what tells the
|
||||
agent to stop pushing. If it were ever optimistic every shop PC in an estate
|
||||
would upload continuously.
|
||||
- Interest lapses on a timer refreshed by each viewer as it reads, so a browser
|
||||
that vanishes without saying so — the normal way a tab closes — stops the
|
||||
upload within seconds.
|
||||
- One push is capped at five minutes. A tab left open for a week must not leave
|
||||
a shop uploading for a week; a viewer who is still there simply reconnects.
|
||||
- Only one camera streams at a time in the UI. A grid that went live all at once
|
||||
would put an estate's worth of cameras on the wire because somebody opened a
|
||||
page.
|
||||
|
||||
**`LiveHub` is the exact opposite of the arrivals `Hub`, deliberately.** There a
|
||||
doorbell pushes nothing because nothing may be lost. Here a dropped frame is the
|
||||
*correct* outcome: each viewer has a one-slot buffer and a full slot is
|
||||
overwritten, because the only frame worth having is the newest one and a queue
|
||||
would show an ever-growing delay behind the shop instead of dropping back to
|
||||
live.
|
||||
|
||||
Other decisions worth keeping:
|
||||
|
||||
- **Ownership is proved once, before anything streams.** Everything after that
|
||||
point is keyed on a camera id and a hub does not know whose camera it holds —
|
||||
and a camera id is not a secret. An agent pushing is checked against its own
|
||||
site for the same reason.
|
||||
- **The agent's poll is held open by the server** rather than answered at once.
|
||||
Polling every few seconds puts a floor under how quickly a view can start;
|
||||
polling slowly puts a ceiling on it. Holding it means pressing Live reaches
|
||||
the shop PC immediately and an idle site costs about two requests a minute.
|
||||
- **One request carries many frames**, each prefixed with its length. At a few
|
||||
frames a second, per-request overhead and TLS handshakes would cost more than
|
||||
the pictures.
|
||||
- **The engine does the re-encode** (`frame.jpg?width=&quality=`). It already
|
||||
has OpenCV open and the frame decoded; a scaler in the agent would be the same
|
||||
work twice. On demand only — a camera nobody watches must not pay for a second
|
||||
encode it will never use.
|
||||
- **Duplicate frames are dropped by hash before they are sent.** Without it a
|
||||
fifth of the bandwidth was the same picture twice, and the poll rate could not
|
||||
safely run ahead of the engine.
|
||||
- **The Live button is offered even when the 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 exactly when somebody wants to look. A hidden control says
|
||||
*"you cannot"* where the honest answer is *"here is why"*, which the live view
|
||||
gives: it distinguishes a camera that is not connecting from a shop PC that is
|
||||
not answering.
|
||||
|
||||
Head office also still shows the camera's **latest frame** on the cards,
|
||||
refreshed every 60 s, which is what a page of cameras should cost when nobody
|
||||
has asked to watch one.
|
||||
|
||||
### The picture only worked if you had an S3 bucket
|
||||
|
||||
Which meant that on any deployment without object storage — every local install,
|
||||
and any self-hosted customer who does not want a bucket — `attachSnapshots`
|
||||
returned *"This system is not storing images"* for every camera, **forever**, on
|
||||
the two screens whose entire job is to show the camera. Making those screens
|
||||
picture-led is what turned a missing feature into a wall of empty tiles.
|
||||
|
||||
`migrations/009` adds `camera_snapshots`, and the agent falls back to
|
||||
`PUT /api/agent/cameras/{camera}/snapshot` when the presigned route answers
|
||||
`images_disabled`. What makes this safe in the database when face images are
|
||||
not:
|
||||
|
||||
- **One row per camera.** The primary key *is* the camera, so a snapshot
|
||||
replaces its predecessor. Storage is (cameras × ~100 KB) and does not grow
|
||||
with time or footfall. Face images grow with every visitor who ever walks in,
|
||||
which is exactly why they stay in a bucket.
|
||||
- It is a picture of a shop floor, not a face crop bound to an identity, and it
|
||||
carries no template.
|
||||
- `ON DELETE CASCADE` from the camera, so removing a camera removes its picture
|
||||
with no second place to remember.
|
||||
|
||||
Details that are not incidental:
|
||||
|
||||
- **The bucket stays primary where one exists.** Both routes exist because they
|
||||
are right for different deployments, not because one supersedes the other —
|
||||
a presigned PUT never passes the bytes through the API at all, which is what
|
||||
makes it the right route at estate scale.
|
||||
- **The fallback is chosen by a sentinel (`bridge.ErrImagesOff`), never by
|
||||
matching the message.** It decides which of two routes to take; getting it
|
||||
wrong from prose somebody later rewords would silently stop every camera
|
||||
picture in the estate.
|
||||
- **The camera is resolved by (site_id, camera_id) inside the INSERT**, so an
|
||||
agent cannot store a picture against another site's camera. The tenant and
|
||||
site come from the agent's credential, never the request.
|
||||
- **`snapshot_at` is written in the same transaction as the bytes.** It is what
|
||||
tells the camera list a picture exists; set apart, a camera could advertise
|
||||
one that is not there, which renders as a broken image on the one screen
|
||||
meant to show it.
|
||||
- **JPEG is verified from the magic bytes, not the Content-Type header**, and
|
||||
the body is bounded by `MaxBytesReader` at 2 MB. This endpoint stores what it
|
||||
is handed and serves it back to a browser, so the one thing it must not become
|
||||
is a way to park arbitrary content under a URL this server will serve.
|
||||
- **The read is session-authenticated, not a signed link.** There is no third
|
||||
party to delegate to — the bytes are in our own database — and minting an
|
||||
unauthenticated URL so that `<img src>` could use it would add a way to reach
|
||||
a photograph of somebody's shop floor with no session at all.
|
||||
|
||||
That last decision has a front-end consequence, and it is why `Shot.jsx` exists:
|
||||
**an `<img>` cannot send an Authorization header.** A presigned bucket URL is
|
||||
absolute and carries its own signature, so a plain `src` loads it; a relative
|
||||
URL served by this server has to be fetched with the session and handed over as
|
||||
an object URL. `useAuthedImage` keys on the URL string rather than the
|
||||
`snapshot` object — which is a fresh object on every poll, so an effect
|
||||
depending on it would re-fetch ~90 KB per camera every few seconds — and revokes
|
||||
the object URL on cleanup, or a screen left open all afternoon holds hundreds of
|
||||
copies of the same photograph.
|
||||
|
||||
Verified against the real office camera with no object storage configured: a
|
||||
90,587-byte frame stored in Postgres, served as `image/jpeg` to a signed-in
|
||||
user, **401 without a session**, and rendered on both the Cameras and Shops
|
||||
cards.
|
||||
|
||||
## Claiming a headless PC, and telling a refused broker from an absent one
|
||||
|
||||
Both found by the local stack falling over on a memory-starved machine and
|
||||
needing to be brought back — the kind of thing that only surfaces when the
|
||||
software is operated rather than written.
|
||||
|
||||
### `behavision-agent claim <code>`
|
||||
|
||||
The desktop app has had a Setup screen since enrolment was built. The **headless
|
||||
agent had nothing**: `Bootstrap` lived only in `desktop/internal/cloud`, so the
|
||||
one configuration the agent binary exists for — a back-office PC with no window
|
||||
— could not be claimed at all. The only route was hand-editing `agent.json`,
|
||||
which is exactly the state that Setup screen was built to end.
|
||||
|
||||
`agent/pkg/enrol` is that call, and the CLI joins its arguments rather than
|
||||
demanding quotes: the code is printed in groups so it can be read aloud, and an
|
||||
operator pasting it will paste the spaces too. It clears `Standalone`, and a
|
||||
config that fails to save is **reported** — a claim that is not on disk works
|
||||
until the next restart and then silently is not claimed, which looks exactly
|
||||
like a wrong code.
|
||||
|
||||
### A rejected connection and an unreachable broker are not the same fault
|
||||
|
||||
Measured, on the real stack: after the site's broker password was re-rolled,
|
||||
mosquitto logged `not authorised` while the agent logged **`connect to
|
||||
tcp://... timed out`**. Those need opposite actions — re-link this PC, or go and
|
||||
look at the network — and paho's `SetConnectRetry` is why they collapse into
|
||||
one: it retries internally, so the connect token never completes and *every*
|
||||
failure arrives as a timeout.
|
||||
|
||||
`describeStall` asks the one question that separates them: can a TCP socket be
|
||||
opened to the broker at all? Reachable-but-not-accepted names the likely cause
|
||||
and the command to fix it; unreachable says to check the network. It does not
|
||||
claim to know the exact reason — the broker does not tell a rejected client why,
|
||||
and a TLS failure looks the same from here — so it reports what is known rather
|
||||
than guessing. Same rule as `artifact` vs `no_faces` in the commissioning
|
||||
verdicts, and the `connected` pointer being three states rather than two.
|
||||
|
||||
`brokerHostPort` parses with `net/url`, never by scanning for the first `:` —
|
||||
this package has already been bitten once by IPv6 literals being bracketed and
|
||||
full of them.
|
||||
|
||||
### The dev machine is 8 GB, not 16
|
||||
|
||||
Corrected in this file, because it feeds a real decision. Measured while the
|
||||
stack was up: **0.45 GB free** with a browser and Docker Desktop open, and
|
||||
Docker alone is allocated 4 GB of the 8. The engine's steady state is only
|
||||
~260 MB, so the OOM kill happened during a build (npm + go + Docker at once),
|
||||
not in normal running — but the margin is what makes `/api/health` reporting
|
||||
`recognition_model` worth checking after every restart. The local gallery
|
||||
already holds **17 embeddings tagged `w600k_mbf` and 19 tagged `w600k_r50`**:
|
||||
proof that the fallback has silently fired before, and that model-tagging is
|
||||
what stopped it corrupting anything.
|
||||
|
||||
## Accounts: how a second person gets one (`invitations`, migration 010)
|
||||
|
||||
A tenant had exactly the users `provision user` had created on the server's
|
||||
command line. That is not a missing screen, it is a missing product: a shop with
|
||||
an owner and four staff either shared one password between five people or raised
|
||||
a support ticket per person, and **a phone app for shop-floor staff could not
|
||||
exist at all** while there was only ever one account to sign in as.
|
||||
|
||||
Registration is by **invitation**, never open signup — the same line
|
||||
`handlers_admin.go` already draws around creating a company. An endpoint a
|
||||
stranger can call to create an account is a far larger thing to secure than one
|
||||
reachable only through somebody who already has one.
|
||||
|
||||
```
|
||||
POST /api/team/invitations manager+ -> the code, ONCE
|
||||
GET /api/auth/invitation?code=… unauthenticated preview
|
||||
POST /api/auth/register unauthenticated -> a SESSION
|
||||
```
|
||||
|
||||
- **The code decides the address and the role; the request decides only the
|
||||
password and a display name.** A code gets forwarded, screenshotted and
|
||||
pasted into chat, so if the body could name either, one staff invitation would
|
||||
be an owner account for anybody who saw it. `decode` rejects unknown fields,
|
||||
so a client cannot even ask — verified live: `unknown field "role"` → 400.
|
||||
- **`register` returns a session, not a 201.** Sending somebody who chose a
|
||||
password four seconds ago to a sign-in form to type it again is the sort of
|
||||
thing that gets blamed on the password.
|
||||
- **Single use is enforced by the UPDATE** (`used_at IS NULL` and the write are
|
||||
one statement) and the account is created **in the same transaction**. A spent
|
||||
invitation with no user is unusable and invisible; a user with the invitation
|
||||
still open is a second account waiting for whoever else has the code. Same
|
||||
rule, same reason, as agent enrolment.
|
||||
- **Unknown, expired, spent and revoked read identically.** The difference only
|
||||
helps somebody guessing, and the holder's next step is the same in all four.
|
||||
- **`admin` is not an invitable role.** A platform administrator is defined by
|
||||
having *no* client, so an invitation — which always carries one — could never
|
||||
mint a real one. What it *could* do is create the tenant-scoped `role='admin'`
|
||||
row that `adminOnly` exists to reject, so it is refused at the constraint.
|
||||
- **A manager cannot mint an owner.** Promoting somebody past yourself is an
|
||||
escalation, and it is the shape of this endpoint that matters if a manager
|
||||
account is ever taken over.
|
||||
- A failed attempt (short password, mistyped code) does **not** spend the
|
||||
invitation. One typo must not cost somebody their invitation.
|
||||
|
||||
### Removing access has to mean now
|
||||
|
||||
`PATCH /api/team/{id}` with `{"active": false}` revokes every session that user
|
||||
holds **in the same transaction**. An access token lives twelve hours, so
|
||||
without that, "remove their access" removes it sometime tomorrow — which is not
|
||||
what anybody pressing that button believes they have just done.
|
||||
|
||||
`OwnerCount` refuses the change that locks a company out of itself: the last
|
||||
active owner may not demote or deactivate themselves. There is no way back from
|
||||
that except a shell on the server, which is precisely what this surface exists
|
||||
to stop needing.
|
||||
|
||||
### Devices: the benefit of opaque tokens, finally collected
|
||||
|
||||
`GET /api/auth/sessions`, `DELETE /api/auth/sessions/{id}`,
|
||||
`POST /api/auth/sessions/revoke-others`.
|
||||
|
||||
The argument for a session table over JWTs was always that this system puts
|
||||
customer data on shop-floor PCs and staff phones that get lost, resold and
|
||||
shared — so *"log that device out, now"* has to actually work. **Nothing could
|
||||
list what was signed in, let alone stop one.** The cost was being paid and the
|
||||
benefit was not being collected.
|
||||
|
||||
- A person may revoke only their **own** sessions; the store scopes the update
|
||||
by `user_id`, because a session id travels in that list and is not a secret.
|
||||
Removing a colleague's access is a different question with a different answer
|
||||
(deactivate them).
|
||||
- **"Sign out everywhere else" keeps the caller's own session.** Somebody who
|
||||
has just lost a phone must not also be signed out of the device they are
|
||||
holding while they deal with it.
|
||||
- `device` is a coarse label (`"Chrome on Mac"`), never a fingerprint. The
|
||||
question it answers is only *"which of these is the one in my hand"*.
|
||||
|
||||
## Face images without an object-storage bucket (`visit_faces`, migration 011)
|
||||
|
||||
009 did this for camera snapshots and its own comment says why face images are
|
||||
different: *"Face images grow with every visitor who ever walks in, which is why
|
||||
they stay in a bucket."* That is true of images kept **per visit**, and it is
|
||||
exactly why this table is bounded to **one row per visitor** instead.
|
||||
|
||||
The gap it closes is the one 009 closed a level up. With no bucket the API
|
||||
answered *"This system is not storing customer photos"* for every arrival,
|
||||
forever — including on the mobile feed, whose entire purpose is to put a face in
|
||||
front of somebody so they can recognise the customer walking towards them. Every
|
||||
local install and every self-hosted customer who does not want an S3 account got
|
||||
nothing.
|
||||
|
||||
```
|
||||
engine data/outbox/<uuid>.jpg (only when app.store_faces is on)
|
||||
agent POST /api/agent/upload-url -> 501 images_disabled
|
||||
POST /api/agent/faces -> {"key": "db:<uuid>"}
|
||||
server visits.image_key = 'db:…'
|
||||
staff GET /api/visits -> {"image":{"available":true,
|
||||
"url":"/api/faces/<uuid>.jpg",
|
||||
"auth":true}}
|
||||
```
|
||||
|
||||
What makes this acceptable in Postgres when per-visit images are not:
|
||||
|
||||
- **The engine still gates capture.** `app.store_faces` is false by default and
|
||||
no crop is written without it. This changes what happens to an image that
|
||||
already exists; it does not change whether one is taken.
|
||||
- **One row survives per visitor.** `RecordVisit` prunes the previous row as it
|
||||
links a newer one, so storage is (customers × ~20 KB) — it grows with the
|
||||
customer base, not with footfall. A shop seen by 5,000 people holds ~100 MB
|
||||
whether they visit once or a thousand times.
|
||||
- **Nothing reads a superseded face anyway.** Every surface shows the customer's
|
||||
latest view, which is what `VisitorImageKey` has always returned.
|
||||
- **Orphans are swept.** An agent uploads before the server has decided who the
|
||||
person is, so a row is briefly unreferenced by design — and permanently so if
|
||||
the visit that would have claimed it never arrives. That is a stored
|
||||
photograph of a real person that erasure could never reach, because erasure
|
||||
finds images through the visitor and this row has none.
|
||||
|
||||
The bucket stays primary wherever one exists: a presigned PUT never passes the
|
||||
bytes through the API at all, which is what makes it the right route at estate
|
||||
scale. The fallback is chosen by the **sentinel** `bridge.ErrImagesOff`, never
|
||||
by matching a message — getting that wrong from prose somebody later rewords
|
||||
would silently stop every customer photo in the estate. Same rule the camera
|
||||
snapshot fallback already follows.
|
||||
|
||||
### `UPDATE … RETURNING` returns the value AFTER the update
|
||||
|
||||
The prune's first version read the superseded keys with
|
||||
`UPDATE visits SET image_key = '' … RETURNING image_key`. Postgres returns the
|
||||
**new** row, so every key came back as the empty string it had just been set to,
|
||||
the delete list was always empty, and `visit_faces` grew with footfall exactly
|
||||
as if the prune did not exist. The visit rows looked perfectly correct; only the
|
||||
row count gave it away.
|
||||
|
||||
It is one CTE now — `doomed` reads the pre-image and drives both the update and
|
||||
the delete — which cannot have that bug. **The in-memory fake would have agreed
|
||||
with either version**; only `TestLiveOnlyOneFaceSurvivesPerVisitor` against a
|
||||
real Postgres caught it, which is the whole reason the live store tests exist.
|
||||
|
||||
### `Image.auth`, and one function that decides where a photo is
|
||||
|
||||
`s.imageFor(key)` is the single place that turns a stored key into the `Image` a
|
||||
client receives — the arrivals feed, the live stream and the customer record all
|
||||
go through it. There are now two places an image can live and four distinct
|
||||
reasons there may not be one, and computing that twice is how the shops screen
|
||||
once ended up labelled **Working** in green directly above *"2 of 3 cameras not
|
||||
connecting"*.
|
||||
|
||||
`auth: true` says the URL is one of ours and needs the session's bearer, rather
|
||||
than a presigned link carrying its own signature. It exists because the two are
|
||||
genuinely different to fetch and **a client cannot tell them apart by looking**:
|
||||
|
||||
- A browser `<img>` **cannot** load the authenticated one — no header — so the
|
||||
web app fetches it and hands over an object URL (`Shot.jsx`).
|
||||
- A **mobile** image view *can* attach the header and load it directly.
|
||||
- The **desktop** webview can do neither: a relative src resolves against
|
||||
`wails://`, not the cloud. `cloud.VisitorImage` therefore fetches the bytes in
|
||||
Go, where the session already lives, and returns a `data:` URI. The
|
||||
alternative — a local proxy inside the app holding the session — is a second
|
||||
authenticated surface on a shop PC to get wrong.
|
||||
|
||||
**Both signals are accepted, and that is not belt-and-braces.** A relative URL
|
||||
always needs the session; there is no public one. Trusting only the flag broke
|
||||
every shop card the moment `Sites.jsx`'s `bestView()` rebuilt a partial
|
||||
`{url, at}` copy and dropped it — found by opening the page, not by a test. The
|
||||
flag adds only the case a URL cannot express: an absolute link that still needs
|
||||
a bearer, which arrives the first time object storage is served from this host.
|
||||
|
||||
The bytes endpoint writes **no audit row**. Every read of a face is recorded
|
||||
where the *link* is handed out — one row per arrivals page, one per customer
|
||||
record — and the bucket route's bytes never touch this server, so counting the
|
||||
fetch as well would count one deployment twice and the other once.
|
||||
|
||||
`ago()` clamps at zero and renders a future timestamp as *"just now"*. That is
|
||||
right for a heartbeat whose clock runs slightly ahead and completely wrong for
|
||||
an expiry: a code valid for a week read *"expires just now"*, which tells the
|
||||
operator not to bother handing it over. `until()` is its opposite number.
|
||||
|
||||
### Verified live, 5 September 2026
|
||||
|
||||
Against real Postgres, on the demo tenant:
|
||||
|
||||
- Owner invites a staff member → code minted once → unauthenticated preview
|
||||
names the company, address and role → a body naming `role` or `email` is
|
||||
refused → proper redemption returns a **signed-in session** → replay 404s.
|
||||
- Staff can read arrivals, shops and the team; **cannot** invite (403).
|
||||
- Two devices listed, the calling one marked `current`; revoking the phone 401s
|
||||
its token immediately while the till keeps working.
|
||||
- Deactivating a member 401s their live session **at once**, and they cannot
|
||||
sign back in. The only owner cannot demote themselves (409 `last_owner`).
|
||||
- Agent enrols → `upload-url` answers **501 images_disabled** → falls back to
|
||||
`POST /api/agent/faces` → a 92,405-byte office-camera JPEG stored in Postgres,
|
||||
served as `image/jpeg` to the owner, **401 with no session**, **404 to another
|
||||
tenant**, and rendered in the arrivals feed avatar in a real browser.
|
||||
- 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.
|
||||
|
||||
2
RUN.md
@@ -251,7 +251,7 @@ the frozen engine once to prove it runs, and compiles the installer.
|
||||
3. Launch from the Start menu. **Set this PC up on its own** — no code needed.
|
||||
4. Cameras → Add camera → pick the make → Test connection → Save. The feed must
|
||||
appear with no restart.
|
||||
5. Check `/api/health` reports `recognition_model`. On a 16 GB machine the
|
||||
5. Check `/api/health` reports `recognition_model`. On a small machine the
|
||||
166 MB r50 can lose the fallback chain to the 13 MB mbf, and embeddings are
|
||||
model-tagged, so which one wins decides whether a gallery carries over.
|
||||
6. Sign out of the tray (Quit) — recognition must stop with it. Reboot; the app
|
||||
|
||||
80
agent/cmd/behavision-demo-pack/main.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Command behavision-demo-pack seals a camera list into demo-cameras.enc for a
|
||||
// demo release. It runs on the machine that builds the release and is never
|
||||
// shipped.
|
||||
//
|
||||
// behavision-demo-pack -cameras cameras.json -out demo-cameras.enc
|
||||
//
|
||||
// Prints the unlock code exactly once. It is not stored anywhere; a code you
|
||||
// can look up later is a code anyone with access to the build machine holds.
|
||||
// Lose it and seal again.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/demo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
in := flag.String("cameras", "", "JSON array of cameras (id, host, port, path, username, password)")
|
||||
out := flag.String("out", "demo-cameras.enc", "sealed bundle to write")
|
||||
flag.Parse()
|
||||
if *in == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: behavision-demo-pack -cameras cameras.json [-out demo-cameras.enc]")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(*in)
|
||||
if err != nil {
|
||||
die("read cameras: %v", err)
|
||||
}
|
||||
var cams []demo.Camera
|
||||
if err := json.Unmarshal(raw, &cams); err != nil {
|
||||
die("cameras.json: %v", err)
|
||||
}
|
||||
if len(cams) == 0 {
|
||||
die("no cameras in %s", *in)
|
||||
}
|
||||
for i, c := range cams {
|
||||
switch {
|
||||
case c.ID == "":
|
||||
die("camera %d has no id", i)
|
||||
case c.Host == "":
|
||||
die("camera %q has no host", c.ID)
|
||||
case c.Path == "":
|
||||
die("camera %q has no path - the stream path is the field nobody can guess", c.ID)
|
||||
}
|
||||
}
|
||||
// Re-marshal so only the fields the engine accepts travel, in a stable
|
||||
// shape, whatever extra keys the input happened to carry.
|
||||
plain, err := json.Marshal(cams)
|
||||
if err != nil {
|
||||
die("marshal: %v", err)
|
||||
}
|
||||
|
||||
code, err := demo.NewCode()
|
||||
if err != nil {
|
||||
die("code: %v", err)
|
||||
}
|
||||
sealed, err := demo.Seal(code, plain)
|
||||
if err != nil {
|
||||
die("seal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(*out, sealed, 0o644); err != nil {
|
||||
die("write: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n sealed %d camera(s) into %s (%d bytes)\n\n", len(cams), *out, len(sealed))
|
||||
fmt.Printf(" unlock code: %s\n\n", code)
|
||||
fmt.Println(" Shown once. Give it to whoever runs behavision-setup, by voice")
|
||||
fmt.Println(" or message - not in the same place as the zip.")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func die(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, " "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
543
agent/cmd/behavision-setup/main.go
Normal file
@@ -0,0 +1,543 @@
|
||||
// Command behavision-setup prepares a shop PC to run the recognition engine.
|
||||
//
|
||||
// It exists because the engine is Python and the rest of the product is Go.
|
||||
// The Go halves cross-compile to Windows from any machine; the engine, frozen
|
||||
// with PyInstaller, does not - PyInstaller bundles the interpreter and native
|
||||
// wheels of the machine it runs on, so a frozen engine can only be built on
|
||||
// Windows. That single fact was the whole reason a release could not be cut.
|
||||
//
|
||||
// So this installs the engine from source instead of shipping it frozen: find
|
||||
// a Python, build a private virtual environment beside the database, install
|
||||
// the engine into it, fetch the models, and record how to start it. Everything
|
||||
// in the release can then be built anywhere.
|
||||
//
|
||||
// The trade, stated plainly because whoever runs this is standing in a shop:
|
||||
// it needs Python and a working internet connection at install time, and it
|
||||
// takes minutes rather than seconds. A frozen build needs neither. What it
|
||||
// buys is a release that exists.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/config"
|
||||
"github.com/loyaly/behavision-agent/pkg/demo"
|
||||
"github.com/loyaly/behavision-agent/pkg/engine"
|
||||
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||
)
|
||||
|
||||
// The engine needs 3.10; nothing here works below it and the failure would
|
||||
// otherwise arrive as a syntax error deep inside a dependency.
|
||||
const minMinor = 10
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\n Setup did not finish: %v\n\n", err)
|
||||
pause()
|
||||
os.Exit(1)
|
||||
}
|
||||
pause()
|
||||
}
|
||||
|
||||
func run() error {
|
||||
fmt.Println()
|
||||
fmt.Println(" Behavision setup")
|
||||
fmt.Println(" ----------------")
|
||||
fmt.Println()
|
||||
|
||||
state := paths.StateRoot()
|
||||
src, err := engineSource()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" engine source %s\n", src)
|
||||
fmt.Printf(" install into %s\n", state)
|
||||
fmt.Println()
|
||||
|
||||
if err := paths.EnsureState(); err != nil {
|
||||
return fmt.Errorf("could not create %s: %w", state, err)
|
||||
}
|
||||
|
||||
// A demo release ships its cameras sealed. Ask for the code NOW, before
|
||||
// the ten-minute download, so a mistyped one costs seconds; the cameras
|
||||
// are actually added at the end, through the running engine.
|
||||
demoCams, err := unlockDemo(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if demoCams != nil {
|
||||
step("Demo cameras", fmt.Sprintf("%d unlocked", len(demoCams)))
|
||||
}
|
||||
|
||||
py, ver, err := findPython()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
step("Python", fmt.Sprintf("%s (%s)", ver, py))
|
||||
|
||||
venv := filepath.Join(state, "runtime")
|
||||
if err := makeVenv(py, venv); err != nil {
|
||||
return err
|
||||
}
|
||||
vpy := venvPython(venv)
|
||||
step("Virtual environment", venv)
|
||||
|
||||
// The engine reads its settings from <state>/config/default.yaml and will
|
||||
// seed that from beside its own code on first run - which works when its
|
||||
// code is a checkout or a frozen folder and not when it is a package in
|
||||
// site-packages, where there is no config beside it. Seeded here, from the
|
||||
// copy the release ships. Never overwritten: an upgrade must not revert an
|
||||
// operator's thresholds.
|
||||
if err := seedConfig(src, state); err != nil {
|
||||
return err
|
||||
}
|
||||
step("Settings", filepath.Join(state, "config", "default.yaml"))
|
||||
|
||||
// --upgrade so re-running after a new release replaces the engine rather
|
||||
// than leaving the old one in place and reporting success.
|
||||
if err := pipInstall(vpy, src); err != nil {
|
||||
return err
|
||||
}
|
||||
step("Engine and dependencies", "installed")
|
||||
|
||||
if err := runEngine(vpy, "setup-models"); err != nil {
|
||||
return fmt.Errorf("downloading the recognition models: %w", err)
|
||||
}
|
||||
step("Recognition models", "downloaded")
|
||||
|
||||
if err := writeConfig(vpy); err != nil {
|
||||
return err
|
||||
}
|
||||
step("Startup settings", filepath.Join(state, "agent.json"))
|
||||
|
||||
// Proving it starts is the point. An installer that reports success and
|
||||
// leaves a shop with an engine that will not run has done worse than
|
||||
// failing: the failure surfaces later, to someone who did not install it.
|
||||
if err := smokeTest(vpy, demoCams); err != nil {
|
||||
return fmt.Errorf("the engine installed but would not start: %w", err)
|
||||
}
|
||||
step("Engine starts and answers", "verified")
|
||||
if demoCams != nil {
|
||||
step("Demo cameras", "added to the engine")
|
||||
// No head office in a demo. Without this the app opens on "type an
|
||||
// installation code" and sits there; with it, it opens on Live.
|
||||
if err := markStandalone(); err != nil {
|
||||
return err
|
||||
}
|
||||
step("Head office", "none - running on this PC only")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" Done. Start Behavision from the Start menu or the desktop icon.")
|
||||
fmt.Println(" It appears in the system tray; right-click there to stop it.")
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
func step(label, detail string) {
|
||||
fmt.Printf(" [ok] %-24s %s\n", label, detail)
|
||||
}
|
||||
|
||||
// engineSource finds the Python source shipped beside this executable. Beside,
|
||||
// not downloaded: the engine and the app must be the same release, and a
|
||||
// version skew between them is the class of bug nobody can reproduce.
|
||||
func engineSource() (string, error) {
|
||||
candidates := []string{
|
||||
filepath.Join(paths.InstallRoot(), "engine-src"),
|
||||
filepath.Join(paths.InstallRoot(), "..", "engine-src"),
|
||||
}
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(wd, "engine-src"), wd)
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(filepath.Join(c, "pyproject.toml")); err == nil {
|
||||
abs, _ := filepath.Abs(c)
|
||||
return abs, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("could not find the engine source (expected an " +
|
||||
"engine-src folder with pyproject.toml beside this program). " +
|
||||
"Unzip the whole release together rather than moving this file out of it")
|
||||
}
|
||||
|
||||
// findPython returns the first interpreter that is new enough.
|
||||
//
|
||||
// `py -3` first on Windows: the launcher is what the official installer puts
|
||||
// on PATH, and `python` there is often the Microsoft Store stub that prints an
|
||||
// advert and exits 9009 instead of running anything.
|
||||
func findPython() (string, string, error) {
|
||||
type cand struct {
|
||||
exe string
|
||||
args []string
|
||||
}
|
||||
var cands []cand
|
||||
if runtime.GOOS == "windows" {
|
||||
cands = append(cands, cand{"py", []string{"-3"}})
|
||||
}
|
||||
cands = append(cands, cand{"python3", nil}, cand{"python", nil})
|
||||
|
||||
var tried []string
|
||||
for _, c := range cands {
|
||||
exe, err := exec.LookPath(c.exe)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
args := append(append([]string{}, c.args...), "-c",
|
||||
"import sys;print('%d.%d'%sys.version_info[:2])")
|
||||
out, err := exec.Command(exe, args...).Output()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ver := strings.TrimSpace(string(out))
|
||||
tried = append(tried, c.exe+" "+ver)
|
||||
if major, minor, ok := parseVer(ver); ok && (major > 3 || (major == 3 && minor >= minMinor)) {
|
||||
full := exe
|
||||
if len(c.args) > 0 {
|
||||
full = exe + " " + strings.Join(c.args, " ")
|
||||
}
|
||||
return full, "Python " + ver, nil
|
||||
}
|
||||
}
|
||||
|
||||
msg := "no Python 3.10 or newer was found on this PC.\n\n" +
|
||||
" Install it from https://www.python.org/downloads/windows/\n" +
|
||||
" and tick \"Add python.exe to PATH\" on the first screen,\n" +
|
||||
" then run this again."
|
||||
if len(tried) > 0 {
|
||||
msg += "\n\n Found, but too old: " + strings.Join(tried, ", ")
|
||||
}
|
||||
return "", "", errors.New(msg)
|
||||
}
|
||||
|
||||
func parseVer(s string) (int, int, bool) {
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) < 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
major, err1 := strconv.Atoi(parts[0])
|
||||
minor, err2 := strconv.Atoi(parts[1])
|
||||
return major, minor, err1 == nil && err2 == nil
|
||||
}
|
||||
|
||||
// splitLauncher turns `py -3` back into a command and its arguments.
|
||||
func splitLauncher(s string) (string, []string) {
|
||||
f := strings.Fields(s)
|
||||
if len(f) == 0 {
|
||||
return s, nil
|
||||
}
|
||||
return f[0], f[1:]
|
||||
}
|
||||
|
||||
func venvPython(venv string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(venv, "Scripts", "python.exe")
|
||||
}
|
||||
return filepath.Join(venv, "bin", "python")
|
||||
}
|
||||
|
||||
// makeVenv builds the engine's own interpreter under the writable state root.
|
||||
//
|
||||
// A virtual environment rather than the system Python: a shop PC may have
|
||||
// Python there for something else, and pinning numpy below 2.0 - which the
|
||||
// engine requires - inside a shared interpreter is how you break the other
|
||||
// thing months later, silently.
|
||||
func makeVenv(py, venv string) error {
|
||||
if _, err := os.Stat(venvPython(venv)); err == nil {
|
||||
return nil // already built; pip below brings it up to date
|
||||
}
|
||||
exe, args := splitLauncher(py)
|
||||
args = append(args, "-m", "venv", venv)
|
||||
return stream(exec.Command(exe, args...), "creating the virtual environment")
|
||||
}
|
||||
|
||||
func pipInstall(vpy, src string) error {
|
||||
fmt.Println(" Installing the engine and its libraries. This downloads a few")
|
||||
fmt.Println(" hundred megabytes and takes a while on a slow connection.")
|
||||
fmt.Println()
|
||||
if err := stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade",
|
||||
"pip", "setuptools", "wheel"), "updating pip"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A wheel if the release ships one - nothing to build on the shop PC, and
|
||||
// pip never has to touch the folder the release was unzipped into.
|
||||
//
|
||||
// That matters more than it sounds: `pip install <folder>` makes setuptools
|
||||
// write behavision.egg-info INTO that folder, and the folder is read-only
|
||||
// whenever the release was unzipped somewhere sensible - Program Files, or
|
||||
// the shared drive INSTALL.txt says is fine. Found by running this in a
|
||||
// container with the source mounted read-only: "could not create
|
||||
// 'behavision.egg-info': Read-only file system". Falling back to source
|
||||
// copies it somewhere writable first, for the same reason.
|
||||
if wheels, _ := filepath.Glob(filepath.Join(src, "behavision-*.whl")); len(wheels) > 0 {
|
||||
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", wheels[0]),
|
||||
"installing the engine")
|
||||
}
|
||||
tmp, err := os.MkdirTemp("", "behavision-src-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
if err := copyTree(src, tmp); err != nil {
|
||||
return fmt.Errorf("staging the engine source: %w", err)
|
||||
}
|
||||
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", tmp),
|
||||
"installing the engine")
|
||||
}
|
||||
|
||||
// seedConfig puts the shipped default.yaml where the engine will look for it,
|
||||
// and leaves an existing one alone.
|
||||
func seedConfig(src, state string) error {
|
||||
dst := filepath.Join(state, "config", "default.yaml")
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
from := filepath.Join(src, "config", "default.yaml")
|
||||
b, err := os.ReadFile(from)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the release is missing config/default.yaml: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, b, 0o644)
|
||||
}
|
||||
|
||||
// copyTree copies a source tree, skipping the caches a checkout accumulates.
|
||||
func copyTree(from, to string) error {
|
||||
return filepath.WalkDir(from, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(from, path)
|
||||
if d.IsDir() {
|
||||
if d.Name() == "__pycache__" || strings.HasSuffix(d.Name(), ".egg-info") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return os.MkdirAll(filepath.Join(to, rel), 0o755)
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(to, rel), b, 0o644)
|
||||
})
|
||||
}
|
||||
|
||||
// runEngine runs the engine exactly as the app will later: same interpreter,
|
||||
// same environment. In particular ChildEnv sets BEHAVISION_DATA_DIR, without
|
||||
// which a pip-installed engine decides its state lives in site-packages and
|
||||
// downloads the models to a place the app never looks.
|
||||
func runEngine(vpy string, args ...string) error {
|
||||
full := append([]string{"-m", "behavision"}, args...)
|
||||
cmd := exec.Command(vpy, full...)
|
||||
cmd.Env = engine.ChildEnv("")
|
||||
return stream(cmd, "running the engine")
|
||||
}
|
||||
|
||||
// writeConfig records how to start the engine, in the same file and through
|
||||
// the same type the app reads, so the two cannot disagree about it.
|
||||
func writeConfig(vpy string) error {
|
||||
path := paths.AgentConfig()
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading %s: %w", path, err)
|
||||
}
|
||||
// An absolute path: the app resolves a relative EngineExe against its own
|
||||
// install root under Program Files, and the interpreter is not there.
|
||||
cfg.EngineExe = vpy
|
||||
cfg.EngineArgs = []string{"-m", "behavision", "run"}
|
||||
if cfg.APIBase == "" {
|
||||
cfg.APIBase = "http://127.0.0.1:8010"
|
||||
}
|
||||
return cfg.Save(path)
|
||||
}
|
||||
|
||||
// smokeTest starts the engine exactly as the app will and waits for its API to
|
||||
// answer. Any reply counts, including 401: the engine invents its own
|
||||
// credential when none is configured, and a refusal proves it is serving.
|
||||
func smokeTest(vpy string, demoCams []demo.Camera) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, vpy, "-m", "behavision", "run")
|
||||
cmd.Env = engine.ChildEnv("")
|
||||
var log strings.Builder
|
||||
cmd.Stdout, cmd.Stderr = &log, &log
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
}()
|
||||
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
deadline := time.Now().Add(75 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := client.Get("http://127.0.0.1:8010/api/health")
|
||||
if err == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if demoCams == nil {
|
||||
return nil
|
||||
}
|
||||
// Through the engine's own Add Camera, not written to its file:
|
||||
// the store is what applies DPAPI to the password on Windows, so
|
||||
// this is how the credential ends up encrypted on disk rather
|
||||
// than sitting in cameras.json for anyone who can read
|
||||
// ProgramData.
|
||||
return addCameras(demoCams)
|
||||
}
|
||||
if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
|
||||
break
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
return fmt.Errorf("it did not answer within 75 seconds.\n\n%s",
|
||||
tail(log.String(), 15))
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
return " " + strings.Join(lines, "\n ")
|
||||
}
|
||||
|
||||
// stream runs a command and shows its output. Shown, not swallowed: pip failing
|
||||
// on a missing build tool prints exactly what is wrong, and hiding that leaves
|
||||
// the operator with "setup failed" and nothing to act on.
|
||||
func stream(cmd *exec.Cmd, what string) error {
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("%s failed: %w", what, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pause keeps the window open. Double-clicked from Explorer, a console program
|
||||
// that finishes closes instantly and the operator sees nothing at all -
|
||||
// success and failure look identical.
|
||||
func pause() {
|
||||
if runtime.GOOS != "windows" {
|
||||
return
|
||||
}
|
||||
fmt.Print(" Press Enter to close. ")
|
||||
_, _ = bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
}
|
||||
|
||||
// unlockDemo returns the sealed cameras a demo release ships, or nil when this
|
||||
// is not a demo release. Asks for the unlock code on the console; three tries,
|
||||
// because a code is read down a phone and typed by hand.
|
||||
func unlockDemo(src string) ([]demo.Camera, error) {
|
||||
sealed, err := os.ReadFile(filepath.Join(src, "demo-cameras.enc"))
|
||||
if err != nil {
|
||||
return nil, nil // not a demo release
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println(" This is a demo release with the cameras already set up.")
|
||||
fmt.Println(" It needs the unlock code you were given.")
|
||||
fmt.Println()
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
fmt.Print(" Unlock code: ")
|
||||
line, _ := in.ReadString('\n')
|
||||
plain, err := demo.Open(line, sealed)
|
||||
if err == nil {
|
||||
var cams []demo.Camera
|
||||
if err := json.Unmarshal(plain, &cams); err != nil {
|
||||
return nil, fmt.Errorf("the bundle unlocked but did not parse: %w", err)
|
||||
}
|
||||
fmt.Println()
|
||||
return cams, nil
|
||||
}
|
||||
fmt.Printf(" %v\n", err)
|
||||
}
|
||||
return nil, errors.New("no valid unlock code after three tries. Check it " +
|
||||
"with whoever gave you this release and run setup again")
|
||||
}
|
||||
|
||||
// addCameras posts each demo camera to the running engine, with the credential
|
||||
// the engine generated for itself on first start.
|
||||
func addCameras(cams []demo.Camera) error {
|
||||
user, pass, err := engineCredential()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
for _, c := range cams {
|
||||
if c.Port == 0 {
|
||||
c.Port = 554
|
||||
}
|
||||
body, _ := json.Marshal(c)
|
||||
req, _ := http.NewRequest(http.MethodPost, "http://127.0.0.1:8010/api/cameras",
|
||||
bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding camera %s: %w", c.ID, err)
|
||||
}
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
resp.Body.Close()
|
||||
// 409 is "already there" - a re-run of setup, which is allowed.
|
||||
if resp.StatusCode >= 300 && resp.StatusCode != http.StatusConflict {
|
||||
return fmt.Errorf("adding camera %s: %s: %s", c.ID, resp.Status,
|
||||
strings.TrimSpace(string(msg)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// engineCredential reads the Basic credential the engine wrote on its first
|
||||
// start. Empty when the engine is configured without one.
|
||||
func engineCredential() (string, string, error) {
|
||||
b, err := os.ReadFile(paths.APICredentials())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", "", nil
|
||||
}
|
||||
return "", "", err
|
||||
}
|
||||
var user, pass string
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "username="); ok {
|
||||
user = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := strings.CutPrefix(line, "password="); ok {
|
||||
pass = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return user, pass, nil
|
||||
}
|
||||
|
||||
// markStandalone records that this PC runs on its own, through the same
|
||||
// config type the app reads.
|
||||
func markStandalone() error {
|
||||
path := paths.AgentConfig()
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Standalone = true
|
||||
return cfg.Save(path)
|
||||
}
|
||||
BIN
agent/cmd/behavision-setup/rsrc_windows_amd64.syso
Normal file
@@ -7,4 +7,5 @@ require (
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
golang.org/x/net v0.8.0 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
)
|
||||
|
||||
@@ -6,3 +6,5 @@ golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/loyaly/behavision-agent/pkg/cameras"
|
||||
"github.com/loyaly/behavision-agent/pkg/config"
|
||||
"github.com/loyaly/behavision-agent/pkg/engine"
|
||||
"github.com/loyaly/behavision-agent/pkg/enrol"
|
||||
"github.com/loyaly/behavision-agent/pkg/mqtt"
|
||||
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||
"github.com/loyaly/behavision-agent/pkg/spool"
|
||||
@@ -38,8 +39,15 @@ var version = "dev"
|
||||
|
||||
func main() {
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "behavision-agent %s\n\nusage: %s <run|status|paths>\n",
|
||||
version, filepath.Base(os.Args[0]))
|
||||
fmt.Fprintf(os.Stderr, `behavision-agent %s
|
||||
|
||||
usage: %s <command>
|
||||
|
||||
run supervise the engine and report to head office (default)
|
||||
claim <code> link this PC to a shop, using an installation code
|
||||
status what this PC is and whether it is claimed
|
||||
paths where this install reads and writes
|
||||
`, version, filepath.Base(os.Args[0]))
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
@@ -53,6 +61,8 @@ func main() {
|
||||
err = cmdRun()
|
||||
case "status":
|
||||
err = cmdStatus()
|
||||
case "claim":
|
||||
err = cmdClaim(flag.Args()[1:])
|
||||
case "paths":
|
||||
err = cmdPaths()
|
||||
default:
|
||||
@@ -64,6 +74,72 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// cmdClaim is the headless half of onboarding.
|
||||
//
|
||||
// The desktop app has had a Setup screen for this; a back-office PC with no
|
||||
// window had nothing at all, so the only way to claim one was to hand-edit
|
||||
// agent.json - which is the state that screen was built to end.
|
||||
func cmdClaim(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: behavision-agent claim <installation code>\n" +
|
||||
"Ask whoever manages your shops for one - they can create it from\n" +
|
||||
"the Behavision platform, under the shop.")
|
||||
}
|
||||
// Joined rather than requiring quotes: the code is printed in groups for
|
||||
// reading aloud, and an operator pasting it will paste the spaces too.
|
||||
code := strings.Join(args, "")
|
||||
|
||||
if err := paths.EnsureState(); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(paths.AgentConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base := cfg.CloudBase
|
||||
if v := os.Getenv("BEHAVISION_CLOUD"); v != "" {
|
||||
base = v
|
||||
}
|
||||
if base == "" {
|
||||
base = "https://mcp.loyaly.ai"
|
||||
}
|
||||
|
||||
b, err := enrol.Claim(context.Background(), base, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The slugs, not the uuids: the topic prefix is <client>.<site> and the
|
||||
// broker's ACL is written against exactly that username.
|
||||
cfg.ClientID = b.ClientSlug
|
||||
cfg.SiteID = b.SiteSlug
|
||||
cfg.SiteName = b.SiteName
|
||||
cfg.BrokerURL = b.MQTTURL
|
||||
cfg.BrokerUsername = b.MQTTUser
|
||||
cfg.BrokerPassword = b.MQTTPass
|
||||
cfg.AgentToken = b.AgentToken
|
||||
cfg.CloudBase = base
|
||||
caPath, err := enrol.SaveCA(b.CACert, paths.BrokerCA())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.BrokerCAFile = caPath
|
||||
// A PC that was running on its own and has now been linked is no longer
|
||||
// standalone.
|
||||
cfg.Standalone = false
|
||||
if err := cfg.Save(paths.AgentConfig()); err != nil {
|
||||
// Reported, never swallowed: a claim that is not on disk works until
|
||||
// the next restart and then silently is not claimed any more, which
|
||||
// looks exactly like a wrong code.
|
||||
return fmt.Errorf("could not save the settings: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("linked to %s (%s.%s)\n", b.SiteName, b.ClientSlug, b.SiteSlug)
|
||||
fmt.Printf("settings written to %s\n", paths.AgentConfig())
|
||||
fmt.Println("restart the agent for it to take effect.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdPaths() error {
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]string{
|
||||
"version": version,
|
||||
@@ -161,7 +237,7 @@ func cmdRun() error {
|
||||
// nothing - the URL was returned, logged and even exposed on the
|
||||
// desktop's status object, and never actually given to the engine.
|
||||
// A claimed shop PC published heartbeats and zero visits.
|
||||
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+hookURL)
|
||||
cmd.Env = engine.ChildEnv(hookURL)
|
||||
return cmd
|
||||
},
|
||||
LogWriter: logFile,
|
||||
@@ -203,6 +279,9 @@ func cmdRun() error {
|
||||
cloud.Upload = uploader.UploadBytes
|
||||
eng := cameras.NewEngineClient(cfg.APIBase, cfg.APIUser, cfg.APIPassword)
|
||||
go cameras.New(eng, cloud, logger).Run(ctx)
|
||||
// The live relay, which uploads nothing until somebody at head office is
|
||||
// actually watching a camera.
|
||||
go cameras.NewLive(eng, cloud, logger).Run(ctx)
|
||||
|
||||
// Before the engine starts, so the engine can be launched already knowing
|
||||
// where to post its detections.
|
||||
|
||||
@@ -106,6 +106,18 @@ func (u *SpacesUploader) UploadBytes(ctx context.Context, body []byte) (string,
|
||||
}
|
||||
|
||||
target, err := u.target(ctx)
|
||||
if errors.Is(err, ErrImagesOff) {
|
||||
// No object storage on this server. Send the bytes to the API itself,
|
||||
// which holds them for a deployment that has no bucket - the same
|
||||
// fallback camera snapshots already take, and chosen by the SENTINEL
|
||||
// rather than by matching the message, because a prose change would
|
||||
// otherwise silently stop every photo in the estate.
|
||||
//
|
||||
// Only after target() has spoken. The unclaimed case returns the same
|
||||
// sentinel from the guard at the top of this function, and a PC with no
|
||||
// credentials has no server to PUT to either.
|
||||
return u.uploadDirect(ctx, body)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -135,6 +147,54 @@ func (u *SpacesUploader) UploadBytes(ctx context.Context, body []byte) (string,
|
||||
return target.Key, nil
|
||||
}
|
||||
|
||||
// uploadDirect posts the image to our own API, for a deployment with no bucket.
|
||||
//
|
||||
// Deliberately the second choice. A presigned PUT never passes a photograph
|
||||
// through the server at all, which is what makes it the right route wherever
|
||||
// object storage exists; this one is what stops "no S3 account" from meaning
|
||||
// "no customer photo, ever" on every local install and every self-hosted site.
|
||||
//
|
||||
// The server decides where it lands and returns the key, exactly as the
|
||||
// presigned route does. That symmetry is the point: the caller cannot tell
|
||||
// which route ran, so the queued visit, the read path and erasure all stay
|
||||
// single implementations.
|
||||
func (u *SpacesUploader) uploadDirect(ctx context.Context, body []byte) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
strings.TrimRight(u.BaseURL, "/")+"/api/agent/faces", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+u.Token)
|
||||
req.Header.Set("Content-Type", "image/jpeg")
|
||||
req.ContentLength = int64(len(body))
|
||||
|
||||
resp, err := u.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("upload face: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotImplemented {
|
||||
// This server stores no images at all. Stop trying rather than retry
|
||||
// every visitor forever.
|
||||
return "", ErrImagesOff
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
return "", fmt.Errorf("upload face returned %s: %s",
|
||||
resp.Status, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
var out struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<10)).Decode(&out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out.Key == "" {
|
||||
return "", errors.New("server stored the face but named no key for it")
|
||||
}
|
||||
return out.Key, nil
|
||||
}
|
||||
|
||||
func (u *SpacesUploader) target(ctx context.Context) (uploadTarget, error) {
|
||||
var out uploadTarget
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -200,3 +201,80 @@ func TestNoUploaderMeansNoImageAndNoLeftovers(t *testing.T) {
|
||||
t.Fatal("the local image was left on disk")
|
||||
}
|
||||
}
|
||||
|
||||
// A deployment with no object storage must still get a photo onto the customer
|
||||
// record. Until the fallback existed, `images_disabled` meant every local
|
||||
// install and every self-hosted site showed no face for anybody, forever.
|
||||
func TestNoBucketFallsBackToTheServer(t *testing.T) {
|
||||
var askedURL, postedFace bool
|
||||
var gotBody []byte
|
||||
var gotAuth, gotType string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/agent/upload-url":
|
||||
askedURL = true
|
||||
// What a server with no bucket answers.
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
_, _ = w.Write([]byte(`{"error":"images_disabled"}`))
|
||||
case "/api/agent/faces":
|
||||
postedFace = true
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotType = r.Header.Get("Content-Type")
|
||||
gotBody, _ = io.ReadAll(r.Body)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"key":"db:11111111-1111-4111-8111-111111111111"}`))
|
||||
default:
|
||||
t.Errorf("unexpected request to %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token", Client: srv.Client()}
|
||||
img := []byte{0xFF, 0xD8, 0xFF, 0xE0, 'x', 'y', 'z'}
|
||||
key, err := u.UploadBytes(context.Background(), img)
|
||||
if err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
if !askedURL {
|
||||
t.Error("the presigned route must be tried first - it is the right one where a bucket exists")
|
||||
}
|
||||
if !postedFace {
|
||||
t.Fatal("no fallback upload was made")
|
||||
}
|
||||
if !strings.HasPrefix(key, "db:") {
|
||||
t.Errorf("want the server's own key, got %q", key)
|
||||
}
|
||||
if !bytes.Equal(gotBody, img) {
|
||||
t.Error("the bytes sent are not the bytes given")
|
||||
}
|
||||
if gotAuth != "Bearer agent-token" || gotType != "image/jpeg" {
|
||||
t.Errorf("auth %q type %q", gotAuth, gotType)
|
||||
}
|
||||
}
|
||||
|
||||
// A server that stores no images AT ALL must stop the agent trying, rather than
|
||||
// have it retry every visitor forever. Distinct from a failure, which is why
|
||||
// it is a sentinel and not a message.
|
||||
func TestAServerThatStoresNothingSaysSoOnce(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token", Client: srv.Client()}
|
||||
_, err := u.UploadBytes(context.Background(), []byte{0xFF, 0xD8, 0xFF, 0xE0})
|
||||
if !errors.Is(err, ErrImagesOff) {
|
||||
t.Fatalf("want ErrImagesOff so the caller stops trying, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An unclaimed PC has no server to send anything to. The fallback must not fire
|
||||
// there - it would be a request to nowhere on every single visit.
|
||||
func TestAnUnclaimedAgentDoesNotTryToUpload(t *testing.T) {
|
||||
u := &SpacesUploader{} // no BaseURL, no token
|
||||
if _, err := u.UploadBytes(context.Background(), []byte{0xFF, 0xD8}); !errors.Is(err, ErrImagesOff) {
|
||||
t.Fatalf("want ErrImagesOff, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,14 @@ type Engine interface {
|
||||
type Cloud interface {
|
||||
Desired(ctx context.Context) ([]Desired, error)
|
||||
Report(ctx context.Context, rep Report) error
|
||||
// UploadSnapshot puts a JPEG in object storage and names it. Used for the
|
||||
// placement check's proof picture, which is transient.
|
||||
UploadSnapshot(ctx context.Context, jpeg []byte) (key string, err error)
|
||||
// PutSnapshot gets a camera's latest frame to head office by whichever
|
||||
// route this deployment has - the bucket, or the server itself when there
|
||||
// is none. An empty key means the server already stored it, so the state
|
||||
// report has nothing to carry.
|
||||
PutSnapshot(ctx context.Context, cameraID string, jpeg []byte) (key string, err error)
|
||||
}
|
||||
|
||||
// Local is a camera as the engine holds it.
|
||||
@@ -48,6 +55,9 @@ type Local struct {
|
||||
|
||||
// Desired is a camera as head office holds it.
|
||||
type Desired struct {
|
||||
// ID is head office's uuid for this camera; CameraID is the name the
|
||||
// engine on this PC knows it by. The live relay translates between them.
|
||||
ID string `json:"id"`
|
||||
CameraID string `json:"camera_id"`
|
||||
Label string `json:"label"`
|
||||
Host string `json:"host"`
|
||||
@@ -252,7 +262,9 @@ func (s *Syncer) reportWith(ctx context.Context, adopt []Desired) {
|
||||
// camera is down matters far more than having a picture of it,
|
||||
// and the picture is the part most likely to fail.
|
||||
if jpeg, err := s.Engine.Snapshot(ctx, c.ID); err == nil && len(jpeg) > 0 {
|
||||
if key, err := s.Cloud.UploadSnapshot(ctx, jpeg); err == nil {
|
||||
// An empty key is not a failure: it means this deployment has
|
||||
// no object storage and the server stored the picture itself.
|
||||
if key, err := s.Cloud.PutSnapshot(ctx, c.ID, jpeg); err == nil {
|
||||
st.SnapshotKey = key
|
||||
} else {
|
||||
s.logf("camera %s: snapshot upload failed: %v", c.ID, err)
|
||||
|
||||
@@ -59,6 +59,10 @@ type fakeCloud struct {
|
||||
reports []Report
|
||||
uploads int
|
||||
uploadErr error
|
||||
// direct records the cameras whose picture went to the server itself
|
||||
// rather than to object storage.
|
||||
direct []string
|
||||
directOnly bool
|
||||
}
|
||||
|
||||
func (f *fakeCloud) Desired(context.Context) ([]Desired, error) {
|
||||
@@ -68,6 +72,20 @@ func (f *fakeCloud) Report(_ context.Context, r Report) error {
|
||||
f.reports = append(f.reports, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PutSnapshot mirrors the real client: the bucket when there is one, the
|
||||
// server itself when there is not.
|
||||
func (f *fakeCloud) PutSnapshot(ctx context.Context, cameraID string, jpeg []byte) (string, error) {
|
||||
if f.directOnly {
|
||||
if f.uploadErr != nil {
|
||||
return "", f.uploadErr
|
||||
}
|
||||
f.direct = append(f.direct, cameraID)
|
||||
return "", nil
|
||||
}
|
||||
return f.UploadSnapshot(ctx, jpeg)
|
||||
}
|
||||
|
||||
func (f *fakeCloud) UploadSnapshot(context.Context, []byte) (string, error) {
|
||||
if f.uploadErr != nil {
|
||||
return "", f.uploadErr
|
||||
@@ -239,3 +257,28 @@ func TestAnEngineThatIsNotRunningIsNotAnError(t *testing.T) {
|
||||
t.Fatal("reported state it could not have observed")
|
||||
}
|
||||
}
|
||||
|
||||
// A deployment with no object storage must still get its picture to head
|
||||
// office. Before this, the camera screen said "This system is not storing
|
||||
// images" for every camera, forever - on the one screen whose entire job is to
|
||||
// show the camera.
|
||||
func TestASnapshotStillReachesHeadOfficeWithNoObjectStorage(t *testing.T) {
|
||||
e := newEngine(Local{ID: "entrance", Connected: true})
|
||||
c := &fakeCloud{directOnly: true}
|
||||
syncer(e, c).Once(context.Background())
|
||||
|
||||
if len(c.direct) != 1 || c.direct[0] != "entrance" {
|
||||
t.Fatalf("the picture did not reach the server: %v", c.direct)
|
||||
}
|
||||
if len(c.reports) == 0 || len(c.reports[0].State) != 1 {
|
||||
t.Fatalf("no state was reported: %+v", c.reports)
|
||||
}
|
||||
// Empty, and that is the point: there is no object to name. A key here
|
||||
// would have head office try to presign a bucket it does not have.
|
||||
if key := c.reports[0].State[0].SnapshotKey; key != "" {
|
||||
t.Fatalf("the direct route reported an object key %q", key)
|
||||
}
|
||||
if !c.reports[0].State[0].Connected {
|
||||
t.Error("connected state was lost")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,16 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/bridge"
|
||||
)
|
||||
|
||||
// EngineClient talks to the recognition engine on this PC's loopback.
|
||||
@@ -95,8 +99,29 @@ func (e *EngineClient) Remove(ctx context.Context, id string) error {
|
||||
// trip. A camera that has not produced a frame yet answers 503, which is a
|
||||
// normal state on a just-added camera and not an error worth logging loudly.
|
||||
func (e *EngineClient) Snapshot(ctx context.Context, id string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
e.Base+"/api/cameras/"+url.PathEscape(id)+"/frame.jpg", nil)
|
||||
return e.Frame(ctx, id, 0, 0)
|
||||
}
|
||||
|
||||
// Frame fetches the latest frame, optionally re-encoded smaller.
|
||||
//
|
||||
// The live relay asks for ~640 px at quality 60 - about a third the bytes of
|
||||
// the full frame - because it sends several a second up a shop's uplink, where
|
||||
// the snapshot sends one a minute and can afford the detail. The engine does
|
||||
// the re-encode: it already has OpenCV open and the frame in memory, and
|
||||
// shipping a scaler into the agent to redo that would be the same work twice.
|
||||
func (e *EngineClient) Frame(ctx context.Context, id string, width, quality int) ([]byte, error) {
|
||||
q := url.Values{}
|
||||
if width > 0 {
|
||||
q.Set("width", strconv.Itoa(width))
|
||||
}
|
||||
if quality > 0 {
|
||||
q.Set("quality", strconv.Itoa(quality))
|
||||
}
|
||||
target := e.Base + "/api/cameras/" + url.PathEscape(id) + "/frame.jpg"
|
||||
if len(q) > 0 {
|
||||
target += "?" + q.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -192,6 +217,64 @@ func (c *CloudClient) UploadSnapshot(ctx context.Context, jpeg []byte) (string,
|
||||
return c.Upload(ctx, jpeg)
|
||||
}
|
||||
|
||||
// PutSnapshot gets a camera's latest frame to head office by whichever route
|
||||
// that deployment has.
|
||||
//
|
||||
// The bucket first: a presigned PUT goes straight to object storage and never
|
||||
// passes through the API, which is what makes it the right route at estate
|
||||
// scale. When there is no bucket the picture goes to the server itself, which
|
||||
// stores one row per camera. Without this second route head office reported
|
||||
// "This system is not storing images" for every camera forever, on the screen
|
||||
// whose entire job is to show the camera.
|
||||
//
|
||||
// The returned key is empty for the direct route - there is no object to name -
|
||||
// and the server records the picture as it stores it, so the state report has
|
||||
// nothing to carry.
|
||||
func (c *CloudClient) PutSnapshot(ctx context.Context, cameraID string, jpeg []byte) (string, error) {
|
||||
if c.Upload != nil {
|
||||
key, err := c.Upload(ctx, jpeg)
|
||||
if err == nil {
|
||||
return key, nil
|
||||
}
|
||||
// A bucket that is configured here but disabled at the server is the
|
||||
// ordinary case on a self-hosted install: fall through rather than
|
||||
// giving up, and let the direct route decide.
|
||||
if !isImagesDisabled(err) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", c.putSnapshotDirect(ctx, cameraID, jpeg)
|
||||
}
|
||||
|
||||
func (c *CloudClient) putSnapshotDirect(ctx context.Context, cameraID string, jpeg []byte) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut,
|
||||
c.Base+"/api/agent/cameras/"+url.PathEscape(cameraID)+"/snapshot",
|
||||
bytes.NewReader(jpeg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
req.Header.Set("Content-Type", "image/jpeg")
|
||||
resp, err := c.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("head office: %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isImagesDisabled recognises the server saying it has no object storage.
|
||||
//
|
||||
// A sentinel, not a string match on the message: this decides whether to take a
|
||||
// completely different route, and getting it wrong from prose that somebody
|
||||
// later rewords would silently stop every camera picture in the estate.
|
||||
func isImagesDisabled(err error) bool {
|
||||
return errors.Is(err, bridge.ErrImagesOff)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- probing
|
||||
|
||||
// Test opens the candidate stream once, without saving it.
|
||||
|
||||
243
agent/pkg/cameras/live.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package cameras
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Live relays camera frames to head office, but only while somebody is
|
||||
// watching.
|
||||
//
|
||||
// The engine serves MJPEG on this PC's loopback and this PC sits behind a
|
||||
// router with no inbound route, so head office cannot pull it. It can answer
|
||||
// our outbound requests, which is the shape of everything else here: we ask
|
||||
// "is anyone watching?", and push frames for as long as the answer is yes.
|
||||
//
|
||||
// It is a few frames a second of re-encoded JPEG, not 25 fps video. True video
|
||||
// needs WebRTC and a TURN server; this needs neither, and answers the question
|
||||
// somebody at head office is actually asking - what does that camera see right
|
||||
// now - at a cost a shop's uplink can carry.
|
||||
//
|
||||
// **Nothing is uploaded when nobody is looking.** That is the entire cost
|
||||
// argument, and it is why the wanted-check comes first and the push stops the
|
||||
// moment the server says the last viewer has gone.
|
||||
type Live struct {
|
||||
Engine *EngineClient
|
||||
Cloud *CloudClient
|
||||
Log *log.Logger
|
||||
FPS float64
|
||||
Width int
|
||||
Quality int
|
||||
pollDelay time.Duration
|
||||
}
|
||||
|
||||
// Defaults, measured against the office camera rather than guessed.
|
||||
//
|
||||
// The engine produces ~12 distinct frames a second, so asking for more than
|
||||
// that only re-sends pictures the viewer already has - which is why the poll
|
||||
// runs slightly ahead of it and identical frames are dropped rather than sent.
|
||||
// 640 px at quality 60 is ~20 KB, so a watcher costs ~200 KB/s at the full
|
||||
// rate, and a camera nobody is watching costs nothing at all.
|
||||
const (
|
||||
DefaultLiveFPS = 15.0
|
||||
DefaultLiveWidth = 640
|
||||
DefaultLiveQuality = 60
|
||||
)
|
||||
|
||||
func NewLive(eng *EngineClient, cloud *CloudClient, logger *log.Logger) *Live {
|
||||
return &Live{Engine: eng, Cloud: cloud, Log: logger,
|
||||
FPS: DefaultLiveFPS, Width: DefaultLiveWidth, Quality: DefaultLiveQuality}
|
||||
}
|
||||
|
||||
// Run waits for viewers and serves them until the context ends.
|
||||
func (l *Live) Run(ctx context.Context) {
|
||||
if l.Engine == nil || l.Cloud == nil {
|
||||
return
|
||||
}
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
wanted, err := l.Cloud.LiveWanted(ctx)
|
||||
if err != nil {
|
||||
// Unclaimed, offline, or head office is down. All three mean the
|
||||
// same thing here - nobody can be watching - so back off rather
|
||||
// than hammering, and keep the shop's own recognition untouched.
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
l.sleep(ctx, 15*time.Second)
|
||||
continue
|
||||
}
|
||||
if len(wanted) == 0 {
|
||||
// The poll is held open by the server, so an empty answer already
|
||||
// means ~25 s passed. No extra delay.
|
||||
continue
|
||||
}
|
||||
for _, id := range wanted {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
l.serve(ctx, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serve pushes frames for one camera until the server says stop.
|
||||
func (l *Live) serve(ctx context.Context, cameraID string) {
|
||||
engineID, err := l.Cloud.LiveEngineID(ctx, cameraID)
|
||||
if err != nil {
|
||||
l.logf("live %s: %v", cameraID, err)
|
||||
l.sleep(ctx, 2*time.Second)
|
||||
return
|
||||
}
|
||||
interval := time.Duration(float64(time.Second) / l.fps())
|
||||
|
||||
// A pipe so frames can be written as they are grabbed while one request
|
||||
// carries all of them. A request per frame would spend more on handshakes
|
||||
// and headers than on pictures.
|
||||
pr, pw := io.Pipe()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- l.Cloud.PushLive(ctx, cameraID, pr) }()
|
||||
|
||||
tick := time.NewTicker(interval)
|
||||
defer tick.Stop()
|
||||
// The engine re-serves its latest frame until the pipeline produces a new
|
||||
// one, so polling faster than it encodes returns the SAME picture again.
|
||||
// Measured: 93 polls in 6 s yielded 72 distinct frames. Sending the
|
||||
// duplicates would cost a fifth of the bandwidth for nothing, so the poll
|
||||
// runs a little ahead of the engine and the repeats are dropped - which is
|
||||
// what lets the rate follow the camera instead of a guess.
|
||||
var lastSum [32]byte
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = pw.CloseWithError(context.Canceled)
|
||||
<-done
|
||||
return
|
||||
case err := <-done:
|
||||
// The server closed the request: the last viewer went away, or the
|
||||
// session cap was reached. Either way stop grabbing frames.
|
||||
_ = pw.Close()
|
||||
if err != nil {
|
||||
l.logf("live %s ended: %v", cameraID, err)
|
||||
}
|
||||
return
|
||||
case <-tick.C:
|
||||
}
|
||||
jpeg, err := l.Engine.Frame(ctx, engineID, l.Width, l.Quality)
|
||||
if err != nil || len(jpeg) == 0 {
|
||||
// A camera that is reconnecting has no frame. Keep the request
|
||||
// open - the viewer sees the last frame rather than a dropped
|
||||
// stream, and the next tick may well have one.
|
||||
continue
|
||||
}
|
||||
if sum := sha256.Sum256(jpeg); sum == lastSum {
|
||||
continue
|
||||
} else {
|
||||
lastSum = sum
|
||||
}
|
||||
var hdr [4]byte
|
||||
binary.BigEndian.PutUint32(hdr[:], uint32(len(jpeg)))
|
||||
if _, err := pw.Write(hdr[:]); err != nil {
|
||||
<-done
|
||||
return
|
||||
}
|
||||
if _, err := pw.Write(jpeg); err != nil {
|
||||
<-done
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Live) fps() float64 {
|
||||
if l.FPS <= 0 || l.FPS > 25 {
|
||||
// A ceiling rather than a target: duplicate frames are dropped, so
|
||||
// polling above what the engine encodes costs requests and no
|
||||
// bandwidth - but it is still work, on the PC doing the recognition.
|
||||
return DefaultLiveFPS
|
||||
}
|
||||
return l.FPS
|
||||
}
|
||||
|
||||
func (l *Live) sleep(ctx context.Context, d time.Duration) {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Live) logf(format string, args ...any) {
|
||||
if l.Log != nil {
|
||||
l.Log.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ wire --
|
||||
|
||||
// LiveWanted asks head office which of this site's cameras are being watched.
|
||||
// The server holds the request open, so this returns promptly when somebody
|
||||
// presses Live and after ~25 s when nobody has.
|
||||
func (c *CloudClient) LiveWanted(ctx context.Context) ([]string, error) {
|
||||
var body struct {
|
||||
Cameras []string `json:"cameras"`
|
||||
}
|
||||
// Longer than the server's own wait, so a held request is not cut off by
|
||||
// our own client timeout and reported as a failure.
|
||||
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
if err := c.do(ctx, http.MethodGet, "/api/agent/live", nil, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return body.Cameras, nil
|
||||
}
|
||||
|
||||
// LiveEngineID maps head office's camera uuid to the name the engine knows,
|
||||
// which is the only name this PC can ask for a frame with.
|
||||
func (c *CloudClient) LiveEngineID(ctx context.Context, cameraID string) (string, error) {
|
||||
desired, err := c.Desired(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, d := range desired {
|
||||
if d.ID == cameraID {
|
||||
return d.CameraID, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("camera %s is not one of this site's", cameraID)
|
||||
}
|
||||
|
||||
// PushLive streams frames until the server stops reading.
|
||||
func (c *CloudClient) PushLive(ctx context.Context, cameraID string, body io.Reader) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.Base+"/api/agent/cameras/"+cameraID+"/live", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
resp, err := c.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
blob, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("head office: %s: %s", resp.Status, bytes.TrimSpace(blob))
|
||||
}
|
||||
var out struct {
|
||||
Frames int `json:"frames"`
|
||||
}
|
||||
_ = json.Unmarshal(blob, &out)
|
||||
return nil
|
||||
}
|
||||
114
agent/pkg/demo/bundle.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Package demo seals a camera list so a release can carry it without carrying
|
||||
// the credentials in any usable form.
|
||||
//
|
||||
// The need: a demo build that installs with the office cameras already set up,
|
||||
// handed to people who should not be able to read the cameras' admin password
|
||||
// out of the zip. "Encode it" does not do that - anything the installer can
|
||||
// decode, anyone holding the installer can decode. So the bundle is encrypted
|
||||
// with a key that is NOT in the package: a short unlock code, generated when
|
||||
// the bundle is sealed, spoken or messaged to whoever runs setup, and typed
|
||||
// once. Without it the file is noise.
|
||||
//
|
||||
// The code is random, not chosen, so it is used as key material directly
|
||||
// (through SHA-256) rather than stretched with a KDF. A human-chosen
|
||||
// passphrase would need argon2 and a dependency; 120 random bits do not.
|
||||
package demo
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Magic identifies the file and the format version, so a future change can be
|
||||
// told apart from corruption instead of failing as "authentication failed".
|
||||
const magic = "BVDEMO1\n"
|
||||
|
||||
// Camera is one entry as the engine's Add Camera endpoint accepts it.
|
||||
type Camera struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Path string `json:"path"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
MaxWidth int `json:"max_width,omitempty"`
|
||||
}
|
||||
|
||||
// NewCode mints an unlock code: 15 random bytes as 24 base32 characters in
|
||||
// four groups, the same shape as an installation code, for the same reason -
|
||||
// it gets read down a phone.
|
||||
func NewCode() (string, error) {
|
||||
raw := make([]byte, 15)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw)
|
||||
return fmt.Sprintf("%s-%s-%s-%s", s[0:6], s[6:12], s[12:18], s[18:24]), nil
|
||||
}
|
||||
|
||||
// NormalizeCode makes the typed and the printed form hash the same: case,
|
||||
// spaces and dashes are all noise a person adds or drops.
|
||||
func NormalizeCode(code string) string {
|
||||
code = strings.ToUpper(code)
|
||||
code = strings.NewReplacer("-", "", " ", "", "\t", "", "\r", "", "\n", "").Replace(code)
|
||||
return code
|
||||
}
|
||||
|
||||
func keyFor(code string) []byte {
|
||||
sum := sha256.Sum256([]byte("behavision-demo-bundle:" + NormalizeCode(code)))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// Seal encrypts plaintext under the code. Output is magic || nonce || ciphertext.
|
||||
func Seal(code string, plaintext []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(keyFor(code))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := append([]byte(magic), nonce...)
|
||||
return gcm.Seal(out, nonce, plaintext, []byte(magic)), nil
|
||||
}
|
||||
|
||||
// ErrWrongCode is what a mistyped code looks like. GCM cannot tell a wrong key
|
||||
// from a corrupted file, and neither can we, so both read as this.
|
||||
var ErrWrongCode = errors.New("that unlock code does not open this bundle")
|
||||
|
||||
// Open decrypts a sealed bundle.
|
||||
func Open(code string, sealed []byte) ([]byte, error) {
|
||||
if !strings.HasPrefix(string(sealed), magic) {
|
||||
return nil, errors.New("not a Behavision demo bundle")
|
||||
}
|
||||
body := sealed[len(magic):]
|
||||
block, err := aes.NewCipher(keyFor(code))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) < gcm.NonceSize() {
|
||||
return nil, errors.New("bundle is truncated")
|
||||
}
|
||||
nonce, ct := body[:gcm.NonceSize()], body[gcm.NonceSize():]
|
||||
plain, err := gcm.Open(nil, nonce, ct, []byte(magic))
|
||||
if err != nil {
|
||||
return nil, ErrWrongCode
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
87
agent/pkg/demo/bundle_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package demo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSealedBundleRoundTripsWithTheCodeAsTyped(t *testing.T) {
|
||||
code, err := NewCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(NormalizeCode(code)) != 24 {
|
||||
t.Fatalf("code should be 24 base32 chars, got %q", code)
|
||||
}
|
||||
secret := []byte(`[{"id":"cam1","password":"the-camera-admin-password"}]`)
|
||||
|
||||
sealed, err := Seal(code, secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// People type codes in lower case, with the dashes dropped, with a space
|
||||
// where a dash was. All of those are the same code.
|
||||
for _, typed := range []string{
|
||||
code,
|
||||
strings.ToLower(code),
|
||||
strings.ReplaceAll(code, "-", ""),
|
||||
strings.ReplaceAll(code, "-", " "),
|
||||
" " + code + "\n",
|
||||
} {
|
||||
got, err := Open(typed, sealed)
|
||||
if err != nil {
|
||||
t.Fatalf("open with %q: %v", typed, err)
|
||||
}
|
||||
if !bytes.Equal(got, secret) {
|
||||
t.Fatalf("round trip changed the contents")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of the file: the password is not in it.
|
||||
func TestTheSealedFileDoesNotContainTheSecret(t *testing.T) {
|
||||
code, _ := NewCode()
|
||||
sealed, _ := Seal(code, []byte(`{"password":"the-camera-admin-password","host":"192.168.1.121"}`))
|
||||
for _, leak := range []string{"the-camera-admin-password", "192.168.1.121", "password"} {
|
||||
if bytes.Contains(sealed, []byte(leak)) {
|
||||
t.Fatalf("sealed bundle contains %q in the clear", leak)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWrongCodeIsRefusedNotMisread(t *testing.T) {
|
||||
code, _ := NewCode()
|
||||
other, _ := NewCode()
|
||||
sealed, _ := Seal(code, []byte("secret"))
|
||||
|
||||
if _, err := Open(other, sealed); !errors.Is(err, ErrWrongCode) {
|
||||
t.Fatalf("a different code should be ErrWrongCode, got %v", err)
|
||||
}
|
||||
// One flipped byte in the ciphertext is the same answer: GCM refuses
|
||||
// rather than returning garbage that then gets written into cameras.json.
|
||||
tampered := append([]byte{}, sealed...)
|
||||
tampered[len(tampered)-1] ^= 0x01
|
||||
if _, err := Open(code, tampered); !errors.Is(err, ErrWrongCode) {
|
||||
t.Fatalf("a tampered bundle should be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSomethingThatIsNotABundleSaysSo(t *testing.T) {
|
||||
if _, err := Open("ABCDEF-GHIJKL-MNOPQR-STUVWX", []byte("hello")); err == nil ||
|
||||
errors.Is(err, ErrWrongCode) {
|
||||
t.Fatalf("a non-bundle should be named as such, not blamed on the code: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Two seals of the same plaintext under the same code must differ: a fixed
|
||||
// nonce would let two releases' bundles be compared byte for byte.
|
||||
func TestEverySealIsDifferent(t *testing.T) {
|
||||
code, _ := NewCode()
|
||||
a, _ := Seal(code, []byte("same"))
|
||||
b, _ := Seal(code, []byte("same"))
|
||||
if bytes.Equal(a, b) {
|
||||
t.Fatal("nonce is not random")
|
||||
}
|
||||
}
|
||||
41
agent/pkg/engine/env.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||
)
|
||||
|
||||
// ChildEnv is the environment the engine is launched with, wherever it is
|
||||
// launched from - the desktop app and the headless agent both go through
|
||||
// here, so a third caller cannot get it half right.
|
||||
//
|
||||
// The line that matters is BEHAVISION_DATA_DIR.
|
||||
//
|
||||
// The engine's paths.py knows two worlds: frozen with PyInstaller, where state
|
||||
// lives under ProgramData, and a checkout, where everything sits in the repo
|
||||
// root. An engine installed from source into a virtual environment is neither.
|
||||
// Left to itself it resolves its state root to site-packages - writes its
|
||||
// database and camera list there, and generates its API credential into a
|
||||
// folder this process never reads - while this process resolves the same
|
||||
// state root to ProgramData. The two halves then disagree about where
|
||||
// everything lives, and every call to the engine is 401 on a stock install,
|
||||
// with nothing in either log saying why. Seen twice: once on a Mac checkout
|
||||
// (the app in ~/Library, the engine in the repo) and once in a clean Linux
|
||||
// container running the installer.
|
||||
//
|
||||
// Telling the engine where THIS process keeps state makes the two agree by
|
||||
// construction, however the engine was installed. paths.py honours the
|
||||
// override ahead of every other rule it has.
|
||||
//
|
||||
// hookURL is where the engine posts detections; empty is allowed and means
|
||||
// the bridge has not started, which the engine treats as "no webhook".
|
||||
func ChildEnv(hookURL string) []string {
|
||||
env := append(os.Environ(),
|
||||
"BEHAVISION_DATA_DIR="+paths.StateRoot(),
|
||||
)
|
||||
if hookURL != "" {
|
||||
env = append(env, "BEHAVISION_WEBHOOK_URL="+hookURL)
|
||||
}
|
||||
return env
|
||||
}
|
||||
44
agent/pkg/engine/env_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||
)
|
||||
|
||||
// The engine must be told where THIS process keeps state, or a pip-installed
|
||||
// engine decides on site-packages and the two halves never find each other.
|
||||
func TestTheEngineIsToldWhereStateLives(t *testing.T) {
|
||||
t.Setenv("BEHAVISION_DATA_DIR", t.TempDir())
|
||||
|
||||
env := ChildEnv("http://127.0.0.1:5555/events")
|
||||
|
||||
want := "BEHAVISION_DATA_DIR=" + paths.StateRoot()
|
||||
if !contains(env, want) {
|
||||
t.Fatalf("engine env lacks %q - a source-installed engine would put its "+
|
||||
"database and credential somewhere this process never looks", want)
|
||||
}
|
||||
if !contains(env, "BEHAVISION_WEBHOOK_URL=http://127.0.0.1:5555/events") {
|
||||
t.Fatal("webhook url not passed to the engine")
|
||||
}
|
||||
}
|
||||
|
||||
// Before the bridge has a port there is no webhook. An empty variable would be
|
||||
// read by the engine as a webhook at "", which is not the same as none.
|
||||
func TestNoWebhookMeansNoVariable(t *testing.T) {
|
||||
for _, v := range ChildEnv("") {
|
||||
if strings.HasPrefix(v, "BEHAVISION_WEBHOOK_URL=") {
|
||||
t.Fatalf("empty hook still exported: %q", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(env []string, want string) bool {
|
||||
for _, v := range env {
|
||||
if v == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -194,9 +194,29 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
cmd.Stderr = cmd.Stdout
|
||||
// Cancel ends the whole process tree, not just the process exec spawned.
|
||||
// `kill` is filled in after Start, once the tree is confined; until then
|
||||
// it is exec's own behaviour.
|
||||
var kill func() error
|
||||
cmd.Cancel = func() error {
|
||||
if kill == nil {
|
||||
return cmd.Process.Kill()
|
||||
}
|
||||
return kill()
|
||||
}
|
||||
prepare(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("engine failed to start: %w", err)
|
||||
}
|
||||
k, release, err := confine(cmd)
|
||||
if err != nil {
|
||||
// Not fatal: the engine runs, and stopping it falls back to killing
|
||||
// the one process. Logged because on Windows that fallback is the
|
||||
// bug this exists to fix.
|
||||
fmt.Fprintf(s.opts.LogWriter, "supervisor: could not confine engine process tree: %v\n", err)
|
||||
}
|
||||
kill = k
|
||||
defer release()
|
||||
|
||||
pumped := make(chan struct{})
|
||||
go func() {
|
||||
|
||||
13
agent/pkg/engine/tree_other.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package engine
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// On every other platform the engine is one process and exec's own kill is
|
||||
// enough. See tree_windows.go for why Windows is not.
|
||||
func prepare(*exec.Cmd) {}
|
||||
|
||||
func confine(cmd *exec.Cmd) (kill func() error, release func(), err error) {
|
||||
return cmd.Process.Kill, func() {}, nil
|
||||
}
|
||||
111
agent/pkg/engine/tree_windows.go
Normal file
@@ -0,0 +1,111 @@
|
||||
//go:build windows
|
||||
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// The engine is not one process on Windows, and stopping it used to leave
|
||||
// recognition running.
|
||||
//
|
||||
// The installer starts it as `<venv>\Scripts\python.exe -m behavision run`.
|
||||
// Since Python 3.7.2 that python.exe is a REDIRECTOR: a small launcher that
|
||||
// spawns the base interpreter as a child and waits for it. Stop() cancelled the
|
||||
// context, exec terminated the launcher, and the interpreter that actually
|
||||
// holds the cameras and the SQLite WAL carried on with no parent, no tray icon
|
||||
// and nothing left that could stop it. Seen on a Windows install: "Quit
|
||||
// Behavision" from the tray, and the engine still running.
|
||||
//
|
||||
// The fix is the primitive Windows has for exactly this: a job object with
|
||||
// KILL_ON_JOB_CLOSE. Every process the engine spawns inherits membership, and
|
||||
// the whole tree dies when the job is terminated or when this process's last
|
||||
// handle to it goes away - so "quitting the app stops recognition" holds even
|
||||
// if the app crashes, which no amount of careful Stop() code can promise.
|
||||
//
|
||||
// The child is started SUSPENDED and resumed only after it is in the job.
|
||||
// Assigning after the fact leaves a window in which the launcher has already
|
||||
// spawned the interpreter outside it, and that window is precisely the case
|
||||
// this file exists to close.
|
||||
|
||||
// prepare is applied to the command before it starts.
|
||||
func prepare(cmd *exec.Cmd) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
// CREATE_NO_WINDOW: python.exe is a console program and Behavision.exe is
|
||||
// not, so without this Windows opens a black console window for the
|
||||
// engine on a shop counter - the app looks like it has crashed into a
|
||||
// terminal. Output still arrives on the pipes.
|
||||
cmd.SysProcAttr.CreationFlags |= windows.CREATE_SUSPENDED | windows.CREATE_NO_WINDOW
|
||||
}
|
||||
|
||||
// confine is applied after Start. It puts the process in a kill-on-close job,
|
||||
// then resumes it. It returns a function that ends the whole tree, and one
|
||||
// that releases the job handle once the tree has exited.
|
||||
//
|
||||
// If the job cannot be set up the process is still resumed and the plain
|
||||
// terminate remains: a suspended engine that never runs is strictly worse
|
||||
// than one that may outlive its parent.
|
||||
func confine(cmd *exec.Cmd) (kill func() error, release func(), err error) {
|
||||
pid := uint32(cmd.Process.Pid)
|
||||
defer resumeProcess(pid)
|
||||
|
||||
kill = cmd.Process.Kill
|
||||
release = func() {}
|
||||
|
||||
job, err := windows.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
return kill, release, fmt.Errorf("create job object: %w", err)
|
||||
}
|
||||
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
|
||||
info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||
if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation,
|
||||
uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return kill, release, fmt.Errorf("configure job object: %w", err)
|
||||
}
|
||||
proc, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, pid)
|
||||
if err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return kill, release, fmt.Errorf("open engine process: %w", err)
|
||||
}
|
||||
defer windows.CloseHandle(proc)
|
||||
if err := windows.AssignProcessToJobObject(job, proc); err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return kill, release, fmt.Errorf("assign engine to job: %w", err)
|
||||
}
|
||||
|
||||
kill = func() error { return windows.TerminateJobObject(job, 1) }
|
||||
release = func() { windows.CloseHandle(job) }
|
||||
return kill, release, nil
|
||||
}
|
||||
|
||||
// resumeProcess resumes every thread of a process started CREATE_SUSPENDED.
|
||||
// exec does not hand back the main thread handle, so it is found through the
|
||||
// toolhelp snapshot; a suspended new process has exactly one.
|
||||
func resumeProcess(pid uint32) {
|
||||
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer windows.CloseHandle(snap)
|
||||
var te windows.ThreadEntry32
|
||||
te.Size = uint32(unsafe.Sizeof(te))
|
||||
for err = windows.Thread32First(snap, &te); err == nil; err = windows.Thread32Next(snap, &te) {
|
||||
if te.OwnerProcessID != pid {
|
||||
continue
|
||||
}
|
||||
h, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, te.ThreadID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
windows.ResumeThread(h)
|
||||
windows.CloseHandle(h)
|
||||
}
|
||||
}
|
||||
113
agent/pkg/enrol/enrol.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Package enrol links a PC to a shop, using the one-shot code an operator is
|
||||
// given.
|
||||
//
|
||||
// It existed only inside the desktop app, which meant a HEADLESS install - a
|
||||
// back-office PC with no window, the configuration the agent binary is for -
|
||||
// could not be claimed at all. The only route was hand-editing agent.json,
|
||||
// which is exactly the state the desktop's Setup screen was built to end.
|
||||
//
|
||||
// The endpoint behind this is deliberately unauthenticated: the PC doing it has
|
||||
// nobody signed in yet, and requiring a login would mean shipping a password to
|
||||
// every shop that installs the software.
|
||||
package enrol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bootstrap is what the server hands back: which shop this PC is, and the
|
||||
// credentials it needs to say so.
|
||||
type Bootstrap struct {
|
||||
ClientSlug string `json:"client_slug"`
|
||||
SiteSlug string `json:"site_slug"`
|
||||
SiteName string `json:"site_name"`
|
||||
MQTTURL string `json:"mqtt_url"`
|
||||
MQTTUser string `json:"mqtt_username"`
|
||||
MQTTPass string `json:"mqtt_password"`
|
||||
CACert string `json:"ca_cert,omitempty"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
|
||||
// Claim redeems an installation code.
|
||||
//
|
||||
// The code is read aloud down a phone and photographed off screens, so what is
|
||||
// typed here can be as untidy as it needs to be: the server strips spaces,
|
||||
// dashes and case at its end. Sending it as typed keeps ONE implementation of
|
||||
// that normalisation, on the side that also issued the code - two would
|
||||
// eventually disagree and hash to something the redeemer never produces.
|
||||
func Claim(ctx context.Context, base, code string) (Bootstrap, error) {
|
||||
var out Bootstrap
|
||||
base = strings.TrimRight(base, "/")
|
||||
if base == "" {
|
||||
return out, fmt.Errorf("no server address configured (set cloud_base or BEHAVISION_CLOUD)")
|
||||
}
|
||||
body, err := json.Marshal(map[string]string{"site_token": code})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
base+"/api/agent/enrol", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("could not reach %s: %w", base, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
blob, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// The server answers unknown, expired and already-used identically on
|
||||
// purpose - the difference only helps somebody guessing codes, and the
|
||||
// operator's next step is the same in all three cases. Its own words
|
||||
// are passed through rather than reworded here.
|
||||
var e struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(blob, &e)
|
||||
if e.Message != "" {
|
||||
return out, fmt.Errorf("%s", e.Message)
|
||||
}
|
||||
return out, fmt.Errorf("head office: %s", resp.Status)
|
||||
}
|
||||
if err := json.Unmarshal(blob, &out); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if out.SiteSlug == "" || out.MQTTURL == "" {
|
||||
return out, fmt.Errorf("head office returned an incomplete setup")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveCA writes the broker's CA beside the agent config and returns its path.
|
||||
//
|
||||
// The server hands the CA out at enrolment precisely so it never has to be
|
||||
// shipped in an installer - and for a while nothing on the receiving end
|
||||
// wrote it anywhere. Every claimed PC then dialled tls://mcp.loyaly.ai:8883
|
||||
// with the system trust store, the private CA failed verification, and the
|
||||
// agent reported "the broker did not accept this PC" (a TLS failure is
|
||||
// indistinguishable from a refusal at that layer). No real site could ever
|
||||
// publish a visit. An empty CA returns "" so a deployment on a public
|
||||
// certificate keeps working unchanged.
|
||||
func SaveCA(pem, path string) (string, error) {
|
||||
if strings.TrimSpace(pem) == "" {
|
||||
return "", nil
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(pem), 0o600); err != nil {
|
||||
return "", fmt.Errorf("write broker CA: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/url"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -104,7 +106,13 @@ func NewClient(opts ClientOptions) (*Client, error) {
|
||||
|
||||
tok := c.client.Connect()
|
||||
if !tok.WaitTimeout(20 * time.Second) {
|
||||
return c, fmt.Errorf("mqtt: connect to %s timed out", opts.BrokerURL)
|
||||
// SetConnectRetry means paho retries internally and this token never
|
||||
// completes, so a REFUSED connection and an UNREACHABLE broker both
|
||||
// arrive here as a timeout. They need opposite actions - re-link this
|
||||
// PC, or go and look at the network - and reporting both as "timed
|
||||
// out" sent the diagnosis to the wrong place. Measured: mosquitto
|
||||
// logged "not authorised" while the agent logged a timeout.
|
||||
return c, fmt.Errorf("mqtt: %s", describeStall(opts.BrokerURL))
|
||||
}
|
||||
if err := tok.Error(); err != nil {
|
||||
return c, fmt.Errorf("mqtt: connect to %s: %w", opts.BrokerURL, err)
|
||||
@@ -112,6 +120,47 @@ func NewClient(opts ClientOptions) (*Client, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// describeStall says which of the two failures this is, by asking the one
|
||||
// question that separates them: can we open a socket to the broker at all?
|
||||
//
|
||||
// It cannot name the exact reason - the broker does not tell a rejected client
|
||||
// why, and a TLS failure looks the same from here - so it says what is known
|
||||
// and what to check, rather than guessing. Being reachable but not accepted is
|
||||
// overwhelmingly a credential this PC no longer has, which is what happens when
|
||||
// a site is re-provisioned.
|
||||
func describeStall(brokerURL string) string {
|
||||
host := brokerHostPort(brokerURL)
|
||||
if host == "" {
|
||||
return fmt.Sprintf("connect to %s timed out", brokerURL)
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", host, 5*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("cannot reach the broker at %s: %v - check the "+
|
||||
"network and that the broker is running", host, err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
return fmt.Sprintf("the broker at %s is reachable but did not accept this "+
|
||||
"PC - usually its credentials are no longer valid; re-link it with "+
|
||||
"`behavision-agent claim <code>`", host)
|
||||
}
|
||||
|
||||
// brokerHostPort extracts host:port for the reachability probe. Parsed with
|
||||
// net/url, never by scanning for the first ":" - an IPv6 literal is bracketed
|
||||
// and full of them.
|
||||
func brokerHostPort(brokerURL string) string {
|
||||
u, err := url.Parse(brokerURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
if u.Port() != "" {
|
||||
return u.Host
|
||||
}
|
||||
if strings.HasPrefix(brokerURL, "tls://") || strings.HasPrefix(brokerURL, "ssl://") {
|
||||
return net.JoinHostPort(u.Hostname(), "8883")
|
||||
}
|
||||
return net.JoinHostPort(u.Hostname(), "1883")
|
||||
}
|
||||
|
||||
// Publish sends one message at QoS 1 and waits for the broker's PUBACK.
|
||||
//
|
||||
// QoS 1, not 0 or 2. At QoS 0 the broker never confirms, so the pump would ack
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -102,3 +103,66 @@ func TestPublishOnADeadClientErrorsRatherThanPanics(t *testing.T) {
|
||||
func writeFile(path, content string) error {
|
||||
return osWriteFile(path, []byte(content), 0o600)
|
||||
}
|
||||
|
||||
// "The broker refused this PC" and "the broker is not there" need opposite
|
||||
// actions - re-link this PC, or go and look at the network - and paho's
|
||||
// connect-retry makes both arrive as a timeout. Measured on a real broker:
|
||||
// mosquitto logged "not authorised" while the agent logged a timeout, which
|
||||
// sent the diagnosis to the wrong place.
|
||||
func TestARefusedBrokerIsNotDescribedAsUnreachable(t *testing.T) {
|
||||
// A listener that accepts TCP and then says nothing is exactly what a
|
||||
// broker rejecting a client looks like from out here.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = c
|
||||
}
|
||||
}()
|
||||
|
||||
got := describeStall("tcp://" + ln.Addr().String())
|
||||
if !strings.Contains(got, "reachable but did not accept") {
|
||||
t.Fatalf("a reachable broker was described as unreachable: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "claim") {
|
||||
t.Errorf("the message does not say what to do about it: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAbsentBrokerIsDescribedAsUnreachable(t *testing.T) {
|
||||
// Bound and immediately closed, so the port is certainly nobody's.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
ln.Close()
|
||||
|
||||
got := describeStall("tcp://" + addr)
|
||||
if !strings.Contains(got, "cannot reach the broker") {
|
||||
t.Fatalf("an absent broker was not described as unreachable: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An IPv6 literal is bracketed and full of colons, so scanning for the first
|
||||
// one gives "[". The same bug this package already fixed once for broker URLs.
|
||||
func TestTheProbeAddressHandlesIPv6AndDefaultPorts(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"tcp://127.0.0.1:51883", "127.0.0.1:51883"},
|
||||
{"tcp://[::1]:1883", "[::1]:1883"},
|
||||
{"tcp://broker.example", "broker.example:1883"},
|
||||
{"tls://broker.example", "broker.example:8883"},
|
||||
{"tls://[2001:db8::1]:8884", "[2001:db8::1]:8884"},
|
||||
} {
|
||||
if got := brokerHostPort(tc.in); got != tc.want {
|
||||
t.Errorf("brokerHostPort(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,8 +57,11 @@ func InstallRoot() string {
|
||||
}
|
||||
|
||||
func AgentConfig() string { return filepath.Join(StateRoot(), "agent.json") }
|
||||
func SpoolDir() string { return filepath.Join(StateRoot(), "spool") }
|
||||
func EngineLog() string { return filepath.Join(StateRoot(), "engine.log") }
|
||||
|
||||
// BrokerCA is the broker's CA certificate, written at enrolment.
|
||||
func BrokerCA() string { return filepath.Join(StateRoot(), "broker-ca.crt") }
|
||||
func SpoolDir() string { return filepath.Join(StateRoot(), "spool") }
|
||||
func EngineLog() string { return filepath.Join(StateRoot(), "engine.log") }
|
||||
|
||||
// APICredentials is the file the engine writes when it generates its own
|
||||
// Basic credentials. The agent reads it rather than storing a second copy,
|
||||
|
||||
BIN
agent/rsrc_windows_amd64.syso
Normal file
@@ -6,6 +6,7 @@ models finish loading without a single unguarded None dereference.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import logging
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
@@ -111,6 +112,35 @@ def _auth_dependencies(api_cfg: ApiSection) -> list:
|
||||
return [Depends(check)]
|
||||
|
||||
|
||||
def _reencode(jpeg: bytes, width: int, quality: int) -> "bytes | None":
|
||||
"""Decode, scale and re-encode one frame. None on any failure.
|
||||
|
||||
None rather than an exception on purpose: the caller falls back to the
|
||||
original frame, so a re-encode that fails costs bandwidth rather than the
|
||||
picture. A live view that goes blank because a resize failed is a worse
|
||||
outcome than one that is briefly larger than asked for.
|
||||
"""
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
img = cv2.imdecode(np.frombuffer(jpeg, np.uint8), cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
return None
|
||||
if 0 < width < img.shape[1]:
|
||||
# Only ever DOWN. Upscaling a frame to a requested width would send
|
||||
# more bytes than the original for no more detail.
|
||||
scale = width / img.shape[1]
|
||||
img = cv2.resize(img, (width, max(1, int(img.shape[0] * scale))),
|
||||
interpolation=cv2.INTER_AREA)
|
||||
q = quality if 1 <= quality <= 100 else 75
|
||||
ok, buf = cv2.imencode(".jpg", img, [int(cv2.IMWRITE_JPEG_QUALITY), q])
|
||||
return buf.tobytes() if ok else None
|
||||
except Exception:
|
||||
log.debug("frame re-encode failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def create_app(engine: Engine) -> FastAPI:
|
||||
app = FastAPI(title="Behavision", version="1.0.0",
|
||||
dependencies=_auth_dependencies(engine.cfg.api))
|
||||
@@ -125,6 +155,15 @@ def create_app(engine: Engine) -> FastAPI:
|
||||
def dashboard() -> str:
|
||||
return (_STATIC / "dashboard.html").read_text(encoding="utf-8")
|
||||
|
||||
@app.get("/static/favicon.png")
|
||||
def favicon() -> Response:
|
||||
# The one static asset besides the page itself. Served explicitly
|
||||
# rather than mounting the directory: nothing else in there is meant
|
||||
# to be reachable, and a mount would make that a matter of what lands
|
||||
# in the folder.
|
||||
return Response((_STATIC / "favicon.png").read_bytes(), media_type="image/png",
|
||||
headers={"cache-control": "public, max-age=86400"})
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict:
|
||||
from .paths import describe
|
||||
@@ -313,10 +352,23 @@ def create_app(engine: Engine) -> FastAPI:
|
||||
return probe_source(source, cam.max_width)
|
||||
|
||||
@app.get("/api/cameras/{camera_id}/frame.jpg")
|
||||
def frame(camera_id: str) -> Response:
|
||||
def frame(camera_id: str, width: int = 0, quality: int = 0) -> Response:
|
||||
"""The latest frame, optionally re-encoded smaller.
|
||||
|
||||
`width`/`quality` exist for the live relay, which sends several frames
|
||||
a second up a shop's uplink and cannot afford the full-size picture the
|
||||
dashboard uses. The re-encode happens here rather than in the agent
|
||||
because this process already has OpenCV open and the frame decoded;
|
||||
shipping a scaler into the agent would be the same work done twice.
|
||||
|
||||
Done on demand, not on every frame: a camera nobody is watching must
|
||||
not pay for a second encode it will never use.
|
||||
"""
|
||||
jpeg = worker_or_404(camera_id).latest_jpeg()
|
||||
if jpeg is None:
|
||||
raise HTTPException(503, "no frame yet")
|
||||
if width > 0 or quality > 0:
|
||||
jpeg = _reencode(jpeg, width, quality) or jpeg
|
||||
return Response(jpeg, media_type="image/jpeg")
|
||||
|
||||
@app.get("/api/cameras/{camera_id}/stream.mjpeg")
|
||||
@@ -328,11 +380,23 @@ def create_app(engine: Engine) -> FastAPI:
|
||||
# Stop when the camera is deleted or its worker dies - otherwise a
|
||||
# removed camera leaves this generator running for the life of the
|
||||
# process, holding a reference to a worker nothing else can see.
|
||||
# Driven by the camera, not a timer: a frame goes out when the
|
||||
# capture thread has one newer than the last one sent, so nothing
|
||||
# is sent twice and nothing waits on the recognition pipeline.
|
||||
# Capped at 15 fps - the office cameras' own rate - so a viewer
|
||||
# never costs more encodes than the camera produces pictures.
|
||||
last_ts, min_gap, sent_at = 0.0, 1.0 / 15, 0.0
|
||||
while engine.workers.get(camera_id) is worker and worker.is_alive():
|
||||
jpeg = worker.latest_jpeg()
|
||||
if jpeg is not None:
|
||||
yield boundary + jpeg + b"\r\n"
|
||||
await asyncio.sleep(0.1) # ~10 fps to the browser
|
||||
now = time.time()
|
||||
if now - sent_at < min_gap:
|
||||
await asyncio.sleep(min_gap - (now - sent_at))
|
||||
continue
|
||||
jpeg, ts = worker.latest_jpeg_since(last_ts)
|
||||
if jpeg is None:
|
||||
await asyncio.sleep(0.02)
|
||||
continue
|
||||
last_ts, sent_at = ts, time.time()
|
||||
yield boundary + jpeg + b"\r\n"
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
|
||||
@@ -17,10 +17,20 @@ import numpy as np
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Force TCP transport and a 5s socket timeout for RTSP before OpenCV loads
|
||||
# ffmpeg. UDP is the default and silently drops frames on lossy Wi-Fi.
|
||||
# Set before OpenCV loads ffmpeg, which reads this once.
|
||||
#
|
||||
# rtsp_transport=tcp: UDP is the default and silently drops frames on lossy
|
||||
# Wi-Fi. stimeout: a 5s socket timeout so a dead camera is noticed.
|
||||
#
|
||||
# fflags=nobuffer and flags=low_delay: without them ffmpeg's RTSP demuxer
|
||||
# holds a comfortable queue of frames before handing over the first, which
|
||||
# on a live feed is half a second to two seconds of latency that no amount of
|
||||
# work downstream can recover - the frame is already old when we get it. A
|
||||
# recorder wants that buffer; a live view does not. max_delay caps the
|
||||
# reorder wait for the same reason.
|
||||
os.environ.setdefault(
|
||||
"OPENCV_FFMPEG_CAPTURE_OPTIONS", "rtsp_transport;tcp|stimeout;5000000"
|
||||
"OPENCV_FFMPEG_CAPTURE_OPTIONS",
|
||||
"rtsp_transport;tcp|stimeout;5000000|fflags;nobuffer|flags;low_delay|max_delay;200000",
|
||||
)
|
||||
|
||||
|
||||
@@ -47,6 +57,23 @@ def _tcp_reachable(source: "str | int", timeout: float
|
||||
return False, f"cannot reach {parsed.hostname}:{port} - {exc.strerror or exc}"
|
||||
|
||||
|
||||
def _fourcc(cap) -> str:
|
||||
"""The stream's codec as a four-character code, or "" if unknown.
|
||||
|
||||
FFmpeg reports H.265 as "hevc" and H.264 as "h264"/"avc1" depending on the
|
||||
container. Returned as-is rather than mapped to a friendly name: the raw
|
||||
value is what somebody searching their camera's manual will match.
|
||||
"""
|
||||
try:
|
||||
raw = int(cap.get(cv2.CAP_PROP_FOURCC))
|
||||
except Exception:
|
||||
return ""
|
||||
if raw <= 0:
|
||||
return ""
|
||||
code = "".join(chr((raw >> (8 * i)) & 0xFF) for i in range(4))
|
||||
return code.strip().strip("\x00")
|
||||
|
||||
|
||||
def probe_source(source: "str | int", max_width: int = 1280,
|
||||
timeout: float = 12.0, connect_timeout: float = 3.0) -> dict:
|
||||
"""Open a candidate camera, grab one frame, and let go.
|
||||
@@ -97,6 +124,17 @@ def probe_source(source: "str | int", max_width: int = 1280,
|
||||
return {
|
||||
"ok": True, "width": int(width), "height": int(height),
|
||||
"downscaled_to": int(preview.shape[1]) if preview is not frame else None,
|
||||
"fps": round(cap.get(cv2.CAP_PROP_FPS) or 0, 1),
|
||||
# The codec decides whether head office can ever show TRUE live
|
||||
# video from this camera. A browser plays H.264 everywhere; H.265
|
||||
# only on some platforms, so a passthrough relay cannot rely on it
|
||||
# and the picture has to be re-encoded frame by frame instead.
|
||||
# Reported here because it is a property of the camera's settings
|
||||
# that an installer can usually change, and because otherwise the
|
||||
# only way to learn it is to read RTSP by hand — which is how this
|
||||
# was found: a camera whose paths end in ".264" was emitting H.265
|
||||
# on both streams.
|
||||
"codec": _fourcc(cap),
|
||||
"snapshot": (base64.b64encode(buf.tobytes()).decode("ascii")
|
||||
if ok else None),
|
||||
}
|
||||
|
||||
@@ -162,7 +162,11 @@ class CameraWorker(threading.Thread):
|
||||
# every test using a stubbed worker passed.
|
||||
self._stopping = threading.Event()
|
||||
self._lock = threading.Lock()
|
||||
self._annotated_jpeg: Optional[bytes] = None
|
||||
# What the live view draws over the freshest frame: the boxes from
|
||||
# the most recent processed frame, and when they were computed. NOT a
|
||||
# pre-rendered JPEG - see latest_jpeg for why.
|
||||
self._overlay: "list[tuple[tuple[int, int, int, int], tuple[int, int, int], str]]" = []
|
||||
self._overlay_ts = 0.0
|
||||
self._last_frame_ts = 0.0
|
||||
self._was_connected = False
|
||||
self.frames_processed = 0
|
||||
@@ -187,8 +191,46 @@ class CameraWorker(threading.Thread):
|
||||
self.source.stop()
|
||||
|
||||
def latest_jpeg(self) -> Optional[bytes]:
|
||||
jpeg, _ = self.latest_jpeg_since(0.0)
|
||||
return jpeg
|
||||
|
||||
def latest_jpeg_since(self, known_ts: float) -> "tuple[Optional[bytes], float]":
|
||||
"""The freshest captured frame with the latest boxes drawn on it, or
|
||||
(None, known_ts) if the camera has produced nothing newer.
|
||||
|
||||
The live picture is deliberately NOT the frame the pipeline last
|
||||
finished with. That version advanced only when detection, tracking and
|
||||
identification had all completed on a frame - a few times a second on a
|
||||
modest shop PC - and every picture it showed was already as old as that
|
||||
processing. It looked like lag because it was lag. Here the picture runs
|
||||
at the camera's rate off the capture thread's latest frame, and the
|
||||
boxes - which genuinely can only update at pipeline rate - are drawn
|
||||
over it from the last processed frame. Boxes may trail a fast walker by
|
||||
one pipeline period; the picture never does.
|
||||
|
||||
Encoded on demand, per request, so a camera nobody is watching pays for
|
||||
no JPEG at all. The old path encoded every processed frame whether or
|
||||
not a viewer existed - CPU spent on precisely the machine short of it.
|
||||
"""
|
||||
frame, ts = self.source.latest_since(known_ts)
|
||||
if frame is None:
|
||||
return None, known_ts
|
||||
with self._lock:
|
||||
return self._annotated_jpeg
|
||||
overlay, overlay_ts = list(self._overlay), self._overlay_ts
|
||||
# A stalled pipeline must not leave a box floating over an empty spot.
|
||||
# Older than a second and the person has walked out from under it.
|
||||
draw = overlay if (time.time() - overlay_ts) < 1.0 else []
|
||||
if draw:
|
||||
frame = frame.copy()
|
||||
for (x1, y1, x2, y2), color, text in draw:
|
||||
cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2)
|
||||
if text:
|
||||
cv2.putText(frame, text, (x1, max(20, y1 - 8)),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2)
|
||||
ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
|
||||
if not ok:
|
||||
return None, known_ts
|
||||
return buf.tobytes(), ts
|
||||
|
||||
def stats(self) -> dict:
|
||||
return {
|
||||
@@ -236,7 +278,7 @@ class CameraWorker(threading.Thread):
|
||||
for track in ended:
|
||||
self._finish_track(track, ts)
|
||||
|
||||
self._publish_annotated(frame, active)
|
||||
self._remember_tracks(active)
|
||||
self.frames_processed += 1
|
||||
except Exception:
|
||||
log.exception("[%s] frame processing failed", self.cam_cfg.id)
|
||||
@@ -426,12 +468,14 @@ class CameraWorker(threading.Thread):
|
||||
track.quality, rcfg=self.rcfg):
|
||||
track.reinforcements += 1
|
||||
|
||||
def _publish_annotated(self, frame: np.ndarray, tracks: "list[Track]") -> None:
|
||||
canvas = frame.copy()
|
||||
def _remember_tracks(self, tracks: "list[Track]") -> None:
|
||||
"""Record what to draw. Cheap: a handful of tuples under the lock,
|
||||
no frame copy and no encode. The encode happens in latest_jpeg_since,
|
||||
only when somebody is looking."""
|
||||
overlay = []
|
||||
for t in tracks:
|
||||
if t.misses > 0:
|
||||
continue # only draw tracks matched in this frame
|
||||
x1, y1, x2, y2 = t.box
|
||||
if t.state == "resolved":
|
||||
color = _COLORS["known"] if t.label and not str(t.label).startswith(
|
||||
"Visitor") else _COLORS["new"]
|
||||
@@ -440,15 +484,10 @@ class CameraWorker(threading.Thread):
|
||||
color, text = _COLORS["ambiguous"], "?"
|
||||
else:
|
||||
color, text = _COLORS["pending"], ""
|
||||
cv2.rectangle(canvas, (x1, y1), (x2, y2), color, 2)
|
||||
if text:
|
||||
cv2.putText(canvas, text, (x1, max(20, y1 - 8)),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2)
|
||||
ok, buf = cv2.imencode(".jpg", canvas,
|
||||
[int(cv2.IMWRITE_JPEG_QUALITY), 80])
|
||||
if ok:
|
||||
with self._lock:
|
||||
self._annotated_jpeg = buf.tobytes()
|
||||
overlay.append((tuple(t.box), color, text))
|
||||
with self._lock:
|
||||
self._overlay = overlay
|
||||
self._overlay_ts = time.time()
|
||||
|
||||
|
||||
class Engine:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Behavision</title>
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
|
||||
BIN
behavision/static/favicon.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
brand/loyaly-icon-128.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
brand/loyaly-icon-16.png
Normal file
|
After Width: | Height: | Size: 732 B |
BIN
brand/loyaly-icon-256.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
brand/loyaly-icon-32.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
brand/loyaly-icon-48.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
brand/loyaly-icon-512.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
brand/loyaly-icon-64.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
brand/loyaly-mark.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
brand/loyaly.ico
Normal file
|
After Width: | Height: | Size: 67 KiB |
@@ -23,6 +23,7 @@ import (
|
||||
agentcameras "github.com/loyaly/behavision-agent/pkg/cameras"
|
||||
agentcfg "github.com/loyaly/behavision-agent/pkg/config"
|
||||
agentengine "github.com/loyaly/behavision-agent/pkg/engine"
|
||||
"github.com/loyaly/behavision-agent/pkg/enrol"
|
||||
agentmqtt "github.com/loyaly/behavision-agent/pkg/mqtt"
|
||||
agentpaths "github.com/loyaly/behavision-agent/pkg/paths"
|
||||
agentspool "github.com/loyaly/behavision-agent/pkg/spool"
|
||||
@@ -43,6 +44,9 @@ type App struct {
|
||||
broker *agentmqtt.Client
|
||||
stopBridge func()
|
||||
hookURL string
|
||||
// Relays camera feeds to the webview so the engine's credential never has
|
||||
// to travel in an <img> src, which a Chromium webview would strip anyway.
|
||||
proxy *streamProxy
|
||||
// Set once the operator logs in. Until then the UI shows the login sheet
|
||||
// and nothing else is reachable.
|
||||
onSessionChange func(bool)
|
||||
@@ -63,6 +67,7 @@ func NewApp() *App {
|
||||
cfg: cfg,
|
||||
cloud: cloud.New(envOr("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")),
|
||||
local: local.New(base, cfg.APIUser, cfg.APIPassword),
|
||||
proxy: newStreamProxy(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +75,14 @@ func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
_ = agentpaths.EnsureState()
|
||||
|
||||
// Before any screen asks for a camera URL. A failure here is logged and
|
||||
// not fatal: the rest of the app - people, cameras, the engine controls -
|
||||
// works without a picture, and refusing to start over a broken tile would
|
||||
// take a working shop offline.
|
||||
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)
|
||||
}
|
||||
|
||||
// A saved session means a shop PC that rebooted overnight comes back
|
||||
// working instead of waiting for someone to log in.
|
||||
if a.cfg.SessionToken != "" {
|
||||
@@ -102,7 +115,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
// be told again. Without it the engine recognised people and the
|
||||
// bridge received nothing: a claimed shop PC published heartbeats
|
||||
// and zero visits.
|
||||
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+a.webhookURL())
|
||||
cmd.Env = agentengine.ChildEnv(a.webhookURL())
|
||||
return cmd
|
||||
},
|
||||
LogWriter: logFile,
|
||||
@@ -112,6 +125,23 @@ func (a *App) startup(ctx context.Context) {
|
||||
})
|
||||
|
||||
a.startPipeline(ctx)
|
||||
|
||||
// Recognition starts with the app. Until this, the engine only ever
|
||||
// started when somebody pressed Start - which meant a till that rebooted
|
||||
// overnight came back with the window open, the tray icon showing, the
|
||||
// session restored, and recognition off until a shop assistant noticed.
|
||||
// That is the failure the tray colours exist to catch, and it should not
|
||||
// be the default state every morning.
|
||||
//
|
||||
// Guarded on the interpreter actually being there: on a PC where setup has
|
||||
// not run yet, starting the supervisor would loop on a missing executable
|
||||
// with nothing useful to say. The Start button still exists for the one
|
||||
// case where somebody has deliberately stopped it.
|
||||
if _, err := os.Stat(exe); err == nil {
|
||||
a.sup.Start()
|
||||
} else {
|
||||
log.Printf("engine not installed yet (%s); run behavision-setup, then Start", exe)
|
||||
}
|
||||
}
|
||||
|
||||
// webhookURL is the loopback address the bridge is listening on, or empty
|
||||
@@ -229,6 +259,9 @@ func (a *App) startLocalCameras(ctx context.Context, logger *log.Logger) {
|
||||
// this app - and the headless agent - both ended up wiring configuration
|
||||
// and forgetting the check runner, so "Test connection" at head office
|
||||
// never completed on any shop PC.
|
||||
// The live relay runs alongside the reconciler and uploads nothing until
|
||||
// somebody at head office is actually watching a camera.
|
||||
go agentcameras.NewLive(camEngine, camCloud, logger).Run(ctx)
|
||||
agentcameras.New(camEngine, camCloud, logger).Run(ctx)
|
||||
}
|
||||
|
||||
@@ -270,8 +303,8 @@ type PipelineStatus struct {
|
||||
// Standalone separates "nothing is being sent because this PC is set up on
|
||||
// its own" from "nothing is being sent and something is wrong". They look
|
||||
// identical from the counters alone, and only one of them is a fault.
|
||||
Standalone bool `json:"standalone"`
|
||||
BrokerUp bool `json:"broker_up"`
|
||||
Standalone bool `json:"standalone"`
|
||||
BrokerUp bool `json:"broker_up"`
|
||||
Accepted uint64 `json:"accepted"`
|
||||
}
|
||||
|
||||
@@ -402,6 +435,11 @@ func (a *App) Claim(code string) (SessionInfo, error) {
|
||||
a.cfg.BrokerPassword = b.MQTTPass
|
||||
a.cfg.AgentToken = b.AgentToken
|
||||
a.cfg.CloudBase = a.cloud.Base
|
||||
caPath, err := enrol.SaveCA(b.CACert, agentpaths.BrokerCA())
|
||||
if err != nil {
|
||||
return SessionInfo{}, err
|
||||
}
|
||||
a.cfg.BrokerCAFile = caPath
|
||||
// A PC that was running on its own and has now been linked is no longer
|
||||
// standalone. Leaving the flag set would keep the head-office screens
|
||||
// hidden on the one machine that just earned them.
|
||||
@@ -546,15 +584,25 @@ func (a *App) PlacementResult(id string) (map[string]any, error) {
|
||||
return a.local.PlacementResult(ctx, id)
|
||||
}
|
||||
|
||||
// StreamURL is the MJPEG endpoint for a camera, with credentials inline so an
|
||||
// <img> tag can load it. Loopback only - it never leaves this machine.
|
||||
// StreamURL is the MJPEG endpoint for a camera tile.
|
||||
//
|
||||
// It points at this app's own loopback relay, not at the engine directly. The
|
||||
// previous version put the engine's Basic credentials inline in the URL, with
|
||||
// a comment saying they were there "so an <img> tag can load it" - which a
|
||||
// browser will not do. Chromium strips credentials from subresource URLs, and
|
||||
// WebView2 is Chromium, so every camera tile on a shop PC was a broken image.
|
||||
// See stream_proxy.go for the measurement.
|
||||
//
|
||||
// The relay is also why no password appears in the page any more. If it is not
|
||||
// running the fallback is the bare engine URL with no credential: correct for
|
||||
// an engine configured without auth, and for one with auth a tile that fails
|
||||
// to load rather than a password sitting in the DOM.
|
||||
func (a *App) StreamURL(cameraID string) string {
|
||||
base := strings.TrimPrefix(strings.TrimPrefix(a.local.Base, "http://"), "https://")
|
||||
if a.local.User == "" {
|
||||
return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID)
|
||||
if u := a.proxy.urlFor(cameraID, "stream.mjpeg"); u != "" {
|
||||
return u
|
||||
}
|
||||
return fmt.Sprintf("http://%s:%s@%s/api/cameras/%s/stream.mjpeg",
|
||||
a.local.User, a.local.Password, base, cameraID)
|
||||
base := strings.TrimPrefix(strings.TrimPrefix(a.local.Base, "http://"), "https://")
|
||||
return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- live --
|
||||
|
||||
BIN
desktop/build/appicon.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
68
desktop/build/darwin/Info.dev.plist
Normal file
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.OutputFilename}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.wails.{{safeBundleID .Name}}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
63
desktop/build/darwin/Info.plist
Normal file
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.OutputFilename}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.wails.{{safeBundleID .Name}}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
desktop/build/windows/icon.ico
Normal file
|
After Width: | Height: | Size: 67 KiB |
15
desktop/build/windows/info.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "{{.Info.ProductVersion}}"
|
||||
},
|
||||
"info": {
|
||||
"0000": {
|
||||
"ProductVersion": "{{.Info.ProductVersion}}",
|
||||
"CompanyName": "{{.Info.CompanyName}}",
|
||||
"FileDescription": "{{.Info.ProductName}}",
|
||||
"LegalCopyright": "{{.Info.Copyright}}",
|
||||
"ProductName": "{{.Info.ProductName}}",
|
||||
"Comments": "{{.Info.Comments}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
desktop/build/windows/wails.exe.manifest
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<asmv3:application>
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
</assembly>
|
||||
40
desktop/frontend/dist/assets/index-MXXBc3qm.js
vendored
Normal file
1
desktop/frontend/dist/assets/index-W0yLGths.css
vendored
Normal file
40
desktop/frontend/dist/assets/index-whFsTNQf.js
vendored
BIN
desktop/frontend/dist/assets/loyaly-mark-Ch-P3VSF.png
vendored
Normal file
|
After Width: | Height: | Size: 96 KiB |
4
desktop/frontend/dist/index.html
vendored
@@ -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-whFsTNQf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-XjqO50wd.css">
|
||||
<script type="module" crossorigin src="./assets/index-MXXBc3qm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-W0yLGths.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api, isDesktop, message } from './bridge.js'
|
||||
import { usePolled } from './hooks.js'
|
||||
import * as Icon from './ui/icons.jsx'
|
||||
import logo from './assets/loyaly-mark.png'
|
||||
import Login from './views/Login.jsx'
|
||||
import Setup from './views/Setup.jsx'
|
||||
import Live from './views/Live.jsx'
|
||||
@@ -22,9 +24,9 @@ import Cameras from './views/Cameras.jsx'
|
||||
// the customer record lives on the server, the cameras and what this PC is
|
||||
// seeing do not.
|
||||
const VIEWS = [
|
||||
{ id: 'live', label: 'Live', glyph: '◉', View: Live },
|
||||
{ id: 'customers', label: 'Customers', glyph: '☺', View: Customers, cloud: true },
|
||||
{ id: 'cameras', label: 'Cameras', glyph: '▢', View: Cameras },
|
||||
{ id: 'live', label: 'Live', Glyph: Icon.Live, View: Live },
|
||||
{ id: 'customers', label: 'Customers', Glyph: Icon.People, View: Customers, cloud: true },
|
||||
{ id: 'cameras', label: 'Cameras', Glyph: Icon.Camera, View: Cameras },
|
||||
]
|
||||
|
||||
export default function App() {
|
||||
@@ -48,6 +50,7 @@ export default function App() {
|
||||
// error nobody will read.
|
||||
return (
|
||||
<div className="login"><div className="box">
|
||||
<span className="mark"><img src={logo} alt="" /></span>
|
||||
<h1>Behavision</h1>
|
||||
<p className="lead">
|
||||
This is the Behavision window running outside the app, so it has no
|
||||
@@ -78,36 +81,41 @@ export default function App() {
|
||||
<div className="shell">
|
||||
<aside className="side">
|
||||
<div className="brand">
|
||||
<h1>Behavision</h1>
|
||||
<p>{session.site_name || session.user?.client_name || 'Store'}</p>
|
||||
<span className="mark"><img src={logo} alt="" /></span>
|
||||
<div className="id">
|
||||
<h1>Behavision</h1>
|
||||
<p>{session.site_name || session.user?.client_name || 'This shop'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="nav">
|
||||
{views.map(v => (
|
||||
<button key={v.id} onClick={() => setView(v.id)}
|
||||
aria-current={v.id === view ? 'page' : undefined}>
|
||||
<span className="glyph">{v.glyph}</span>{v.label}
|
||||
{views.map(({ id, label, Glyph }) => (
|
||||
<button key={id} onClick={() => setView(id)}
|
||||
aria-current={id === view ? 'page' : undefined}>
|
||||
<Glyph size={17} />{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<EngineBox />
|
||||
<div style={{ padding: '10px 12px 14px', borderTop: '1px solid var(--line-soft)' }}>
|
||||
<div className="who">
|
||||
{session.standalone
|
||||
? <>
|
||||
<div className="note" style={{ marginBottom: 8 }}>
|
||||
Running on its own
|
||||
<div className="id">
|
||||
<b>On its own</b>
|
||||
<span>No head office</span>
|
||||
</div>
|
||||
<button className="btn sm" style={{ width: '100%' }}
|
||||
<button className="btn sm icon" title="Link to head office"
|
||||
onClick={() => setLinking(true)}>
|
||||
Link to head office
|
||||
<Icon.Link size={15} />
|
||||
</button>
|
||||
</>
|
||||
: <>
|
||||
<div className="note" style={{ marginBottom: 8 }}>
|
||||
{session.user?.email}
|
||||
<div className="id">
|
||||
<b>Signed in</b>
|
||||
<span>{session.user?.email}</span>
|
||||
</div>
|
||||
<button className="btn sm" style={{ width: '100%' }}
|
||||
<button className="btn sm icon" title="Sign out"
|
||||
onClick={async () => setSession(await api.logout())}>
|
||||
Sign out
|
||||
<Icon.Logout size={15} />
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
@@ -136,7 +144,12 @@ function EngineBox() {
|
||||
const up = cams.filter(Boolean).length
|
||||
|
||||
let tone = 'idle', text = 'Stopped'
|
||||
if (s.state === 'failed' || s.state === 'backoff') { tone = 'bad'; text = 'Not running' }
|
||||
// Reachable but not ours: somebody started the engine outside this app, or a
|
||||
// previous copy is still up. Saying "Stopped" beside live camera feeds is the
|
||||
// two-surfaces-disagreeing bug the tray exists to avoid - and it is exactly
|
||||
// what this panel showed while recognition was visibly running.
|
||||
if (!running && s.reachable) { tone = 'warn'; text = 'Running outside the app' }
|
||||
else if (s.state === 'failed' || s.state === 'backoff') { tone = 'bad'; text = 'Not running' }
|
||||
else if (running && !s.reachable) { tone = 'warn'; text = 'Starting…' }
|
||||
else if (running && cams.length === 0) { tone = 'warn'; text = 'No cameras' }
|
||||
else if (running && up === 0) { tone = 'bad'; text = 'No camera connected' }
|
||||
@@ -145,16 +158,21 @@ function EngineBox() {
|
||||
|
||||
return (
|
||||
<div className="enginebox">
|
||||
<div className="row"><i className={`dot ${tone}`} /><strong>{text}</strong></div>
|
||||
{s.recognition_model && (
|
||||
<span className="label">Model: {s.recognition_model}</span>
|
||||
)}
|
||||
<div className="row">
|
||||
<i className={`dot ${tone === 'ok' ? 'live' : tone}`} />
|
||||
<span className="state">{text}</span>
|
||||
</div>
|
||||
{s.recognition_model && <span className="label">{s.recognition_model}</span>}
|
||||
{s.error && <span className="label" style={{ color: 'var(--bad)' }}>{s.error}</span>}
|
||||
<div className="actions">
|
||||
<button className="btn sm" disabled={busy || running}
|
||||
onClick={() => act(api.startEngine)}>Start</button>
|
||||
<button className="btn sm" disabled={busy || running || s.reachable}
|
||||
onClick={() => act(api.startEngine)}>
|
||||
<Icon.Play size={13} />Start
|
||||
</button>
|
||||
<button className="btn sm" disabled={busy || !running}
|
||||
onClick={() => act(api.stopEngine)}>Stop</button>
|
||||
onClick={() => act(api.stopEngine)}>
|
||||
<Icon.Stop size={13} />Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
BIN
desktop/frontend/src/assets/loyaly-mark.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
@@ -1,252 +1,469 @@
|
||||
/* Behavision desktop — an instrument panel, not a website.
|
||||
A shop PC runs this all day on a cheap monitor, so: high contrast, dense
|
||||
but not cramped, and state readable at a glance from across a counter. */
|
||||
/* Behavision desktop — a shop-floor instrument, not a website.
|
||||
===========================================================================
|
||||
Designed for one situation: a PC behind a counter, on a cheap monitor, in a
|
||||
room with daylight, glanced at by somebody who is mid-conversation with a
|
||||
customer. Everything below follows from that.
|
||||
|
||||
- Dark, because the screen sits in peripheral vision all day and a white
|
||||
field at 1000 lux is a lamp pointed at the operator.
|
||||
- State is carried by shape AND colour: a pill, a dot and an edge stripe,
|
||||
never colour alone. This gets read from two metres away, and some
|
||||
operators do not see red and green apart.
|
||||
- One spacing scale and one type scale. The previous version set margins
|
||||
inline, per screen, which is how a UI ends up looking assembled rather
|
||||
than designed.
|
||||
- Motion only where it carries meaning: a live camera, a fresh arrival.
|
||||
Nothing loops for decoration — this process shares a CPU with recognition.
|
||||
=========================================================================== */
|
||||
|
||||
:root {
|
||||
--ground: #0E1317;
|
||||
--surface: #161D23;
|
||||
--surface-2: #1D262D;
|
||||
--line: #27333B;
|
||||
--line-soft: #1F2A31;
|
||||
--ink: #E7EEF3;
|
||||
--ink-2: #B4C2CC;
|
||||
--muted: #7C8B97;
|
||||
--accent: #45B0C7;
|
||||
--accent-dim:#123039;
|
||||
--ok: #4FB37B;
|
||||
--warn: #E0A33A;
|
||||
--bad: #E0655A;
|
||||
--radius: 8px;
|
||||
--mono: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
|
||||
/* ground → raised, four steps, blue-green biased: the product lives in the
|
||||
world of lenses and CCTV, and a neutral grey reads as unfinished. */
|
||||
--bg: #0A0F13;
|
||||
--s1: #111A20;
|
||||
--s2: #17232B;
|
||||
--s3: #1E2D37;
|
||||
--line: #223038;
|
||||
--line-2: #1A252C;
|
||||
|
||||
--ink: #ECF3F7;
|
||||
--ink-2: #A3B6C2;
|
||||
--ink-3: #6C808D;
|
||||
|
||||
/* Accent is for state and focus only, never decoration, so that when it does
|
||||
appear the eye goes to it. */
|
||||
--accent: #40C4DC;
|
||||
--accent-2: #0F3B47;
|
||||
--accent-3: #0B2A33;
|
||||
|
||||
--ok: #48C78E; --ok-2: #102E22;
|
||||
--warn: #EAAA3D; --warn-2: #31260F;
|
||||
--bad: #EC6A5C; --bad-2: #331815;
|
||||
|
||||
--r-sm: 6px; --r: 10px; --r-lg: 14px;
|
||||
|
||||
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
|
||||
--sp-5: 20px; --sp-6: 24px; --sp-7: 32px; --sp-8: 40px;
|
||||
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / .4), 0 8px 24px -12px rgb(0 0 0 / .6);
|
||||
--shadow-lg: 0 2px 4px rgb(0 0 0 / .4), 0 24px 48px -16px rgb(0 0 0 / .7);
|
||||
|
||||
/* Segoe UI Variable first: it is on every Windows 11 shop PC, it has real
|
||||
optical sizes, and it is what makes this look like an application rather
|
||||
than a web page in a frame. No webfont — a shop PC has no internet at
|
||||
install time, and a font that fails to arrive is a layout that shifts
|
||||
under the operator. */
|
||||
--font: "Segoe UI Variable Text", "Segoe UI", Inter, -apple-system,
|
||||
BlinkMacSystemFont, system-ui, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-display: "Segoe UI Variable Display", var(--font);
|
||||
--mono: "Cascadia Mono", "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
|
||||
|
||||
/* Kept as aliases so any screen not yet rewritten keeps its colours. */
|
||||
--ground: var(--bg); --surface: var(--s1); --surface-2: var(--s2);
|
||||
--line-soft: var(--line-2); --muted: var(--ink-3); --radius: var(--r);
|
||||
--accent-dim: var(--accent-3);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
body {
|
||||
background: var(--ground);
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 14px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* ---------------------------------------------------------------- shell -- */
|
||||
.shell { display: grid; grid-template-columns: 216px 1fr; height: 100%; }
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
input, textarea { user-select: text; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
|
||||
::selection { background: var(--accent-2); color: var(--ink); }
|
||||
|
||||
/* Digits that line up wherever they are compared or refreshed in place. */
|
||||
.num, .value, .metric-v, .when, .mono, .code, td { font-variant-numeric: tabular-nums; }
|
||||
.mono, .code { font-family: var(--mono); }
|
||||
|
||||
/* The default light scrollbar on a dark panel is the most obvious "this is a
|
||||
web page" tell there is. */
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--s3) transparent; }
|
||||
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
*::-webkit-scrollbar-thumb { background: var(--s3); border-radius: 99px; border: 3px solid var(--bg); }
|
||||
*::-webkit-scrollbar-thumb:hover { background: #2A3D49; }
|
||||
|
||||
/* ================================================================ shell == */
|
||||
|
||||
.shell { display: grid; grid-template-columns: 232px 1fr; height: 100%; }
|
||||
|
||||
.side {
|
||||
background: var(--surface); border-right: 1px solid var(--line);
|
||||
background: var(--s1); border-right: 1px solid var(--line);
|
||||
display: flex; flex-direction: column; min-height: 0;
|
||||
}
|
||||
.side .brand {
|
||||
padding: 18px 18px 14px; border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
.side .brand h1 { font-size: 15px; font-weight: 650; letter-spacing: -.01em; }
|
||||
.side .brand p { font-size: 11.5px; color: var(--muted); margin-top: 3px; }
|
||||
.nav { padding: 10px 10px; display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||
.side .brand { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-5) var(--sp-5) var(--sp-4); }
|
||||
.side .brand .mark { width: 30px; height: 30px; flex: none; display: grid; place-items: center; }
|
||||
.side .brand .mark img, .login .mark img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
.side .brand .id { min-width: 0; }
|
||||
.side .brand h1 { font-family: var(--font-display); font-size: 15px; font-weight: 600; letter-spacing: -.012em; line-height: 1.2; }
|
||||
.side .brand p { font-size: 11.5px; color: var(--ink-3); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.nav { padding: var(--sp-2) var(--sp-3); display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||
.nav button {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%;
|
||||
background: none; border: 0; border-radius: 6px; padding: 8px 10px;
|
||||
color: var(--ink-2); cursor: pointer; text-align: left; font-size: 13.5px;
|
||||
position: relative; display: flex; align-items: center; gap: var(--sp-3); width: 100%;
|
||||
background: none; border: 0; border-radius: var(--r-sm); padding: 9px var(--sp-3);
|
||||
color: var(--ink-2); cursor: pointer; text-align: left; font-size: 13.5px; font-weight: 450;
|
||||
transition: background .12s ease, color .12s ease;
|
||||
}
|
||||
.nav button:hover { background: var(--surface-2); color: var(--ink); }
|
||||
.nav button[aria-current="page"] { background: var(--accent-dim); color: var(--accent); font-weight: 550; }
|
||||
.nav .glyph { width: 16px; text-align: center; opacity: .85; font-size: 13px; }
|
||||
|
||||
.enginebox { padding: 12px; border-top: 1px solid var(--line-soft); }
|
||||
.enginebox .row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||||
.enginebox .label { color: var(--muted); font-size: 11px; margin-top: 2px;
|
||||
display: block; line-height: 1.4; }
|
||||
.enginebox .actions { display: flex; gap: 6px; margin-top: 10px; }
|
||||
|
||||
.main { min-width: 0; min-height: 0; overflow-y: auto; }
|
||||
.page { padding: 22px 26px 40px; max-width: 1180px; }
|
||||
.page > header { margin-bottom: 18px; }
|
||||
.page h2 { font-size: 19px; font-weight: 620; letter-spacing: -.01em; }
|
||||
.page header p { color: var(--muted); font-size: 13px; margin-top: 3px; }
|
||||
|
||||
/* --------------------------------------------------------------- pieces -- */
|
||||
.card {
|
||||
background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); padding: 16px;
|
||||
.nav button svg { flex: none; opacity: .9; }
|
||||
.nav button:hover { background: var(--s2); color: var(--ink); }
|
||||
.nav button[aria-current="page"] { background: var(--accent-3); color: var(--accent); font-weight: 550; }
|
||||
/* A rail, not a background wash: it survives being looked at sideways. */
|
||||
.nav button[aria-current="page"]::before {
|
||||
content: ""; position: absolute; left: -12px; top: 7px; bottom: 7px;
|
||||
width: 2.5px; border-radius: 0 2px 2px 0; background: var(--accent);
|
||||
}
|
||||
.card h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .07em;
|
||||
color: var(--muted); font-weight: 600; margin-bottom: 12px; }
|
||||
.grid { display: grid; gap: 14px; }
|
||||
.cols-4 { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
|
||||
.cols-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
|
||||
|
||||
.stat .value { font-size: 30px; font-weight: 620; letter-spacing: -.02em;
|
||||
font-variant-numeric: tabular-nums; line-height: 1.1; }
|
||||
.stat .unit { font-size: 15px; color: var(--muted); margin-left: 3px; }
|
||||
.stat .sub { color: var(--muted); font-size: 12px; margin-top: 5px; }
|
||||
/* The one control that starts and stops the product, so it gets its own block
|
||||
at the foot rather than a row in a list. */
|
||||
.enginebox {
|
||||
margin: var(--sp-3); padding: var(--sp-3) var(--sp-4) var(--sp-4);
|
||||
border: 1px solid var(--line); border-radius: var(--r); background: var(--s2);
|
||||
}
|
||||
.enginebox .row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.enginebox .state { font-size: 12.5px; font-weight: 600; letter-spacing: -.005em; }
|
||||
.enginebox .label { display: block; color: var(--ink-3); font-size: 11px; line-height: 1.45; margin-top: 3px; font-variant-numeric: tabular-nums; }
|
||||
.enginebox .actions, .enginebox .controls { display: flex; gap: var(--sp-2); margin-top: var(--sp-3); }
|
||||
.enginebox .actions .btn, .enginebox .controls .btn { flex: 1; justify-content: center; padding: 6px 8px; font-size: 12px; }
|
||||
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
||||
.dot.ok { background: var(--ok); }
|
||||
.dot.warn { background: var(--warn); }
|
||||
.dot.bad { background: var(--bad); }
|
||||
.dot.idle { background: var(--muted); }
|
||||
.side .who {
|
||||
padding: var(--sp-3) var(--sp-5) var(--sp-5); border-top: 1px solid var(--line-2);
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
}
|
||||
.side .who .id { min-width: 0; flex: 1; }
|
||||
.side .who .id b { display: block; font-size: 12.5px; font-weight: 550; }
|
||||
.side .who .id span { display: block; font-size: 11px; color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.pill { display: inline-flex; align-items: center; gap: 5px; font-size: 11px;
|
||||
padding: 3px 8px; border-radius: 99px; border: 1px solid var(--line);
|
||||
color: var(--muted); white-space: nowrap; }
|
||||
.pill.ok { color: var(--ok); border-color: #2b5c42; background: #12251b; }
|
||||
.pill.warn { color: var(--warn); border-color: #5c4a22; background: #241d0f; }
|
||||
.pill.bad { color: var(--bad); border-color: #5c2e2a; background: #241312; }
|
||||
.main { min-width: 0; min-height: 0; overflow: auto; }
|
||||
|
||||
/* ================================================================= page == */
|
||||
|
||||
.page { padding: var(--sp-6) var(--sp-7) var(--sp-8); max-width: 1500px; }
|
||||
.page > header { margin-bottom: var(--sp-5); }
|
||||
.page > header h2, .page h2 { font-family: var(--font-display); font-size: 22px; font-weight: 600; letter-spacing: -.02em; line-height: 1.2; }
|
||||
.page > header p, .page header p { color: var(--ink-3); font-size: 13px; margin-top: 3px; }
|
||||
|
||||
.pagehead { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); margin-bottom: var(--sp-5); flex-wrap: wrap; }
|
||||
|
||||
h3 { font-size: 11px; font-weight: 600; letter-spacing: .085em; text-transform: uppercase; color: var(--ink-3); }
|
||||
|
||||
/* ================================================================ cards == */
|
||||
|
||||
.card { background: var(--s1); border: 1px solid var(--line); border-radius: var(--r); padding: var(--sp-4); }
|
||||
.card > h3 { margin-bottom: var(--sp-3); }
|
||||
.card.flush { padding: 0; overflow: hidden; }
|
||||
|
||||
.panel { background: var(--s1); border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
|
||||
.panel > .panelhead {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--line-2);
|
||||
background: linear-gradient(var(--s2), var(--s1)); flex: none;
|
||||
}
|
||||
.panel > .panelhead h3 { margin: 0; }
|
||||
.panel > .panelbody { padding: var(--sp-4); min-height: 0; overflow: auto; }
|
||||
.panel > .panelbody.flush { padding: 0; }
|
||||
|
||||
.grid { display: grid; gap: var(--sp-4); }
|
||||
.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
@media (max-width: 1180px) { .cols-4 { grid-template-columns: repeat(2, minmax(0,1fr)); } }
|
||||
@media (max-width: 980px) { .cols-2, .cols-3 { grid-template-columns: minmax(0,1fr); } }
|
||||
|
||||
/* Four equal boxes used to dominate this screen. The numbers matter, but they
|
||||
are not what anybody opens the app to see. */
|
||||
.metrics {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(152px, 1fr));
|
||||
gap: 1px; background: var(--line); border: 1px solid var(--line);
|
||||
border-radius: var(--r); overflow: hidden;
|
||||
}
|
||||
.metric { background: var(--s1); padding: var(--sp-3) var(--sp-4) var(--sp-4); }
|
||||
.metric .metric-k { font-size: 10.5px; font-weight: 600; letter-spacing: .085em; text-transform: uppercase; color: var(--ink-3); }
|
||||
.metric .metric-v { font-family: var(--font-display); font-size: 26px; font-weight: 600; letter-spacing: -.025em; line-height: 1.1; margin-top: 5px; }
|
||||
.metric .metric-s { font-size: 11.5px; color: var(--ink-3); margin-top: 3px; line-height: 1.4; }
|
||||
.metric.ok .metric-v { color: var(--ok); }
|
||||
.metric.warn .metric-v { color: var(--warn); }
|
||||
.metric.bad .metric-v { color: var(--bad); }
|
||||
|
||||
/* legacy .stat, for screens not yet rewritten */
|
||||
.stat h3 { margin-bottom: var(--sp-2); }
|
||||
.stat .value { font-family: var(--font-display); font-size: 26px; font-weight: 600; letter-spacing: -.025em; line-height: 1.1; }
|
||||
.stat .unit { font-size: 15px; color: var(--ink-3); margin-left: 3px; }
|
||||
.stat .sub { font-size: 11.5px; color: var(--ink-3); margin-top: 4px; line-height: 1.4; }
|
||||
|
||||
/* =============================================================== status == */
|
||||
|
||||
.dot { width: 7px; height: 7px; border-radius: 99px; flex: none; background: var(--ink-3); }
|
||||
.dot.ok { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 18%, transparent); }
|
||||
.dot.warn { background: var(--warn); box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 18%, transparent); }
|
||||
.dot.bad { background: var(--bad); box-shadow: 0 0 0 3px color-mix(in srgb, var(--bad) 18%, transparent); }
|
||||
.dot.idle { background: var(--ink-3); }
|
||||
|
||||
/* A live camera is the one thing that should breathe: it is how an operator
|
||||
knows the picture is not frozen. Everything else holds still. */
|
||||
.dot.live { background: var(--ok); animation: pulse 2.4s ease-in-out infinite; }
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ok) 55%, transparent); }
|
||||
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--ok) 0%, transparent); }
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px 3px 7px;
|
||||
border-radius: 99px; font-size: 11px; font-weight: 600; letter-spacing: .02em;
|
||||
background: var(--s3); color: var(--ink-2); border: 1px solid var(--line); white-space: nowrap;
|
||||
}
|
||||
.pill.ok { background: var(--ok-2); color: var(--ok); border-color: color-mix(in srgb, var(--ok) 28%, transparent); }
|
||||
.pill.warn { background: var(--warn-2); color: var(--warn); border-color: color-mix(in srgb, var(--warn) 28%, transparent); }
|
||||
.pill.bad { background: var(--bad-2); color: var(--bad); border-color: color-mix(in srgb, var(--bad) 28%, transparent); }
|
||||
.pill.accent { background: var(--accent-3); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 30%, transparent); }
|
||||
|
||||
.tag {
|
||||
display: inline-flex; align-items: center; padding: 2px 7px; border-radius: var(--r-sm);
|
||||
font-size: 10.5px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
|
||||
background: var(--s3); color: var(--ink-2);
|
||||
}
|
||||
.tag.new { background: var(--accent-3); color: var(--accent); }
|
||||
.tag.seen { background: var(--ok-2); color: var(--ok); }
|
||||
.tag.miss { background: var(--warn-2); color: var(--warn); }
|
||||
|
||||
/* One line that answers "is this shop working" above everything else. */
|
||||
.statusbar {
|
||||
display: flex; align-items: center; gap: var(--sp-5); flex-wrap: wrap;
|
||||
padding: var(--sp-3) var(--sp-4); background: var(--s1);
|
||||
border: 1px solid var(--line); border-radius: var(--r); margin-bottom: var(--sp-4);
|
||||
}
|
||||
.statusbar .item { display: flex; align-items: center; gap: var(--sp-2); font-size: 12.5px; }
|
||||
.statusbar .item b { font-weight: 600; letter-spacing: -.005em; }
|
||||
.statusbar .item svg { color: var(--ink-3); }
|
||||
.statusbar .sep { width: 1px; align-self: stretch; background: var(--line); }
|
||||
.statusbar .grow { flex: 1; }
|
||||
|
||||
/* ============================================================= arrivals == */
|
||||
|
||||
/* The reason the product exists, so it gets the width and the weight. */
|
||||
.arrivals { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); }
|
||||
|
||||
.arrival {
|
||||
display: grid; grid-template-columns: 46px 1fr auto; gap: var(--sp-3); align-items: center;
|
||||
padding: var(--sp-3); border-radius: var(--r);
|
||||
background: var(--s2); border: 1px solid var(--line-2);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.arrival::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 2.5px; background: var(--ink-3); }
|
||||
.arrival.is-new::before { background: var(--accent); }
|
||||
.arrival.is-seen::before { background: var(--ok); }
|
||||
.arrival.is-miss::before { background: var(--warn); }
|
||||
|
||||
/* Only the newest row animates, and only once. */
|
||||
.arrival.fresh { animation: slidein .28s cubic-bezier(.2,.8,.3,1); }
|
||||
@keyframes slidein { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.arrival .avatar {
|
||||
width: 46px; height: 46px; border-radius: var(--r-sm); display: grid; place-items: center;
|
||||
overflow: hidden; background: var(--s3); border: 1px solid var(--line);
|
||||
font-family: var(--font-display); font-size: 15px; font-weight: 600;
|
||||
color: var(--ink-2); letter-spacing: -.01em; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.arrival .avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.arrival.is-new .avatar { background: var(--accent-3); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 25%, transparent); }
|
||||
|
||||
.arrival .who { min-width: 0; }
|
||||
.arrival .who .name { font-size: 14.5px; font-weight: 600; letter-spacing: -.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.arrival .who .meta { font-size: 11.5px; color: var(--ink-3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.arrival .right { text-align: right; display: flex; flex-direction: column; align-items: flex-end; gap: 5px; }
|
||||
.arrival .right .when { font-size: 11.5px; color: var(--ink-3); }
|
||||
|
||||
/* ================================================================ feeds == */
|
||||
|
||||
.feeds { display: grid; gap: var(--sp-3); grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); padding: var(--sp-3); }
|
||||
|
||||
.feed { position: relative; border-radius: var(--r); overflow: hidden; background: #05090C; border: 1px solid var(--line); aspect-ratio: 16 / 9; }
|
||||
.feed img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.feed .placeholder { width: 100%; height: 100%; display: grid; place-items: center; color: var(--ink-3); }
|
||||
/* Caption over the picture, not beneath it: the tile stays a picture. */
|
||||
.feed .cap {
|
||||
position: absolute; left: 0; right: 0; bottom: 0;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
|
||||
padding: var(--sp-5) var(--sp-3) var(--sp-3);
|
||||
background: linear-gradient(transparent, rgb(0 0 0 / .8));
|
||||
font-size: 12.5px; font-weight: 600; letter-spacing: -.005em;
|
||||
}
|
||||
|
||||
/* ================================================================ lists == */
|
||||
|
||||
.events, .timeline { list-style: none; padding: 0; display: flex; flex-direction: column; }
|
||||
.events li { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-4); border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
||||
.timeline li { display: flex; align-items: center; gap: var(--sp-3); padding: 9px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
||||
.events li:last-child, .timeline li:last-child { border-bottom: 0; }
|
||||
.events .when, .timeline .when { font-size: 11.5px; color: var(--ink-3); width: 46px; flex: none; }
|
||||
|
||||
.empty {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: var(--sp-3); padding: var(--sp-8) var(--sp-5); color: var(--ink-3); text-align: center; font-size: 12.5px;
|
||||
}
|
||||
.empty svg { opacity: .35; }
|
||||
.empty b { display: block; color: var(--ink-2); font-size: 13.5px; font-weight: 550; }
|
||||
.empty p { max-width: 34ch; line-height: 1.5; }
|
||||
|
||||
.tablewrap { overflow: auto; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
th {
|
||||
text-align: left; padding: 9px var(--sp-4); font-size: 10.5px; font-weight: 600;
|
||||
letter-spacing: .085em; text-transform: uppercase; color: var(--ink-3);
|
||||
background: var(--s2); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 1;
|
||||
}
|
||||
td { padding: 10px var(--sp-4); border-bottom: 1px solid var(--line-2); }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
tbody tr[role="button"], tbody tr.clickable { cursor: pointer; }
|
||||
tbody tr[role="button"]:hover, tbody tr.clickable:hover { background: var(--s2); }
|
||||
|
||||
/* ============================================================= controls == */
|
||||
|
||||
.btn {
|
||||
background: var(--surface-2); border: 1px solid var(--line);
|
||||
border-radius: 6px; padding: 7px 13px; cursor: pointer; font-size: 13px;
|
||||
color: var(--ink); white-space: nowrap;
|
||||
display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px;
|
||||
border-radius: var(--r-sm); background: var(--s3); color: var(--ink);
|
||||
border: 1px solid var(--line); cursor: pointer;
|
||||
font-size: 13px; font-weight: 550; letter-spacing: -.005em; white-space: nowrap;
|
||||
transition: background .12s ease, border-color .12s ease, transform .06s ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: #26323a; }
|
||||
.btn:disabled { opacity: .45; cursor: default; }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #06222a;
|
||||
font-weight: 600; }
|
||||
.btn.primary:hover:not(:disabled) { background: #5ac0d6; }
|
||||
.btn.danger { color: var(--bad); border-color: #4a2823; }
|
||||
.btn.sm { padding: 4px 9px; font-size: 12px; }
|
||||
.btn:hover:not(:disabled) { background: #253643; border-color: #2E414E; }
|
||||
.btn:active:not(:disabled) { transform: translateY(.5px); }
|
||||
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.btn svg { flex: none; }
|
||||
.btn.primary { background: var(--accent); color: #04171C; border-color: transparent; font-weight: 600; }
|
||||
.btn.primary:hover:not(:disabled) { background: #55D0E6; }
|
||||
.btn.danger { background: var(--bad-2); color: var(--bad); border-color: color-mix(in srgb, var(--bad) 32%, transparent); }
|
||||
.btn.danger:hover:not(:disabled) { background: #43201C; }
|
||||
.btn.ghost { background: transparent; }
|
||||
.btn.ghost:hover:not(:disabled) { background: var(--s2); }
|
||||
.btn.sm { padding: 5px 10px; font-size: 12px; }
|
||||
.btn.icon { padding: 7px; }
|
||||
|
||||
.field { display: block; margin-bottom: 12px; }
|
||||
.field span { display: block; font-size: 11.5px; color: var(--muted);
|
||||
margin-bottom: 4px; letter-spacing: .01em; }
|
||||
.linkbtn { background: none; border: 0; color: var(--accent); cursor: pointer; font-size: 12.5px; padding: 2px 0; text-align: left; }
|
||||
.linkbtn:hover { text-decoration: underline; }
|
||||
|
||||
.seg { display: inline-flex; background: var(--s2); border: 1px solid var(--line); border-radius: var(--r-sm); padding: 2px; gap: 2px; }
|
||||
.seg button { background: none; border: 0; border-radius: 4px; padding: 5px 11px; color: var(--ink-3); cursor: pointer; font-size: 12.5px; font-weight: 500; }
|
||||
.seg button[aria-pressed="true"] { background: var(--s3); color: var(--ink); }
|
||||
|
||||
/* ================================================================ forms == */
|
||||
|
||||
.field { display: block; margin-bottom: var(--sp-4); }
|
||||
.field > span { display: block; font-size: 11.5px; font-weight: 550; color: var(--ink-2); margin-bottom: 6px; }
|
||||
.field input, .field select, .field textarea {
|
||||
width: 100%; background: var(--ground); border: 1px solid var(--line);
|
||||
border-radius: 6px; padding: 8px 10px; font-size: 13.5px;
|
||||
user-select: text;
|
||||
width: 100%; padding: 9px 11px; background: var(--s2); color: var(--ink);
|
||||
border: 1px solid var(--line); border-radius: var(--r-sm);
|
||||
transition: border-color .12s ease, background .12s ease, box-shadow .12s ease;
|
||||
}
|
||||
.field input::placeholder { color: var(--ink-3); }
|
||||
.field input:hover, .field select:hover, .field textarea:hover { border-color: #2C3D49; }
|
||||
.field input:focus, .field select:focus, .field textarea:focus {
|
||||
border-color: var(--accent); outline: none;
|
||||
outline: none; border-color: var(--accent); background: var(--s1); box-shadow: 0 0 0 3px var(--accent-3);
|
||||
}
|
||||
.field textarea { resize: vertical; min-height: 66px; }
|
||||
.fieldrow { display: grid; gap: 0 12px; grid-template-columns: 1fr 1fr; }
|
||||
.field .hint { display: block; font-size: 11.5px; color: var(--ink-3); margin-top: 5px; line-height: 1.45; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { text-align: left; font-size: 10.5px; text-transform: uppercase;
|
||||
letter-spacing: .08em; color: var(--muted); font-weight: 600;
|
||||
padding: 8px 10px; border-bottom: 1px solid var(--line); }
|
||||
td { padding: 9px 10px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
tbody tr.click { cursor: pointer; }
|
||||
tbody tr.click:hover { background: var(--surface-2); }
|
||||
td.num { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.tablewrap { overflow-x: auto; }
|
||||
.fieldrow { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: var(--sp-3); }
|
||||
.fieldrow.three { grid-template-columns: repeat(3, minmax(0,1fr)); }
|
||||
|
||||
.empty { color: var(--muted); font-size: 13px; padding: 26px 4px; text-align: center; }
|
||||
.err {
|
||||
border: 1px solid #5c2e2a; background: #241312; color: #f0b3ad;
|
||||
border-radius: 6px; padding: 10px 12px; font-size: 13px; margin-bottom: 14px;
|
||||
display: flex; align-items: flex-start; gap: var(--sp-2);
|
||||
background: var(--bad-2); color: var(--bad);
|
||||
border: 1px solid color-mix(in srgb, var(--bad) 30%, transparent);
|
||||
border-radius: var(--r-sm); padding: 9px 11px; font-size: 12.5px; line-height: 1.45; margin-bottom: var(--sp-4);
|
||||
}
|
||||
.note { color: var(--muted); font-size: 12.5px; }
|
||||
.mono { font-family: var(--mono); font-size: 12px; }
|
||||
.err svg { flex: none; margin-top: 1px; }
|
||||
|
||||
/* --------------------------------------------------------------- login --- */
|
||||
.login { height: 100%; display: grid; place-items: center; padding: 24px; }
|
||||
.login .box { width: 100%; max-width: 380px; }
|
||||
.login h1 { font-size: 21px; font-weight: 650; letter-spacing: -.015em; }
|
||||
.login .lead { color: var(--muted); font-size: 13px; margin: 6px 0 22px; }
|
||||
.login form { background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: 10px; padding: 20px; }
|
||||
.login .btn { width: 100%; margin-top: 6px; }
|
||||
.login .foot { color: var(--muted); font-size: 11.5px; margin-top: 14px;
|
||||
text-align: center; line-height: 1.5; }
|
||||
/* The second way out of the setup screen: a shop with no head office. Styled
|
||||
quieter than the form above it because linking is still the common case,
|
||||
but present, because for a single-till shop it is the only one that works. */
|
||||
.login .alt { margin-top: 18px; padding-top: 16px; text-align: center;
|
||||
border-top: 1px solid var(--line-soft); }
|
||||
.login .alt .note { line-height: 1.55; margin-bottom: 12px; text-align: left; }
|
||||
.note { color: var(--ink-3); font-size: 12px; line-height: 1.5; }
|
||||
.note.warn { color: var(--warn); }
|
||||
.note.bad { color: var(--bad); }
|
||||
.lead { color: var(--ink-2); font-size: 13.5px; line-height: 1.55; }
|
||||
.sm { font-size: 12px; }
|
||||
.lbl, .key { color: var(--ink-3); font-size: 11.5px; }
|
||||
.grow { flex: 1; }
|
||||
.row { display: flex; align-items: center; gap: var(--sp-3); }
|
||||
|
||||
/* ================================================================= gate == */
|
||||
|
||||
/* Login and Setup: the first thing anybody sees, and previously a grey box on
|
||||
a grey field. One soft light behind the card gives the window a centre and
|
||||
costs nothing — it is a static gradient, not an animation. */
|
||||
.login {
|
||||
height: 100%; display: grid; place-items: center; padding: var(--sp-6); overflow: auto;
|
||||
background: radial-gradient(900px 480px at 50% -10%, #10303A 0%, transparent 62%), var(--bg);
|
||||
}
|
||||
.login .box {
|
||||
width: 100%; max-width: 396px; background: var(--s1); border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg); padding: var(--sp-7); box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.login .mark { width: 44px; height: 44px; margin-bottom: var(--sp-4); display: grid; place-items: center; }
|
||||
.login h1 { font-family: var(--font-display); font-size: 21px; font-weight: 600; letter-spacing: -.022em; }
|
||||
.login .lead { margin: 6px 0 var(--sp-5); }
|
||||
.login .btn { width: 100%; justify-content: center; margin-top: var(--sp-1); }
|
||||
.login .foot { font-size: 11.5px; color: var(--ink-3); line-height: 1.55; margin-top: var(--sp-5); padding-top: var(--sp-4); border-top: 1px solid var(--line-2); }
|
||||
.login .alt { margin-top: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
.login .alt .btn { margin-top: 0; }
|
||||
.linkbtn { background: none; border: 0; padding: 0; cursor: pointer;
|
||||
font: inherit; font-size: 12.5px; color: var(--accent);
|
||||
text-decoration: underline; text-underline-offset: 3px; }
|
||||
.linkbtn:hover { color: var(--ink); }
|
||||
.login .note { margin: 0; }
|
||||
|
||||
/* ---------------------------------------------------------------- live --- */
|
||||
.feeds { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
|
||||
.feed { background: #000; border: 1px solid var(--line); border-radius: var(--radius);
|
||||
overflow: hidden; }
|
||||
.feed img { width: 100%; display: block; aspect-ratio: 16/9; object-fit: cover; background: #000; }
|
||||
.feed .cap { display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 8px 11px; background: var(--surface); font-size: 12.5px; }
|
||||
/* =============================================================== drawer == */
|
||||
|
||||
.events { list-style: none; max-height: 420px; overflow-y: auto; }
|
||||
.events li { display: flex; gap: 9px; align-items: baseline;
|
||||
padding: 7px 2px; border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
|
||||
.events li:last-child { border-bottom: 0; }
|
||||
.events .when { color: var(--muted); font-family: var(--mono); font-size: 11px;
|
||||
flex: none; }
|
||||
.tag { font-size: 10px; padding: 2px 6px; border-radius: 4px; flex: none;
|
||||
background: var(--surface-2); color: var(--muted); }
|
||||
.tag.new { background: #17364f; color: #86c2ec; }
|
||||
.tag.seen { background: #14301f; color: #7fcb9c; }
|
||||
.tag.miss { background: #3a1c1a; color: #eb9a92; }
|
||||
.drawer { position: fixed; inset: 0; z-index: 40; background: rgb(4 8 11 / .6); display: flex; justify-content: flex-end; animation: fade .16s ease; }
|
||||
@keyframes fade { from { opacity: 0 } to { opacity: 1 } }
|
||||
.drawer .sheet {
|
||||
width: min(540px, 100%); height: 100%; overflow: auto;
|
||||
background: var(--s1); border-left: 1px solid var(--line); box-shadow: var(--shadow-lg);
|
||||
animation: slidein-r .2s cubic-bezier(.2,.8,.3,1);
|
||||
}
|
||||
@keyframes slidein-r { from { transform: translateX(16px); opacity: .6 } to { transform: none; opacity: 1 } }
|
||||
.drawer .sheethead {
|
||||
position: sticky; top: 0; z-index: 1; display: flex; align-items: center; justify-content: space-between;
|
||||
gap: var(--sp-3); padding: var(--sp-4) var(--sp-5); background: var(--s1); border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.drawer .sheethead h2 { font-family: var(--font-display); font-size: 17px; font-weight: 600; letter-spacing: -.015em; }
|
||||
.drawer .sheetbody { padding: var(--sp-5); }
|
||||
.drawer .close { background: none; border: 0; color: var(--ink-3); cursor: pointer; padding: 6px; border-radius: var(--r-sm); display: grid; place-items: center; }
|
||||
.drawer .close:hover { background: var(--s2); color: var(--ink); }
|
||||
|
||||
/* -------------------------------------------------------------- charts --- */
|
||||
.bars { display: flex; align-items: flex-end; gap: 3px; height: 150px; margin-top: 4px; }
|
||||
.bars .col { flex: 1; display: flex; flex-direction: column; justify-content: flex-end;
|
||||
gap: 2px; min-width: 0; }
|
||||
.bars .seg { border-radius: 2px 2px 0 0; }
|
||||
.bars .seg.ret { background: var(--accent); }
|
||||
.bars .seg.new { background: #2f6f81; }
|
||||
.axis { display: flex; justify-content: space-between; color: var(--muted);
|
||||
font-size: 10.5px; margin-top: 6px; font-family: var(--mono); }
|
||||
.key { display: flex; gap: 14px; font-size: 11.5px; color: var(--muted); margin-top: 10px; }
|
||||
.key i { display: inline-block; width: 9px; height: 9px; border-radius: 2px;
|
||||
margin-right: 5px; vertical-align: -1px; }
|
||||
.avatar {
|
||||
width: 44px; height: 44px; border-radius: var(--r-sm); flex: none; display: grid; place-items: center;
|
||||
overflow: hidden; background: var(--s3); border: 1px solid var(--line);
|
||||
font-weight: 600; color: var(--ink-2); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
/* --------------------------------------------------------------- drawer -- */
|
||||
.drawer { position: fixed; inset: 0; background: rgba(4,8,10,.6);
|
||||
display: flex; justify-content: flex-end; z-index: 30; }
|
||||
.drawer .panel { width: min(480px, 100%); height: 100%; background: var(--surface);
|
||||
border-left: 1px solid var(--line); overflow-y: auto; padding: 20px 22px 40px; }
|
||||
.drawer h3 { font-size: 16px; font-weight: 620; text-transform: none;
|
||||
letter-spacing: -.01em; color: var(--ink); margin-bottom: 2px; }
|
||||
/* Close lives in the sticky header (.who) now. Positioned against the fixed
|
||||
overlay it stayed put while the sheet scrolled underneath it, printing the
|
||||
button on top of whatever happened to be at the top of the viewport. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
|
||||
/* -- customer record ---------------------------------------------------- */
|
||||
/* Full-bleed sticky header: a customer record is long enough to scroll, and
|
||||
both the name and the way out have to stay reachable. The negative margins
|
||||
cancel the panel's padding so the background covers the full width. */
|
||||
.who { position: sticky; top: -20px; z-index: 1; display: flex; gap: 14px;
|
||||
align-items: flex-start; background: var(--surface);
|
||||
margin: -20px -22px 18px; padding: 20px 22px 14px;
|
||||
border-bottom: 1px solid var(--line-soft); }
|
||||
.who .grow { flex: 1; min-width: 0; }
|
||||
.who h3 { margin-bottom: 2px; }
|
||||
.avatar { width: 64px; height: 64px; border-radius: 10px; flex: none;
|
||||
object-fit: cover; background: var(--ground);
|
||||
border: 1px solid var(--line); }
|
||||
.avatar.none { display: grid; place-items: center; color: var(--muted);
|
||||
font-size: 20px; font-weight: 600; letter-spacing: .02em; }
|
||||
/* Cameras and arrivals side by side, the same height, each scrolling its own
|
||||
content. Left to itself the arrivals panel shrank to fit two cards and left
|
||||
a hole beside a tall camera tile - the layout looked broken precisely when
|
||||
the shop was quiet, which is most of the time. */
|
||||
.live-split {
|
||||
display: grid; gap: var(--sp-4);
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
min-height: 420px;
|
||||
}
|
||||
.live-split > .panel { max-height: 62vh; }
|
||||
@media (max-width: 1100px) {
|
||||
.live-split { grid-template-columns: minmax(0, 1fr); }
|
||||
.live-split > .panel { max-height: none; }
|
||||
}
|
||||
|
||||
.timeline { list-style: none; max-height: 220px; overflow-y: auto; }
|
||||
.timeline li { display: flex; gap: 10px; align-items: baseline; padding: 6px 0;
|
||||
border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
|
||||
.timeline li:last-child { border-bottom: 0; }
|
||||
.timeline .when { font-family: var(--mono); font-size: 11px; color: var(--muted);
|
||||
flex: none; min-width: 108px; }
|
||||
.timeline .where { flex: 1; min-width: 0; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* Visually separated from Save: this is the one control in the sheet that
|
||||
cannot be undone, and it must not read as just another button in a row. */
|
||||
.danger-zone { margin-top: 22px; border-color: #4a2823; }
|
||||
.danger-zone > h3 { color: var(--bad); }
|
||||
.danger-zone .note { margin-bottom: 10px; }
|
||||
|
||||
.confirm h4 { font-size: 13.5px; font-weight: 620; margin-bottom: 10px; }
|
||||
.confirm .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px;
|
||||
margin-bottom: 12px; }
|
||||
@media (max-width: 560px) { .confirm .cols { grid-template-columns: 1fr; } }
|
||||
.confirm .lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .07em;
|
||||
color: var(--muted); margin-bottom: 5px; }
|
||||
.confirm .lbl.bad { color: var(--bad); }
|
||||
.confirm ul { list-style: none; font-size: 12.5px; }
|
||||
.confirm li { padding: 3px 0 3px 12px; position: relative; color: var(--ink); }
|
||||
.confirm li::before { content: '·'; position: absolute; left: 2px;
|
||||
color: var(--muted); }
|
||||
.confirm .row { display: flex; gap: 8px; }
|
||||
/* Arrivals alone on the Live screen: one column, capped so a long day scrolls
|
||||
inside the panel rather than pushing the metrics off the bottom. */
|
||||
.arrivals-panel { max-height: 64vh; margin-bottom: var(--sp-4); }
|
||||
.arrivals-panel .arrivals { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: var(--sp-2); }
|
||||
|
||||
71
desktop/frontend/src/ui/icons.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
// One icon set, drawn rather than typed.
|
||||
//
|
||||
// The navigation used to be text characters — ◉ ☺ ▢ — which render in whatever
|
||||
// the system decides, sit on the text baseline instead of optical centre, and
|
||||
// cannot take a stroke weight. On a shop PC that is the difference between
|
||||
// software somebody trusts with their customers and something that looks
|
||||
// improvised.
|
||||
//
|
||||
// All of these are 24-unit grid, 1.6 stroke, currentColor, no fill. That means
|
||||
// one icon works on every surface and in every state without a second copy.
|
||||
|
||||
const base = {
|
||||
width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none',
|
||||
stroke: 'currentColor', strokeWidth: 1.6,
|
||||
strokeLinecap: 'round', strokeLinejoin: 'round',
|
||||
'aria-hidden': 'true', focusable: 'false',
|
||||
}
|
||||
|
||||
function Svg({ size, children, ...rest }) {
|
||||
return <svg {...base} {...rest} width={size ?? base.width} height={size ?? base.height}>{children}</svg>
|
||||
}
|
||||
|
||||
export const Live = p => (
|
||||
<Svg {...p}><circle cx="12" cy="12" r="3.2" /><path d="M5.6 5.6a9 9 0 0 0 0 12.8M18.4 18.4a9 9 0 0 0 0-12.8" /></Svg>
|
||||
)
|
||||
export const People = p => (
|
||||
<Svg {...p}><circle cx="9" cy="8.5" r="3.2" /><path d="M2.8 19.5a6.4 6.4 0 0 1 12.4 0" /><path d="M16.5 6.2a3.2 3.2 0 0 1 0 6.1M18 19.5a6 6 0 0 0-1.6-4" /></Svg>
|
||||
)
|
||||
export const Camera = p => (
|
||||
<Svg {...p}><path d="M3 8.5h3.4L8 6h8l1.6 2.5H21v10.2H3z" /><circle cx="12" cy="13.2" r="3.1" /></Svg>
|
||||
)
|
||||
export const Search = p => (
|
||||
<Svg {...p}><circle cx="11" cy="11" r="6.4" /><path d="M15.8 15.8 20.5 20.5" /></Svg>
|
||||
)
|
||||
export const Plus = p => (<Svg {...p}><path d="M12 5.5v13M5.5 12h13" /></Svg>)
|
||||
export const Close = p => (<Svg {...p}><path d="M6.5 6.5l11 11M17.5 6.5l-11 11" /></Svg>)
|
||||
export const Check = p => (<Svg {...p}><path d="M5 12.8l4.4 4.2L19 7" /></Svg>)
|
||||
export const Play = p => (<Svg {...p}><path d="M8 5.6v12.8L18.5 12z" /></Svg>)
|
||||
export const Stop = p => (<Svg {...p}><rect x="7" y="7" width="10" height="10" rx="1.6" /></Svg>)
|
||||
export const Warning = p => (
|
||||
<Svg {...p}><path d="M12 4.6 21 19.4H3z" /><path d="M12 10v4.1" /><path d="M12 17.1v.01" /></Svg>
|
||||
)
|
||||
export const Signal = p => (
|
||||
<Svg {...p}><path d="M5 19.4v-4.2M10.3 19.4v-7.6M15.7 19.4v-11M21 19.4V4.6" /></Svg>
|
||||
)
|
||||
export const Cloud = p => (
|
||||
<Svg {...p}><path d="M7.2 18.4a4.2 4.2 0 0 1-.6-8.35A6.2 6.2 0 0 1 18.4 9a4.2 4.2 0 0 1 .3 9.4z" /></Svg>
|
||||
)
|
||||
export const CloudOff = p => (
|
||||
<Svg {...p}><path d="M7.2 18.4a4.2 4.2 0 0 1-.6-8.35 6.2 6.2 0 0 1 2-3.2M10.6 5.2A6.2 6.2 0 0 1 18.4 9a4.2 4.2 0 0 1 1.9 7.6" /><path d="M3.6 3.6l16.8 16.8" /></Svg>
|
||||
)
|
||||
export const Shield = p => (
|
||||
<Svg {...p}><path d="M12 3.8 19.4 6.6v5.2c0 4.2-3 7.4-7.4 8.4-4.4-1-7.4-4.2-7.4-8.4V6.6z" /></Svg>
|
||||
)
|
||||
export const Link = p => (
|
||||
<Svg {...p}><path d="M10.2 13.8a3.6 3.6 0 0 0 5.2 0l2.8-2.8a3.7 3.7 0 0 0-5.2-5.2l-1.3 1.3" /><path d="M13.8 10.2a3.6 3.6 0 0 0-5.2 0l-2.8 2.8a3.7 3.7 0 0 0 5.2 5.2l1.3-1.3" /></Svg>
|
||||
)
|
||||
export const Logout = p => (
|
||||
<Svg {...p}><path d="M14.4 7.6V5.4H4.6v13.2h9.8v-2.2" /><path d="M10 12h9.4M16.4 8.8 19.8 12l-3.4 3.2" /></Svg>
|
||||
)
|
||||
export const Back = p => (<Svg {...p}><path d="M14.6 5.6 8 12l6.6 6.4" /></Svg>)
|
||||
export const Chevron = p => (<Svg {...p}><path d="M9.4 5.6 16 12l-6.6 6.4" /></Svg>)
|
||||
export const Dot = p => (<Svg {...p}><circle cx="12" cy="12" r="4.5" fill="currentColor" stroke="none" /></Svg>)
|
||||
|
||||
// Drawn for the empty states rather than an apologetic sentence in grey.
|
||||
export const NoCamera = p => (
|
||||
<Svg {...p} strokeWidth="1.2"><path d="M3 8.5h3.4L8 6h8l1.6 2.5H21v10.2H3z" /><circle cx="12" cy="13.2" r="3.1" /><path d="M3.6 3.6l16.8 16.8" /></Svg>
|
||||
)
|
||||
export const NoFaces = p => (
|
||||
<Svg {...p} strokeWidth="1.2"><circle cx="12" cy="9" r="3.4" /><path d="M5.4 20a6.8 6.8 0 0 1 13.2 0" /></Svg>
|
||||
)
|
||||
@@ -57,6 +57,7 @@ export default function CustomerForm({ customer, session, onClose, onSaved }) {
|
||||
<div className="panel">
|
||||
<div className="who">
|
||||
<CustomerPhoto photo={shown} name={customer.full_name || customer.label}
|
||||
customerRef={customer.ref}
|
||||
onBroken={() => setPhoto({ available: false,
|
||||
reason: 'The photo could not be loaded.' })} />
|
||||
<div className="grow">
|
||||
|
||||
@@ -30,16 +30,31 @@ export function useCustomerPhoto(id) {
|
||||
|
||||
// No photo is the normal case — images are off by default — so this renders
|
||||
// initials, not an error.
|
||||
export default function CustomerPhoto({ photo, name, onBroken }) {
|
||||
export default function CustomerPhoto({ photo, name, customerRef, onBroken }) {
|
||||
if (photo?.available) {
|
||||
return <img className="avatar" src={photo.url} alt={`Photo of ${name}`}
|
||||
onError={onBroken} />
|
||||
}
|
||||
const initials = String(name || '').split(/\s+/).filter(Boolean).slice(0, 2)
|
||||
.map(w => w[0].toUpperCase()).join('') || '?'
|
||||
return (
|
||||
<div className="avatar none" role="img" aria-label={`No photo of ${name}`}>
|
||||
<span>{initials}</span>
|
||||
<span>{avatarText(name, customerRef)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Initials of a name a human typed; the NUMBER for a customer the system named
|
||||
// itself. Taking the first letter of each word of "Visitor 13" gives "V1" —
|
||||
// which is also what "Visitor 10" and "Visitor 15" give, so three different
|
||||
// people wear the same badge, and it reads as the V-1 reference for a fourth.
|
||||
// Same fix as the web app's arrivals feed; the two must not disagree.
|
||||
//
|
||||
// customerRef, not `ref`: React reserves that prop name and it would never
|
||||
// reach this component.
|
||||
function avatarText(name, customerRef) {
|
||||
const auto = /^Visitor (\d+)$/.exec(String(name || '').trim())
|
||||
if (auto) return auto[1]
|
||||
const n = /^V-(\d+)$/.exec(String(customerRef || ''))
|
||||
if (n) return n[1]
|
||||
return String(name || '').split(/\s+/).filter(Boolean).slice(0, 2)
|
||||
.map(w => w[0].toUpperCase()).join('') || '?'
|
||||
}
|
||||
|
||||
@@ -56,7 +56,14 @@ export default function Customers({ session }) {
|
||||
<tbody>
|
||||
{rows.map(c => (
|
||||
<tr key={c.id} className="click" onClick={() => setSelected(c)}>
|
||||
<td>{c.full_name || <span className="note">{c.label}</span>}</td>
|
||||
<td>
|
||||
{c.full_name || <span className="note">{c.label}</span>}
|
||||
{/* Only beside a name a human typed: the auto label
|
||||
already IS the number ("Visitor 13"), so showing
|
||||
both reads as two identifiers for one person. */}
|
||||
{c.full_name && c.ref &&
|
||||
<span className="note"> · {c.ref}</span>}
|
||||
</td>
|
||||
<td className="mono">{c.phone || '—'}</td>
|
||||
<td className="num">{c.visit_count}</td>
|
||||
<td>{fmtDate(c.first_seen_at)}</td>
|
||||
|
||||
@@ -1,192 +1,192 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../bridge.js'
|
||||
import { usePolled, fmtTime } from '../hooks.js'
|
||||
import * as Icon from '../ui/icons.jsx'
|
||||
|
||||
// What is happening right now. The first screen a shop manager opens, so it
|
||||
// answers "is it working" before it answers anything else.
|
||||
// The shop floor screen.
|
||||
//
|
||||
// Rebuilt around what somebody standing at the counter is actually here for:
|
||||
// WHO JUST WALKED IN. The previous version led with four large stat boxes and
|
||||
// left arrivals as a thin list of "person.seen" rows in the corner — the least
|
||||
// actionable content taking the most space, and the product's whole reason for
|
||||
// existing rendered as a log.
|
||||
//
|
||||
// Now: a status strip that answers "is this working" in one line, and arrivals
|
||||
// as cards big enough to recognise a customer from while looking up at them.
|
||||
// No camera picture here - the person at the counter is not watching CCTV,
|
||||
// and a live video tile costs CPU the recognition pipeline needs. The picture
|
||||
// lives on the Cameras screen, where it is a setup tool.
|
||||
export default function Live() {
|
||||
const { data, error } = usePolled(() => api.live(), 3000)
|
||||
const { data: pipe } = usePolled(() => api.pipelineStatus(), 5000)
|
||||
const cams = useCameraFeeds()
|
||||
|
||||
const cameras = data?.stats?.cameras ?? []
|
||||
const gallery = data?.stats?.gallery ?? {}
|
||||
const events = data?.events ?? []
|
||||
|
||||
// fraction_below_gate is the number that decides a site: what share of the
|
||||
// faces this camera saw were too poor to enrol. Surfaced here rather than
|
||||
// buried, because a high value looks exactly like "a quiet day".
|
||||
// faces this camera saw were too poor to enrol. Surfaced rather than buried,
|
||||
// because a high value looks exactly like "a quiet day".
|
||||
const worst = cameras.reduce((acc, c) => {
|
||||
const f = c?.pipeline?.best_quality?.fraction_below_gate
|
||||
return typeof f === 'number' && f > acc ? f : acc
|
||||
}, 0)
|
||||
const up = cameras.filter(c => c.connected).length
|
||||
|
||||
const arrivals = events.filter(e => e.type === 'person.new' || e.type === 'person.seen')
|
||||
const freshest = useFreshest(arrivals[0])
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header>
|
||||
<h2>Live</h2>
|
||||
<p>Cameras, recent detections, and whether this site is recognising people.</p>
|
||||
<p>Who is in the shop, and whether it is reaching head office.</p>
|
||||
</header>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
|
||||
<div className="grid cols-4" style={{ marginBottom: 16 }}>
|
||||
<Stat label="People known" value={gallery.identities ?? '—'} />
|
||||
<Stat label="Sightings" value={gallery.sightings ?? '—'} />
|
||||
<Stat label="Cameras live"
|
||||
value={`${cameras.filter(c => c.connected).length}/${cameras.length || 0}`} />
|
||||
<Stat label="Below quality gate"
|
||||
value={cameras.length ? `${Math.round(worst * 100)}%` : '—'}
|
||||
tone={worst > 0.5 ? 'bad' : worst > 0.2 ? 'warn' : 'ok'}
|
||||
sub={worst > 0.5 ? 'Most visitors are being missed — check camera placement'
|
||||
: 'Share of faces too poor to enrol'} />
|
||||
<PipelineStrip pipe={pipe} cameras={cameras} up={up} />
|
||||
|
||||
<div className="panel arrivals-panel">
|
||||
<div className="panelhead">
|
||||
<h3>Who just walked in</h3>
|
||||
{arrivals.length > 0 && <span className="note">{arrivals.length} today</span>}
|
||||
</div>
|
||||
<div className="panelbody flush">
|
||||
{arrivals.length === 0
|
||||
? <div className="empty">
|
||||
<Icon.NoFaces size={34} />
|
||||
<b>Nobody yet</b>
|
||||
<p>Customers appear here the moment a camera recognises a face.</p>
|
||||
</div>
|
||||
: <div className="arrivals">
|
||||
{arrivals.slice(0, 30).map((e, i) => (
|
||||
<Arrival key={`${e.ts}-${i}`} e={e} fresh={i === 0 && freshest} />
|
||||
))}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pipeline pipe={pipe} />
|
||||
|
||||
<div className="grid cols-2">
|
||||
<div>
|
||||
<div className="card">
|
||||
<h3>Cameras</h3>
|
||||
{cameras.length === 0
|
||||
? <div className="empty">No cameras yet. Add one in Cameras.</div>
|
||||
: <div className="feeds">
|
||||
{cameras.map(c => (
|
||||
<div className="feed" key={c.camera_id}>
|
||||
{cams[c.camera_id]
|
||||
? <img src={cams[c.camera_id]} alt={c.camera_id} />
|
||||
: <div style={{ aspectRatio: '16/9' }} />}
|
||||
<div className="cap">
|
||||
<span>{c.camera_id}</span>
|
||||
<span className={`pill ${c.connected ? 'ok' : 'bad'}`}>
|
||||
<i className={`dot ${c.connected ? 'ok' : 'bad'}`} />
|
||||
{c.connected ? 'live' : 'offline'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Recent detections</h3>
|
||||
{events.length === 0
|
||||
? <div className="empty">Nothing detected yet.</div>
|
||||
: <ul className="events">
|
||||
{events.map((e, i) => <EventRow key={i} e={e} />)}
|
||||
</ul>}
|
||||
</div>
|
||||
<div className="metrics" style={{ marginTop: 'var(--sp-4)' }}>
|
||||
<Metric k="People known" v={gallery.identities ?? '—'} />
|
||||
<Metric k="Sightings" v={gallery.sightings ?? '—'} />
|
||||
<Metric k="Cameras live" v={cameras.length ? `${up}/${cameras.length}` : '—'}
|
||||
tone={!cameras.length ? null : up === 0 ? 'bad' : up < cameras.length ? 'warn' : 'ok'} />
|
||||
<Metric k="Faces too poor to use"
|
||||
v={cameras.length ? `${Math.round(worst * 100)}%` : '—'}
|
||||
tone={worst > 0.5 ? 'bad' : worst > 0.2 ? 'warn' : 'ok'}
|
||||
s={worst > 0.5 ? 'Most visitors are being missed — move the camera'
|
||||
: 'Share of faces below the enrolment gate'} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Whether anything is actually reaching head office. Without this the app can
|
||||
// look perfectly healthy while every detection piles up on disk unsent — which
|
||||
// is exactly what it did before the bridge existed.
|
||||
function Pipeline({ pipe }) {
|
||||
// One customer, big enough to match against the person in front of you.
|
||||
function Arrival({ e, fresh }) {
|
||||
const isNew = e.type === 'person.new'
|
||||
const name = e.data?.label || 'Unrecognised'
|
||||
const bits = [e.data?.gender, e.data?.age ?? e.data?.age_range, e.data?.emotion].filter(Boolean)
|
||||
const sim = typeof e.data?.similarity === 'number' ? e.data.similarity : null
|
||||
|
||||
return (
|
||||
<article className={`arrival ${isNew ? 'is-new' : 'is-seen'} ${fresh ? 'fresh' : ''}`}>
|
||||
<div className="avatar">{avatarText(name)}</div>
|
||||
<div className="who">
|
||||
<div className="name">{name}</div>
|
||||
<div className="meta">
|
||||
{e.camera_id}
|
||||
{bits.length > 0 && <> · {bits.join(', ')}</>}
|
||||
{sim !== null && !isNew && <> · match {sim.toFixed(2)}</>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="right">
|
||||
<span className={`tag ${isNew ? 'new' : 'seen'}`}>{isNew ? 'new' : 'returning'}</span>
|
||||
<span className="when">{fmtTime(e.ts)}</span>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
// "Visitor 13" must show 13, not V1 — initials() would give the same two
|
||||
// characters to Visitor 10, 13 and 15, and read as the reference V-1 for a
|
||||
// fourth person. Found by looking at the screen, not by a test.
|
||||
function avatarText(name) {
|
||||
const auto = /^Visitor (\d+)$/.exec(String(name).trim())
|
||||
if (auto) return auto[1]
|
||||
const words = String(name).trim().split(/\s+/).filter(Boolean)
|
||||
if (!words.length) return '?'
|
||||
return (words[0][0] + (words[1]?.[0] ?? '')).toUpperCase()
|
||||
}
|
||||
|
||||
// One line, above everything, answering the question every other screen is a
|
||||
// detail of: is this shop working, and is anything leaving it.
|
||||
function PipelineStrip({ pipe, cameras, up }) {
|
||||
if (!pipe) return null
|
||||
// A PC set up on its own is not "not linked yet" — nothing is coming, and
|
||||
// saying so with an idle dot beside a count of zero reads as a fault.
|
||||
|
||||
// A PC set up on its own is not "not linked yet" — nothing is coming, and an
|
||||
// idle dot beside a count of zero reads as a fault.
|
||||
if (pipe.standalone) {
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 16, display: 'flex',
|
||||
gap: 10, alignItems: 'center' }}>
|
||||
<i className="dot ok" />
|
||||
<strong style={{ fontSize: 13 }}>Running on this PC only</strong>
|
||||
<span className="note">Recognition and customers stay here.</span>
|
||||
<div className="statusbar">
|
||||
<span className="item"><i className="dot ok" /><b>Running on this PC only</b></span>
|
||||
<span className="sep" />
|
||||
<span className="item note">Recognition and customers stay here.</span>
|
||||
<span className="grow" />
|
||||
<span className="item note"><Icon.Signal size={14} />{up} of {cameras.length} cameras</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const stuck = pipe.claimed && !pipe.broker_up
|
||||
const tone = !pipe.claimed ? 'idle' : pipe.broker_up ? 'ok' : 'bad'
|
||||
const text = !pipe.claimed ? 'Not linked to head office'
|
||||
: pipe.broker_up ? 'Sending to head office' : 'Offline — saving locally'
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 16, display: 'flex',
|
||||
gap: 22, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<i className={`dot ${!pipe.claimed ? 'idle' : pipe.broker_up ? 'ok' : 'bad'}`} />
|
||||
<strong style={{ fontSize: 13 }}>
|
||||
{!pipe.claimed ? 'Not linked to head office'
|
||||
: pipe.broker_up ? 'Sending to head office' : 'Offline — saving locally'}
|
||||
</strong>
|
||||
<div className="statusbar">
|
||||
<span className="item">
|
||||
{pipe.broker_up ? <Icon.Cloud size={15} /> : <Icon.CloudOff size={15} />}
|
||||
<i className={`dot ${tone}`} /><b>{text}</b>
|
||||
</span>
|
||||
<span className="note">{pipe.accepted} recorded today</span>
|
||||
<span className="sep" />
|
||||
<span className="item note">{pipe.accepted} recorded today</span>
|
||||
{pipe.queued > 0 && (
|
||||
<span className="note" style={stuck ? { color: 'var(--warn)' } : undefined}>
|
||||
{pipe.queued} waiting to send
|
||||
</span>
|
||||
<span className={`item note ${stuck ? 'warn' : ''}`}>{pipe.queued} waiting to send</span>
|
||||
)}
|
||||
{pipe.dropped > 0 && (
|
||||
<span className="note" style={{ color: 'var(--bad)' }}>
|
||||
{pipe.dropped} lost — this PC was offline too long
|
||||
</span>
|
||||
<span className="item note bad"><Icon.Warning size={14} />{pipe.dropped} lost — this PC was offline too long</span>
|
||||
)}
|
||||
<span className="grow" />
|
||||
<span className="item note"><Icon.Signal size={14} />{up} of {cameras.length} cameras</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventRow({ e }) {
|
||||
const cls = e.type === 'person.new' ? 'new'
|
||||
: e.type === 'person.seen' ? 'seen'
|
||||
: e.type === 'person.missed' ? 'miss' : ''
|
||||
const age = e.data?.age ?? e.data?.age_range
|
||||
const extra = [e.data?.gender, age, e.data?.emotion].filter(Boolean).join(', ')
|
||||
function Metric({ k, v, s, tone }) {
|
||||
return (
|
||||
<li>
|
||||
<span className="when">{fmtTime(e.ts)}</span>
|
||||
<span className={`tag ${cls}`}>{label(e.type)}</span>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
{e.data?.label || e.camera_id}
|
||||
{extra && <span className="note"> · {extra}</span>}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// The event names are internal; a shop manager should not have to learn them.
|
||||
function label(type) {
|
||||
return {
|
||||
'person.new': 'new',
|
||||
'person.seen': 'returning',
|
||||
'person.missed': 'missed',
|
||||
'camera.up': 'camera up',
|
||||
'camera.down': 'camera down',
|
||||
'identity.merged': 'merged',
|
||||
}[type] ?? type
|
||||
}
|
||||
|
||||
function Stat({ label, value, sub, tone }) {
|
||||
return (
|
||||
<div className="card stat">
|
||||
<h3>{label}</h3>
|
||||
<div className="value" style={tone ? { color: `var(--${tone})` } : undefined}>
|
||||
{value}
|
||||
</div>
|
||||
{sub && <div className="sub">{sub}</div>}
|
||||
<div className={`metric ${tone ?? ''}`}>
|
||||
<div className="metric-k">{k}</div>
|
||||
<div className="metric-v">{v}</div>
|
||||
{s && <div className="metric-s">{s}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Stream URLs are fetched once per camera and then left alone: reassigning an
|
||||
// MJPEG <img> src restarts the stream, so rebuilding them on every poll would
|
||||
// make every feed flicker permanently.
|
||||
function useCameraFeeds() {
|
||||
const [urls, setUrls] = useState({})
|
||||
const { data } = usePolled(() => api.cameras(), 10000)
|
||||
// True for a few seconds after a genuinely new arrival, so the top card can
|
||||
// announce itself once. Keyed on the timestamp rather than the array, which
|
||||
// changes identity on every poll.
|
||||
function useFreshest(top) {
|
||||
const [fresh, setFresh] = useState(false)
|
||||
const seen = useRef(null)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const next = {}
|
||||
for (const cam of data ?? []) {
|
||||
if (urls[cam.id]) { next[cam.id] = urls[cam.id]; continue }
|
||||
try { next[cam.id] = await api.streamURL(cam.id) } catch { /* engine down */ }
|
||||
}
|
||||
const changed = Object.keys(next).length !== Object.keys(urls).length ||
|
||||
Object.keys(next).some(k => next[k] !== urls[k])
|
||||
if (!cancelled && changed) setUrls(next)
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data])
|
||||
return urls
|
||||
if (!top || top.ts === seen.current) return
|
||||
const first = seen.current === null
|
||||
seen.current = top.ts
|
||||
if (first) return // do not flash the whole list on mount
|
||||
setFresh(true)
|
||||
const id = setTimeout(() => setFresh(false), 1200)
|
||||
return () => clearTimeout(id)
|
||||
}, [top?.ts])
|
||||
return fresh
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
import * as Icon from '../ui/icons.jsx'
|
||||
import logo from '../assets/loyaly-mark.png'
|
||||
|
||||
// The gate. Nothing else in the app is reachable until this succeeds, because
|
||||
// the broker credentials and the customer database both live behind it.
|
||||
@@ -24,10 +26,11 @@ export default function Login({ onDone }) {
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="box">
|
||||
<span className="mark"><img src={logo} alt="" /></span>
|
||||
<h1>Behavision</h1>
|
||||
<p className="lead">Sign in to connect this PC to your store.</p>
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className="err">{error}</div>}
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
<label className="field">
|
||||
<span>Email</span>
|
||||
<input type="email" value={email} autoComplete="username" required
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
import * as Icon from '../ui/icons.jsx'
|
||||
import logo from '../assets/loyaly-mark.png'
|
||||
|
||||
// Linking this PC to a shop — the first thing that happens on a new install,
|
||||
// and until now the one thing the app could not do.
|
||||
@@ -41,12 +43,13 @@ export default function Setup({ onDone, onCancel }) {
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="box">
|
||||
<span className="mark"><img src={logo} alt="" /></span>
|
||||
<h1>{onCancel ? 'Link to head office' : 'Set up this PC'}</h1>
|
||||
<p className="lead">
|
||||
Type the installation code for this shop. You only do this once.
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className="err">{error}</div>}
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
<label className="field">
|
||||
<span>Installation code</span>
|
||||
{/* Uppercase and letter-spaced because the code arrives read aloud
|
||||
|
||||
@@ -14,16 +14,34 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.3 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.10.2 // indirect
|
||||
github.com/labstack/gommon v0.4.0 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.0 // indirect
|
||||
github.com/leaanthony/gosod v1.0.3 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/samber/lo v1.38.1 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.6 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.16 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
|
||||
@@ -20,6 +21,7 @@ github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN
|
||||
github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
|
||||
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
|
||||
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.0 h1:T8TuMhFB6TUMIUm0oRrSbgJudTFw9csT3ZK09w0t4Pg=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.0/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
@@ -30,6 +32,7 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.0 h1:2n0d2BwPVXSUq5yhe8lJPHdxevE2qK5G99PMStMZMaI=
|
||||
github.com/leaanthony/u v1.1.0/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
@@ -42,6 +45,7 @@ github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
@@ -50,6 +54,8 @@ github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
|
||||
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tkrajina/go-reflector v0.5.6 h1:hKQ0gyocG7vgMD2M3dRlYN6WBBOmdoOzJ6njQSepKdE=
|
||||
github.com/tkrajina/go-reflector v0.5.6/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
@@ -92,3 +98,5 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -2,28 +2,32 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
agentpaths "github.com/loyaly/behavision-agent/pkg/paths"
|
||||
)
|
||||
|
||||
// iconFor renders the tray icon at run time rather than embedding four PNGs.
|
||||
// iconFor renders the tray icon at run time: the Loyaly mark with a state
|
||||
// dot in the corner. Green, amber, red or grey is the only thing a taskbar
|
||||
// conveys at this size, and the mark is what makes it OURS among a row of
|
||||
// other icons - a plain coloured circle read as a generic status light.
|
||||
//
|
||||
// A 16x16 filled circle is all the taskbar shows at this size, and generating
|
||||
// it means the four states cannot drift apart visually or have one file go
|
||||
// missing from a build.
|
||||
// The mark is embedded once at 128px and scaled down here, so the four
|
||||
// states cannot drift apart and no file can go missing from a build.
|
||||
//
|
||||
// The encoding is per-platform and is NOT cosmetic. systray writes these bytes
|
||||
// to a temp file and, on Windows, hands the path to LoadImageW with
|
||||
// IMAGE_ICON|LR_LOADFROMFILE — which decodes .ico and nothing else. A PNG
|
||||
// IMAGE_ICON|LR_LOADFROMFILE - which decodes .ico and nothing else. A PNG
|
||||
// there returns 0, systray logs "unable to set icon", and the product ships
|
||||
// with no tray icon at all: the one control surface a shop manager has.
|
||||
func iconFor(state string) []byte {
|
||||
img := circle(colorFor(state))
|
||||
img := trayImage(colorFor(state))
|
||||
if runtime.GOOS == "windows" {
|
||||
return encodeICO(img)
|
||||
}
|
||||
@@ -46,30 +50,93 @@ func colorFor(state string) color.RGBA {
|
||||
}
|
||||
}
|
||||
|
||||
const iconSize = 16
|
||||
// 32px rather than 16: Windows shows 16 at 100% scaling and 24 at 150%, and
|
||||
// scaling a 32 down looks right at both, where a 16 scaled up looks like 2005.
|
||||
const iconSize = 32
|
||||
|
||||
func circle(c color.RGBA) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
||||
const r = 6.5
|
||||
cx, cy := float64(iconSize)/2-0.5, float64(iconSize)/2-0.5
|
||||
//go:embed tray-logo.png
|
||||
var trayLogoPNG []byte
|
||||
|
||||
var (
|
||||
trayLogoOnce sync.Once
|
||||
trayLogo *image.RGBA
|
||||
)
|
||||
|
||||
// logo is the embedded mark, decoded once and box-filtered down to iconSize.
|
||||
// A box filter rather than nearest-neighbour: 128->32 is an exact 4x4 average
|
||||
// and nearest would drop three pixels in four, which shreds the thin outline.
|
||||
func logo() *image.RGBA {
|
||||
trayLogoOnce.Do(func() {
|
||||
src, err := png.Decode(bytes.NewReader(trayLogoPNG))
|
||||
if err != nil {
|
||||
trayLogo = image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
||||
return
|
||||
}
|
||||
b := src.Bounds()
|
||||
f := b.Dx() / iconSize
|
||||
out := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
||||
for y := 0; y < iconSize; y++ {
|
||||
for x := 0; x < iconSize; x++ {
|
||||
var r, g, bl, a uint64
|
||||
for dy := 0; dy < f; dy++ {
|
||||
for dx := 0; dx < f; dx++ {
|
||||
// Premultiplied so transparent pixels do not drag the
|
||||
// colour of the edge towards black.
|
||||
pr, pg, pb, pa := src.At(b.Min.X+x*f+dx, b.Min.Y+y*f+dy).RGBA()
|
||||
r += uint64(pr)
|
||||
g += uint64(pg)
|
||||
bl += uint64(pb)
|
||||
a += uint64(pa)
|
||||
}
|
||||
}
|
||||
n := uint64(f * f)
|
||||
out.SetRGBA(x, y, color.RGBA{
|
||||
R: uint8(r / n >> 8), G: uint8(g / n >> 8), B: uint8(bl / n >> 8), A: uint8(a / n >> 8),
|
||||
})
|
||||
}
|
||||
}
|
||||
trayLogo = out
|
||||
})
|
||||
return trayLogo
|
||||
}
|
||||
|
||||
// trayImage is the mark with a state dot over its bottom-right corner, ringed
|
||||
// so it reads against both the yellow of the mark and a dark taskbar.
|
||||
func trayImage(c color.RGBA) *image.RGBA {
|
||||
base := logo()
|
||||
img := image.NewRGBA(base.Bounds())
|
||||
copy(img.Pix, base.Pix)
|
||||
const r = 6.0
|
||||
cx, cy := float64(iconSize)-r-0.5, float64(iconSize)-r-0.5
|
||||
ring := color.RGBA{R: 0x11, G: 0x14, B: 0x18, A: 0xFF}
|
||||
for y := 0; y < iconSize; y++ {
|
||||
for x := 0; x < iconSize; x++ {
|
||||
dx, dy := float64(x)-cx, float64(y)-cy
|
||||
d := dx*dx + dy*dy
|
||||
switch {
|
||||
case d <= (r-1)*(r-1):
|
||||
case d <= (r-1.5)*(r-1.5):
|
||||
img.SetRGBA(x, y, c)
|
||||
case d <= r*r:
|
||||
img.SetRGBA(x, y, ring)
|
||||
case d <= (r+1)*(r+1):
|
||||
// One-pixel feathered edge; a hard-aliased circle looks broken
|
||||
// next to every other icon in the tray.
|
||||
a := uint8(float64(c.A) * (r*r - d) / (r*r - (r-1)*(r-1)))
|
||||
img.SetRGBA(x, y, color.RGBA{R: c.R, G: c.G, B: c.B, A: a})
|
||||
a := uint8(255 * ((r+1)*(r+1) - d) / ((r+1)*(r+1) - r*r))
|
||||
bg := img.RGBAAt(x, y)
|
||||
img.SetRGBA(x, y, blend(bg, ring, a))
|
||||
}
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func blend(under, over color.RGBA, a uint8) color.RGBA {
|
||||
fa := float64(a) / 255
|
||||
mix := func(u, o uint8) uint8 { return uint8(float64(u)*(1-fa) + float64(o)*fa) }
|
||||
ua := float64(under.A)/255*(1-fa) + fa
|
||||
return color.RGBA{R: mix(under.R, over.R), G: mix(under.G, over.G), B: mix(under.B, over.B), A: uint8(ua * 255)}
|
||||
}
|
||||
|
||||
// encodeICO writes a single-image .ico holding an uncompressed 32-bit DIB.
|
||||
//
|
||||
// Vista and later also accept a PNG stored inside the .ico container, which
|
||||
@@ -94,8 +161,8 @@ func encodeICO(img *image.RGBA) []byte {
|
||||
// ICONDIRENTRY. 256 is encoded as 0 in these byte fields; at 16px it is moot.
|
||||
b.WriteByte(byte(w))
|
||||
b.WriteByte(byte(h))
|
||||
b.WriteByte(0) // palette size: none
|
||||
b.WriteByte(0) // reserved
|
||||
b.WriteByte(0) // palette size: none
|
||||
b.WriteByte(0) // reserved
|
||||
binary.Write(&b, binary.LittleEndian, uint16(1)) // colour planes
|
||||
binary.Write(&b, binary.LittleEndian, uint16(32)) // bits per pixel
|
||||
binary.Write(&b, binary.LittleEndian, uint32(dib)) // bytes in resource
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// nothing — and nothing on a Mac could notice. These tests are the substitute
|
||||
// for the Windows box we do not have.
|
||||
func TestEncodeICOIsAValidIconFile(t *testing.T) {
|
||||
b := encodeICO(circle(colorFor("ok")))
|
||||
b := encodeICO(trayImage(colorFor("ok")))
|
||||
if len(b) < 22 {
|
||||
t.Fatalf("far too short: %d bytes", len(b))
|
||||
}
|
||||
@@ -48,12 +48,13 @@ func TestEncodeICOIsAValidIconFile(t *testing.T) {
|
||||
|
||||
func TestEncodeICOPixelsAreBGRABottomUp(t *testing.T) {
|
||||
want := colorFor("error") // red: distinguishable from B and G if swapped
|
||||
img := circle(want)
|
||||
img := trayImage(want)
|
||||
b := encodeICO(img)
|
||||
// Centre of the circle, which is solid fill. Bottom-up means image row
|
||||
// iconSize/2 lands at DIB row iconSize/2-1 counting from the start.
|
||||
row := iconSize - 1 - iconSize/2
|
||||
i := 22 + 40 + (row*iconSize+iconSize/2)*4
|
||||
// Centre of the state dot, which is solid fill. Bottom-up means image row
|
||||
// y lands at DIB row iconSize-1-y counting from the start.
|
||||
x, y := iconSize-6-1, iconSize-6-1
|
||||
row := iconSize - 1 - y
|
||||
i := 22 + 40 + (row*iconSize+x)*4
|
||||
got := b[i : i+4]
|
||||
if !bytes.Equal(got, []byte{want.B, want.G, want.R, 0xFF}) {
|
||||
t.Errorf("centre pixel = % x, want % x (BGRA)",
|
||||
|
||||
@@ -9,6 +9,7 @@ package cloud
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -179,9 +180,16 @@ func (c *Client) send(ctx context.Context, method, path string, raw []byte, out
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusUnauthorized && e.Error == "token_expired":
|
||||
return errTokenExpired
|
||||
case resp.StatusCode == http.StatusUnauthorized:
|
||||
case resp.StatusCode == http.StatusUnauthorized && tok != "":
|
||||
// A 401 on a call we sent a session with: the session is the problem.
|
||||
return ErrUnauthorized
|
||||
case resp.StatusCode >= 400:
|
||||
// Every other 4xx/5xx - including a 401 on a call that carried NO
|
||||
// session, such as redeeming an installation code - is about the
|
||||
// request, and the server wrote its message for exactly this moment.
|
||||
// Mapping those to "session expired" told an installer their session
|
||||
// had lapsed on a screen where they had never signed in, and hid
|
||||
// "That installation code is not valid" behind it.
|
||||
msg := e.Message
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("%s %s: %s", method, path, resp.Status)
|
||||
@@ -393,6 +401,11 @@ type Photo struct {
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason"`
|
||||
// Auth is set by the server when the URL is one of its own endpoints and
|
||||
// needs this session's bearer, rather than a presigned object-store link
|
||||
// that carries its own signature. It never reaches the front end - see
|
||||
// VisitorImage, which resolves it here.
|
||||
Auth bool `json:"auth"`
|
||||
}
|
||||
|
||||
// VisitorImage fetches a short-lived signed link to this customer's photo.
|
||||
@@ -413,9 +426,91 @@ func (c *Client) VisitorImage(ctx context.Context, id string) (Photo, error) {
|
||||
return Photo{}, err
|
||||
}
|
||||
out.Available = out.URL != ""
|
||||
|
||||
// A deployment with no object storage serves the photo from the API itself,
|
||||
// which means a RELATIVE url that needs this session's bearer. Neither
|
||||
// works in the window: a webview <img> resolves a relative src against
|
||||
// wails://, not against the cloud, and it cannot send an Authorization
|
||||
// header at all - so handing it straight through renders a broken picture
|
||||
// on exactly the deployments that have just started storing photos.
|
||||
//
|
||||
// Fetched here and passed as a data: URI. The alternative is a local proxy
|
||||
// inside this process holding the session, which is a second authenticated
|
||||
// surface on the shop PC to get wrong. One photo per sheet, ~90 KB, and the
|
||||
// server already records the read where the link was handed out.
|
||||
if out.Available && out.Auth {
|
||||
data, err := c.fetchImage(ctx, out.URL)
|
||||
if err != nil {
|
||||
// The record itself is worth far more than the picture, so this is
|
||||
// an absence with a reason rather than a failure that blanks the
|
||||
// customer - the same rule the whole image path follows.
|
||||
return Photo{Reason: "That photo could not be loaded."}, nil
|
||||
}
|
||||
out.URL = data
|
||||
out.Auth = false
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fetchImage reads an image this server holds itself and returns a data: URI.
|
||||
//
|
||||
// Deliberately not routed through send(): that decodes JSON into `out`, and
|
||||
// these are bytes. It shares the token and the expiry retry, because a sheet
|
||||
// opened twelve hours after the last one must not show a broken photo.
|
||||
func (c *Client) fetchImage(ctx context.Context, path string) (string, error) {
|
||||
body, err := c.imageBytes(ctx, path)
|
||||
if errors.Is(err, errTokenExpired) {
|
||||
if rerr := c.Refresh(ctx); rerr != nil {
|
||||
return "", rerr
|
||||
}
|
||||
body, err = c.imageBytes(ctx, path)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(body), nil
|
||||
}
|
||||
|
||||
// maxPhotoBytes bounds what will be pulled into memory and then base64'd into
|
||||
// the window. Face crops are ~20 KB and a camera still ~100 KB; anything near
|
||||
// this is a different file or a fault, and a shop PC should not spend its
|
||||
// memory finding that out.
|
||||
const maxPhotoBytes = 4 << 20
|
||||
|
||||
func (c *Client) imageBytes(ctx context.Context, path string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.mu.RLock()
|
||||
tok := c.token
|
||||
c.mu.RUnlock()
|
||||
if tok != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot reach %s: %w", c.Base, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
var e struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
_ = json.Unmarshal(body, &e)
|
||||
if e.Error == "token_expired" {
|
||||
return nil, errTokenExpired
|
||||
}
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("photo: %s", resp.Status)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, maxPhotoBytes))
|
||||
}
|
||||
|
||||
// ForgetVisitor erases a customer: face template, photo and profile.
|
||||
//
|
||||
// Irreversible by design — a soft-deleted face template is a retained
|
||||
@@ -457,7 +552,10 @@ func (c *Client) Sales(ctx context.Context, from, to string) (SalesReport, error
|
||||
}
|
||||
|
||||
type Customer struct {
|
||||
ID string `json:"id"`
|
||||
ID string `json:"id"`
|
||||
// Ref is the customer number - "V-42" - and is what staff say to each
|
||||
// other. It is accepted anywhere this customer's id is.
|
||||
Ref string `json:"ref"`
|
||||
Label string `json:"label"`
|
||||
FullName string `json:"full_name"`
|
||||
Phone string `json:"phone"`
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -137,3 +138,43 @@ func TestVisitorIDIsPathEscaped(t *testing.T) {
|
||||
t.Errorf("path = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Redeeming an installation code is the one call a fresh PC makes before it
|
||||
// has any session. When the server refuses it - wrong code, wrong head office -
|
||||
// it answers 401 with a message written for the installer. That message must
|
||||
// reach them: "session expired" on a screen where nobody has signed in sent a
|
||||
// real installer looking for a login problem that did not exist.
|
||||
func TestARefusedInstallationCodeSaysWhyNotSessionExpired(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
t.Errorf("enrol must not carry a session, got %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
fail(w, http.StatusUnauthorized, "bad_token",
|
||||
"That installation code is not valid. Ask for a new one.")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := New(srv.URL) // deliberately no session
|
||||
|
||||
_, err := c.Bootstrap(context.Background(), "KWFH5S-EH46LT-EE4X47-OSOH7D")
|
||||
if err == nil {
|
||||
t.Fatal("a refused code must be an error")
|
||||
}
|
||||
if errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("a refused code is not a session problem, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "installation code is not valid") {
|
||||
t.Fatalf("the server's own words should reach the installer, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The other side of the same rule: a 401 on a call that DID carry a session is
|
||||
// a session problem, and must still read as one.
|
||||
func TestARejectedSessionStillReadsAsSessionExpired(t *testing.T) {
|
||||
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, http.StatusUnauthorized, "unauthorized", "Sign in again.")
|
||||
})
|
||||
err := c.do(context.Background(), http.MethodGet, "/api/auth/me", nil, nil)
|
||||
if !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("a 401 with a session should be ErrUnauthorized, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,33 @@ func main() {
|
||||
app := NewApp()
|
||||
tray := newTray(app)
|
||||
|
||||
// One process per PC, enforced by the OS rather than by hoping.
|
||||
//
|
||||
// The window hides to the tray on close, so the ordinary next thing a shop
|
||||
// assistant does is double-click the desktop shortcut again to get it
|
||||
// back. Without this lock that started a SECOND complete copy: a second
|
||||
// tray icon, a second engine supervisor on the same SQLite WAL and the
|
||||
// same port - the "start twice" failure the agent package exists to
|
||||
// prevent, on the one binary that never had the guard. Seen on a Windows
|
||||
// install as a row of Behavision icons in the tray. A second launch now
|
||||
// only brings the existing window to the front, which is what the person
|
||||
// wanted in the first place.
|
||||
var ctxRef context.Context
|
||||
single := &options.SingleInstanceLock{
|
||||
UniqueId: "ai.loyaly.behavision.desktop",
|
||||
OnSecondInstanceLaunch: func(options.SecondInstanceData) {
|
||||
if ctxRef != nil {
|
||||
runtime.Show(ctxRef)
|
||||
runtime.WindowUnminimise(ctxRef)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
err := wails.Run(&options.App{
|
||||
Title: "Behavision",
|
||||
Width: 1280,
|
||||
Height: 820,
|
||||
SingleInstanceLock: single,
|
||||
Title: "Behavision",
|
||||
Width: 1280,
|
||||
Height: 820,
|
||||
// Small enough to still be usable on a cramped shop-counter monitor.
|
||||
MinWidth: 1024,
|
||||
MinHeight: 640,
|
||||
@@ -40,6 +63,7 @@ func main() {
|
||||
// is where they get the window back.
|
||||
HideWindowOnClose: true,
|
||||
OnStartup: func(ctx context.Context) {
|
||||
ctxRef = ctx
|
||||
app.startup(ctx)
|
||||
tray.start(ctx)
|
||||
},
|
||||
@@ -49,6 +73,7 @@ func main() {
|
||||
},
|
||||
OnShutdown: func(ctx context.Context) {
|
||||
tray.stop()
|
||||
app.proxy.stop()
|
||||
app.StopEngine()
|
||||
},
|
||||
Bind: []any{app},
|
||||
|
||||
BIN
desktop/rsrc_windows_amd64.syso
Normal file
249
desktop/stream_proxy.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package main
|
||||
|
||||
// streamProxy serves the engine's camera feeds to this app's own webview
|
||||
// without putting a credential in the page.
|
||||
//
|
||||
// What this replaces: StreamURL used to build
|
||||
// http://user:pass@127.0.0.1:8010/api/cameras/<id>/stream.mjpeg and hand it
|
||||
// to an <img>, with a comment saying the credentials were inline "so an <img>
|
||||
// tag can load it". It cannot. Chromium strips credentials from subresource
|
||||
// URLs and has since M59, and WebView2 is Chromium - so on the one platform
|
||||
// this product ships to, every camera tile on the shop floor renders as a
|
||||
// broken image. Measured against the same running engine: the app's Go-side
|
||||
// calls returned stats and people while an <img> on the very same URL failed,
|
||||
// and curl proved the URL itself answered 200. The engine was never the
|
||||
// problem; the browser was throwing the password away before it asked.
|
||||
//
|
||||
// So the password stays on this side of the process boundary. The webview
|
||||
// asks this loopback listener, the listener attaches Basic auth and relays
|
||||
// the engine's bytes back unchanged. It is the same reasoning the head-office
|
||||
// web app already follows in Shot.jsx, where an <img> equally cannot carry a
|
||||
// session and the bytes are fetched and handed over as an object URL.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A camera id reaches this from the engine and from a person typing into the
|
||||
// Add Camera form. Validated rather than interpolated: without this a `..`
|
||||
// would climb out of the two paths below and turn a camera relay into a proxy
|
||||
// for any engine endpoint, with the credential helpfully attached.
|
||||
var safeCameraIDChars = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,64}$`)
|
||||
|
||||
// safeCameraID is the character check AND the two names that pass it and still
|
||||
// mean something to a path resolver.
|
||||
//
|
||||
// The pattern allows `.` because real camera ids contain them - which means it
|
||||
// also allows exactly `.` and `..`, and `/api/cameras/../stream.mjpeg` is not
|
||||
// the endpoint anyone intended. The id can never contain a slash (the path is
|
||||
// split on them before we get here), so these two strings are the entire
|
||||
// remaining traversal surface. Found by the test, not by reading the regex.
|
||||
func safeCameraID(id string) bool {
|
||||
if id == "." || id == ".." {
|
||||
return false
|
||||
}
|
||||
return safeCameraIDChars.MatchString(id)
|
||||
}
|
||||
|
||||
type streamProxy struct {
|
||||
mu sync.RWMutex
|
||||
ln net.Listener
|
||||
srv *http.Server
|
||||
client *http.Client
|
||||
token string
|
||||
target string // engine origin, e.g. http://127.0.0.1:8010
|
||||
user string
|
||||
pass string
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// The engine's own credential exists precisely so that the live face feed
|
||||
// is never served open - CLAUDE.md is explicit that an unauthenticated
|
||||
// listener would expose it. An unauthenticated loopback relay would hand
|
||||
// that same feed to any other process on this PC, which on a shop counter
|
||||
// is not a theoretical set. A per-run token, minted here and given only to
|
||||
// this app's own webview, keeps the relay as private as the engine is.
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return fmt.Errorf("proxy token: %w", err)
|
||||
}
|
||||
|
||||
// Port 0: the OS picks a free one. A fixed port would collide with
|
||||
// whatever else a shop PC happens to be running, and the failure would be
|
||||
// "the cameras stopped working" with nothing pointing at the cause.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return fmt.Errorf("stream proxy listen: %w", err)
|
||||
}
|
||||
|
||||
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.
|
||||
p.client = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
},
|
||||
}
|
||||
srv := &http.Server{Handler: http.HandlerFunc(p.handle)}
|
||||
p.srv = srv
|
||||
|
||||
// srv and ln are captured, not read off the struct inside the goroutine:
|
||||
// stop() sets both to nil, so a serve loop that reached for them after a
|
||||
// quick start/stop would dereference nil and take the whole app down. The
|
||||
// test that stops the relay found exactly that.
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *streamProxy) stop() {
|
||||
p.mu.Lock()
|
||||
srv, ln := p.srv, p.ln
|
||||
p.srv, p.ln, p.token = nil, nil, ""
|
||||
p.mu.Unlock()
|
||||
if srv != nil {
|
||||
_ = srv.Close()
|
||||
}
|
||||
if ln != nil {
|
||||
_ = ln.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// urlFor returns the loopback URL for one camera resource, or "" when the
|
||||
// proxy is not running so the caller can fall back.
|
||||
func (p *streamProxy) urlFor(cameraID, file string) string {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
if p.ln == nil || p.token == "" || !safeCameraID(cameraID) {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("http://%s/s/%s/%s/%s",
|
||||
p.ln.Addr().String(), p.token, cameraID, file)
|
||||
}
|
||||
|
||||
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
|
||||
p.mu.RUnlock()
|
||||
if token == "" || client == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// /s/<token>/<camera>/<file>
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/")
|
||||
if len(parts) != 4 || parts[0] != "s" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// Constant time: the token is the only thing standing between another
|
||||
// local process and a live view of customers' faces.
|
||||
if subtle.ConstantTimeCompare([]byte(parts[1]), []byte(token)) != 1 {
|
||||
// 404 rather than 403. There is nothing here to tell an unwelcome
|
||||
// caller they have found the right door with the wrong key.
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
cameraID := parts[2]
|
||||
if !safeCameraID(cameraID) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// An allow-list, not a prefix match. Everything else the engine serves -
|
||||
// the identity list, the gallery, erasure - stays unreachable through here
|
||||
// even for a caller holding the token.
|
||||
//
|
||||
// frame.jpg is listed although no screen asks for one yet. It is reachable
|
||||
// only through urlFor, which is internal, so it adds no bound API nobody
|
||||
// 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.
|
||||
var enginePath string
|
||||
switch parts[3] {
|
||||
case "stream.mjpeg":
|
||||
enginePath = "/api/cameras/" + cameraID + "/stream.mjpeg"
|
||||
case "frame.jpg":
|
||||
enginePath = "/api/cameras/" + cameraID + "/frame.jpg"
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target+enginePath, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "bad upstream request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// frame.jpg takes width and quality; the engine re-encodes on demand.
|
||||
req.URL.RawQuery = r.URL.RawQuery
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
http.Error(w, "engine unreachable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
for _, h := range []string{"Content-Type", "Cache-Control", "Pragma", "Expires"} {
|
||||
if v := resp.Header.Get(h); v != "" {
|
||||
w.Header().Set(h, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
|
||||
// Copied by hand rather than with io.Copy so every chunk is flushed. An
|
||||
// MJPEG stream never ends, so anything buffered waiting for a full buffer
|
||||
// is a tile that stays blank forever - which is the same symptom as the
|
||||
// bug this file exists to fix, and would look like it had not worked.
|
||||
flusher, _ := w.(http.Flusher)
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := w.Write(buf[:n]); werr != nil {
|
||||
return // webview went away
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
if rerr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
296
desktop/stream_proxy_test.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeEngine stands in for the Python engine: it demands Basic auth exactly as
|
||||
// the real one does when a credential is configured, and records what it was
|
||||
// asked for.
|
||||
type fakeEngine struct {
|
||||
*httptest.Server
|
||||
gotPath string
|
||||
gotUser string
|
||||
gotPass string
|
||||
hadAuth bool
|
||||
}
|
||||
|
||||
func newFakeEngine(t *testing.T, body string) *fakeEngine {
|
||||
t.Helper()
|
||||
f := &fakeEngine{}
|
||||
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.gotPath = r.URL.Path
|
||||
if r.URL.RawQuery != "" {
|
||||
f.gotPath += "?" + r.URL.RawQuery
|
||||
}
|
||||
f.gotUser, f.gotPass, f.hadAuth = r.BasicAuth()
|
||||
if !f.hadAuth {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="behavision"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||
_, _ = io.WriteString(w, body)
|
||||
}))
|
||||
t.Cleanup(f.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func startProxy(t *testing.T, engine string, user, pass string) *streamProxy {
|
||||
t.Helper()
|
||||
p := newStreamProxy()
|
||||
if err := p.start(engine, user, pass); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
t.Cleanup(p.stop)
|
||||
return p
|
||||
}
|
||||
|
||||
func get(t *testing.T, url string) (int, string) {
|
||||
t.Helper()
|
||||
c := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := c.Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
// The whole point: the webview gets a URL it can actually load, and the
|
||||
// password stays behind. A credential in the src is both unloadable in a
|
||||
// Chromium webview and readable by anything that can see the DOM.
|
||||
func TestTheCameraURLCarriesNoPassword(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := startProxy(t, engine.URL, "behavision", "hunter2-the-real-one")
|
||||
|
||||
u := p.urlFor("cam2", "stream.mjpeg")
|
||||
if u == "" {
|
||||
t.Fatal("no url while the proxy is running")
|
||||
}
|
||||
if strings.Contains(u, "hunter2-the-real-one") || strings.Contains(u, "behavision:") {
|
||||
t.Fatalf("credential leaked into the tile URL: %s", u)
|
||||
}
|
||||
if !strings.HasPrefix(u, "http://127.0.0.1:") {
|
||||
t.Fatalf("relay must be loopback only, got %s", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRelayAttachesTheCredentialItself(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frame-bytes")
|
||||
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||
|
||||
code, body := get(t, p.urlFor("cam2", "stream.mjpeg"))
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("want 200 through the relay, got %d", code)
|
||||
}
|
||||
if body != "frame-bytes" {
|
||||
t.Fatalf("body not relayed unchanged: %q", body)
|
||||
}
|
||||
if !engine.hadAuth || engine.gotUser != "behavision" || engine.gotPass != "s3cret" {
|
||||
t.Fatalf("engine did not receive the credential: auth=%v user=%q",
|
||||
engine.hadAuth, engine.gotUser)
|
||||
}
|
||||
if engine.gotPath != "/api/cameras/cam2/stream.mjpeg" {
|
||||
t.Fatalf("wrong upstream path: %s", engine.gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
// The token is what keeps every other process on a shop PC from opening a live
|
||||
// view of customers' faces, now that the relay itself has no password.
|
||||
func TestAnotherProcessCannotGuessItsWayIn(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||
addr := p.ln.Addr().String()
|
||||
|
||||
for _, bad := range []string{"", "0", strings.Repeat("a", 64), "wrong-token"} {
|
||||
url := fmt.Sprintf("http://%s/s/%s/cam2/stream.mjpeg", addr, bad)
|
||||
if code, _ := get(t, url); code != http.StatusNotFound {
|
||||
t.Fatalf("token %q got %d, want 404", bad, code)
|
||||
}
|
||||
}
|
||||
if engine.hadAuth {
|
||||
t.Fatal("a rejected request still reached the engine")
|
||||
}
|
||||
}
|
||||
|
||||
// A camera id is interpolated into the upstream path, so it has to be a camera
|
||||
// id and not a way to walk to a different endpoint with the credential
|
||||
// attached.
|
||||
func TestACameraIdCannotClimbOutOfItsPath(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||
addr := p.ln.Addr().String()
|
||||
|
||||
for _, bad := range []string{"..", "%2e%2e", "cam2/../../api/identities", "cam 2", ""} {
|
||||
url := fmt.Sprintf("http://%s/s/%s/%s/stream.mjpeg", addr, p.token, bad)
|
||||
code, _ := get(t, url)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("camera id %q got %d, want 404", bad, code)
|
||||
}
|
||||
}
|
||||
if strings.Contains(engine.gotPath, "identities") {
|
||||
t.Fatalf("reached a non-camera endpoint: %s", engine.gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Only the two files a tile needs. The engine also serves the identity list and
|
||||
// the erasure endpoint; holding the token must not open those.
|
||||
func TestOnlyTheTwoCameraFilesAreReachable(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||
addr := p.ln.Addr().String()
|
||||
|
||||
for _, bad := range []string{"identities", "stats", "commission", "stream.mjpeg.bak"} {
|
||||
url := fmt.Sprintf("http://%s/s/%s/cam2/%s", addr, p.token, bad)
|
||||
if code, _ := get(t, url); code != http.StatusNotFound {
|
||||
t.Fatalf("file %q got %d, want 404", bad, code)
|
||||
}
|
||||
}
|
||||
|
||||
for _, good := range []string{"stream.mjpeg", "frame.jpg"} {
|
||||
url := fmt.Sprintf("http://%s/s/%s/cam2/%s", addr, p.token, good)
|
||||
if code, _ := get(t, url); code != http.StatusOK {
|
||||
t.Fatalf("file %q got %d, want 200", good, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// frame.jpg takes width and quality - the engine re-encodes on demand, and a
|
||||
// relay that dropped the query would silently serve full-size frames.
|
||||
func TestTheQueryStringSurvivesTheRelay(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||
|
||||
url := p.urlFor("cam2", "frame.jpg") + "?width=640&quality=70"
|
||||
if code, _ := get(t, url); code != http.StatusOK {
|
||||
t.Fatalf("got %d", code)
|
||||
}
|
||||
if !strings.Contains(engine.gotPath, "width=640") ||
|
||||
!strings.Contains(engine.gotPath, "quality=70") {
|
||||
t.Fatalf("query dropped: %s", engine.gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
// An engine that is not running must read as a bad gateway, not as a hang. A
|
||||
// blank tile that never resolves is the symptom this whole file exists to end.
|
||||
func TestAnEngineThatIsDownFailsQuickly(t *testing.T) {
|
||||
// Port 1 on loopback: nothing listens, and the connection is refused
|
||||
// rather than dropped, so this is fast and deterministic.
|
||||
p := startProxy(t, "http://127.0.0.1:1", "behavision", "s3cret")
|
||||
|
||||
done := make(chan int, 1)
|
||||
go func() { code, _ := get(t, p.urlFor("cam2", "stream.mjpeg")); done <- code }()
|
||||
select {
|
||||
case code := <-done:
|
||||
if code != http.StatusBadGateway {
|
||||
t.Fatalf("want 502, got %d", code)
|
||||
}
|
||||
case <-time.After(8 * time.Second):
|
||||
t.Fatal("a dead engine left the request hanging")
|
||||
}
|
||||
}
|
||||
|
||||
// Stopping must actually free the port, or a restarted engine leaves listeners
|
||||
// behind for the life of the process.
|
||||
func TestStoppingReleasesEverything(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := newStreamProxy()
|
||||
if err := p.start(engine.URL, "u", "p"); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
url := p.urlFor("cam2", "stream.mjpeg")
|
||||
if code, _ := get(t, url); code != http.StatusOK {
|
||||
t.Fatalf("want 200 before stop, got %d", code)
|
||||
}
|
||||
|
||||
p.stop()
|
||||
|
||||
if got := p.urlFor("cam2", "stream.mjpeg"); got != "" {
|
||||
t.Fatalf("still handing out URLs after stop: %s", got)
|
||||
}
|
||||
c := &http.Client{Timeout: 3 * time.Second}
|
||||
if resp, err := c.Get(url); err == nil {
|
||||
resp.Body.Close()
|
||||
t.Fatal("listener still accepting after stop")
|
||||
}
|
||||
}
|
||||
|
||||
// start twice must not leave two listeners, which is what a restarted engine
|
||||
// would otherwise cause.
|
||||
func TestStartingTwiceIsANoOp(t *testing.T) {
|
||||
engine := newFakeEngine(t, "frames")
|
||||
p := startProxy(t, engine.URL, "u", "p")
|
||||
|
||||
first := p.urlFor("cam2", "stream.mjpeg")
|
||||
if err := p.start(engine.URL, "u", "p"); err != nil {
|
||||
t.Fatalf("second start: %v", err)
|
||||
}
|
||||
if second := p.urlFor("cam2", "stream.mjpeg"); second != first {
|
||||
t.Fatalf("second start moved the relay: %s -> %s", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
// Against the real engine, which the unit tests above deliberately do not
|
||||
// touch. Skipped unless TEST_ENGINE_URL is set, the same rule the server's
|
||||
// live store tests follow: the suite must stay runnable with no services.
|
||||
//
|
||||
// TEST_ENGINE_URL=http://127.0.0.1:8010 \
|
||||
// TEST_ENGINE_USER=... TEST_ENGINE_PASS=... go test ./desktop/ -run Live
|
||||
//
|
||||
// It exists because everything above proves the relay against a fake that
|
||||
// agrees with me. Only a real engine proves the thing that was actually
|
||||
// broken: that a multipart MJPEG stream arrives through the relay in pieces,
|
||||
// rather than being buffered into a tile that never paints.
|
||||
func TestLiveRelayCarriesRealMJPEGFrames(t *testing.T) {
|
||||
base := os.Getenv("TEST_ENGINE_URL")
|
||||
if base == "" {
|
||||
t.Skip("set TEST_ENGINE_URL to run the live relay test")
|
||||
}
|
||||
cam := os.Getenv("TEST_ENGINE_CAMERA")
|
||||
if cam == "" {
|
||||
cam = "cam2"
|
||||
}
|
||||
p := startProxy(t, base, os.Getenv("TEST_ENGINE_USER"), os.Getenv("TEST_ENGINE_PASS"))
|
||||
|
||||
url := p.urlFor(cam, "stream.mjpeg")
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("relay: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("relay returned %d - the credential did not reach the engine", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "multipart") {
|
||||
t.Fatalf("not a stream: Content-Type %q", ct)
|
||||
}
|
||||
|
||||
// Read until two JPEG start markers have gone past. One proves it opened;
|
||||
// two prove it is still delivering, which is the difference between a
|
||||
// working tile and a single frozen frame.
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
var seen, total int
|
||||
buf := make([]byte, 16*1024)
|
||||
for seen < 2 && time.Now().Before(deadline) {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
total += n
|
||||
seen += strings.Count(string(buf[:n]), "\xff\xd8\xff")
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if seen < 2 {
|
||||
t.Fatalf("only %d JPEG frames in %d bytes - the relay is not streaming", seen, total)
|
||||
}
|
||||
t.Logf("relayed %d frames in %d bytes with no credential in the URL", seen, total)
|
||||
}
|
||||
BIN
desktop/tray-logo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -10,6 +11,25 @@ import (
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// BEHAVISION_NO_TRAY runs the window with no tray icon.
|
||||
//
|
||||
// It exists so the UI can be looked at on a Mac. fyne.io/systray's nativeLoop
|
||||
// must own the main thread on macOS - a Cocoa requirement, not a library
|
||||
// choice - and Wails already holds it, so starting both kills the process with
|
||||
// a SIGTRAP inside cgo before a single screen is drawn. On Windows, which is
|
||||
// what ships, a tray on its own goroutine is fine. That asymmetry is why this
|
||||
// went unnoticed for so long: the shop-floor UI had never once been run on the
|
||||
// platform it is developed on, so every screen in it was unreviewed.
|
||||
//
|
||||
// Deliberately an environment variable and NOT a GOOS check. A build that
|
||||
// quietly drops the tray on some platform is how a shop PC ends up with no
|
||||
// control surface at all - the one thing a shop manager has - and it would
|
||||
// fail exactly where nobody is watching. Nothing is skipped unless a person
|
||||
// asked for it, by name, on this run.
|
||||
const noTrayEnv = "BEHAVISION_NO_TRAY"
|
||||
|
||||
func trayDisabled() bool { return os.Getenv(noTrayEnv) != "" }
|
||||
|
||||
// tray is the always-present control surface. Wails v2 has no systray of its
|
||||
// own, so this drives fyne.io/systray alongside the window.
|
||||
//
|
||||
@@ -32,6 +52,9 @@ type tray struct {
|
||||
func newTray(a *App) *tray { return &tray{app: a, quit: make(chan struct{})} }
|
||||
|
||||
func (t *tray) start(ctx context.Context) {
|
||||
if trayDisabled() {
|
||||
return
|
||||
}
|
||||
t.once.Do(func() {
|
||||
go systray.Run(func() { t.onReady(ctx) }, func() {})
|
||||
})
|
||||
@@ -43,6 +66,13 @@ func (t *tray) stop() {
|
||||
default:
|
||||
close(t.quit)
|
||||
}
|
||||
// systray.Quit() on a systray that was never started is not a no-op in
|
||||
// v1.12.2, so the guard has to be on both ends or quitting the window
|
||||
// takes the process down with it - a crash on exit, which is the failure
|
||||
// most likely to be shrugged off as "it closed, fine".
|
||||
if trayDisabled() {
|
||||
return
|
||||
}
|
||||
systray.Quit()
|
||||
}
|
||||
|
||||
|
||||
577
docs/Behavision-Architecture.html
Normal file
@@ -0,0 +1,577 @@
|
||||
<title>Behavision Architecture</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700&family=Source+Serif+4:opsz,wght@8..60,400;8..60,600&family=IBM+Plex+Mono:wght@400;500&display=swap">
|
||||
|
||||
<style>
|
||||
:root{
|
||||
--paper:#f1f4f6;--surface:#fff;--surface-2:#e7ecef;--ink:#131b22;--ink-soft:#46545f;--ink-faint:#6d7d88;--rule:#d3dbe0;
|
||||
--acc:#12707e;--acc-ink:#0b4d57;--acc-bg:#dcedf0;--good:#2f7d55;--warn:#9a6413;--bad:#a8403c;--good-bg:#e2efe8;--warn-bg:#f5ecdc;
|
||||
--sans:"Archivo","Helvetica Neue",Arial,sans-serif;--serif:"Source Serif 4",Georgia,serif;--mono:"IBM Plex Mono",ui-monospace,Menlo,monospace;
|
||||
}
|
||||
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--paper:#0e141b;--surface:#161f28;--surface-2:#1d2833;--ink:#e6edf2;--ink-soft:#a7b6c1;--ink-faint:#7b8b97;--rule:#2a3742;--acc:#4fc3d6;--acc-ink:#9adfeb;--acc-bg:#13303a;--good:#6cc394;--warn:#d5a55c;--bad:#e0817c;--good-bg:#172c22;--warn-bg:#2e2617}}
|
||||
:root[data-theme="dark"]{--paper:#0e141b;--surface:#161f28;--surface-2:#1d2833;--ink:#e6edf2;--ink-soft:#a7b6c1;--ink-faint:#7b8b97;--rule:#2a3742;--acc:#4fc3d6;--acc-ink:#9adfeb;--acc-bg:#13303a;--good:#6cc394;--warn:#d5a55c;--bad:#e0817c;--good-bg:#172c22;--warn-bg:#2e2617}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--serif);font-size:1rem;line-height:1.55;-webkit-font-smoothing:antialiased}
|
||||
h1,h2,h3,.eyebrow,.nav,.legend,.facts,.tag,.metric{font-family:var(--sans)}
|
||||
h1{font-size:clamp(2.2rem,5vw,3.2rem);line-height:1.02;font-weight:700;letter-spacing:-.025em;margin:0;text-wrap:balance}
|
||||
h2{font-size:1.5rem;line-height:1.15;font-weight:600;letter-spacing:-.01em;margin:0;text-wrap:balance}
|
||||
p{margin:0} code{font-family:var(--mono);font-size:.88em;background:var(--surface-2);padding:.06em .35em;border-radius:2px}
|
||||
a{color:var(--acc)} a:focus-visible{outline:2px solid var(--acc);outline-offset:3px}
|
||||
.wrap{max-width:74rem;margin:0 auto;padding-inline:20px}
|
||||
.eyebrow{font-size:.8rem;text-transform:uppercase;letter-spacing:.14em;font-weight:600;color:var(--acc)}
|
||||
|
||||
header.mast{background:var(--surface);border-bottom:1px solid var(--rule)}
|
||||
header.mast .wrap{padding-block:clamp(2.5rem,6vw,4rem) clamp(1.5rem,4vw,2.5rem);display:flex;flex-direction:column;gap:1.1rem}
|
||||
.brand{display:flex;align-items:center;gap:.7rem;font-family:var(--sans);font-weight:600;letter-spacing:.16em;text-transform:uppercase;font-size:.8rem;color:var(--ink-faint)}
|
||||
.lens{width:1rem;height:1rem;border-radius:50%;border:2px solid var(--acc)}
|
||||
.sub{font-size:1.15rem;color:var(--ink-soft);max-width:38rem;line-height:1.45}
|
||||
.nav{display:flex;flex-wrap:wrap;gap:.35rem .9rem;font-size:.8rem;margin-top:.5rem}
|
||||
.nav a{text-decoration:none;color:var(--ink-faint)} .nav a:hover{color:var(--ink)} .nav .n{font-family:var(--mono);color:var(--acc);margin-right:.35rem}
|
||||
|
||||
/* legend */
|
||||
.legend{display:flex;flex-wrap:wrap;gap:.6rem 1.6rem;font-size:.8rem;color:var(--ink-soft);align-items:center}
|
||||
.legend span{display:inline-flex;align-items:center;gap:.45rem}
|
||||
.legend svg{width:34px;height:20px;display:block}
|
||||
|
||||
/* plates */
|
||||
.plate{padding-block:clamp(2.2rem,5vw,3.5rem);border-bottom:1px solid var(--rule)}
|
||||
.plate:last-of-type{border-bottom:0}
|
||||
.head{display:grid;grid-template-columns:3.2rem 1fr;gap:1rem;align-items:baseline;margin-bottom:1.2rem}
|
||||
.head .n{font-family:var(--mono);font-size:.95rem;color:var(--acc)}
|
||||
.head p{color:var(--ink-soft);margin-top:.35rem;max-width:42rem}
|
||||
.fig{background:var(--surface);border:1px solid var(--rule);padding:clamp(.8rem,2.2vw,1.4rem);overflow-x:auto}
|
||||
.fig svg{display:block;max-width:100%;height:auto;min-width:40rem}
|
||||
.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr));gap:.9rem 2rem;margin-top:1.1rem;font-size:.86rem;line-height:1.45}
|
||||
.facts div{display:grid;grid-template-columns:.7rem 1fr;gap:.6rem}
|
||||
.facts div::before{content:"";width:.5rem;height:.5rem;border-radius:1px;background:var(--acc);margin-top:.45rem}
|
||||
.facts b{font-weight:600}
|
||||
|
||||
/* svg semantics */
|
||||
.s{stroke:currentColor;stroke-width:1.5;fill:none}
|
||||
.sa{stroke:var(--acc);stroke-width:1.75;fill:none}
|
||||
.sd{stroke:currentColor;stroke-width:1.25;fill:none;stroke-dasharray:4 4;opacity:.75}
|
||||
.fa{fill:var(--acc)} .fab{fill:var(--acc-bg)} .fs{fill:var(--surface-2)} .fg{fill:var(--good)} .fw{fill:var(--warn)} .fb{fill:var(--bad)} .fgb{fill:var(--good-bg)} .fwb{fill:var(--warn-bg)}
|
||||
.t{font-family:var(--sans);font-size:12.5px;font-weight:600;fill:currentColor}
|
||||
.ta{font-family:var(--sans);font-size:12.5px;font-weight:600;fill:var(--acc)}
|
||||
.m{font-family:var(--mono);font-size:10.5px;fill:currentColor;opacity:.68}
|
||||
.ma{font-family:var(--mono);font-size:10.5px;fill:var(--acc)}
|
||||
.l{font-family:var(--sans);font-size:10.5px;fill:currentColor;opacity:.78}
|
||||
.la{font-family:var(--sans);font-size:10.5px;fill:var(--acc)}
|
||||
.z{font-family:var(--sans);font-size:11.5px;font-weight:600;letter-spacing:1.5px;fill:currentColor;opacity:.5}
|
||||
.cap{font-family:var(--serif);font-size:12.5px;fill:currentColor;opacity:.8}
|
||||
|
||||
.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule)}
|
||||
.metric{background:var(--surface);padding:1rem 1.05rem 1.1rem;display:flex;flex-direction:column;gap:.2rem}
|
||||
.metric .v{font-size:1.9rem;font-weight:700;line-height:1;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
|
||||
.metric .k{font-size:.8rem;color:var(--ink-faint);line-height:1.35}
|
||||
|
||||
footer{background:var(--surface);border-top:1px solid var(--rule);color:var(--ink-faint);font-size:.8rem}
|
||||
footer .wrap{padding-block:1.6rem 2.6rem}
|
||||
@media (max-width:40rem){.head{grid-template-columns:1fr;gap:.2rem}}
|
||||
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
||||
</style>
|
||||
|
||||
<!-- shared glyphs -->
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
||||
<defs>
|
||||
<marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="currentColor"/></marker>
|
||||
<marker id="aa" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="fa"/></marker>
|
||||
<symbol id="g-cam" viewBox="0 0 24 24"><path d="M3 8h4l2-3h6l2 3h4v11H3z" class="s"/><circle cx="12" cy="13" r="3.2" class="s"/></symbol>
|
||||
<symbol id="g-db" viewBox="0 0 24 24"><ellipse cx="12" cy="6" rx="8" ry="3" class="s"/><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6" class="s"/><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" class="s"/></symbol>
|
||||
<symbol id="g-pc" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="1" class="s"/><path d="M8 20h8M12 16v4" class="s"/></symbol>
|
||||
<symbol id="g-phone" viewBox="0 0 24 24"><rect x="7" y="2" width="10" height="20" rx="2" class="s"/><path d="M11 18h2" class="s"/></symbol>
|
||||
<symbol id="g-lock" viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="10" rx="1" class="s"/><path d="M8 10V7a4 4 0 0 1 8 0v3" class="s"/></symbol>
|
||||
<symbol id="g-file" viewBox="0 0 24 24"><path d="M6 2h8l5 5v15H6z" class="s"/><path d="M14 2v5h5" class="s"/></symbol>
|
||||
<symbol id="g-cloud" viewBox="0 0 24 24"><path d="M7 18a4 4 0 0 1-.6-7.95A6 6 0 0 1 18 9a4 4 0 0 1 0 9z" class="s"/></symbol>
|
||||
<symbol id="g-person" viewBox="0 0 24 24"><circle cx="12" cy="8" r="3.5" class="s"/><path d="M5 20a7 7 0 0 1 14 0" class="s"/></symbol>
|
||||
<symbol id="g-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3" class="s"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4L7 17M17 7l1.4-1.4" class="s"/></symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<header class="mast">
|
||||
<div class="wrap">
|
||||
<div class="brand"><span class="lens" aria-hidden="true"></span> Behavision · Technical Overview</div>
|
||||
<h1>Behavision Architecture</h1>
|
||||
<p class="sub">Face recognition for retail. An engine that sees, an agent that delivers, a platform that understands — in nine diagrams.</p>
|
||||
<nav class="nav" aria-label="Plates">
|
||||
<a href="#p1"><span class="n">01</span>System</a><a href="#p2"><span class="n">02</span>Shop PC</a><a href="#p3"><span class="n">03</span>Recognition</a><a href="#p4"><span class="n">04</span>Delivery</a><a href="#p5"><span class="n">05</span>Local & master data</a><a href="#p6"><span class="n">06</span>Clients & API</a><a href="#p7"><span class="n">07</span>Onboarding</a><a href="#p8"><span class="n">08</span>Secrets</a><a href="#p9"><span class="n">09</span>Stack & numbers</a>
|
||||
</nav>
|
||||
<div class="legend" aria-label="Diagram legend">
|
||||
<span><svg viewBox="0 0 34 20"><rect x="2" y="3" width="30" height="14" class="s"/></svg>process</span>
|
||||
<span><svg viewBox="0 0 34 20"><ellipse cx="17" cy="5" rx="11" ry="3" class="s"/><path d="M6 5v10c0 1.7 4.9 3 11 3s11-1.3 11-3V5" class="s"/></svg>store</span>
|
||||
<span><svg viewBox="0 0 34 20"><rect x="2" y="3" width="30" height="14" class="sa"/></svg>the path in focus</span>
|
||||
<span><svg viewBox="0 0 34 20"><line x1="2" y1="10" x2="30" y2="10" class="s" marker-end="url(#a)"/></svg>data</span>
|
||||
<span><svg viewBox="0 0 34 20"><line x1="2" y1="10" x2="30" y2="10" class="sd" marker-end="url(#a)"/></svg>control / pull</span>
|
||||
<span><svg viewBox="0 0 34 20"><line x1="17" y1="1" x2="17" y2="19" stroke="currentColor" stroke-width="1.5" stroke-dasharray="4 3" opacity=".5"/></svg>trust boundary</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="wrap">
|
||||
|
||||
<!-- ============================================================ 01 -->
|
||||
<section class="plate" id="p1">
|
||||
<div class="head"><span class="n">01</span><div><h2>The whole system</h2><p>Video stays inside the shop. Only visit records cross the boundary — and every connection across it is made from the inside, outward.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 520" role="img" aria-label="Cameras stream RTSP to a shop PC running the engine, the agent and the shop app. The agent publishes visits over TLS MQTT to Mosquitto in the cloud; an ingest consumer writes them to PostgreSQL; the API serves the head-office console, the mobile app and platform administration. The shop PC pulls camera settings and check jobs from the API. A dashed boundary marks the shop network, with no inbound route.">
|
||||
<text x="24" y="28" class="z">SHOP NETWORK</text><text x="520" y="28" class="z">LOYALY CLOUD</text><text x="880" y="28" class="z">PEOPLE</text>
|
||||
<line x1="486" y1="42" x2="486" y2="490" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 4" opacity=".45"/>
|
||||
<use href="#g-lock" x="474" y="492" width="24" height="24"/>
|
||||
<text x="486" y="510" text-anchor="middle" class="l" dy="8">outbound only</text>
|
||||
|
||||
<!-- cameras -->
|
||||
<use href="#g-cam" x="30" y="188" width="40" height="40"/>
|
||||
<use href="#g-cam" x="30" y="236" width="40" height="40"/>
|
||||
<text x="50" y="292" text-anchor="middle" class="m">RTSP</text>
|
||||
|
||||
<!-- shop pc -->
|
||||
<rect x="120" y="70" width="330" height="400" rx="3" class="s"/>
|
||||
<use href="#g-pc" x="134" y="82" width="22" height="22"/><text x="164" y="99" class="t">Shop PC</text>
|
||||
<rect x="150" y="122" width="270" height="78" rx="2" class="sa"/>
|
||||
<use href="#g-gear" x="162" y="134" width="20" height="20"/>
|
||||
<text x="190" y="148" class="ta">Recognition engine</text>
|
||||
<text x="190" y="166" class="m">Python · ONNX Runtime · FAISS</text>
|
||||
<text x="190" y="182" class="m">detect → track → identify</text>
|
||||
<use href="#g-db" x="384" y="160" width="26" height="26"/><text x="397" y="200" text-anchor="middle" class="m">SQLite</text>
|
||||
|
||||
<rect x="150" y="226" width="270" height="96" rx="2" class="s"/>
|
||||
<text x="164" y="248" class="t">Agent</text><text x="164" y="266" class="m">Go · supervisor · camera sync</text>
|
||||
<rect x="164" y="278" width="242" height="32" rx="2" class="fab"/>
|
||||
<use href="#g-file" x="172" y="284" width="20" height="20"/>
|
||||
<text x="200" y="299" class="ma">durable spool — one file per event</text>
|
||||
|
||||
<rect x="150" y="348" width="270" height="56" rx="2" class="s"/>
|
||||
<text x="164" y="370" class="t">Shop app</text><text x="164" y="388" class="m">Wails · window + system tray</text>
|
||||
<text x="285" y="440" text-anchor="middle" class="cap">runs with no internet;</text>
|
||||
<text x="285" y="456" text-anchor="middle" class="cap">the spool drains when it returns</text>
|
||||
|
||||
<line x1="76" y1="212" x2="148" y2="160" class="s" marker-end="url(#a)"/><text x="126" y="214" class="l">video</text>
|
||||
<line x1="285" y1="202" x2="285" y2="224" class="sa" marker-end="url(#aa)"/><text x="294" y="217" class="la">detections</text>
|
||||
<line x1="285" y1="324" x2="285" y2="346" class="s" marker-end="url(#a)"/>
|
||||
|
||||
<!-- broker -->
|
||||
<rect x="530" y="108" width="170" height="60" rx="2" class="s"/>
|
||||
<use href="#g-cloud" x="542" y="118" width="22" height="22"/><text x="572" y="133" class="t">Mosquitto</text><text x="572" y="151" class="m">MQTT · TLS · per-tenant ACL</text>
|
||||
|
||||
<!-- server -->
|
||||
<rect x="530" y="210" width="290" height="120" rx="2" class="s"/>
|
||||
<text x="544" y="232" class="t">Behavision server</text><text x="544" y="249" class="m">Go · one binary</text>
|
||||
<rect x="546" y="262" width="120" height="50" rx="2" class="s"/><text x="606" y="284" text-anchor="middle" class="t">ingest</text><text x="606" y="300" text-anchor="middle" class="m">dedupe · reinforce</text>
|
||||
<rect x="684" y="262" width="120" height="50" rx="2" class="s"/><text x="744" y="284" text-anchor="middle" class="t">API + web</text><text x="744" y="300" text-anchor="middle" class="m">48 routes · SSE</text>
|
||||
|
||||
<!-- postgres -->
|
||||
<use href="#g-db" x="656" y="388" width="40" height="40"/>
|
||||
<text x="676" y="450" text-anchor="middle" class="ta">PostgreSQL</text>
|
||||
<text x="676" y="466" text-anchor="middle" class="m">master database</text>
|
||||
|
||||
<!-- arrows cloud -->
|
||||
<path d="M422 294 L505 294 L505 138 L528 138" class="sa" marker-end="url(#aa)"/>
|
||||
<text x="462" y="284" text-anchor="middle" class="la">visits · QoS 1</text>
|
||||
<line x1="615" y1="170" x2="606" y2="260" class="s" marker-end="url(#a)"/>
|
||||
<line x1="606" y1="314" x2="668" y2="386" class="s" marker-end="url(#a)"/>
|
||||
<line x1="744" y1="314" x2="690" y2="386" class="s" marker-start="url(#a)" marker-end="url(#a)"/>
|
||||
<path d="M528 300 L470 300 L470 246 L424 246" class="sd" marker-end="url(#a)"/>
|
||||
<text x="470" y="322" text-anchor="middle" class="l">pull: cameras, checks</text>
|
||||
|
||||
<!-- people -->
|
||||
<rect x="880" y="96" width="196" height="56" rx="2" class="s"/><use href="#g-pc" x="892" y="106" width="22" height="22"/><text x="922" y="121" class="t">Head-office console</text><text x="922" y="138" class="m">owner · manager</text>
|
||||
<rect x="880" y="182" width="196" height="56" rx="2" class="s"/><use href="#g-phone" x="892" y="192" width="22" height="22"/><text x="922" y="207" class="t">Mobile app</text><text x="922" y="224" class="m">sales staff</text>
|
||||
<rect x="880" y="268" width="196" height="56" rx="2" class="s"/><use href="#g-person" x="892" y="278" width="22" height="22"/><text x="922" y="293" class="t">Platform admin</text><text x="922" y="310" class="m">creates merchants</text>
|
||||
<path d="M878 124 L846 124 L846 288 L822 288" class="s" marker-end="url(#a)"/>
|
||||
<path d="M878 210 L846 210" class="s"/>
|
||||
<path d="M878 296 L846 296" class="s"/>
|
||||
<text x="846" y="360" text-anchor="middle" class="l">https · session</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>Three tiers</b>, one direction of trust: the shop initiates every connection it has.</div>
|
||||
<div><b>One server binary</b> carries ingest, the API and the head-office web app.</div>
|
||||
<div><b>One API</b> for the console, the mobile app and the shop app alike.</div>
|
||||
<div><b>Offline is a delay, not a loss</b>: visits queue on disk until the broker confirms them.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 02 -->
|
||||
<section class="plate" id="p2">
|
||||
<div class="head"><span class="n">02</span><div><h2>Inside the shop PC</h2><p>Three processes on one machine, each in the language its job is best done in, sharing one state root.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 400" role="img" aria-label="On the shop PC: the engine (Python) captures RTSP, runs detection and recognition, and keeps a SQLite gallery with a FAISS index. The agent (Go) supervises the engine, receives detections on a loopback webhook, spools them, and syncs cameras with head office. The shop app (Wails) hosts the window and tray and embeds the agent as a library. All three read and write one state root under ProgramData.">
|
||||
<!-- engine -->
|
||||
<rect x="30" y="50" width="340" height="230" rx="3" class="sa"/>
|
||||
<text x="46" y="76" class="ta">Recognition engine</text><text x="46" y="93" class="m">Python 3.10+ · private venv</text>
|
||||
<rect x="46" y="112" width="140" height="42" rx="2" class="s"/><text x="116" y="130" text-anchor="middle" class="t">capture thread</text><text x="116" y="146" text-anchor="middle" class="m">per camera · latest frame</text>
|
||||
<rect x="214" y="112" width="140" height="42" rx="2" class="s"/><text x="284" y="130" text-anchor="middle" class="t">worker thread</text><text x="284" y="146" text-anchor="middle" class="m">per camera · one track = one person</text>
|
||||
<line x1="188" y1="133" x2="212" y2="133" class="s" marker-end="url(#a)"/>
|
||||
<rect x="46" y="176" width="308" height="42" rx="2" class="s"/><text x="200" y="194" text-anchor="middle" class="t">models · ONNX Runtime</text><text x="200" y="210" text-anchor="middle" class="m">YuNet · ArcFace r50 · genderage · CoreML / DirectML</text>
|
||||
<use href="#g-db" x="60" y="232" width="30" height="30"/><text x="104" y="246" class="t">SQLite gallery</text><text x="104" y="262" class="m">identities · embeddings · sightings</text>
|
||||
<rect x="250" y="232" width="104" height="34" rx="2" class="fab"/><text x="302" y="253" text-anchor="middle" class="ma">FAISS index</text>
|
||||
<line x1="196" y1="249" x2="248" y2="249" class="sd" marker-end="url(#a)"/><text x="222" y="243" text-anchor="middle" class="l">rebuilt at boot</text>
|
||||
<text x="200" y="300" text-anchor="middle" class="m">FastAPI on 127.0.0.1:8010 · Basic auth, credential generated on first start</text>
|
||||
|
||||
<!-- agent -->
|
||||
<rect x="430" y="50" width="300" height="230" rx="3" class="s"/>
|
||||
<text x="446" y="76" class="t">Agent — Go library</text><text x="446" y="93" class="m">agent/pkg · shared by app and headless agent</text>
|
||||
<rect x="446" y="112" width="130" height="40" rx="2" class="s"/><text x="511" y="130" text-anchor="middle" class="t">supervisor</text><text x="511" y="146" text-anchor="middle" class="m">start · restart · backoff</text>
|
||||
<rect x="586" y="112" width="130" height="40" rx="2" class="s"/><text x="651" y="130" text-anchor="middle" class="t">bridge</text><text x="651" y="146" text-anchor="middle" class="m">loopback webhook</text>
|
||||
<rect x="446" y="166" width="130" height="40" rx="2" class="fab"/><text x="511" y="184" text-anchor="middle" class="ma">spool</text><text x="511" y="200" text-anchor="middle" class="m">bounded · acked per event</text>
|
||||
<rect x="586" y="166" width="130" height="40" rx="2" class="s"/><text x="651" y="184" text-anchor="middle" class="t">pump</text><text x="651" y="200" text-anchor="middle" class="m">MQTT QoS 1 · TLS</text>
|
||||
<rect x="446" y="220" width="270" height="40" rx="2" class="s"/><text x="581" y="238" text-anchor="middle" class="t">camera reconciler</text><text x="581" y="254" text-anchor="middle" class="m">pulls desired state · runs placement checks</text>
|
||||
|
||||
<!-- app -->
|
||||
<rect x="790" y="50" width="280" height="230" rx="3" class="s"/>
|
||||
<text x="806" y="76" class="t">Shop app — Wails</text><text x="806" y="93" class="m">Go + React in the system webview · 12 MB</text>
|
||||
<rect x="806" y="112" width="248" height="40" rx="2" class="s"/><text x="930" y="130" text-anchor="middle" class="t">window</text><text x="930" y="146" text-anchor="middle" class="m">Live · Customers · Cameras</text>
|
||||
<rect x="806" y="166" width="248" height="40" rx="2" class="s"/><text x="930" y="184" text-anchor="middle" class="t">system tray</text><text x="930" y="200" text-anchor="middle" class="m">green / amber / red · start · stop · quit</text>
|
||||
<rect x="806" y="220" width="248" height="40" rx="2" class="s"/><text x="930" y="238" text-anchor="middle" class="t">camera relay</text><text x="930" y="254" text-anchor="middle" class="m">loopback · no credential in the page</text>
|
||||
|
||||
<!-- links -->
|
||||
<path d="M372 133 L428 133" class="s" marker-end="url(#a)"/><text x="400" y="126" text-anchor="middle" class="l">events</text>
|
||||
<path d="M428 186 L372 186" class="sd" marker-end="url(#a)"/><text x="400" y="204" text-anchor="middle" class="l">health · stats</text>
|
||||
<path d="M732 165 L788 165" class="s" marker-start="url(#a)" marker-end="url(#a)"/><text x="760" y="158" text-anchor="middle" class="l">embeds</text>
|
||||
|
||||
<!-- state root -->
|
||||
<rect x="30" y="316" width="1040" height="60" rx="3" class="fs"/>
|
||||
<text x="50" y="340" class="t">One state root — ProgramData\Behavision</text>
|
||||
<text x="50" y="360" class="m">data\behavision.db · data\cameras.json (DPAPI) · data\api_credentials.txt · models\ · runtime\ (the engine's Python) · agent.json · spool\</text>
|
||||
<path d="M200 282 L200 314" class="sd"/><path d="M580 282 L580 314" class="sd"/><path d="M930 282 L930 314" class="sd"/>
|
||||
<text x="1050" y="360" text-anchor="end" class="ma">BEHAVISION_DATA_DIR</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>Python</b> where the recognition ecosystem is — ONNX, OpenCV, FAISS are first-class.</div>
|
||||
<div><b>Go</b> for lifecycle and delivery — static binaries, cross-compiled to Windows from anywhere.</div>
|
||||
<div><b>Wails</b> for the UI — window, tray and supervisor in one process; a service cannot draw a tray icon.</div>
|
||||
<div><b>Exact search</b>: 100,000 identities in 21.9 ms. Identity is decided once per track, so this is queries per minute, not per frame.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 03 -->
|
||||
<section class="plate" id="p3">
|
||||
<div class="head"><span class="n">03</span><div><h2>Recognition: one decision per visit</h2><p>Frames become tracks; tracks accumulate evidence; a track is identified once. A "not sure" outcome is what stops one person becoming three, and a stranger becoming a regular.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 430" role="img" aria-label="Flow: frames from a camera are detected by YuNet, associated into tracks by IoU, scored for quality, aligned and embedded with ArcFace, averaged over at least three views, then compared to the gallery. Similarity at or above 0.42 is a known person; between 0.32 and 0.42 the system waits for a better view; below 0.32 the person is new and enrolled. Known matches at good quality reinforce the gallery.">
|
||||
<g class="t" text-anchor="middle">
|
||||
<rect x="20" y="60" width="120" height="66" rx="2" class="s"/><text x="80" y="88">Frames</text>
|
||||
<rect x="176" y="60" width="130" height="66" rx="2" class="s"/><text x="241" y="88">Detect</text>
|
||||
<rect x="342" y="60" width="130" height="66" rx="2" class="s"/><text x="407" y="88">Track</text>
|
||||
<rect x="508" y="60" width="130" height="66" rx="2" class="s"/><text x="573" y="88">Quality gate</text>
|
||||
<rect x="674" y="60" width="130" height="66" rx="2" class="s"/><text x="739" y="88">Align + embed</text>
|
||||
<rect x="840" y="60" width="130" height="66" rx="2" class="sa"/><text x="905" y="88" class="ta">Average ≥ 3</text>
|
||||
</g>
|
||||
<g class="m" text-anchor="middle">
|
||||
<text x="80" y="108">15 fps · latest frame</text>
|
||||
<text x="241" y="108">YuNet · 5 landmarks</text><text x="241" y="121">score ≥ 0.82</text>
|
||||
<text x="407" y="108">greedy IoU 0.3</text><text x="407" y="121">one track per person</text>
|
||||
<text x="573" y="108">sharp · size · light · frontal</text><text x="573" y="121">per-camera threshold</text>
|
||||
<text x="739" y="108">Umeyama → 112×112</text><text x="739" y="121">ArcFace r50 · 512-d</text>
|
||||
<text x="905" y="108">normalised mean</text><text x="905" y="121">≥ 4 hits</text>
|
||||
</g>
|
||||
<g class="s" marker-end="url(#a)"><line x1="142" y1="93" x2="174" y2="93"/><line x1="308" y1="93" x2="340" y2="93"/><line x1="474" y1="93" x2="506" y2="93"/><line x1="640" y1="93" x2="672" y2="93"/><line x1="806" y1="93" x2="838" y2="93"/></g>
|
||||
|
||||
<!-- decision -->
|
||||
<path d="M905 128 L905 176" class="sa" marker-end="url(#aa)"/>
|
||||
<path d="M905 180 L985 236 L905 292 L825 236 Z" class="sa"/>
|
||||
<text x="905" y="231" text-anchor="middle" class="ta">cosine vs</text><text x="905" y="246" text-anchor="middle" class="ta">gallery</text>
|
||||
|
||||
<!-- outcomes -->
|
||||
<path d="M825 236 L720 236" class="s" marker-end="url(#a)"/>
|
||||
<rect x="590" y="206" width="128" height="60" rx="2" class="fgb"/><text x="654" y="230" text-anchor="middle" class="t">known</text><text x="654" y="248" text-anchor="middle" class="m">≥ 0.42 · person.seen</text>
|
||||
<path d="M905 292 L905 330" class="s" marker-end="url(#a)"/>
|
||||
<rect x="841" y="334" width="128" height="60" rx="2" class="fwb"/><text x="905" y="358" text-anchor="middle" class="t">not sure</text><text x="905" y="376" text-anchor="middle" class="m">0.32 – 0.42 · retry ≤ 8×</text>
|
||||
<path d="M985 236 L1090 236" class="s" marker-end="url(#a)" style="display:none"/>
|
||||
<path d="M985 236 L1020 236 L1020 260" class="s" marker-end="url(#a)"/>
|
||||
<rect x="956" y="264" width="128" height="60" rx="2" class="fab"/><text x="1020" y="288" text-anchor="middle" class="t">new</text><text x="1020" y="306" text-anchor="middle" class="m">< 0.32 · enrol</text>
|
||||
|
||||
<!-- gallery + reinforcement -->
|
||||
<use href="#g-db" x="380" y="216" width="40" height="40"/>
|
||||
<text x="400" y="278" text-anchor="middle" class="t">Gallery</text><text x="400" y="294" text-anchor="middle" class="m">SQLite + FAISS · ≤ 5 views per person</text>
|
||||
<path d="M588 236 L426 236" class="sd" marker-end="url(#a)"/><text x="507" y="228" text-anchor="middle" class="l">reinforce: good quality, not a near-duplicate</text>
|
||||
<path d="M956 300 L940 300 L940 410 L400 410 L400 262" class="sd" marker-end="url(#a)"/><text x="670" y="403" text-anchor="middle" class="l">enrol as "Visitor N"</text>
|
||||
<path d="M400 214 L400 140 L905 140" class="sd" stroke-dasharray="2 3"/><text x="650" y="134" text-anchor="middle" class="l">index searched once per track</text>
|
||||
|
||||
<!-- retry loop -->
|
||||
<path d="M841 364 L780 364 L780 93" class="sd" marker-end="url(#a)"/><text x="720" y="380" text-anchor="middle" class="l">wait 0.5 s for a better frame</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>Never per frame.</b> Single-frame decisions turned one walk-past into three or four "people"; averaging fixed it.</div>
|
||||
<div><b>Model-tagged embeddings.</b> Only same-model vectors share an index; swapping encoders can never mix spaces.</div>
|
||||
<div><b>Quality is per camera, match is shared.</b> Every camera writes into one gallery.</div>
|
||||
<div><b>Measured:</b> 103 tracks → 7 people, 44 correct re-recognitions, in five minutes on the office camera.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 04 -->
|
||||
<section class="plate" id="p4">
|
||||
<div class="head"><span class="n">04</span><div><h2>Delivery: durable before published</h2><p>Nothing is removed from the shop's disk until the broker has confirmed it, and the server drops what it has already seen. That pair is what makes an outage a delay and not a hole.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 380" role="img" aria-label="Swimlanes for shop PC, broker and cloud. A visit event flows from engine to bridge, is appended to the spool and derived an event id, the pump is woken, publishes at QoS 1 over TLS to Mosquitto, the broker acknowledges, only then is the spool file deleted. The ingest consumer deduplicates on the event id, writes to PostgreSQL, and rings a doorbell that wakes live SSE streams to head office. When offline the pump retries with backoff and the spool grows on disk.">
|
||||
<text x="24" y="26" class="z">SHOP PC</text><text x="560" y="26" class="z">BROKER</text><text x="790" y="26" class="z">CLOUD</text>
|
||||
<line x1="536" y1="36" x2="536" y2="350" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 4" opacity=".45"/>
|
||||
<line x1="760" y1="36" x2="760" y2="350" stroke="currentColor" stroke-width="1" opacity=".25"/>
|
||||
|
||||
<g class="t" text-anchor="middle">
|
||||
<rect x="24" y="70" width="110" height="56" rx="2" class="s"/><text x="79" y="94">engine</text>
|
||||
<rect x="164" y="70" width="110" height="56" rx="2" class="s"/><text x="219" y="94">bridge</text>
|
||||
<rect x="304" y="70" width="110" height="56" rx="2" class="sa"/><text x="359" y="94" class="ta">spool</text>
|
||||
<rect x="24" y="200" width="110" height="56" rx="2" class="s"/><text x="79" y="224">waker</text>
|
||||
<rect x="304" y="200" width="110" height="56" rx="2" class="s"/><text x="359" y="224">pump</text>
|
||||
<rect x="580" y="130" width="130" height="66" rx="2" class="s"/><text x="645" y="158">Mosquitto</text>
|
||||
<rect x="790" y="130" width="120" height="66" rx="2" class="s"/><text x="850" y="158">ingest</text>
|
||||
<rect x="790" y="250" width="120" height="56" rx="2" class="s"/><text x="850" y="274">API · SSE</text>
|
||||
</g>
|
||||
<g class="m" text-anchor="middle">
|
||||
<text x="79" y="113">webhook POST</text>
|
||||
<text x="219" y="113">event_id = site·cam·id·sec</text>
|
||||
<text x="359" y="113">append → fsync</text>
|
||||
<text x="79" y="243">rung AFTER append</text>
|
||||
<text x="359" y="243">QoS 1 · in order</text>
|
||||
<text x="645" y="178">TLS 8883 · ACL by tenant</text>
|
||||
<text x="850" y="178">INSERT … ON CONFLICT</text>
|
||||
<text x="850" y="293">arrivals feed</text>
|
||||
</g>
|
||||
<use href="#g-db" x="1000" y="140" width="40" height="40"/><text x="1020" y="200" text-anchor="middle" class="t">PostgreSQL</text>
|
||||
<use href="#g-pc" x="1000" y="262" width="36" height="36"/><text x="1020" y="316" text-anchor="middle" class="m">head office</text>
|
||||
|
||||
<g class="s" marker-end="url(#a)">
|
||||
<line x1="136" y1="98" x2="162" y2="98"/><line x1="276" y1="98" x2="302" y2="98"/>
|
||||
<path d="M219 128 L219 228 L136 228" /><path d="M136 228 L302 228" style="display:none"/>
|
||||
<line x1="136" y1="228" x2="302" y2="228" />
|
||||
<path d="M416 228 L470 228 L470 163 L578 163" class="sa" marker-end="url(#aa)"/>
|
||||
<line x1="712" y1="163" x2="788" y2="163"/>
|
||||
<line x1="912" y1="163" x2="998" y2="163"/>
|
||||
<line x1="850" y1="198" x2="850" y2="248"/>
|
||||
<line x1="912" y1="278" x2="998" y2="278"/>
|
||||
</g>
|
||||
<text x="228" y="245" text-anchor="middle" class="l">wake</text>
|
||||
<text x="470" y="152" text-anchor="middle" class="la">publish</text>
|
||||
<text x="750" y="156" text-anchor="middle" class="l">deliver</text>
|
||||
<text x="955" y="156" text-anchor="middle" class="l">write</text>
|
||||
<text x="862" y="228" class="l">doorbell</text>
|
||||
<text x="955" y="271" text-anchor="middle" class="l">push</text>
|
||||
|
||||
<!-- ack path -->
|
||||
<path d="M645 198 L645 320 L359 320 L359 258" class="sa" stroke-dasharray="5 4" marker-end="url(#aa)"/>
|
||||
<text x="500" y="338" text-anchor="middle" class="la">PUBACK → delete the spool file. Never before.</text>
|
||||
|
||||
<!-- offline loop -->
|
||||
<path d="M304 240 L280 240 L280 300 L304 300" class="sd" style="display:none"/>
|
||||
<rect x="160" y="284" width="126" height="44" rx="2" class="fs"/>
|
||||
<text x="223" y="302" text-anchor="middle" class="l">offline?</text><text x="223" y="318" text-anchor="middle" class="m">backoff 1 → 30 s · spool grows</text>
|
||||
<path d="M302 244 L286 300" class="sd" marker-end="url(#a)"/>
|
||||
<path d="M286 306 L350 260" class="sd" style="display:none"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>QoS 1, clean session.</b> QoS 0 could delete an event the wire dropped; QoS 2 buys nothing the derived id doesn't already give.</div>
|
||||
<div><b>Ordered.</b> A failed publish stops the batch — a customer's visits are a timeline.</div>
|
||||
<div><b>Bounded and honest.</b> The spool has a cap and reports what it dropped; a corrupt entry is quarantined, never retried forever.</div>
|
||||
<div><b>Measured:</b> 120 simultaneous visits published, 120 delivered; end to end in ~3 s.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 05 -->
|
||||
<section class="plate" id="p5">
|
||||
<div class="head"><span class="n">05</span><div><h2>Local gallery, master database</h2><p>Two stores with two jobs. The shop PC's gallery recognises people in that shop, offline if need be. The platform's database knows the business: customers across shops, history, reports, tenancy.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 360" role="img" aria-label="Left: the shop PC's SQLite gallery with identities, model-tagged embeddings and sightings, plus a camera list. Right: PostgreSQL with clients, sites, users, visitors, visits, embeddings, cameras and sessions, every row carrying a client id. Between them: templates travel up with each visit; camera configuration and check jobs travel down; nothing else crosses.">
|
||||
<text x="24" y="26" class="z">SHOP PC</text><text x="640" y="26" class="z">PLATFORM</text>
|
||||
<line x1="540" y1="36" x2="540" y2="330" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 4" opacity=".45"/>
|
||||
|
||||
<use href="#g-db" x="60" y="60" width="56" height="56"/>
|
||||
<text x="140" y="80" class="ta">SQLite gallery</text>
|
||||
<text x="140" y="98" class="m">the only persistent state on the PC · WAL</text>
|
||||
<g class="m"><text x="140" y="124">identities Visitor N, label</text><text x="140" y="140">embeddings 512-d · tagged by model</text><text x="140" y="156">sightings identity × camera × time</text></g>
|
||||
<text x="140" y="184" class="t">FAISS index — rebuilt from SQLite at boot</text>
|
||||
<text x="140" y="200" class="m">exact inner product · numpy fallback identical</text>
|
||||
|
||||
<use href="#g-file" x="60" y="230" width="44" height="44"/>
|
||||
<text x="140" y="250" class="t">cameras.json</text><text x="140" y="266" class="m">passwords DPAPI-encrypted · machine-bound</text>
|
||||
<text x="140" y="296" class="t">agent.json</text><text x="140" y="312" class="m">broker login · agent token · sealed at rest</text>
|
||||
|
||||
<use href="#g-db" x="590" y="60" width="56" height="56"/>
|
||||
<text x="670" y="80" class="ta">PostgreSQL</text>
|
||||
<text x="670" y="98" class="m">every table carries client_id · self-migrating schema · 13 migrations</text>
|
||||
<g class="m">
|
||||
<text x="670" y="124">clients slug = MQTT topic prefix</text>
|
||||
<text x="670" y="140">sites · agents slug · tz · heartbeat · fraction_below_gate</text>
|
||||
<text x="670" y="156">app_users owner · manager · staff · bcrypt</text>
|
||||
<text x="670" y="172">visitors number → V-42 · per tenant</text>
|
||||
<text x="670" y="188">visits seq (feed cursor) · source_event_id (dedupe)</text>
|
||||
<text x="670" y="204">visitor_embeddings ≤ 5 · reinforced server-side</text>
|
||||
<text x="670" y="220">site_cameras password sealed AES-GCM, aad = site</text>
|
||||
<text x="670" y="236">sessions SHA-256 of tokens · revocable</text>
|
||||
<text x="670" y="252">visit_faces · camera_snapshots · audit_log</text>
|
||||
</g>
|
||||
<text x="670" y="290" class="t">Object storage (optional)</text><text x="670" y="306" class="m">presigned PUT from the shop PC · presigned GET for staff · private ACL in the signature</text>
|
||||
|
||||
<!-- flows across -->
|
||||
<path d="M420 120 L660 120" style="display:none"/>
|
||||
<path d="M380 210 L528 210 L528 190 L556 190" class="sa" marker-end="url(#aa)" style="display:none"/>
|
||||
<path d="M400 216 L520 216" class="sa" marker-end="url(#aa)"/><text x="460" y="208" text-anchor="middle" class="la">visit + template ↑</text>
|
||||
<path d="M520 244 L400 244" class="sd" marker-end="url(#a)"/><text x="460" y="262" text-anchor="middle" class="l">cameras · checks ↓</text>
|
||||
<path d="M400 290 L520 290" class="sd" marker-end="url(#a)" opacity=".5"/><text x="460" y="308" text-anchor="middle" class="l">heartbeat · health ↑</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>Video, frames and raw images never cross.</b> A template and a timestamp do.</div>
|
||||
<div><b>Tenancy is a column and a rule</b>, and the two agree: the tenant comes from the session, never from the request.</div>
|
||||
<div><b>References are immutable</b> — slugs, camera ids, customer numbers — because other systems store them. Display names are free to change.</div>
|
||||
<div><b>Feed by <code>seq</code></b>, never by the camera's clock: lossless under bursts and backlogs; cursors are opaque.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 06 -->
|
||||
<section class="plate" id="p6">
|
||||
<div class="head"><span class="n">06</span><div><h2>Clients and the API</h2><p>Three kinds of people and one kind of machine, all through one API. Sessions are opaque tokens in a table, so "log that device out, now" actually works.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 400" role="img" aria-label="Head-office console, mobile app and shop app sign in with email and password and receive an opaque session; the tenant and role come from that session. They call the API's visits, visitors, sites, cameras, reports, team and assistant routes. The shop PC's agent uses its own token, issued at enrolment, against the agent routes only. Live camera video reaches head office through the agent's outbound relay.">
|
||||
<g class="t">
|
||||
<rect x="24" y="50" width="200" height="60" rx="2" class="s"/><use href="#g-pc" x="36" y="62" width="22" height="22"/><text x="66" y="77">Head-office console</text><text x="66" y="95" class="m">React · embedded in server</text>
|
||||
<rect x="24" y="130" width="200" height="60" rx="2" class="s"/><use href="#g-phone" x="36" y="142" width="22" height="22"/><text x="66" y="157">Mobile app</text><text x="66" y="175" class="m">arrivals · customers · sales</text>
|
||||
<rect x="24" y="210" width="200" height="60" rx="2" class="s"/><use href="#g-pc" x="36" y="222" width="22" height="22"/><text x="66" y="237">Shop app</text><text x="66" y="255" class="m">engine on loopback · cloud for the rest</text>
|
||||
<rect x="24" y="300" width="200" height="60" rx="2" class="sa"/><use href="#g-gear" x="36" y="312" width="22" height="22"/><text x="66" y="327" class="ta">Shop PC agent</text><text x="66" y="345" class="m">token issued once at enrolment</text>
|
||||
</g>
|
||||
|
||||
<rect x="300" y="50" width="220" height="220" rx="2" class="sa"/>
|
||||
<text x="316" y="76" class="ta">Session</text>
|
||||
<g class="m"><text x="316" y="100">256-bit opaque token</text><text x="316" y="116">stored as SHA-256 only</text><text x="316" y="132">refresh rotates in place</text><text x="316" y="148">revocable per device, instantly</text></g>
|
||||
<rect x="316" y="166" width="188" height="88" rx="2" class="fs"/>
|
||||
<text x="330" y="186" class="t">tenant ← session.client_id</text>
|
||||
<text x="330" y="206" class="m">staff arrivals · customers · sales</text>
|
||||
<text x="330" y="222" class="m">manager + cameras · team · erasure</text>
|
||||
<text x="330" y="238" class="m">owner + mint owners</text>
|
||||
|
||||
<rect x="600" y="50" width="476" height="310" rx="2" class="s"/>
|
||||
<text x="616" y="76" class="t">/api</text>
|
||||
<g class="m">
|
||||
<text x="616" y="104">/auth/login · refresh · sessions · register</text>
|
||||
<text x="616" y="124">/visits · /visits/stream ·································· SSE, cursor</text>
|
||||
<text x="616" y="144">/visitors · /history · /profile · /image · /purchases</text>
|
||||
<text x="616" y="164">/sites · /sites/{s}/check · /enrolment-code</text>
|
||||
<text x="616" y="184">/cameras · /check · /snapshot.jpg · /live ······· SSE relay</text>
|
||||
<text x="616" y="204">/reports/footfall · /reports/conversion</text>
|
||||
<text x="616" y="224">/team · /team/members · /team/{id}/password · /invitations</text>
|
||||
<text x="616" y="244">/assistant ··································· tools, never SQL</text>
|
||||
<text x="616" y="264">/admin/clients ····························· platform admin only</text>
|
||||
</g>
|
||||
<rect x="616" y="284" width="444" height="56" rx="2" class="fab"/>
|
||||
<text x="630" y="306" class="ma">/agent/* — enrol · cameras · checks · faces · upload-url · live</text>
|
||||
<text x="630" y="326" class="m">agent token only · a user session is refused</text>
|
||||
|
||||
<g class="s" marker-end="url(#a)"><line x1="226" y1="80" x2="298" y2="80"/><line x1="226" y1="160" x2="298" y2="160"/><line x1="226" y1="240" x2="298" y2="240"/><line x1="522" y1="160" x2="598" y2="160"/></g>
|
||||
<path d="M226 330 L560 330 L560 312 L614 312" class="sa" marker-end="url(#aa)"/>
|
||||
<text x="262" y="72" class="l">email + password</text>
|
||||
<text x="1090" y="385" text-anchor="end" class="cap">Ids accept names: /api/visitors/V-42 · ?site=chennai · /api/cameras/cam1 — a uuid still works everywhere.</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>Login is boring on purpose.</b> Unknown address and wrong password are byte-identical and cost the same time.</div>
|
||||
<div><b>Another tenant's data is 404</b>, never 403 — nothing to enumerate.</div>
|
||||
<div><b>Live video</b> at head office: the agent pushes ~13 fps only while someone watches. 259 KB/s measured.</div>
|
||||
<div><b>The assistant</b> answers from the same report tools, as the signed-in user; it has no tenant parameter to misuse.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 07 -->
|
||||
<section class="plate" id="p7">
|
||||
<div class="head"><span class="n">07</span><div><h2>Onboarding: each tier creates the next</h2><p>No credential ships inside an installer, and nobody creates their own account from nothing.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 300" role="img" aria-label="Sequence: the platform admin creates a merchant and its owner, receiving a one-time password. The owner creates sales staff, receiving a one-time password, or issues an invitation code. Staff sign in on the mobile app. The owner mints a single-use installation code; the shop PC redeems it and receives its broker login and API token. Head office then pushes the shop's cameras to the PC.">
|
||||
<g class="t" text-anchor="middle">
|
||||
<use href="#g-person" x="60" y="40" width="40" height="40"/><text x="80" y="100">Platform admin</text>
|
||||
<use href="#g-person" x="300" y="40" width="40" height="40"/><text x="320" y="100">Merchant owner</text>
|
||||
<use href="#g-phone" x="540" y="40" width="40" height="40"/><text x="560" y="100">Sales staff</text>
|
||||
<use href="#g-pc" x="780" y="40" width="40" height="40"/><text x="800" y="100">Shop PC</text>
|
||||
<use href="#g-cam" x="1000" y="40" width="40" height="40"/><text x="1020" y="100">Cameras</text>
|
||||
</g>
|
||||
<g class="s" marker-end="url(#a)">
|
||||
<line x1="120" y1="140" x2="278" y2="140"/>
|
||||
<line x1="360" y1="140" x2="518" y2="140"/>
|
||||
<line x1="360" y1="200" x2="758" y2="200"/>
|
||||
<line x1="840" y1="200" x2="998" y2="200"/>
|
||||
</g>
|
||||
<path d="M840 240 L1000 240" class="sd" marker-end="url(#a)"/>
|
||||
<g class="m" text-anchor="middle">
|
||||
<text x="199" y="130">POST /api/admin/clients</text><text x="199" y="158">company + owner, one transaction</text><text x="199" y="172" class="ma" opacity="1">owner password, shown once</text>
|
||||
<text x="439" y="130">POST /api/team/members</text><text x="439" y="158">or /team/invitations → a code they redeem</text><text x="439" y="172" class="ma" opacity="1">staff password, shown once</text>
|
||||
<text x="559" y="190">POST /api/sites/{shop}/enrolment-code</text><text x="559" y="218">single use · 7 days · redeemed by the PC:</text><text x="559" y="232" class="ma" opacity="1">broker login + agent token + CA to pin</text>
|
||||
<text x="919" y="190">head office pushes cameras</text><text x="919" y="230">the PC pulls · adopts local ones up</text><text x="919" y="258">passwords travel only to that site's agent</text>
|
||||
</g>
|
||||
<text x="24" y="288" class="cap">Single use is enforced by the UPDATE itself, so two PCs racing on one code cannot both win. A wrong, spent or expired code all read the same.</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>Direct or by invitation.</b> A manager can hand over a generated password, or let the salesperson choose their own via a code.</div>
|
||||
<div><b>Reset signs the lost phone out</b> in the same transaction as the new password.</div>
|
||||
<div><b>Standalone</b> is a first-class answer on the setup screen: a single-till shop with no head office runs the full product locally.</div>
|
||||
<div><b>Demo build:</b> cameras ship sealed (AES-256-GCM); the unlock code travels separately from the zip.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 08 -->
|
||||
<section class="plate" id="p8">
|
||||
<div class="head"><span class="n">08</span><div><h2>Where every secret lives</h2><p>Biometric data is treated as biometric data. Each credential has one home and one protection, and none of them is ever returned by an API.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 330" role="img" aria-label="Map of secrets: on the shop PC, camera passwords under DPAPI, the engine API credential in a 0600 file, agent token and broker password sealed in agent.json; in the database, site broker passwords and camera passwords under AES-256-GCM with the site as additional data, user passwords under bcrypt cost 12, session tokens as SHA-256; in transit, TLS for MQTT and HTTPS for the API; face templates never leave the shop as images, photos are opt-in, erasure deletes templates and objects, every image read is audited.">
|
||||
<text x="24" y="26" class="z">SHOP PC</text><text x="400" y="26" class="z">IN TRANSIT</text><text x="640" y="26" class="z">DATABASE</text><text x="900" y="26" class="z">POLICY</text>
|
||||
<line x1="376" y1="36" x2="376" y2="300" stroke="currentColor" stroke-width="1" opacity=".25"/>
|
||||
<line x1="616" y1="36" x2="616" y2="300" stroke="currentColor" stroke-width="1" opacity=".25"/>
|
||||
<line x1="876" y1="36" x2="876" y2="300" stroke="currentColor" stroke-width="1" opacity=".25"/>
|
||||
<g class="t"><use href="#g-lock" x="24" y="52" width="20" height="20"/><text x="52" y="67">camera passwords</text><text x="52" y="84" class="m">DPAPI · machine-bound · has_password only</text>
|
||||
<use href="#g-lock" x="24" y="110" width="20" height="20"/><text x="52" y="125">engine API credential</text><text x="52" y="142" class="m">generated on first start · 0600 · read by the agent</text>
|
||||
<use href="#g-lock" x="24" y="168" width="20" height="20"/><text x="52" y="183">agent token · broker password</text><text x="52" y="200" class="m">sealed in agent.json · earned by enrolment, never shipped</text>
|
||||
<use href="#g-lock" x="24" y="226" width="20" height="20"/><text x="52" y="241">face templates</text><text x="52" y="258" class="m">SQLite · treated as personal data · erasure deletes outright</text>
|
||||
</g>
|
||||
<g class="t"><text x="400" y="67">MQTT</text><text x="400" y="84" class="m">TLS 8883 · pinned issuer · plaintext to any non-loopback host is refused</text>
|
||||
<text x="400" y="125">API</text><text x="400" y="142" class="m">HTTPS behind Traefik · bearer sessions</text>
|
||||
<text x="400" y="183">images</text><text x="400" y="200" class="m">presigned URLs, minutes-long · private ACL inside the signature</text>
|
||||
<text x="400" y="241">shop PC ↔ engine</text><text x="400" y="258" class="m">loopback only · relay token per run, no password in the page</text>
|
||||
</g>
|
||||
<g class="t"><text x="640" y="67">broker + camera passwords</text><text x="640" y="84" class="m">AES-256-GCM · aad = owning site · row copies don't decrypt</text>
|
||||
<text x="640" y="125">user passwords</text><text x="640" y="142" class="m">bcrypt cost 12 · 10 failures / 15 min per account</text>
|
||||
<text x="640" y="183">session tokens</text><text x="640" y="200" class="m">SHA-256 only · a dump holds no usable session</text>
|
||||
<text x="640" y="241">audit_log</text><text x="640" y="258" class="m">every face-image hand-out, every code minted, every merchant created</text>
|
||||
</g>
|
||||
<g class="t"><text x="900" y="67">video never leaves</text><text x="900" y="84" class="m">recognition runs in the shop</text>
|
||||
<text x="900" y="125">photos are opt-in</text><text x="900" y="142" class="m">store_faces defaults to off</text>
|
||||
<text x="900" y="183">tenancy is structural</text><text x="900" y="200" class="m">session decides · 404, never 403</text>
|
||||
<text x="900" y="241">erasure erases</text><text x="900" y="258" class="m">object first · 502 changes nothing</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 09 -->
|
||||
<section class="plate" id="p9">
|
||||
<div class="head"><span class="n">09</span><div><h2>The stack, and the numbers behind it</h2><p>Each layer, the choice, and the one reason that decided it.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 330" role="img" aria-label="Layer stack: clients (React console, mobile, Wails app); API and web (Go, one binary, opaque sessions); transport (Mosquitto MQTT, QoS 1, TLS); master data (PostgreSQL, self-migrating); shop agent (Go library: spool, pump, supervisor); recognition (Python: YuNet, ArcFace r50 on ONNX Runtime, FAISS, SQLite); cameras (any RTSP). Each with its deciding reason.">
|
||||
<g class="t">
|
||||
<rect x="24" y="30" width="1052" height="38" rx="2" class="s"/><text x="40" y="54">Clients</text><text x="200" y="54" class="m">React console (embedded) · mobile app · Wails shop app</text><text x="1060" y="54" text-anchor="end" class="l">one API; UI can never lag its server</text>
|
||||
<rect x="24" y="74" width="1052" height="38" rx="2" class="s"/><text x="40" y="98">API + web</text><text x="200" y="98" class="m">Go · one binary · opaque sessions in a table</text><text x="1060" y="98" text-anchor="end" class="l">instant per-device revocation; JWTs cannot</text>
|
||||
<rect x="24" y="118" width="1052" height="38" rx="2" class="s"/><text x="40" y="142">Transport</text><text x="200" y="142" class="m">Mosquitto · MQTT QoS 1 · TLS · per-tenant ACL</text><text x="1060" y="142" text-anchor="end" class="l">built for many outbound clients; ~10 MB</text>
|
||||
<rect x="24" y="162" width="1052" height="38" rx="2" class="s"/><text x="40" y="186">Master data</text><text x="200" y="186" class="m">PostgreSQL · self-applying migrations · advisory lock · checksums</text><text x="1060" y="186" text-anchor="end" class="l">transactions across tenant + owner; keyset feeds</text>
|
||||
<rect x="24" y="206" width="1052" height="38" rx="2" class="s"/><text x="40" y="230">Shop agent</text><text x="200" y="230" class="m">Go library · spool · pump · supervisor · reconciler</text><text x="1060" y="230" text-anchor="end" class="l">static binary, cross-compiled; one tested implementation</text>
|
||||
<rect x="24" y="250" width="1052" height="38" rx="2" class="sa"/><text x="40" y="274" class="ta">Recognition</text><text x="200" y="274" class="m">Python · YuNet · ArcFace r50 (ONNX Runtime) · FAISS IndexFlatIP · SQLite WAL</text><text x="1060" y="274" text-anchor="end" class="la">97.25 IJB-C · exact search · no second process</text>
|
||||
<rect x="24" y="294" width="1052" height="30" rx="2" class="fs"/><text x="40" y="314">Cameras</text><text x="200" y="314" class="m">any RTSP camera · make picker fills the stream path · placement proved by a 25-second walk-past</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="metrics" style="margin-top:1.1rem">
|
||||
<div class="metric"><span class="v">103 → 7</span><span class="k">tracks to people, 5 min, office camera — 44 re-recognitions</span></div>
|
||||
<div class="metric"><span class="v">120 / 120</span><span class="k">simultaneous visits delivered, real broker and database</span></div>
|
||||
<div class="metric"><span class="v">21.9 ms</span><span class="k">exact search over 100,000 identities</span></div>
|
||||
<div class="metric"><span class="v">~3 s</span><span class="k">camera to head-office feed</span></div>
|
||||
<div class="metric"><span class="v">14 fps</span><span class="k">live picture on the shop PC vs a 15 fps camera</span></div>
|
||||
<div class="metric"><span class="v">10 / 10</span><span class="k">install steps on a clean machine, both cameras connected</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="wrap">Behavision — Loyaly · Technical overview, 11 September 2026 · release 0.4.1 · engine 1.1.0 · schema at migration 13. All figures measured on the running system.</div>
|
||||
</footer>
|
||||
120
installer/INSTALL.txt
Normal file
@@ -0,0 +1,120 @@
|
||||
Behavision — installing on a shop PC
|
||||
====================================
|
||||
|
||||
This is a source install. It needs Python and a working internet connection
|
||||
once, at setup. After that the shop PC runs on its own.
|
||||
|
||||
|
||||
WHAT YOU NEED FIRST
|
||||
-------------------
|
||||
|
||||
Python 3.10 or newer.
|
||||
|
||||
https://www.python.org/downloads/windows/
|
||||
|
||||
On the very first screen of the Python installer, tick
|
||||
"Add python.exe to PATH". If you miss it, setup cannot find Python and
|
||||
you will have to run the Python installer again.
|
||||
|
||||
|
||||
SETTING UP
|
||||
----------
|
||||
|
||||
1. Unzip this whole folder somewhere permanent — for example
|
||||
C:\Behavision. Keep the files together; behavision-setup.exe looks for
|
||||
the engine-src folder next to itself.
|
||||
|
||||
2. Double-click behavision-setup.exe
|
||||
|
||||
It will:
|
||||
- find your Python and check it is new enough
|
||||
- build a private Python environment under
|
||||
C:\ProgramData\Behavision\runtime
|
||||
- install the recognition engine and its libraries (from the wheel
|
||||
in engine-src; the folder you unzipped is never written to)
|
||||
- download the recognition models (a few hundred megabytes)
|
||||
- start the engine once to prove it works
|
||||
|
||||
This takes several minutes. Leave the window open until it says Done.
|
||||
If anything fails it prints why, and running it again is safe.
|
||||
|
||||
DEMO RELEASE ONLY: if the release came with the cameras already set up,
|
||||
setup first asks for an unlock code. Type the code you were given. The
|
||||
camera details are sealed inside the release and cannot be read without
|
||||
it; with it, both cameras are added and the PC is set to run on its own,
|
||||
with no head office. Skip the installation-code screen - it will not
|
||||
appear.
|
||||
|
||||
3. Double-click Behavision.exe
|
||||
|
||||
The window opens and an icon appears in the system tray, next to the
|
||||
clock. Right-click the tray icon to open the window again, or to stop
|
||||
recognition.
|
||||
|
||||
|
||||
CONNECTING IT TO HEAD OFFICE
|
||||
----------------------------
|
||||
|
||||
The first screen asks for an installation code. Ask whoever manages your
|
||||
shops — they create one from the Behavision platform, under the shop.
|
||||
|
||||
No head office? Choose "set this PC up on its own" on the same screen.
|
||||
Recognition, the cameras and the customer list all work locally; nothing is
|
||||
sent anywhere.
|
||||
|
||||
|
||||
ADDING A CAMERA
|
||||
---------------
|
||||
|
||||
Cameras → Add. You need the camera's address on the shop network, its
|
||||
username and password. Choose your camera's make from the list and the
|
||||
stream path is filled in for you — that is the field nobody can look up.
|
||||
|
||||
Press "Test" before saving. Then press "Check placement" and walk past the
|
||||
camera a few times. It will tell you whether the camera can actually
|
||||
recognise faces from where it is mounted, which is not the same question as
|
||||
whether it is connected.
|
||||
|
||||
Camera placement matters more than camera quality. Aim for roughly head
|
||||
height, facing the direction people walk in. A camera high in a corner
|
||||
looking down, or pointing at a bright window or glass door, will connect
|
||||
perfectly and recognise almost nobody.
|
||||
|
||||
|
||||
WHERE THINGS LIVE
|
||||
-----------------
|
||||
|
||||
C:\ProgramData\Behavision\ database, logs, camera list, models
|
||||
C:\ProgramData\Behavision\runtime the engine's own Python
|
||||
|
||||
Everything the software writes is under ProgramData. The folder you unzipped
|
||||
is never written to, so you can keep it on a shared drive.
|
||||
|
||||
|
||||
STOPPING IT
|
||||
-----------
|
||||
|
||||
Right-click the tray icon and choose Quit. That stops recognition as well —
|
||||
leaving it running with no visible control would be worse than stopping it.
|
||||
|
||||
Closing the window does NOT stop recognition. The window hides and the tray
|
||||
icon stays, because a shop assistant clicking X should not switch the shop's
|
||||
footfall counting off for the rest of the day.
|
||||
|
||||
|
||||
IF SOMETHING IS WRONG
|
||||
---------------------
|
||||
|
||||
"No Python 3.10 or newer was found"
|
||||
Python is missing, too old, or was installed without the
|
||||
"Add python.exe to PATH" tick. Reinstall Python with that ticked.
|
||||
|
||||
Setup fails while installing libraries
|
||||
Almost always no internet, or a proxy in the way. The error printed
|
||||
just above the failure says which.
|
||||
|
||||
The window opens but says the engine is not running
|
||||
Run behavision-setup.exe again; it will report what is missing.
|
||||
|
||||
Logs
|
||||
C:\ProgramData\Behavision\engine.log
|
||||
@@ -31,6 +31,8 @@
|
||||
#define MyAppExeName "Behavision.exe"
|
||||
|
||||
[Setup]
|
||||
; The Loyaly mark, on the installer and in Add/Remove Programs.
|
||||
SetupIconFile=..\brand\loyaly.ico
|
||||
AppId={{7C4B9E2A-3F51-4C86-9D0A-B1E7A2F65D11}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
|
||||
@@ -66,15 +66,13 @@ npm run build
|
||||
Pop-Location
|
||||
Push-Location (Join-Path $root "desktop")
|
||||
# Wails v2 talks to WebView2 through pure-Go bindings, so no cgo and no
|
||||
# toolchain beyond Go itself. Verified by cross-compiling the same package from
|
||||
# a Mac with CGO_ENABLED=0.
|
||||
# toolchain beyond Go itself. A plain go build is used on purpose: the icon
|
||||
# and the manifest are compiled in from rsrc_windows_amd64.syso (go-winres,
|
||||
# from brand/loyaly-icon-512.png), and `wails build` would add a second copy
|
||||
# of both and fail the link with duplicate resources.
|
||||
$env:CGO_ENABLED = "0"
|
||||
if (Get-Command wails -ErrorAction SilentlyContinue) {
|
||||
wails build -platform windows/amd64 -clean -ldflags "-X main.version=$Version"
|
||||
} else {
|
||||
Write-Warning "wails CLI not found - falling back to a plain go build (no icon, no manifest)."
|
||||
go build -ldflags "-H windowsgui -X main.version=$Version" -o (Join-Path $root "desktop\build\bin\Behavision.exe") .
|
||||
}
|
||||
go build -ldflags "-H windowsgui -X main.version=$Version" -o (Join-Path $root "desktop\build\bin\Behavision.exe") .
|
||||
if ($LASTEXITCODE -ne 0) { throw "desktop build failed" }
|
||||
Pop-Location
|
||||
|
||||
Step "Headless agent"
|
||||
|
||||
21
installer/run-with-lan-head-office.cmd
Normal file
@@ -0,0 +1,21 @@
|
||||
@echo off
|
||||
rem Start Behavision against a head office running on another PC on this LAN,
|
||||
rem instead of the production server it uses by default.
|
||||
rem
|
||||
rem For demos and pilots only. Two things are deliberately weaker than
|
||||
rem production and both are named here so nobody copies this into a shop:
|
||||
rem
|
||||
rem - head office over plain http, not https
|
||||
rem - the message broker over plain tcp. The app REFUSES plaintext MQTT to
|
||||
rem any address that is not its own machine, by design - the payloads are
|
||||
rem customer visit records - so the second line below is the documented
|
||||
rem escape hatch and must not be set anywhere that is not a demo.
|
||||
rem
|
||||
rem Edit the address to the PC running head office, then double-click this
|
||||
rem instead of Behavision.exe. Everything else - the installation code, the
|
||||
rem sign-in, the cameras - works exactly as INSTALL.txt describes.
|
||||
|
||||
set BEHAVISION_CLOUD=http://192.168.1.117:8088
|
||||
set BEHAVISION_ALLOW_PLAINTEXT_MQTT=1
|
||||
|
||||
start "" "%~dp0Behavision.exe"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "behavision"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
description = "Production face recognition over RTSP"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
@@ -14,6 +14,10 @@ dependencies = [
|
||||
"python-dotenv>=1.0",
|
||||
"faiss-cpu>=1.7.4",
|
||||
"requests>=2.31",
|
||||
# DPAPI for camera passwords at rest (behavision/cameras.py). Without it the
|
||||
# store logs a warning and writes them in the clear - which is what every
|
||||
# Windows install had been doing, since nothing pulled this in.
|
||||
"pywin32>=306; sys_platform == 'win32'",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -22,5 +26,8 @@ dev = ["pytest>=8.0"]
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["behavision*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
behavision = ["static/*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
69
release.sh
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the Windows shop-PC package and publish it as a Gitea release.
|
||||
#
|
||||
# ./release.sh v0.4.2 build dist/Behavision-v0.4.2-windows-x64.zip and publish
|
||||
# PUBLISH=0 ./release.sh v0.4.2 build only
|
||||
#
|
||||
# The package is a SOURCE install: the Go binaries are cross-compiled here, the
|
||||
# engine ships as a pure-Python wheel and behavision-setup.exe builds a venv on
|
||||
# the shop PC. PyInstaller cannot cross-compile, so a frozen engine needs a
|
||||
# Windows build machine we do not have; this is what lets a release happen
|
||||
# from this Mac at all. Layout matches what behavision-setup expects and what
|
||||
# INSTALL.txt describes.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
export PATH="$PATH:$HOME/go/bin:/opt/homebrew/bin"
|
||||
|
||||
TAG=${1:?usage: release.sh vX.Y.Z}
|
||||
REPO_API=https://gitapp.workolik.com/api/v1/repos/Loyaly/Behavision
|
||||
STAGE=dist/Behavision
|
||||
ZIP="dist/Behavision-$TAG-windows-x64.zip"
|
||||
|
||||
step() { printf '\n\033[1m%s\033[0m\n' "$*"; }
|
||||
case "$(git describe --tags --always --dirty)" in *-dirty) echo "refusing to release uncommitted changes" >&2; exit 1;; esac
|
||||
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
||||
[ "$(git rev-parse "$TAG^{}")" = "$(git rev-parse HEAD)" ] || { echo "$TAG exists and is not HEAD" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
step "1. Desktop app (Wails, pure-Go Windows target)"
|
||||
(cd desktop/frontend && npm run build >/dev/null)
|
||||
rm -rf "$STAGE" && mkdir -p "$STAGE/engine-src"
|
||||
(cd desktop && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath \
|
||||
-ldflags "-H windowsgui -s -w -X main.version=$TAG" -o "../$STAGE/Behavision.exe" .)
|
||||
|
||||
step "2. Agent and setup tool"
|
||||
(cd agent && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=$TAG" -o "../$STAGE/behavision-agent.exe" . \
|
||||
&& CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=$TAG" -o "../$STAGE/behavision-setup.exe" ./cmd/behavision-setup)
|
||||
|
||||
step "3. Engine source and wheel"
|
||||
# The wheel is built with the checkout's own interpreter; requires-python is a
|
||||
# statement about the SHOP PC, which setup enforces when it finds Python there.
|
||||
.venv/bin/python -m pip wheel --no-deps --ignore-requires-python -q -w "$STAGE/engine-src" . 2>&1 | grep -v "DEPRECATION\|WARNING: Ignoring" || true
|
||||
ls "$STAGE"/engine-src/behavision-*.whl >/dev/null || { echo "wheel was not built" >&2; exit 1; }
|
||||
cp pyproject.toml requirements.txt "$STAGE/engine-src/"
|
||||
mkdir -p "$STAGE/engine-src/config" && cp config/default.yaml "$STAGE/engine-src/config/"
|
||||
rsync -a --exclude '__pycache__' behavision/ "$STAGE/engine-src/behavision/"
|
||||
cp installer/INSTALL.txt installer/run-with-lan-head-office.cmd "$STAGE/"
|
||||
|
||||
step "4. Package"
|
||||
rm -f "$ZIP" && (cd dist && zip -qr "$(basename "$ZIP")" Behavision) && ls -la "$ZIP" | awk '{print " " $5 " bytes " $9}'
|
||||
unzip -l "$ZIP" | grep -E "Behavision\.exe|agent\.exe|setup\.exe|\.whl|INSTALL" | awk '{print " " $4}'
|
||||
|
||||
[ "${PUBLISH:-1}" = "1" ] || { echo "built, not published"; exit 0; }
|
||||
|
||||
step "5. Tag and publish"
|
||||
git rev-parse -q --verify "refs/tags/$TAG" >/dev/null || git tag -a "$TAG" -m "$TAG"
|
||||
git push -q origin "$TAG"
|
||||
# The notes come from a file so they are reviewed, not typed into a shell.
|
||||
NOTES=${NOTES:-dist/RELEASE-NOTES-$TAG.md}
|
||||
[ -f "$NOTES" ] || { echo "write the release notes to $NOTES first" >&2; exit 1; }
|
||||
# Same credential git pushes with; Gitea accepts it as Basic auth for the API.
|
||||
CRED=$(printf 'protocol=https\nhost=gitapp.workolik.com\n' | git credential fill)
|
||||
USER=$(printf '%s' "$CRED" | sed -n 's/^username=//p'); PASS=$(printf '%s' "$CRED" | sed -n 's/^password=//p')
|
||||
BODY=$(python3 -c 'import json,sys;print(json.dumps({"tag_name":sys.argv[1],"name":sys.argv[2],"body":open(sys.argv[3]).read(),"prerelease":True}))' "$TAG" "$TAG — $(head -1 "$NOTES" | sed 's/^#* *//')" "$NOTES")
|
||||
REL=$(curl -sS -u "$USER:$PASS" -H 'content-type: application/json' -d "$BODY" "$REPO_API/releases")
|
||||
ID=$(printf '%s' "$REL" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
|
||||
curl -sS -u "$USER:$PASS" -F "attachment=@$ZIP" "$REPO_API/releases/$ID/assets?name=$(basename "$ZIP")" >/dev/null
|
||||
echo " published: https://gitapp.workolik.com/Loyaly/Behavision/releases/tag/$TAG"
|
||||
@@ -8,3 +8,4 @@ PyYAML>=6.0
|
||||
python-dotenv>=1.0
|
||||
faiss-cpu>=1.7.4
|
||||
requests>=2.31
|
||||
pywin32>=306; sys_platform == "win32"
|
||||
|
||||
49
run-local.sh
@@ -72,15 +72,34 @@ step "3b. Schema"
|
||||
"./$STATE/bv-server" migrate
|
||||
|
||||
step "4. Mosquitto"
|
||||
if [ ! -f "$STATE/mosquitto/mosquitto.conf" ]; then
|
||||
# Dynamic security, not a passwd file - the same shape as production. The
|
||||
# server registers each site's broker login itself over the control topic, so
|
||||
# there is no per-site password to type here and nothing to restart. The store
|
||||
# is seeded once with the server's own login as the plugin admin; after that
|
||||
# the plugin owns the file.
|
||||
mkdir -p "$STATE/mosquitto/data"
|
||||
# Rewritten when it is the pre-plugin shape, so a checkout that ran the old
|
||||
# script comes up in the new one rather than half of each.
|
||||
if ! grep -q mosquitto_dynamic_security "$STATE/mosquitto/mosquitto.conf" 2>/dev/null; then
|
||||
rm -f "$STATE/mosquitto/passwd" "$STATE/mosquitto/acl"
|
||||
docker rm -f bv-mqtt >/dev/null 2>&1 || true
|
||||
cat > "$STATE/mosquitto/mosquitto.conf" <<EOF
|
||||
per_listener_settings false
|
||||
listener 1883
|
||||
allow_anonymous false
|
||||
password_file /mosquitto/config/passwd
|
||||
acl_file /mosquitto/config/acl
|
||||
plugin /usr/lib/mosquitto_dynamic_security.so
|
||||
plugin_opt_config_file /mosquitto/data/dynamic-security.json
|
||||
EOF
|
||||
printf 'user behavision-server\ntopic read bv/#\n' > "$STATE/mosquitto/acl"
|
||||
: > "$STATE/mosquitto/passwd"
|
||||
fi
|
||||
if [ ! -f "$STATE/mosquitto/data/dynamic-security.json" ]; then
|
||||
: > "$STATE/mosquitto/passwd.seed"
|
||||
docker run --rm -v "$PWD/$STATE/mosquitto:/m" eclipse-mosquitto:2 \
|
||||
mosquitto_passwd -b /m/passwd.seed behavision-server "$MQTT_PASSWORD" 2>/dev/null
|
||||
"./$STATE/bv-server" broker-init -passwd "$STATE/mosquitto/passwd.seed" \
|
||||
-out "$STATE/mosquitto/data/dynamic-security.json" -backend-user behavision-server >/dev/null
|
||||
rm -f "$STATE/mosquitto/passwd.seed"
|
||||
# The plugin rewrites this file, so the broker's user (1883) must own it.
|
||||
chmod 666 "$STATE/mosquitto/data/dynamic-security.json"
|
||||
fi
|
||||
# A container is reused only if its config mount still points HERE. The bind
|
||||
# source is baked in when the container is created, so one made while the
|
||||
@@ -98,6 +117,7 @@ if docker inspect bv-mqtt >/dev/null 2>&1; then
|
||||
fi
|
||||
docker inspect bv-mqtt >/dev/null 2>&1 || docker run -d --name bv-mqtt \
|
||||
-p "${MQTT_PORT}:1883" -v "$MQTT_CONF:/mosquitto/config" \
|
||||
-v "$MQTT_CONF/data:/mosquitto/data" \
|
||||
eclipse-mosquitto:2 >/dev/null
|
||||
docker start bv-mqtt >/dev/null 2>&1 || true
|
||||
|
||||
@@ -114,13 +134,7 @@ if ! docker exec bv-mqtt sh -c 'exit 0' >/dev/null 2>&1; then
|
||||
docker logs --tail 5 bv-mqtt >&2
|
||||
exit 1
|
||||
fi
|
||||
# stderr is NOT discarded here. A failure means the server cannot authenticate
|
||||
# to its own broker, and the whole point of this script is that you find that
|
||||
# out now rather than from an empty arrivals feed.
|
||||
docker exec bv-mqtt mosquitto_passwd -b /mosquitto/config/passwd \
|
||||
behavision-server "$MQTT_PASSWORD" >/dev/null
|
||||
docker restart bv-mqtt >/dev/null
|
||||
echo " broker on ${MQTT_PORT}"
|
||||
echo " broker on ${MQTT_PORT} (dynamic security)"
|
||||
|
||||
step "5. First accounts"
|
||||
# Idempotent throughout: every provision subcommand upserts, so re-running this
|
||||
@@ -140,14 +154,9 @@ step "5. First accounts"
|
||||
# never readable again - so it is pushed into Mosquitto here in the same breath.
|
||||
# A shop PC enrolled on an earlier run therefore has to be claimed again, which
|
||||
# is the right trade locally and is why this is not how production works.
|
||||
SITE_OUT=$("./$STATE/bv-server" provision site -client tenext-retail -slug chennai \
|
||||
-name "TeNext Chennai" -tz Asia/Kolkata)
|
||||
BUSER=$(printf '%s' "$SITE_OUT" | sed -n "s/.*passwd \([^ ]*\) .*/\1/p")
|
||||
BPASS=$(printf '%s' "$SITE_OUT" | sed -n "s/.*passwd [^ ]* '\(.*\)'.*/\1/p")
|
||||
docker exec bv-mqtt mosquitto_passwd -b /mosquitto/config/passwd "$BUSER" "$BPASS" >/dev/null
|
||||
grep -q "^user $BUSER$" "$STATE/mosquitto/acl" || \
|
||||
printf '\nuser %s\ntopic write bv/%s/#\n' "$BUSER" "$BUSER" >> "$STATE/mosquitto/acl"
|
||||
docker restart bv-mqtt >/dev/null
|
||||
MQTT_URL="tcp://127.0.0.1:${MQTT_PORT}" MQTT_USERNAME=behavision-server MQTT_PASSWORD="$MQTT_PASSWORD" \
|
||||
"./$STATE/bv-server" provision site -client tenext-retail -slug chennai \
|
||||
-name "TeNext Chennai" -tz Asia/Kolkata | sed 's/^/ /'
|
||||
|
||||
printf ' platform admin admin@loyaly.ai / loyaly-platform-2026 (Companies only)\n'
|
||||
printf ' TeNext owner suriya@tenext.in / tenext-2026 (Shops, Live, Cameras, Customers, Reports)\n'
|
||||
|
||||
14
server/Dockerfile.runtime
Normal file
@@ -0,0 +1,14 @@
|
||||
# Runtime-only image, for a host that must not compile.
|
||||
#
|
||||
# The production box has 3.6 GB of RAM shared with other tenants' services;
|
||||
# `Dockerfile` pulls a Go toolchain and builds there, which is how deploys
|
||||
# became something nobody wanted to run. deploy.sh builds the static binary
|
||||
# on the developer's machine and ships only that. Same runtime layer as
|
||||
# Dockerfile, on purpose - the two must not drift.
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates tzdata && \
|
||||
adduser -D -u 10001 behavision
|
||||
COPY behavision-server /usr/local/bin/behavision-server
|
||||
USER behavision
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/local/bin/behavision-server"]
|
||||
59
server/broker-cutover.sh
Executable file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Move a running broker from passwd/acl files to the dynamic-security plugin,
|
||||
# keeping every existing login and password. Run AFTER deploy.sh has put a
|
||||
# server on the host that has `broker-init`.
|
||||
#
|
||||
# server/broker-cutover.sh do it
|
||||
# ROLLBACK=1 server/broker-cutover.sh put the previous config back
|
||||
#
|
||||
# What it does: backs up mosquitto/config, converts passwd → the plugin's store
|
||||
# inside the broker's data volume (same hashes, so no shop PC re-claims),
|
||||
# rewrites mosquitto.conf, restarts the broker, and proves the server and the
|
||||
# health probe reconnect. Every step before the restart is reversible by not
|
||||
# doing the restart; the rollback restores the backed-up config and restarts.
|
||||
set -euo pipefail
|
||||
HOST=${HOST:-root@66.116.226.161}
|
||||
KEY=${KEY:-$HOME/.ssh/behavision_deploy}
|
||||
DIR=/root/behavision
|
||||
SSH=(ssh -i "$KEY" -o BatchMode=yes -o ConnectTimeout=10 "$HOST")
|
||||
step() { printf '\n\033[1m%s\033[0m\n' "$*"; }
|
||||
|
||||
if [ -n "${ROLLBACK:-}" ]; then
|
||||
step "Rolling back to the passwd/acl configuration"
|
||||
"${SSH[@]}" "cd $DIR && latest=\$(ls -d mosquitto/config.bak-* | tail -1) && cp \$latest/mosquitto.conf mosquitto/config/mosquitto.conf && docker compose restart mosquitto && sleep 3 && docker logs --tail 5 behavision-mqtt"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
step "1. Back up the broker configuration"
|
||||
"${SSH[@]}" "cd $DIR && cp -a mosquitto/config mosquitto/config.bak-\$(date +%Y%m%d-%H%M%S) && ls -d mosquitto/config.bak-* | tail -1"
|
||||
|
||||
step "2. Convert passwd into the plugin's store (hashes unchanged)"
|
||||
# The data volume belongs to the broker's user (1883); the init runs as root to
|
||||
# write there and then hands the file over. Refuses if a store already exists.
|
||||
"${SSH[@]}" "cd $DIR && docker run --rm --user root \
|
||||
-v $DIR/mosquitto/config:/m:ro -v behavision_mosquitto-data:/d \
|
||||
--entrypoint /usr/local/bin/behavision-server behavision-backend:latest \
|
||||
broker-init -passwd /m/passwd -out /d/dynamic-security.json \
|
||||
&& docker run --rm --user root -v behavision_mosquitto-data:/d alpine:3.20 sh -c 'chown 1883:1883 /d/dynamic-security.json && chmod 600 /d/dynamic-security.json && ls -la /d/dynamic-security.json'"
|
||||
|
||||
step "3. Rewrite mosquitto.conf for the plugin"
|
||||
"${SSH[@]}" "cd $DIR && python3 - <<'PY'
|
||||
import re
|
||||
p = 'mosquitto/config/mosquitto.conf'
|
||||
s = open(p).read()
|
||||
s = re.sub(r'^per_listener_settings\s+true\s*$', 'per_listener_settings false', s, flags=re.M)
|
||||
s = re.sub(r'^(password_file|acl_file)\s+.*\n', '', s, flags=re.M)
|
||||
if 'mosquitto_dynamic_security' not in s:
|
||||
s = s.rstrip('\n') + '\n\n# Logins and topic permissions live in the dynamic-security plugin now.\n# The server creates a shop\'s login over the control topic; nothing is\n# edited by hand and nothing is reloaded.\nplugin /usr/lib/mosquitto_dynamic_security.so\nplugin_opt_config_file /mosquitto/data/dynamic-security.json\n'
|
||||
open(p, 'w').write(s)
|
||||
print(open(p).read())
|
||||
PY"
|
||||
|
||||
step "4. Restart the broker"
|
||||
"${SSH[@]}" "cd $DIR && docker compose restart mosquitto && sleep 4 && docker logs --tail 8 behavision-mqtt 2>&1 | grep -i 'error\|plugin\|running\|connected' | tail -6"
|
||||
|
||||
step "5. Prove the server and the health probe are back"
|
||||
"${SSH[@]}" "cd $DIR && sleep 6 && docker logs --since 30s behavision-backend 2>&1 | grep -i 'broker\|subscribed' | tail -3; docker inspect behavision-mqtt --format 'health: {{.State.Health.Status}}' 2>/dev/null || true; docker logs --since 40s behavision-mqtt 2>&1 | grep -i 'not authori\|denied' | head -3 || true"
|
||||
echo
|
||||
echo "If step 5 shows 'subscribed to bv/#' and no 'not authorised', the cutover is done."
|
||||
echo "Anything wrong: ROLLBACK=1 server/broker-cutover.sh"
|
||||
69
server/cmd/behavision-server/brokerinit.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/broker"
|
||||
)
|
||||
|
||||
// broker-init converts Mosquitto's passwd file into the dynamic-security
|
||||
// plugin's store, once, at cutover. After it the broker is driven over MQTT
|
||||
// and the passwd and acl files are no longer read.
|
||||
func runBrokerInit(args []string) error {
|
||||
fs := flag.NewFlagSet("broker-init", flag.ExitOnError)
|
||||
passwd := fs.String("passwd", "", "path to the mosquitto passwd file to convert")
|
||||
out := fs.String("out", "", "where to write dynamic-security.json (must be writable by the broker)")
|
||||
backend := fs.String("backend-user", "behavision-backend", "the server's own broker username; becomes the plugin admin")
|
||||
health := fs.String("health-user", "health", "the healthcheck username")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, `usage: behavision-server broker-init -passwd FILE -out FILE
|
||||
|
||||
Converts a mosquitto_passwd file into the dynamic-security plugin's store,
|
||||
keeping every password hash exactly as it is, so no shop PC has to be
|
||||
re-claimed. Then in mosquitto.conf replace password_file/acl_file with:
|
||||
|
||||
per_listener_settings false
|
||||
plugin /usr/lib/mosquitto_dynamic_security.so
|
||||
plugin_opt_config_file /mosquitto/data/dynamic-security.json
|
||||
|
||||
and restart the broker. From then on 'provision site' and POST /api/sites
|
||||
register a shop's login themselves.
|
||||
`)
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *passwd == "" || *out == "" {
|
||||
fs.Usage()
|
||||
return errors.New("-passwd and -out are required")
|
||||
}
|
||||
f, err := os.Open(*passwd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
st, err := broker.FromPasswd(f, *backend, *health)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(*out); err == nil {
|
||||
return fmt.Errorf("%s already exists - refusing to overwrite a live store", *out)
|
||||
}
|
||||
w, err := os.OpenFile(*out, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer w.Close()
|
||||
if err := st.Encode(w); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("wrote %s: %d clients, %d roles\n", *out, len(st.Clients), len(st.Roles))
|
||||
for _, c := range st.Clients {
|
||||
fmt.Printf(" %-28s %s\n", c.Username, c.Roles[0].Rolename)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -25,11 +25,12 @@ import (
|
||||
"github.com/loyaly/behavision-server/internal/api"
|
||||
"github.com/loyaly/behavision-server/internal/assistant"
|
||||
"github.com/loyaly/behavision-server/internal/blob"
|
||||
"github.com/loyaly/behavision-server/internal/broker"
|
||||
"github.com/loyaly/behavision-server/internal/ingest"
|
||||
"github.com/loyaly/behavision-server/internal/migrate"
|
||||
"github.com/loyaly/behavision-server/internal/web"
|
||||
"github.com/loyaly/behavision-server/internal/secret"
|
||||
"github.com/loyaly/behavision-server/internal/store"
|
||||
"github.com/loyaly/behavision-server/internal/web"
|
||||
"github.com/loyaly/behavision-server/migrations"
|
||||
)
|
||||
|
||||
@@ -46,6 +47,13 @@ func main() {
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(os.Args) > 1 && os.Args[1] == "broker-init" {
|
||||
if err := runBrokerInit(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(os.Args) > 1 && os.Args[1] == "migrate" {
|
||||
if err := runMigrate(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
@@ -194,8 +202,11 @@ func run() error {
|
||||
apiSrv := &api.Server{
|
||||
Store: st,
|
||||
Log: logger,
|
||||
Blob: objectStore(ctx, logger),
|
||||
Hub: hub,
|
||||
// Opening a shop registers its broker login at the same moment, over the
|
||||
// same broker credential the ingest side already holds.
|
||||
Broker: broker.New(brokerURL, brokerUser, brokerPass, logger),
|
||||
Blob: objectStore(ctx, logger),
|
||||
Hub: hub,
|
||||
Bootstrap: api.BootstrapConfig{
|
||||
// What an enrolling PC is told to connect to. From the server's own
|
||||
// environment, never from the request: an agent asking where to
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/broker"
|
||||
"github.com/loyaly/behavision-server/internal/provision"
|
||||
"github.com/loyaly/behavision-server/internal/secret"
|
||||
)
|
||||
@@ -43,6 +44,14 @@ func runProvision(args []string) error {
|
||||
|
||||
box, boxErr := secret.FromEnv("BEHAVISION_SECRET_KEY")
|
||||
p := &provision.Provisioner{Pool: pool, Secrets: box}
|
||||
// The broker, so a new site's login is registered here and now instead of
|
||||
// printed for somebody to type into a password file. Same variables the
|
||||
// server itself connects with.
|
||||
if u := os.Getenv("MQTT_URL"); u != "" && os.Getenv("MQTT_USERNAME") != "" {
|
||||
dyn := broker.New(u, os.Getenv("MQTT_USERNAME"), os.Getenv("MQTT_PASSWORD"), nil)
|
||||
defer dyn.Close()
|
||||
p.Broker = dyn
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "client":
|
||||
@@ -82,12 +91,16 @@ func runProvision(args []string) error {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("site created: %s\n", res.SiteID)
|
||||
if res.BrokerRegistered {
|
||||
fmt.Printf("broker login %s registered - this site can publish now.\n", res.Username)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("\nAdd this broker user to Mosquitto, then this site can publish:\n\n")
|
||||
fmt.Printf(" mosquitto_passwd -b /mosquitto/config/passwd %s '%s'\n\n",
|
||||
res.Username, res.Password)
|
||||
// The broker keeps a hash; we keep it sealed. Neither side can show it
|
||||
// again, which is why it is printed here in full.
|
||||
fmt.Printf("The password is stored encrypted and handed out only at "+
|
||||
fmt.Printf("The password is stored encrypted and handed out only at " +
|
||||
"enrolment.\nIt is not recoverable from the logs. Copy it now.\n")
|
||||
return nil
|
||||
|
||||
|
||||
71
server/deploy.sh
Executable file
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy the server to the production host, from this checkout.
|
||||
#
|
||||
# Until this existed the deployment was manual to a box nobody had written
|
||||
# down, and production ran code months behind main - three of the desktop
|
||||
# app's screens talked to routes that were not there. A deploy that is a
|
||||
# script gets run; one that is a memory does not.
|
||||
#
|
||||
# server/deploy.sh build, back up the database, migrate, switch
|
||||
# BASELINE=3 server/deploy.sh first run against a database that predates
|
||||
# migration tracking: adopt 1..3 unrun
|
||||
# DRY_RUN=1 server/deploy.sh build and ship, touch nothing running
|
||||
#
|
||||
# What it does, in order, and why the order matters:
|
||||
# 1. builds the head-office web app INTO the Go module, then a static
|
||||
# linux/amd64 binary here - the host has 3.6 GB of RAM and must not compile
|
||||
# 2. pg_dumps the database to backups/ on the host BEFORE anything changes
|
||||
# 3. builds the runtime-only image on the host from the shipped binary
|
||||
# 4. runs `migrate` with the NEW binary while the OLD server still serves;
|
||||
# a failing migration therefore stops here with production untouched
|
||||
# 5. switches the container, then proves the routes answer over the public URL
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
HOST=${HOST:-root@66.116.226.161}
|
||||
KEY=${KEY:-$HOME/.ssh/behavision_deploy}
|
||||
REMOTE_DIR=/root/behavision
|
||||
PUBLIC=https://mcp.loyaly.ai
|
||||
SSH=(ssh -i "$KEY" -o BatchMode=yes -o ConnectTimeout=10 "$HOST")
|
||||
|
||||
VERSION=$(git describe --tags --always --dirty)
|
||||
case "$VERSION" in *-dirty) echo "refusing to deploy uncommitted changes ($VERSION)" >&2; exit 1;; esac
|
||||
|
||||
step() { printf '\n\033[1m%s\033[0m\n' "$*"; }
|
||||
|
||||
step "1. Build $VERSION"
|
||||
(cd ../web && npm run build >/dev/null)
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=${VERSION}" -o /tmp/behavision-server ./cmd/behavision-server
|
||||
ls -la /tmp/behavision-server | awk '{print " " $5 " bytes"}'
|
||||
|
||||
step "2. Ship"
|
||||
"${SSH[@]}" "mkdir -p $REMOTE_DIR/release/$VERSION $REMOTE_DIR/backups"
|
||||
scp -q -i "$KEY" /tmp/behavision-server Dockerfile.runtime "$HOST:$REMOTE_DIR/release/$VERSION/"
|
||||
git rev-parse HEAD | "${SSH[@]}" "cat > $REMOTE_DIR/release/$VERSION/GIT_SHA"
|
||||
|
||||
if [ "${DRY_RUN:-}" != "" ]; then echo "DRY_RUN: shipped to $REMOTE_DIR/release/$VERSION, nothing changed"; exit 0; fi
|
||||
|
||||
step "3. Back up the database"
|
||||
"${SSH[@]}" "docker exec behavision-db sh -c 'PGPASSWORD=\$POSTGRES_PASSWORD pg_dump -U behavision -d behavision' | gzip > $REMOTE_DIR/backups/pre-$VERSION-\$(date +%Y%m%d-%H%M%S).sql.gz && ls -la $REMOTE_DIR/backups | tail -1"
|
||||
|
||||
step "4. Image"
|
||||
"${SSH[@]}" "cd $REMOTE_DIR/release/$VERSION && docker build -q -t behavision-backend:$VERSION -f Dockerfile.runtime . && docker tag behavision-backend:$VERSION behavision-backend:latest"
|
||||
|
||||
step "5. Migrate (old server still serving)"
|
||||
# `run` uses the compose service's environment and network, so the new binary
|
||||
# reaches postgres exactly as the server will. --no-deps: do not restart the
|
||||
# broker or the database to run a migration.
|
||||
if [ -n "${BASELINE:-}" ]; then
|
||||
"${SSH[@]}" "cd $REMOTE_DIR && docker compose run --rm --no-deps -T backend migrate -baseline $BASELINE"
|
||||
fi
|
||||
"${SSH[@]}" "cd $REMOTE_DIR && docker compose run --rm --no-deps -T backend migrate && docker compose run --rm --no-deps -T backend migrate -status"
|
||||
|
||||
step "6. Switch"
|
||||
"${SSH[@]}" "cd $REMOTE_DIR && docker compose up -d --no-build --no-deps backend && sleep 4 && docker logs --tail 15 behavision-backend"
|
||||
|
||||
step "7. Verify over $PUBLIC"
|
||||
for p in /healthz /api/admin/clients /api/team /api/visits /api/cameras; do
|
||||
printf ' %-20s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' -m 15 "$PUBLIC$p")"
|
||||
done
|
||||
curl -s -m 15 "$PUBLIC/healthz" | head -c 300; echo
|
||||
128
server/internal/api/admin_clients_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func seedTenantWithOwner(fs *fakeStore) {
|
||||
fs.clients = []ClientRow{{ID: "client-acme", Slug: "acme", Name: "Acme Retail", Active: true}}
|
||||
fs.addUser("owner@acme.com", "correct horse battery", UserRecord{
|
||||
ID: "u-owner", ClientID: "client-acme", Role: "owner", Active: true, Email: "owner@acme.com",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSuspendingACompanyEndsItsSessionsNow(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedPlatformAdmin(fs)
|
||||
seedTenantWithOwner(fs)
|
||||
owner := login(t, s, "owner@acme.com", "correct horse battery")
|
||||
admin := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
rec := do(t, s, "PATCH", "/api/admin/clients/client-acme", admin.Token, map[string]any{"active": false})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
SessionsRevoked int `json:"sessions_revoked"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out.SessionsRevoked != 1 {
|
||||
t.Fatalf("expected the owner's one session revoked, got %d", out.SessionsRevoked)
|
||||
}
|
||||
// The owner's token stops working immediately, not at expiry.
|
||||
if rec := do(t, s, "GET", "/api/team", owner.Token, nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("suspended tenant's session still works: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletingACompanyIsATwoStepDecision(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedPlatformAdmin(fs)
|
||||
seedTenantWithOwner(fs)
|
||||
b := &fakeBroker{}
|
||||
s.Broker = b
|
||||
admin := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
// Active: refused, whatever the confirmation says.
|
||||
rec := do(t, s, "DELETE", "/api/admin/clients/client-acme", admin.Token, map[string]any{"confirm": "acme"})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("deleted an active company: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
do(t, s, "PATCH", "/api/admin/clients/client-acme", admin.Token, map[string]any{"active": false})
|
||||
// Suspended but the slug is wrong: refused.
|
||||
rec = do(t, s, "DELETE", "/api/admin/clients/client-acme", admin.Token, map[string]any{"confirm": "acm"})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("deleted without the slug: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = do(t, s, "DELETE", "/api/admin/clients/client-acme", admin.Token, map[string]any{"confirm": "acme"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(fs.clients) != 0 {
|
||||
t.Fatal("company row survived")
|
||||
}
|
||||
if len(b.deleted) != 1 || b.deleted[0] != "acme.shop1" {
|
||||
t.Fatalf("broker logins not removed: %v", b.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminResetsTheOwnersPasswordAndItIsShownOnce(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedPlatformAdmin(fs)
|
||||
seedTenantWithOwner(fs)
|
||||
admin := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
rec := do(t, s, "POST", "/api/admin/clients/client-acme/owner-password", admin.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out struct{ Email, Password string }
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out.Email != "owner@acme.com" || out.Password == "" {
|
||||
t.Fatalf("unexpected result: %s", rec.Body.String())
|
||||
}
|
||||
if rec := do(t, s, "POST", "/api/auth/login", "", map[string]string{"email": "owner@acme.com", "password": "correct horse battery"}); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("old password still works: %d", rec.Code)
|
||||
}
|
||||
login(t, s, "owner@acme.com", out.Password)
|
||||
}
|
||||
|
||||
func TestATenantUserCannotReachTheAdminClientRoutes(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedTenantWithOwner(fs)
|
||||
owner := login(t, s, "owner@acme.com", "correct horse battery")
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{"PATCH", "/api/admin/clients/client-acme"},
|
||||
{"POST", "/api/admin/clients/client-acme/owner-password"},
|
||||
{"DELETE", "/api/admin/clients/client-acme"},
|
||||
} {
|
||||
if rec := do(t, s, c.method, c.path, owner.Token, map[string]any{"active": false, "confirm": "acme"}); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s %s: tenant user got %d, want 404", c.method, c.path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOwnerRemovesAnEmptyShopButNotOneWithCameras(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
b := &fakeBroker{}
|
||||
s.Broker = b
|
||||
seedTenantWithOwner(fs)
|
||||
fs.sites = []SiteHealth{
|
||||
{SiteID: "site-empty", Slug: "empty", Name: "Empty"},
|
||||
{SiteID: siteA, Slug: "chennai", Name: "TeNext Chennai"},
|
||||
}
|
||||
fs.cameras = []Camera{{ID: "c1", SiteID: siteA, CameraID: "entrance"}}
|
||||
owner := login(t, s, "owner@acme.com", "correct horse battery")
|
||||
|
||||
if rec := do(t, s, "DELETE", "/api/sites/chennai", owner.Token, nil); rec.Code != http.StatusConflict {
|
||||
t.Fatalf("removed a shop with a camera: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := do(t, s, "DELETE", "/api/sites/empty", owner.Token, nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(b.deleted) != 1 {
|
||||
t.Fatalf("broker login not removed: %v", b.deleted)
|
||||
}
|
||||
}
|
||||
@@ -36,12 +36,58 @@ import (
|
||||
type Store interface {
|
||||
// --- identity ---
|
||||
UserByEmail(ctx context.Context, email string) (UserRecord, error)
|
||||
|
||||
// --- shops ---
|
||||
// CreateSite writes the shop and its sealed broker password in one
|
||||
// transaction and returns the plaintext once, for the broker registration
|
||||
// that must follow. DeleteNewSite is the compensation when that
|
||||
// registration fails: a shop whose PC can enrol but never publish is the
|
||||
// silent failure this whole endpoint exists to end.
|
||||
CreateSite(ctx context.Context, clientID, slug, name, tz string) (NewSite, error)
|
||||
DeleteNewSite(ctx context.Context, clientID, siteID string) error
|
||||
TouchUserLogin(ctx context.Context, userID string) error
|
||||
CreateSession(ctx context.Context, s NewSession) error
|
||||
SessionByAccess(ctx context.Context, hash []byte) (auth.Principal, time.Time, error)
|
||||
SessionByRefresh(ctx context.Context, hash []byte) (auth.Principal, time.Time, error)
|
||||
RotateSession(ctx context.Context, sessionID string, s NewSession) error
|
||||
RevokeSession(ctx context.Context, sessionID string) error
|
||||
// Which devices are signed in, and signing one of them out. This is what
|
||||
// an opaque-token session table buys over a JWT, and until these existed
|
||||
// the product paid the cost of that choice without the benefit.
|
||||
UserSessions(ctx context.Context, userID string) ([]DeviceSession, error)
|
||||
RevokeUserSession(ctx context.Context, userID, sessionID string) error
|
||||
RevokeOtherSessions(ctx context.Context, userID, keepSessionID string) (int, error)
|
||||
|
||||
// --- team and invitations ---
|
||||
// Registration is by invitation: the code carries the address and the role
|
||||
// so neither can be chosen by whoever redeems it.
|
||||
CreateInvitation(ctx context.Context, in NewInvitation) (Invitation, error)
|
||||
PendingInvitations(ctx context.Context, clientID string) ([]Invitation, error)
|
||||
RevokeInvitation(ctx context.Context, clientID, id string) error
|
||||
InvitationByCode(ctx context.Context, hash []byte) (InvitationPreview, error)
|
||||
// RedeemInvitation spends the code and creates the account in ONE
|
||||
// transaction: a spent invitation with no user behind it is unusable, and a
|
||||
// user with the invitation still open is a second account waiting for
|
||||
// whoever else was forwarded the code.
|
||||
RedeemInvitation(ctx context.Context, hash []byte, fullName, passwordHash string) (UserRecord, error)
|
||||
Team(ctx context.Context, clientID string) ([]TeamMember, error)
|
||||
UpdateTeamMember(ctx context.Context, clientID, userID string, up TeamUpdate) (TeamMember, error)
|
||||
// CreateMember inserts an active account into the caller's tenant. The
|
||||
// hash is computed by the handler, so the plaintext never reaches the
|
||||
// store - same boundary invitations and sessions already keep.
|
||||
CreateMember(ctx context.Context, clientID string, in NewMemberInput, hash string) (TeamMember, error)
|
||||
// ResetMemberPassword replaces the hash and revokes every session the
|
||||
// member holds, in one transaction. A reset is what happens after a lost
|
||||
// phone; leaving that phone signed in would defeat it.
|
||||
ResetMemberPassword(ctx context.Context, clientID, userID, hash string) (TeamMember, error)
|
||||
|
||||
// --- public references ---
|
||||
// Resolving the names people actually use to the uuids the schema stores.
|
||||
// All three answer "" with a nil error when nothing matches; a found id is
|
||||
// never empty, so a miss cannot be confused with a fault. See refs.go.
|
||||
SiteIDBySlug(ctx context.Context, clientID, slug string) (string, error)
|
||||
CameraIDByRef(ctx context.Context, clientID, ref string) (string, error)
|
||||
VisitorIDByNumber(ctx context.Context, clientID string, number int64) (string, error)
|
||||
|
||||
// --- reports ---
|
||||
Footfall(ctx context.Context, q ReportQuery) ([]FootfallPoint, Totals, error)
|
||||
@@ -65,6 +111,18 @@ type Store interface {
|
||||
// reachable only with that site's own agent token.
|
||||
AgentCameras(ctx context.Context, siteID string) ([]AgentCamera, error)
|
||||
ApplyAgentReport(ctx context.Context, clientID, siteID string, rep AgentCameraReport) error
|
||||
// CameraRef resolves one of a TENANT's cameras to its site and the name the
|
||||
// engine knows it by. Used to prove ownership before anything is streamed.
|
||||
CameraRef(ctx context.Context, clientID, cameraID string) (siteID, engineID string, err error)
|
||||
// CameraRefBySite is the same question asked by an agent, which is
|
||||
// authenticated for a site rather than a tenant.
|
||||
CameraRefBySite(ctx context.Context, siteID, cameraID string) (site, engineID string, err error)
|
||||
// SiteCameraIDs lists a site's camera uuids, for the agent's live poll.
|
||||
SiteCameraIDs(ctx context.Context, siteID string) ([]string, error)
|
||||
// Camera pictures held by this server, for deployments with no object
|
||||
// storage. Where a bucket is configured neither of these is called.
|
||||
PutCameraSnapshot(ctx context.Context, clientID, siteID, cameraID string, jpeg []byte) error
|
||||
CameraSnapshot(ctx context.Context, clientID, cameraID string) ([]byte, time.Time, error)
|
||||
|
||||
// --- claiming a shop PC ---
|
||||
IssueEnrolmentCode(ctx context.Context, clientID, siteID, actorID,
|
||||
@@ -79,6 +137,28 @@ type Store interface {
|
||||
// --- platform administration ---
|
||||
CreateClientWithOwner(ctx context.Context, in NewClientInput) (NewClientResult, error)
|
||||
ListClients(ctx context.Context) ([]ClientRow, error)
|
||||
// SetClientActive suspends or reinstates a company. Suspending revokes every
|
||||
// session its users hold in the same transaction - login and ingest already
|
||||
// refuse an inactive client, but a live access token would otherwise keep
|
||||
// reading for up to twelve hours. Returns the slug and how many sessions
|
||||
// were ended.
|
||||
SetClientActive(ctx context.Context, clientID string, active bool) (ClientRow, int, error)
|
||||
// ClientOwners lists the active owners of a company, for a platform admin
|
||||
// resetting one of their passwords.
|
||||
ClientOwners(ctx context.Context, clientID string) ([]TeamMember, error)
|
||||
// ClientImageKeys is every face image a company holds - the first step of
|
||||
// deleting the company, for the same reason it is the first step of erasing
|
||||
// a person: once the rows are gone nothing knows which objects to remove.
|
||||
ClientImageKeys(ctx context.Context, clientID string) ([]string, error)
|
||||
// DeleteClient removes a SUSPENDED company and everything under it, and
|
||||
// returns the broker usernames of its sites so their logins can be removed.
|
||||
// Refuses an active company: suspension first is what makes this a
|
||||
// two-step decision instead of one click.
|
||||
DeleteClient(ctx context.Context, clientID string) (ClientRow, []string, error)
|
||||
// DeleteEmptySite removes a shop that has no visits and no cameras - the
|
||||
// one opened by mistake - and returns its broker username. A shop with
|
||||
// history is closed, not deleted.
|
||||
DeleteEmptySite(ctx context.Context, clientID, siteID string) (string, error)
|
||||
|
||||
// --- enrolment ---
|
||||
RedeemEnrolment(ctx context.Context, hash []byte) (Enrolment, error)
|
||||
@@ -86,6 +166,11 @@ type Store interface {
|
||||
AgentByToken(ctx context.Context, hash []byte) (AgentPrincipal, error)
|
||||
|
||||
// --- images ---
|
||||
// Face images held by this server, for a deployment with no object
|
||||
// storage. Where a bucket is configured none of these three is called.
|
||||
PutVisitFace(ctx context.Context, clientID, siteID string, jpeg []byte) (string, error)
|
||||
VisitFace(ctx context.Context, clientID, key string) ([]byte, error)
|
||||
DeleteVisitFaces(ctx context.Context, clientID string, keys []string) error
|
||||
VisitorImageKey(ctx context.Context, clientID, visitorID string) (string, error)
|
||||
VisitorImageKeys(ctx context.Context, clientID, visitorID string) ([]string, error)
|
||||
ForgetVisitor(ctx context.Context, clientID, visitorID string) error
|
||||
@@ -114,6 +199,14 @@ type Server struct {
|
||||
// business questions the screens ask. Nil means this deployment has no
|
||||
// API key, which is supported: the UI hides the panel.
|
||||
Assistant Assistant
|
||||
// Broker registers a shop's login with Mosquitto at the moment the shop is
|
||||
// created. Nil means this deployment cannot create shops through the API
|
||||
// and says so, rather than creating one that can never publish.
|
||||
Broker SiteBroker
|
||||
// Live relays camera frames from a shop PC to whoever is watching, on
|
||||
// demand. Created on first use.
|
||||
Live *LiveHub
|
||||
liveOnce sync.Once
|
||||
// Hub wakes live arrival streams when the MQTT consumer records a visit.
|
||||
// Nil is supported and means the streams fall back to their slow tick -
|
||||
// a server assembled without one is slower, not broken.
|
||||
@@ -180,10 +273,34 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
mux.HandleFunc("POST /api/auth/refresh", s.handleRefresh)
|
||||
mux.HandleFunc("POST /api/auth/logout", s.authed(s.handleLogout))
|
||||
mux.HandleFunc("GET /api/auth/me", s.authed(s.handleMe))
|
||||
// Registration. Unauthenticated for the same reason agent enrolment is:
|
||||
// whoever is doing this has no account yet, and requiring one first would
|
||||
// mean shipping a password to everybody who needs one.
|
||||
mux.HandleFunc("GET /api/auth/invitation", s.handleInvitationPreview)
|
||||
mux.HandleFunc("POST /api/auth/register", s.handleRegister)
|
||||
// Devices. A person may list and revoke their own sessions; removing a
|
||||
// colleague's access is a different question, answered by deactivating them
|
||||
// on the team endpoint below.
|
||||
mux.HandleFunc("GET /api/auth/sessions", s.authed(s.handleSessions))
|
||||
mux.HandleFunc("DELETE /api/auth/sessions/{id}", s.authed(s.handleRevokeSession))
|
||||
mux.HandleFunc("POST /api/auth/sessions/revoke-others",
|
||||
s.authed(s.handleRevokeOtherSessions))
|
||||
|
||||
// --- the people who work here ---
|
||||
mux.HandleFunc("GET /api/team", s.authed(s.handleTeam))
|
||||
mux.HandleFunc("PATCH /api/team/{id}", s.authed(s.handleUpdateTeamMember))
|
||||
mux.HandleFunc("POST /api/team/members", s.authed(s.handleCreateMember))
|
||||
mux.HandleFunc("POST /api/team/{id}/password", s.authed(s.handleResetPassword))
|
||||
mux.HandleFunc("GET /api/team/invitations", s.authed(s.handleInvitations))
|
||||
mux.HandleFunc("POST /api/team/invitations", s.authed(s.handleInvite))
|
||||
mux.HandleFunc("DELETE /api/team/invitations/{id}",
|
||||
s.authed(s.handleRevokeInvitation))
|
||||
|
||||
mux.HandleFunc("GET /api/reports/footfall", s.authed(s.handleFootfall))
|
||||
mux.HandleFunc("GET /api/reports/conversion", s.authed(s.handleConversion))
|
||||
mux.HandleFunc("GET /api/sites", s.authed(s.handleSites))
|
||||
mux.HandleFunc("POST /api/sites", s.authed(s.handleCreateSite))
|
||||
mux.HandleFunc("DELETE /api/sites/{site}", s.authed(s.handleDeleteSite))
|
||||
|
||||
// Cameras, onboarded from head office. The shop PC still does the
|
||||
// connecting - it is the only thing on the camera's network - so these
|
||||
@@ -192,6 +309,8 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
mux.HandleFunc("POST /api/sites/{site}/cameras", s.authed(s.handleCreateCamera))
|
||||
mux.HandleFunc("PATCH /api/cameras/{id}", s.authed(s.handleUpdateCamera))
|
||||
mux.HandleFunc("DELETE /api/cameras/{id}", s.authed(s.handleDeleteCamera))
|
||||
mux.HandleFunc("GET /api/cameras/{id}/snapshot.jpg", s.authed(s.handleGetSnapshot))
|
||||
mux.HandleFunc("GET /api/cameras/{id}/live", s.authed(s.handleWatchLive))
|
||||
// Prove a camera works: "connection" asks whether the shop PC can open the
|
||||
// stream, "placement" asks whether somebody walking past produces a view
|
||||
// good enough to recognise. Two questions, because a camera passes the
|
||||
@@ -226,19 +345,34 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
// because creating the first admin cannot require being signed in as one.
|
||||
mux.HandleFunc("GET /api/admin/clients", s.adminOnly(s.handleListClients))
|
||||
mux.HandleFunc("POST /api/admin/clients", s.adminOnly(s.handleCreateClient))
|
||||
mux.HandleFunc("PATCH /api/admin/clients/{id}", s.adminOnly(s.handleSetClientActive))
|
||||
mux.HandleFunc("POST /api/admin/clients/{id}/owner-password", s.adminOnly(s.handleResetOwnerPassword))
|
||||
mux.HandleFunc("DELETE /api/admin/clients/{id}", s.adminOnly(s.handleDeleteClient))
|
||||
|
||||
// Not session-authenticated: this is how a PC with no credentials gets
|
||||
// some. The enrolment token is the credential.
|
||||
mux.HandleFunc("POST /api/agent/enrol", s.handleEnrol)
|
||||
// Authenticated by the agent's own API token, not a user session.
|
||||
mux.HandleFunc("POST /api/agent/upload-url", s.agentAuthed(s.handleUploadURL))
|
||||
// The fallback the agent takes when upload-url answers images_disabled.
|
||||
mux.HandleFunc("POST /api/agent/faces", s.agentAuthed(s.handlePutFace))
|
||||
// What this shop PC should be running, and what it reports back.
|
||||
mux.HandleFunc("GET /api/agent/cameras", s.agentAuthed(s.handleAgentCameras))
|
||||
mux.HandleFunc("POST /api/agent/cameras", s.agentAuthed(s.handleAgentCameraReport))
|
||||
mux.HandleFunc("PUT /api/agent/cameras/{camera}/snapshot",
|
||||
s.agentAuthed(s.handlePutSnapshot))
|
||||
mux.HandleFunc("GET /api/agent/live", s.agentAuthed(s.handleAgentLiveWanted))
|
||||
mux.HandleFunc("POST /api/agent/cameras/{camera}/live",
|
||||
s.agentAuthed(s.handleAgentPushLive))
|
||||
mux.HandleFunc("GET /api/agent/checks", s.agentAuthed(s.handleAgentChecks))
|
||||
mux.HandleFunc("POST /api/agent/checks", s.agentAuthed(s.handleAgentCheckResult))
|
||||
|
||||
mux.HandleFunc("GET /api/visitors/{id}/image", s.authed(s.handleVisitorImage))
|
||||
// The bytes of a face this server holds itself. Session-authenticated
|
||||
// rather than a signed link: there is no third party to delegate to, and an
|
||||
// unauthenticated URL would be a way to reach a customer's photograph with
|
||||
// no session at all.
|
||||
mux.HandleFunc("GET /api/faces/{id}", s.authed(s.handleGetFace))
|
||||
// The erasure path. Destroys the template and the photo; keeps the
|
||||
// anonymous visit counts, which are legitimate aggregate data.
|
||||
mux.HandleFunc("DELETE /api/visitors/{id}", s.authed(s.handleForgetVisitor))
|
||||
@@ -408,6 +542,11 @@ func looksLikeUUID(s string) bool {
|
||||
// without importing the store package.
|
||||
var ErrNoSecrets = errors.New("this server has no encryption key, so camera passwords cannot be stored")
|
||||
|
||||
// ErrNoSnapshot means a camera has no stored picture. An ordinary state - a
|
||||
// camera added a minute ago has none - so it is reported as absence, never as
|
||||
// a failure.
|
||||
var ErrNoSnapshot = errors.New("no snapshot for this camera")
|
||||
|
||||
// BlobStore is what the API needs from object storage. Declared here and
|
||||
// implemented by internal/blob, so the handlers can be tested without a bucket
|
||||
// and so a deployment with images switched off is a nil field rather than a
|
||||
|
||||
@@ -86,9 +86,12 @@ func TestFourPeopleArrivingTogetherComeBackInOneRequest(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("cursor from a burst is unreadable: %v", err)
|
||||
}
|
||||
if seq != page.Arrivals[3].Seq {
|
||||
// Against the SEEDED position, not one read back off the wire: `seq` is
|
||||
// json:"-" because it counts every visit on the platform, so a client can
|
||||
// no longer see it - and the cursor is the whole reason it does not need to.
|
||||
if want := int64(4); seq != want {
|
||||
t.Errorf("cursor should point at the LAST row of the burst, got %d want %d",
|
||||
seq, page.Arrivals[3].Seq)
|
||||
seq, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +203,11 @@ func TestNoPhotoIsDataNotAnError(t *testing.T) {
|
||||
s.Blob = nil
|
||||
seedUser(fs)
|
||||
seedArrivals(fs, 1)
|
||||
// No key, because that is what this deployment actually produces: the
|
||||
// engine's `app.store_faces` is off, so no crop is ever captured and no
|
||||
// key is ever written. A bucket key on a server with no bucket is a
|
||||
// different state entirely and gets its own sentence below.
|
||||
fs.arrivals[0].ImageKey = ""
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
page := getPage(t, s, "/api/visits", sess.Token)
|
||||
@@ -212,6 +220,54 @@ func TestNoPhotoIsDataNotAnError(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Three absences now, not two: face images may live in a bucket OR in this
|
||||
// database, so "there is no bucket" stopped being a synonym for "there are
|
||||
// no photos" the moment the fallback existed.
|
||||
t.Run("a bucket key on a server that has lost its bucket", func(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = nil
|
||||
seedUser(fs)
|
||||
seedArrivals(fs, 1) // seeded with an object-store key
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
page := getPage(t, s, "/api/visits", sess.Token)
|
||||
got := page.Arrivals[0].Image
|
||||
if got.Available {
|
||||
t.Fatalf("nothing can be served without the bucket, got %+v", got)
|
||||
}
|
||||
// Deliberately NOT "we store no photos". The photo exists and this
|
||||
// server can no longer reach it, which is a configuration fault
|
||||
// somebody can fix - and reporting it as an ordinary empty record is
|
||||
// how it would go unnoticed for a year.
|
||||
if !strings.Contains(got.Reason, "no longer reach") {
|
||||
t.Errorf("want a configuration reason, got %q", got.Reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a face this server holds itself", func(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = nil // no object storage anywhere
|
||||
seedUser(fs)
|
||||
seedArrivals(fs, 1)
|
||||
fs.arrivals[0].ImageKey = "db:00000000-0000-4000-b000-000000000001"
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
page := getPage(t, s, "/api/visits", sess.Token)
|
||||
got := page.Arrivals[0].Image
|
||||
if !got.Available {
|
||||
t.Fatalf("a stored face should be offered, got %+v", got)
|
||||
}
|
||||
// Auth is what tells a client this URL needs the session bearer. A
|
||||
// browser <img> cannot load it and a mobile image view can, and there
|
||||
// is nothing in the URL itself that says so.
|
||||
if !got.Auth {
|
||||
t.Error("a face held by this server must be marked as needing auth")
|
||||
}
|
||||
if strings.Contains(got.URL, "db:") {
|
||||
t.Errorf("the storage key leaked into the URL: %q", got.URL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("this visit simply had none", func(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Blob = &fakeBlob{}
|
||||
|
||||
230
server/internal/api/faces_test.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Face images held by this server, for a deployment with no object storage.
|
||||
//
|
||||
// The property under test throughout is that the two storage routes differ in
|
||||
// exactly one hop: the key is minted differently and everything downstream -
|
||||
// ingest, the feed, the customer record, erasure - is one implementation.
|
||||
|
||||
func putFace(t *testing.T, srv *Server, token string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/agent/faces", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
srv.Routes().ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestAnAgentStoresAFaceAndAPersonReadsItBack(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
srv.Blob = nil // no object storage anywhere: the case this exists for
|
||||
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
|
||||
seedUser(fs)
|
||||
|
||||
img := jpegBytes(512)
|
||||
rr := putFace(t, srv, "agent-token", img)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("upload: %d %s", rr.Code, rr.Body)
|
||||
}
|
||||
var out struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A prefixed key, so `visits.image_key` can name an object in either store
|
||||
// and the read path can tell which without a second lookup.
|
||||
if !strings.HasPrefix(out.Key, "db:") {
|
||||
t.Fatalf("want a db: key, got %q", out.Key)
|
||||
}
|
||||
// The tenant and the site come from the AGENT's credential, never the
|
||||
// request, so a shop PC cannot file an image under another company.
|
||||
if fs.lastFaceClient != "client-acme" || fs.lastFaceSite != "site-1" {
|
||||
t.Fatalf("stored against %s/%s", fs.lastFaceClient, fs.lastFaceSite)
|
||||
}
|
||||
|
||||
sess := login(t, srv, "manager@acme.com", "correct horse battery")
|
||||
rec := do(t, srv, "GET", faceURL(out.Key), sess.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("read back: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != "image/jpeg" {
|
||||
t.Errorf("content type %q", got)
|
||||
}
|
||||
if !bytes.Equal(rec.Body.Bytes(), img) {
|
||||
t.Error("the bytes that came back are not the ones that went in")
|
||||
}
|
||||
}
|
||||
|
||||
// This endpoint stores what it is handed and serves it back to a browser, so
|
||||
// the one thing it must not become is a way to park arbitrary content under a
|
||||
// URL this server will serve. Checked against the bytes, never the header.
|
||||
func TestOnlyAJPEGIsStoredAsAFace(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
|
||||
|
||||
for _, body := range []string{
|
||||
"<html><script>alert(1)</script></html>",
|
||||
"GIF89a",
|
||||
"%PDF-1.4",
|
||||
"",
|
||||
} {
|
||||
rr := putFace(t, srv, "agent-token", []byte(body))
|
||||
if rr.Code == http.StatusCreated {
|
||||
t.Errorf("accepted %q as a face image", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFaceIsNotReadableWithoutASession(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
|
||||
rr := putFace(t, srv, "agent-token", jpegBytes(64))
|
||||
var out struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &out)
|
||||
|
||||
// The reason it is session-authenticated rather than a signed link: there
|
||||
// is no third party to delegate to, and an unauthenticated URL would be a
|
||||
// way to reach a customer's photograph with no session at all.
|
||||
if rec := do(t, srv, "GET", faceURL(out.Key), "", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("a face was served with no session, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnotherTenantCannotReadYourStoredFace(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
fs.addAgent("acme-agent", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
|
||||
seedUser(fs)
|
||||
fs.addUser("other@beta.com", "correct horse battery", UserRecord{
|
||||
ID: "u2", ClientID: "client-beta", ClientName: "Beta Ltd",
|
||||
FullName: "Bo", Role: "manager", Active: true,
|
||||
})
|
||||
|
||||
rr := putFace(t, srv, "acme-agent", jpegBytes(64))
|
||||
var out struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &out)
|
||||
|
||||
// An image key travels in API responses, so a caller who kept one - or
|
||||
// guessed one - must get nothing rather than somebody else's customer.
|
||||
beta := login(t, srv, "other@beta.com", "correct horse battery")
|
||||
if rec := do(t, srv, "GET", faceURL(out.Key), beta.Token, nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("another tenant read a stored face, got %d", rec.Code)
|
||||
}
|
||||
acme := login(t, srv, "manager@acme.com", "correct horse battery")
|
||||
if rec := do(t, srv, "GET", faceURL(out.Key), acme.Token, nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("the owning tenant could not read its own face, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The customer record has to work on a deployment with no bucket too - it is
|
||||
// the screen staff use to recognise the person in front of them.
|
||||
func TestTheCustomerPhotoWorksWithNoObjectStorage(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
srv.Blob = nil
|
||||
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
|
||||
seedUser(fs)
|
||||
|
||||
rr := putFace(t, srv, "agent-token", jpegBytes(64))
|
||||
var up struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &up)
|
||||
const visitor = "44444444-4444-4444-8444-444444444444"
|
||||
fs.imageKeys[visitor] = up.Key
|
||||
|
||||
sess := login(t, srv, "manager@acme.com", "correct horse battery")
|
||||
rec := do(t, srv, "GET", "/api/visitors/"+visitor+"/image", sess.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("customer photo: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var img Image
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !img.Available || !img.Auth {
|
||||
t.Fatalf("want an available image that needs the session, got %+v", img)
|
||||
}
|
||||
// The storage key names a tenant's prefix and must never be what a client
|
||||
// receives, on either route.
|
||||
if strings.Contains(rec.Body.String(), "db:") {
|
||||
t.Errorf("the storage key leaked: %s", rec.Body.String())
|
||||
}
|
||||
// Reading a face is worth an audit row wherever the LINK is handed out.
|
||||
// Recorded here rather than at the byte fetch, because the bucket route's
|
||||
// bytes never touch this server and the two must be counted the same way.
|
||||
if !audited(fs, "image.view") {
|
||||
t.Error("reading a customer photo left no audit row")
|
||||
}
|
||||
}
|
||||
|
||||
func audited(fs *fakeStore, action string) bool {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
for _, a := range fs.audits {
|
||||
if a.Action == action {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Erasure has to destroy an image this server holds, not only one in a bucket.
|
||||
// A face image that survives an erasure request is the one outcome that
|
||||
// endpoint must never produce.
|
||||
func TestErasureDestroysAStoredFace(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
srv.Blob = nil
|
||||
fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"})
|
||||
seedUser(fs)
|
||||
|
||||
rr := putFace(t, srv, "agent-token", jpegBytes(64))
|
||||
var up struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &up)
|
||||
const visitor = "55555555-5555-4555-8555-555555555555"
|
||||
fs.imageKeys[visitor] = up.Key
|
||||
|
||||
sess := login(t, srv, "manager@acme.com", "correct horse battery")
|
||||
if rec := do(t, srv, "DELETE", "/api/visitors/"+visitor, sess.Token, nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("erase: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := do(t, srv, "GET", faceURL(up.Key), sess.Token, nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("the face survived erasure, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// If the image cannot be destroyed, NOTHING is erased and the caller is told.
|
||||
// Reporting a legal request as honoured when it was not is the failure this
|
||||
// path exists to prevent.
|
||||
func TestAFailedFaceDeleteAbortsTheWholeErasure(t *testing.T) {
|
||||
srv, fs := newServer(t)
|
||||
srv.Blob = nil
|
||||
seedUser(fs)
|
||||
const visitor = "66666666-6666-4666-8666-666666666666"
|
||||
fs.imageKeys[visitor] = "db:66666666-6666-4666-8666-666666666666"
|
||||
fs.faceDeleteErr = errors.New("storage is down")
|
||||
|
||||
sess := login(t, srv, "manager@acme.com", "correct horse battery")
|
||||
rec := do(t, srv, "DELETE", "/api/visitors/"+visitor, sess.Token, nil)
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("want 502 and nothing erased, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(fs.forgotten) != 0 {
|
||||
t.Fatalf("the record was erased even though the photo could not be: %v", fs.forgotten)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -19,6 +21,19 @@ import (
|
||||
// live - which tenant, which message on failure, what is echoed back - and
|
||||
// those are exactly what a real database would make slow and awkward to test.
|
||||
type fakeStore struct {
|
||||
// Which tenant and site each camera belongs to. The live relay is keyed on
|
||||
// a camera id and a hub does not know whose camera it holds, so ownership
|
||||
// is proved before anything streams - and that is what these tests check.
|
||||
cameraRefs map[string]cameraRef
|
||||
|
||||
// Camera pictures held by the server, for a deployment with no bucket.
|
||||
// Keyed as written by PutCameraSnapshot (by camera_id) and as read by
|
||||
// CameraSnapshot ("client/camera"), so a test has to say which it means.
|
||||
snapshots map[string][]byte
|
||||
snapshotRejects bool
|
||||
lastSnapshotClient string
|
||||
lastSnapshotSite string
|
||||
|
||||
mu sync.Mutex
|
||||
|
||||
users map[string]UserRecord // by lower-cased email
|
||||
@@ -57,6 +72,14 @@ type fakeStore struct {
|
||||
lastCheckKind string
|
||||
lastCheckSeconds int
|
||||
|
||||
// Invitations, and the faces this server holds itself.
|
||||
invites map[string]*fakeInvite // by code hash hex
|
||||
faces map[string][]byte // "client/id"
|
||||
lastFaceClient string
|
||||
lastFaceSite string
|
||||
deletedFaces []string
|
||||
faceDeleteErr error
|
||||
|
||||
cameras []Camera
|
||||
agentCameras []AgentCamera
|
||||
lastCameraReport AgentCameraReport
|
||||
@@ -84,6 +107,7 @@ type fakeSession struct {
|
||||
id string
|
||||
p auth.Principal
|
||||
accessExp, refreshExp time.Time
|
||||
device string
|
||||
revoked bool
|
||||
}
|
||||
|
||||
@@ -137,7 +161,11 @@ func (f *fakeStore) CreateSession(_ context.Context, n NewSession) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.nextID++
|
||||
id := "sess-" + itoa(f.nextID)
|
||||
// uuid-SHAPED, because the handlers validate the shape of an id before
|
||||
// spending a database round trip on it. A fake that mints "sess-1" would
|
||||
// make every id-addressed session route 404 in tests and pass in
|
||||
// production, which is the wrong way round.
|
||||
id := fmt.Sprintf("00000000-0000-4000-8000-%012d", f.nextID)
|
||||
var rec UserRecord
|
||||
for _, u := range f.users {
|
||||
if u.ID == n.UserID {
|
||||
@@ -152,6 +180,7 @@ func (f *fakeStore) CreateSession(_ context.Context, n NewSession) error {
|
||||
FullName: rec.FullName, Role: rec.Role,
|
||||
},
|
||||
accessExp: n.AccessExpiry, refreshExp: n.RefreshExp,
|
||||
device: n.Device,
|
||||
}
|
||||
f.sessions[id] = s
|
||||
f.byAccess[hex.EncodeToString(n.AccessHash)] = id
|
||||
@@ -233,6 +262,32 @@ func (f *fakeStore) Conversion(_ context.Context, q ReportQuery) (SalesReport, e
|
||||
return f.sales, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateSite(_ context.Context, clientID, slug, name, tz string) (NewSite, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, s := range f.sites {
|
||||
if s.Slug == slug {
|
||||
return NewSite{}, &pgconn.PgError{Code: "23505"}
|
||||
}
|
||||
}
|
||||
id := "site-" + slug
|
||||
f.sites = append(f.sites, SiteHealth{SiteID: id, Slug: slug, Name: name, Timezone: tz})
|
||||
return NewSite{SiteID: id, Slug: slug, Name: name, Timezone: tz, Username: "acme." + slug, Password: "pw-" + slug}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteNewSite(_ context.Context, _ string, siteID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
kept := f.sites[:0]
|
||||
for _, s := range f.sites {
|
||||
if s.SiteID != siteID {
|
||||
kept = append(kept, s)
|
||||
}
|
||||
}
|
||||
f.sites = kept
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SiteHealth(_ context.Context, _ string) ([]SiteHealth, error) {
|
||||
return f.sites, nil
|
||||
}
|
||||
@@ -447,6 +502,75 @@ func (f *fakeStore) ReleaseStaleChecks(_ context.Context, _ time.Duration) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetClientActive(_ context.Context, clientID string, active bool) (ClientRow, int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for i := range f.clients {
|
||||
if f.clients[i].ID != clientID {
|
||||
continue
|
||||
}
|
||||
f.clients[i].Active = active
|
||||
revoked := 0
|
||||
if !active {
|
||||
for _, sess := range f.sessions {
|
||||
if sess.p.ClientID == clientID && !sess.revoked {
|
||||
sess.revoked = true
|
||||
revoked++
|
||||
}
|
||||
}
|
||||
}
|
||||
return f.clients[i], revoked, nil
|
||||
}
|
||||
return ClientRow{}, 0, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (f *fakeStore) ClientOwners(_ context.Context, clientID string) ([]TeamMember, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []TeamMember
|
||||
for _, u := range f.users {
|
||||
if u.ClientID == clientID && u.Role == "owner" && u.Active {
|
||||
out = append(out, TeamMember{ID: u.ID, Email: u.Email, FullName: u.FullName, Role: u.Role, Active: u.Active})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ClientImageKeys(_ context.Context, _ string) ([]string, error) { return nil, nil }
|
||||
|
||||
func (f *fakeStore) DeleteClient(_ context.Context, clientID string) (ClientRow, []string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for i, c := range f.clients {
|
||||
if c.ID != clientID {
|
||||
continue
|
||||
}
|
||||
if c.Active {
|
||||
return c, nil, errors.New("client is active")
|
||||
}
|
||||
f.clients = append(f.clients[:i], f.clients[i+1:]...)
|
||||
return c, []string{c.Slug + ".shop1"}, nil
|
||||
}
|
||||
return ClientRow{}, nil, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteEmptySite(_ context.Context, _ string, siteID string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, c := range f.cameras {
|
||||
if c.SiteID == siteID {
|
||||
return "", ErrSiteInUse
|
||||
}
|
||||
}
|
||||
for i, s := range f.sites {
|
||||
if s.SiteID == siteID {
|
||||
f.sites = append(f.sites[:i], f.sites[i+1:]...)
|
||||
return "acme." + s.Slug, nil
|
||||
}
|
||||
}
|
||||
return "", pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListClients(_ context.Context) ([]ClientRow, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -586,3 +710,440 @@ func (b *fakeBlob) Delete(_ context.Context, key string) error {
|
||||
b.deleted = append(b.deleted, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- camera snapshots --
|
||||
|
||||
func (f *fakeStore) PutCameraSnapshot(_ context.Context,
|
||||
clientID, siteID, cameraID string, jpeg []byte) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.snapshots == nil {
|
||||
f.snapshots = map[string][]byte{}
|
||||
}
|
||||
if f.snapshotRejects {
|
||||
return ErrNoSnapshot
|
||||
}
|
||||
f.lastSnapshotClient, f.lastSnapshotSite = clientID, siteID
|
||||
f.snapshots[cameraID] = append([]byte(nil), jpeg...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CameraSnapshot(_ context.Context, clientID, cameraID string) (
|
||||
[]byte, time.Time, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
img, ok := f.snapshots[clientID+"/"+cameraID]
|
||||
if !ok {
|
||||
return nil, time.Time{}, ErrNoSnapshot
|
||||
}
|
||||
return img, time.Unix(1756900000, 0).UTC(), nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ live relay --
|
||||
|
||||
func (f *fakeStore) CameraRef(_ context.Context, clientID, cameraID string) (string, string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
ref, ok := f.cameraRefs[cameraID]
|
||||
if !ok || ref.client != clientID {
|
||||
return "", "", ErrNoSnapshot
|
||||
}
|
||||
return ref.site, ref.engineID, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CameraRefBySite(_ context.Context, siteID, cameraID string) (string, string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
ref, ok := f.cameraRefs[cameraID]
|
||||
if !ok || ref.site != siteID {
|
||||
return "", "", ErrNoSnapshot
|
||||
}
|
||||
return ref.site, ref.engineID, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SiteCameraIDs(_ context.Context, siteID string) ([]string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []string
|
||||
for id, ref := range f.cameraRefs {
|
||||
if ref.site == siteID {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// addCameraRef registers a camera so ownership checks have something to check.
|
||||
func (f *fakeStore) addCameraRef(id, client, site, engineID string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.cameraRefs == nil {
|
||||
f.cameraRefs = map[string]cameraRef{}
|
||||
}
|
||||
f.cameraRefs[id] = cameraRef{client: client, site: site, engineID: engineID}
|
||||
}
|
||||
|
||||
type cameraRef struct{ client, site, engineID string }
|
||||
|
||||
// ==================================== team, invitations, sessions, faces ====
|
||||
//
|
||||
// These behave rather than merely satisfy the interface: single use, tenant
|
||||
// scoping and "the role comes from the invitation" are the properties the
|
||||
// handlers are trusted for, so a fake that always says yes would make the tests
|
||||
// that check them meaningless.
|
||||
|
||||
type fakeInvite struct {
|
||||
id, clientID, email, fullName, role string
|
||||
expires time.Time
|
||||
used, revoked bool
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateInvitation(_ context.Context, in NewInvitation) (Invitation, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.invites == nil {
|
||||
f.invites = map[string]*fakeInvite{}
|
||||
}
|
||||
f.nextID++
|
||||
id := fmt.Sprintf("00000000-0000-4000-9000-%012d", f.nextID)
|
||||
f.invites[hex.EncodeToString(in.CodeHash)] = &fakeInvite{
|
||||
id: id, clientID: in.ClientID, email: in.Email,
|
||||
fullName: in.FullName, role: in.Role, expires: in.ExpiresAt,
|
||||
}
|
||||
return Invitation{
|
||||
ID: id, Email: in.Email, FullName: in.FullName, Role: in.Role,
|
||||
ExpiresAt: in.ExpiresAt.UTC().Format(time.RFC3339),
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) PendingInvitations(_ context.Context, clientID string) ([]Invitation, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []Invitation
|
||||
for _, v := range f.invites {
|
||||
if v.clientID != clientID || v.used || v.revoked {
|
||||
continue
|
||||
}
|
||||
out = append(out, Invitation{ID: v.id, Email: v.email,
|
||||
FullName: v.fullName, Role: v.role,
|
||||
ExpiresAt: v.expires.UTC().Format(time.RFC3339)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RevokeInvitation(_ context.Context, clientID, id string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, v := range f.invites {
|
||||
if v.id == id && v.clientID == clientID && !v.used && !v.revoked {
|
||||
v.revoked = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("no such pending invitation")
|
||||
}
|
||||
|
||||
func (f *fakeStore) InvitationByCode(_ context.Context, hash []byte) (InvitationPreview, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
v, ok := f.invites[hex.EncodeToString(hash)]
|
||||
if !ok || v.used || v.revoked || time.Now().After(v.expires) {
|
||||
return InvitationPreview{}, errors.New("that invitation is not valid")
|
||||
}
|
||||
return InvitationPreview{Client: "Fake Co", Email: v.email,
|
||||
FullName: v.fullName, Role: v.role}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RedeemInvitation(_ context.Context, hash []byte,
|
||||
fullName, passwordHash string) (UserRecord, error) {
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
v, ok := f.invites[hex.EncodeToString(hash)]
|
||||
if !ok || v.used || v.revoked || time.Now().After(v.expires) {
|
||||
return UserRecord{}, errors.New("that invitation is not valid")
|
||||
}
|
||||
if _, taken := f.users[v.email]; taken {
|
||||
return UserRecord{}, errors.New("app_users_email_idx")
|
||||
}
|
||||
// Marked spent BEFORE the account exists, mirroring the real store's one
|
||||
// transaction: a test that redeems the same code twice must get one user.
|
||||
v.used = true
|
||||
f.nextID++
|
||||
rec := UserRecord{
|
||||
ID: fmt.Sprintf("00000000-0000-4000-a000-%012d", f.nextID),
|
||||
// From the INVITATION, never from the request - which is the property
|
||||
// worth having a fake at all for.
|
||||
ClientID: v.clientID, ClientName: "Fake Co", Email: v.email,
|
||||
FullName: fullName, Role: v.role, Active: true, Found: true,
|
||||
PasswordHash: passwordHash,
|
||||
}
|
||||
if rec.FullName == "" {
|
||||
rec.FullName = v.fullName
|
||||
}
|
||||
f.users[v.email] = rec
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Team(_ context.Context, clientID string) ([]TeamMember, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []TeamMember
|
||||
for _, u := range f.users {
|
||||
if u.ClientID != clientID {
|
||||
continue
|
||||
}
|
||||
out = append(out, TeamMember{ID: u.ID, Email: u.Email,
|
||||
FullName: u.FullName, Role: u.Role, Active: u.Active})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpdateTeamMember(_ context.Context, clientID, userID string,
|
||||
up TeamUpdate) (TeamMember, error) {
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for email, u := range f.users {
|
||||
if u.ID != userID || u.ClientID != clientID {
|
||||
continue
|
||||
}
|
||||
if up.Role != nil {
|
||||
u.Role = *up.Role
|
||||
}
|
||||
if up.Active != nil {
|
||||
u.Active = *up.Active
|
||||
if !u.Active {
|
||||
// The real store revokes in the same transaction; the fake
|
||||
// does it here so a test can prove "they have left" actually
|
||||
// signs them out rather than waiting twelve hours.
|
||||
for _, s := range f.sessions {
|
||||
if s.p.UserID == userID {
|
||||
s.revoked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
f.users[email] = u
|
||||
return TeamMember{ID: u.ID, Email: u.Email, FullName: u.FullName,
|
||||
Role: u.Role, Active: u.Active}, nil
|
||||
}
|
||||
return TeamMember{}, errors.New("no such team member")
|
||||
}
|
||||
|
||||
func (f *fakeStore) UserSessions(_ context.Context, userID string) ([]DeviceSession, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []DeviceSession
|
||||
for _, s := range f.sessions {
|
||||
if s.p.UserID != userID || s.revoked {
|
||||
continue
|
||||
}
|
||||
out = append(out, DeviceSession{ID: s.id, Device: s.device,
|
||||
ExpiresAt: s.refreshExp.UTC().Format(time.RFC3339)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RevokeUserSession(_ context.Context, userID, sessionID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
s, ok := f.sessions[sessionID]
|
||||
// Scoped by user id, exactly as the real UPDATE is: a session id travels in
|
||||
// a list and is not a secret, so it must not sign anybody else out.
|
||||
if !ok || s.p.UserID != userID || s.revoked {
|
||||
return errors.New("no such session")
|
||||
}
|
||||
s.revoked = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RevokeOtherSessions(_ context.Context, userID, keep string) (int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
n := 0
|
||||
for _, s := range f.sessions {
|
||||
if s.p.UserID == userID && s.id != keep && !s.revoked {
|
||||
s.revoked = true
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) PutVisitFace(_ context.Context, clientID, siteID string,
|
||||
jpeg []byte) (string, error) {
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.faces == nil {
|
||||
f.faces = map[string][]byte{}
|
||||
}
|
||||
f.nextID++
|
||||
id := fmt.Sprintf("00000000-0000-4000-b000-%012d", f.nextID)
|
||||
f.faces[clientID+"/"+id] = jpeg
|
||||
f.lastFaceClient, f.lastFaceSite = clientID, siteID
|
||||
return "db:" + id, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) VisitFace(_ context.Context, clientID, key string) ([]byte, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
img, ok := f.faces[clientID+"/"+strings.TrimPrefix(key, "db:")]
|
||||
if !ok {
|
||||
return nil, errors.New("no such face image")
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteVisitFaces(_ context.Context, clientID string, keys []string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.faceDeleteErr != nil {
|
||||
return f.faceDeleteErr
|
||||
}
|
||||
for _, k := range keys {
|
||||
delete(f.faces, clientID+"/"+strings.TrimPrefix(k, "db:"))
|
||||
f.deletedFaces = append(f.deletedFaces, k)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================ public reference resolution ===
|
||||
//
|
||||
// These behave rather than merely satisfy the interface. The properties the
|
||||
// handlers are trusted for - a reference resolves only within the caller's own
|
||||
// tenant, and an ambiguous camera name resolves to nothing rather than to
|
||||
// whichever row came first - are exactly what a fake that always said yes would
|
||||
// stop any test from checking.
|
||||
|
||||
func (f *fakeStore) SiteIDBySlug(_ context.Context, clientID, slug string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, s := range f.sites {
|
||||
// An owner of "" is a site the fake was not told about, which is the
|
||||
// ordinary case: SiteHealth carries no client id, and most tests seed
|
||||
// one tenant. Tests that assert cross-tenant resolution seed a camera,
|
||||
// which is what gives a site an owner here.
|
||||
if owner := f.siteClient(s.Slug, s.SiteID); s.Slug == slug &&
|
||||
(owner == "" || owner == clientID) {
|
||||
return s.SiteID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// siteClient answers which tenant a site belongs to. SiteHealth carries no
|
||||
// client id of its own - it is already scoped by the query that returns it - so
|
||||
// the fake reads ownership from the cameras it was seeded with.
|
||||
func (f *fakeStore) siteClient(_, siteID string) string {
|
||||
for _, ref := range f.cameraRefs {
|
||||
if ref.site == siteID {
|
||||
return ref.client
|
||||
}
|
||||
}
|
||||
for _, c := range f.cameras {
|
||||
if c.SiteID == siteID {
|
||||
return f.cameraOwner(c.ID)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f *fakeStore) cameraOwner(id string) string {
|
||||
if ref, ok := f.cameraRefs[id]; ok {
|
||||
return ref.client
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f *fakeStore) CameraIDByRef(_ context.Context, clientID, ref string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var found []string
|
||||
for _, c := range f.cameras {
|
||||
if c.CameraID != ref {
|
||||
continue
|
||||
}
|
||||
if owner := f.cameraOwner(c.ID); owner != "" && owner != clientID {
|
||||
continue
|
||||
}
|
||||
found = append(found, c.ID)
|
||||
}
|
||||
// A camera id is unique per site, not per tenant. Two shops may each have
|
||||
// an "Office1", and acting on whichever sorted first would edit the wrong
|
||||
// shop's camera, so ambiguity is no match.
|
||||
if len(found) != 1 {
|
||||
return "", nil
|
||||
}
|
||||
return found[0], nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) VisitorIDByNumber(_ context.Context, clientID string, number int64) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
want := VisitorRef(number)
|
||||
for _, v := range f.visitors {
|
||||
if v.Ref == want {
|
||||
return v.ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// CreateMember behaves like the real store on the two things the handler
|
||||
// branches on: the account lands in the caller's tenant and nowhere else, and
|
||||
// an address that already exists anywhere is a conflict named the way Postgres
|
||||
// names it, so conflictMessage recognises it.
|
||||
func (f *fakeStore) CreateMember(_ context.Context, clientID string,
|
||||
in NewMemberInput, hash string) (TeamMember, error) {
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, taken := f.users[in.Email]; taken {
|
||||
return TeamMember{}, errors.New(`duplicate key value violates unique constraint "app_users_email_idx"`)
|
||||
}
|
||||
// The real UserByEmail joins clients for the name; this fake reads it off
|
||||
// the record, so copy it from a tenant-mate or a login as the new member
|
||||
// comes back with no company name and looks like it landed nowhere.
|
||||
clientName := ""
|
||||
for _, u := range f.users {
|
||||
if u.ClientID == clientID && u.ClientName != "" {
|
||||
clientName = u.ClientName
|
||||
break
|
||||
}
|
||||
}
|
||||
id := "member-" + itoa(len(f.users)+1)
|
||||
f.users[in.Email] = UserRecord{
|
||||
ID: id, ClientID: clientID, ClientName: clientName,
|
||||
Email: in.Email, FullName: in.FullName,
|
||||
Role: in.Role, Active: true, PasswordHash: hash, Found: true,
|
||||
}
|
||||
return TeamMember{ID: id, Email: in.Email, FullName: in.FullName,
|
||||
Role: in.Role, Active: true}, nil
|
||||
}
|
||||
|
||||
// ResetMemberPassword mirrors the real one: tenant-scoped, and every session
|
||||
// the member holds is revoked with it.
|
||||
func (f *fakeStore) ResetMemberPassword(_ context.Context, clientID, userID,
|
||||
hash string) (TeamMember, error) {
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for email, u := range f.users {
|
||||
if u.ID != userID || u.ClientID != clientID {
|
||||
continue
|
||||
}
|
||||
u.PasswordHash = hash
|
||||
f.users[email] = u
|
||||
for _, s := range f.sessions {
|
||||
if s.p.UserID == userID {
|
||||
s.revoked = true
|
||||
}
|
||||
}
|
||||
return TeamMember{ID: u.ID, Email: u.Email, FullName: u.FullName,
|
||||
Role: u.Role, Active: u.Active}, nil
|
||||
}
|
||||
return TeamMember{}, errors.New("no such team member")
|
||||
}
|
||||
|
||||
259
server/internal/api/handlers_admin_clients.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
// The platform administrator's remaining shell-only jobs, as endpoints:
|
||||
// suspend or reinstate a company, reset its owner's password, delete it.
|
||||
//
|
||||
// All three are behind adminOnly (a principal with the role AND no client), and
|
||||
// all three read the company id from the path. None takes a client id from a
|
||||
// body - the same rule every tenant handler follows.
|
||||
|
||||
// PATCH /api/admin/clients/{id} {"active": false}
|
||||
//
|
||||
// Suspension is the reversible step and it is complete: login refuses the
|
||||
// company's users, the broker's visits are dropped at ingest, and every live
|
||||
// session is revoked in the same transaction. Without the last, "suspend" would
|
||||
// mean "suspend some time tomorrow", which is not what anybody pressing it
|
||||
// believes they did.
|
||||
func (s *Server) handleSetClientActive(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
var in struct {
|
||||
Active *bool `json:"active"`
|
||||
}
|
||||
if err := decode(w, r, &in); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if in.Active == nil {
|
||||
badRequest(w, `"active" is required: true to reinstate, false to suspend`)
|
||||
return
|
||||
}
|
||||
row, revoked, err := s.Store.SetClientActive(r.Context(), r.PathValue("id"), *in.Active)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such company.")
|
||||
return
|
||||
}
|
||||
s.serverError(w, "set client active", err)
|
||||
return
|
||||
}
|
||||
action := "client.suspended"
|
||||
if *in.Active {
|
||||
action = "client.reinstated"
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ActorID: p.UserID, ActorKind: "user", Action: action,
|
||||
Entity: "client", EntityID: row.ID,
|
||||
Detail: map[string]any{"slug": row.Slug, "sessions_revoked": revoked},
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"client": row, "sessions_revoked": revoked})
|
||||
}
|
||||
|
||||
// POST /api/admin/clients/{id}/owner-password {"email": "…"}
|
||||
//
|
||||
// The support case this exists for: the owner has locked themselves out and
|
||||
// there is nobody above them in the company to reset it. The new password is
|
||||
// generated, shown once, and every session that owner held is revoked. `email`
|
||||
// picks the owner when the company has more than one; with exactly one it may
|
||||
// be omitted.
|
||||
func (s *Server) handleResetOwnerPassword(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
clientID := r.PathValue("id")
|
||||
var in struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := decodeOptional(w, r, &in); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
owners, err := s.Store.ClientOwners(r.Context(), clientID)
|
||||
if err != nil {
|
||||
s.serverError(w, "list owners", err)
|
||||
return
|
||||
}
|
||||
var target *TeamMember
|
||||
switch {
|
||||
case len(owners) == 0:
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That company has no active owner.")
|
||||
return
|
||||
case in.Email != "":
|
||||
want := auth.NormalizeEmail(in.Email)
|
||||
for i := range owners {
|
||||
if owners[i].Email == want {
|
||||
target = &owners[i]
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No active owner with that address.")
|
||||
return
|
||||
}
|
||||
case len(owners) == 1:
|
||||
target = &owners[0]
|
||||
default:
|
||||
emails := make([]string, 0, len(owners))
|
||||
for _, o := range owners {
|
||||
emails = append(emails, o.Email)
|
||||
}
|
||||
badRequest(w, "That company has several owners; say which with \"email\": "+strings.Join(emails, ", "))
|
||||
return
|
||||
}
|
||||
|
||||
password, err := auth.RandomPassword()
|
||||
if err != nil {
|
||||
s.serverError(w, "generate password", err)
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
s.serverError(w, "hash password", err)
|
||||
return
|
||||
}
|
||||
m, err := s.Store.ResetMemberPassword(r.Context(), clientID, target.ID, hash)
|
||||
if err != nil {
|
||||
s.serverError(w, "reset owner password", err)
|
||||
return
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ActorID: p.UserID, ActorKind: "user", Action: "admin.reset_owner_password",
|
||||
Entity: "user", EntityID: m.ID, Detail: map[string]any{"email": m.Email, "client_id": clientID},
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"email": m.Email, "password": password})
|
||||
}
|
||||
|
||||
// DELETE /api/admin/clients/{id} {"confirm": "<slug>"}
|
||||
//
|
||||
// Irreversible, and the data is biometric, so it is deliberately hard to do by
|
||||
// accident: the company must already be suspended, and the request must repeat
|
||||
// the slug. Objects go first - once the rows are gone nothing knows which files
|
||||
// to remove - then the broker logins, then the rows (everything cascades from
|
||||
// clients). A storage failure aborts before anything else is touched.
|
||||
func (s *Server) handleDeleteClient(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
clientID := r.PathValue("id")
|
||||
var in struct {
|
||||
Confirm string `json:"confirm"`
|
||||
}
|
||||
if err := decode(w, r, &in); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
rows, err := s.Store.ListClients(r.Context())
|
||||
if err != nil {
|
||||
s.serverError(w, "list clients", err)
|
||||
return
|
||||
}
|
||||
var row *ClientRow
|
||||
for i := range rows {
|
||||
if rows[i].ID == clientID {
|
||||
row = &rows[i]
|
||||
}
|
||||
}
|
||||
if row == nil {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such company.")
|
||||
return
|
||||
}
|
||||
if row.Active {
|
||||
writeErr(w, http.StatusConflict, "still_active",
|
||||
"Suspend the company first (PATCH active=false). Deleting is the second step, not the first.")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(in.Confirm) != row.Slug {
|
||||
badRequest(w, "Repeat the company's slug in \"confirm\" to delete it.")
|
||||
return
|
||||
}
|
||||
|
||||
keys, err := s.Store.ClientImageKeys(r.Context(), clientID)
|
||||
if err != nil {
|
||||
s.serverError(w, "list images for client delete", err)
|
||||
return
|
||||
}
|
||||
if s.Blob != nil {
|
||||
for _, key := range keys {
|
||||
if isDBKey(key) {
|
||||
continue // goes with the rows
|
||||
}
|
||||
if err := s.Blob.Delete(r.Context(), key); err != nil {
|
||||
s.logf("ERROR delete client %s: cannot delete %s: %v", row.Slug, key, err)
|
||||
writeErr(w, http.StatusBadGateway, "storage_error",
|
||||
"A stored photo could not be deleted, so the company was not deleted. Try again.")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
_, brokerUsers, err := s.Store.DeleteClient(r.Context(), clientID)
|
||||
if err != nil {
|
||||
s.serverError(w, "delete client", err)
|
||||
return
|
||||
}
|
||||
if s.Broker != nil {
|
||||
for _, u := range brokerUsers {
|
||||
if err := s.Broker.DeleteSite(r.Context(), u); err != nil {
|
||||
// The rows are gone and the login cannot publish anywhere the
|
||||
// server will accept (ingest resolves the site and finds none),
|
||||
// so this is a leftover to tidy, not a failure to report as one.
|
||||
s.logf("delete client %s: broker login %s not removed: %v", row.Slug, u, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ActorID: p.UserID, ActorKind: "user", Action: "client.deleted",
|
||||
Entity: "client", EntityID: clientID,
|
||||
Detail: map[string]any{"slug": row.Slug, "images_deleted": len(keys), "broker_logins": brokerUsers},
|
||||
})
|
||||
s.logf("WARNING company %s deleted by %s: %d images, %d broker logins", row.Slug, p.UserID, len(keys), len(brokerUsers))
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": row.Slug, "images_deleted": len(keys)})
|
||||
}
|
||||
|
||||
// DELETE /api/sites/{site} - an owner removes a shop opened by mistake.
|
||||
//
|
||||
// Only a shop with no visits and no cameras. A shop with history holds the
|
||||
// tenant's footfall and, through its visits, faces; taking that away is an
|
||||
// erasure decision, not a tidy-up, and there is no endpoint for it yet.
|
||||
func (s *Server) handleDeleteSite(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if p.Role != "owner" || p.ClientID == "" {
|
||||
writeErr(w, http.StatusForbidden, "forbidden", "Only the owner can remove a shop.")
|
||||
return
|
||||
}
|
||||
site, ok := s.resolveSite(w, r, r.PathValue("site"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
username, err := s.Store.DeleteEmptySite(r.Context(), p.ClientID, site)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSiteInUse) {
|
||||
writeErr(w, http.StatusConflict, "in_use",
|
||||
"This shop has cameras or visits, so it cannot simply be removed. Remove its cameras first; a shop with visit history is kept.")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such shop.")
|
||||
return
|
||||
}
|
||||
s.serverError(w, "delete site", err)
|
||||
return
|
||||
}
|
||||
if s.Broker != nil && username != "" {
|
||||
if err := s.Broker.DeleteSite(r.Context(), username); err != nil {
|
||||
s.logf("delete site %s: broker login %s not removed: %v", site, username, err)
|
||||
}
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "site.deleted", Entity: "site", EntityID: site,
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ErrSiteInUse is returned by DeleteEmptySite for a shop that has anything
|
||||
// under it.
|
||||
var ErrSiteInUse = errors.New("site has cameras or visits")
|
||||
@@ -41,12 +41,14 @@ func (s *Server) handleArrivals(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
q := ArrivalQuery{
|
||||
ClientID: p.ClientID,
|
||||
SiteID: trim(r.URL.Query().Get("site_id")),
|
||||
SiteID: siteParam(r),
|
||||
Limit: queryInt(r, "limit", defaultArrivals, maxArrivals),
|
||||
}
|
||||
if q.SiteID != "" && !looksLikeUUID(q.SiteID) {
|
||||
badRequest(w, "site_id must be a site identifier")
|
||||
return
|
||||
if q.SiteID != "" {
|
||||
var ok bool
|
||||
if q.SiteID, ok = s.resolveSiteFilter(w, r, q.SiteID); !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
// The site is still filtered by client_id in SQL as well. A site_id from
|
||||
// the query string is caller-controlled, and this is a read of other
|
||||
@@ -122,27 +124,9 @@ func (s *Server) attachImages(r *http.Request, rows []Arrival) {
|
||||
for i := range rows {
|
||||
key := rows[i].ImageKey
|
||||
rows[i].ImageKey = ""
|
||||
switch {
|
||||
case s.Blob == nil:
|
||||
rows[i].Image.Reason = "This system is not storing customer photos."
|
||||
case key == "":
|
||||
rows[i].Image.Reason = "No photo was captured for this visit."
|
||||
default:
|
||||
url, err := s.Blob.PresignGet(key, viewTTL)
|
||||
if err != nil {
|
||||
// Log it, but never fail the feed over a picture. The visit is
|
||||
// the number the customer pays for; the photo is decoration on
|
||||
// top of it. This is the same rule the agent follows when an
|
||||
// upload fails.
|
||||
s.logf("ERROR presign arrival image: %v", err)
|
||||
rows[i].Image.Reason = "That photo could not be loaded."
|
||||
continue
|
||||
}
|
||||
rows[i].Image = Image{Available: true, URL: url,
|
||||
ExpiresIn: int(viewTTL.Seconds())}
|
||||
if rows[i].VisitorID != "" {
|
||||
seen = append(seen, rows[i].VisitorID)
|
||||
}
|
||||
rows[i].Image = s.imageFor(key)
|
||||
if rows[i].Image.Available && rows[i].VisitorID != "" {
|
||||
seen = append(seen, rows[i].VisitorID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,12 +159,14 @@ func (s *Server) handleArrivalStream(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
q := ArrivalQuery{
|
||||
ClientID: p.ClientID,
|
||||
SiteID: trim(r.URL.Query().Get("site_id")),
|
||||
SiteID: siteParam(r),
|
||||
Limit: queryInt(r, "limit", defaultArrivals, maxArrivals),
|
||||
}
|
||||
if q.SiteID != "" && !looksLikeUUID(q.SiteID) {
|
||||
badRequest(w, "site_id must be a site identifier")
|
||||
return
|
||||
if q.SiteID != "" {
|
||||
var ok bool
|
||||
if q.SiteID, ok = s.resolveSiteFilter(w, r, q.SiteID); !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Last-Event-ID is what the browser's EventSource resends automatically on
|
||||
// a dropped connection, so honouring it is what makes a reconnect lossless
|
||||
|
||||
@@ -22,9 +22,11 @@ const snapshotTTL = 5 * time.Minute
|
||||
func (s *Server) handleCameras(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
siteID := trim(r.URL.Query().Get("site_id"))
|
||||
if siteID != "" && !looksLikeUUID(siteID) {
|
||||
badRequest(w, "site_id must be a site identifier")
|
||||
return
|
||||
if siteID != "" {
|
||||
var ok bool
|
||||
if siteID, ok = s.resolveSiteFilter(w, r, siteID); !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
cams, err := s.Store.Cameras(r.Context(), p.ClientID, siteID)
|
||||
if err != nil {
|
||||
@@ -49,10 +51,28 @@ func (s *Server) attachSnapshots(cams []Camera) {
|
||||
key := cams[i].Snapshot.Key
|
||||
cams[i].Snapshot.Key = ""
|
||||
switch {
|
||||
case s.Blob == nil:
|
||||
cams[i].Snapshot.Reason = "This system is not storing images."
|
||||
case key == "" && cams[i].SnapshotAt != "":
|
||||
// Held by this server, because the deployment has no object
|
||||
// storage. Served from an endpoint rather than a signed link:
|
||||
// there is no third party to delegate to, the bytes are in our own
|
||||
// database, and an unauthenticated URL to somebody's shop floor
|
||||
// would be a new way in for no gain.
|
||||
cams[i].Snapshot = Image{
|
||||
Available: true,
|
||||
URL: "/api/cameras/" + cams[i].ID + "/snapshot.jpg",
|
||||
ExpiresIn: int(snapshotTTL.Seconds()),
|
||||
// Says out loud that this URL needs the session's bearer.
|
||||
// Clients used to infer it from the URL being relative, which
|
||||
// is true today and stops being true the first time object
|
||||
// storage is served from this same host.
|
||||
Auth: true,
|
||||
}
|
||||
case key == "":
|
||||
cams[i].Snapshot.Reason = "No picture from this camera yet."
|
||||
case s.Blob == nil:
|
||||
// A key from a bucket this server can no longer reach. Distinct
|
||||
// from "no picture yet": one is waiting, the other is misconfigured.
|
||||
cams[i].Snapshot.Reason = "This system is not storing images."
|
||||
default:
|
||||
url, err := s.Blob.PresignGet(key, snapshotTTL)
|
||||
if err != nil {
|
||||
@@ -73,9 +93,8 @@ func (s *Server) handleCreateCamera(w http.ResponseWriter, r *http.Request) {
|
||||
"Your account cannot change camera settings.")
|
||||
return
|
||||
}
|
||||
siteID := r.PathValue("site")
|
||||
if !looksLikeUUID(siteID) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That shop no longer exists.")
|
||||
siteID, ok := s.resolveSite(w, r, r.PathValue("site"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in CameraInput
|
||||
@@ -111,9 +130,8 @@ func (s *Server) handleUpdateCamera(w http.ResponseWriter, r *http.Request) {
|
||||
"Your account cannot change camera settings.")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if !looksLikeUUID(id) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
|
||||
id, ok := s.resolveCamera(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
existing, err := s.Store.CameraByID(r.Context(), p.ClientID, id)
|
||||
@@ -174,9 +192,8 @@ func (s *Server) handleDeleteCamera(w http.ResponseWriter, r *http.Request) {
|
||||
"Your account cannot change camera settings.")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if !looksLikeUUID(id) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
|
||||
id, ok := s.resolveCamera(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cam, err := s.Store.DeleteCamera(r.Context(), p.ClientID, id)
|
||||
|
||||