Compare commits
33 Commits
v0.4.4-dem
...
f5eb2bb124
| Author | SHA1 | Date | |
|---|---|---|---|
| f5eb2bb124 | |||
| 0b29dd4a50 | |||
| e33761d6d0 | |||
| 02b2c5bc39 | |||
| e5a63cc412 | |||
| e27eb8e927 | |||
| 7c564aca3c | |||
| 7dda6ab508 | |||
| e0bd764e44 | |||
| 6068b2c3c7 | |||
| dae18d651b | |||
| c7312d31b4 | |||
| f4102443ea | |||
| 359d48e1c4 | |||
| 830c1c1573 | |||
| 4db71e9381 | |||
| 6fafecd6e4 | |||
| 0e4cb1274e | |||
| abcf6aa012 | |||
| f61da2eeed | |||
| 2e60fbb57a | |||
| f97ffc913a | |||
| 62c2cc8a7b | |||
| 16f0e69cec | |||
| 9062d2fc51 | |||
| 177584e812 | |||
| 81e2c605b9 | |||
| 1607f4ce74 | |||
| f88d441bbf | |||
| 4ac08e5a85 | |||
| 7c74431fcf | |||
| 01f1c17c7f | |||
| 448fba8770 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -66,3 +66,4 @@ node_modules/
|
||||
# Left behind by `pip install .` of the engine (setuptools metadata), not source.
|
||||
/behavision.egg-info/
|
||||
/.prod/
|
||||
/.demo/
|
||||
|
||||
197
API.md
197
API.md
@@ -43,9 +43,12 @@ user; the tenant is always taken from the session and never from the request.
|
||||
|---|---|
|
||||
| `POST /api/auth/login` `refresh` · `GET /api/auth/invitation` · `POST /api/auth/register` | **no auth** |
|
||||
| `POST /api/auth/logout` · `GET /api/auth/me` · `/api/auth/sessions*` | authed |
|
||||
| `POST /api/auth/password` — change your OWN | authed (platform admins too) |
|
||||
| `GET /api/visits` · `GET /api/visits/stream` | authed |
|
||||
| `GET /api/visitors` · `GET /api/visitors/{id}/history` · `GET /api/visitors/{id}/image` · `GET /api/faces/{id}` | authed |
|
||||
| `PUT /api/visitors/{id}/profile` · `POST /api/purchases` | staff |
|
||||
| `POST /api/customers` | staff |
|
||||
| `POST /api/visitors/{id}/merge` — **irreversible** | manager |
|
||||
| `DELETE /api/visitors/{id}` — erasure | manager |
|
||||
| `GET /api/sites` · `GET /api/sites/{site}/check` · `GET /api/cameras` · `GET /api/cameras/{id}/snapshot.jpg` · `GET /api/cameras/{id}/live` | authed |
|
||||
| `POST /api/sites/{site}/cameras` · `PATCH` / `DELETE /api/cameras/{id}` · `POST /api/cameras/{id}/check` | manager |
|
||||
@@ -53,15 +56,23 @@ user; the tenant is always taken from the session and never from the request.
|
||||
| `POST /api/sites/{site}/enrolment-code` | manager |
|
||||
| `GET /api/team` | authed (tenant users only) |
|
||||
| `POST /api/team/members` · `POST /api/team/{id}/password` · `PATCH /api/team/{id}` · `/api/team/invitations*` | manager |
|
||||
| `GET /api/reports/footfall` · `GET /api/reports/conversion` | authed |
|
||||
| `GET /api/reports/footfall` · `GET /api/reports/conversion` · `GET /api/sales` · `GET /api/sales/{id}` · `GET /api/dashboard/summary` | authed |
|
||||
| `POST /api/assistant` | authed |
|
||||
| `GET` / `POST /api/admin/clients` · `PATCH /api/admin/clients/{id}` · `POST /api/admin/clients/{id}/owner-password` · `DELETE /api/admin/clients/{id}` | **platform admin** |
|
||||
| `GET /api/admin/clients/{id}` · `…/{id}/sites` · `…/sites/{site}` · `…/sites/{site}/cameras` · `…/cameras/{camera}` · `GET /api/admin/monitoring/summary` | **platform admin** |
|
||||
| `/api/agent/*` | **shop PC token** — never a user |
|
||||
|
||||
A role that may not call something gets **403 `forbidden`** with a message
|
||||
saying who can. Another tenant's data returns **404**, never 403: a tenant user
|
||||
has no business learning that a resource exists.
|
||||
|
||||
`authed` above means any signed-in user **of a company**. A platform admin has
|
||||
no company — that absence is what defines one — so a company's own routes
|
||||
answer them **403 `not_a_tenant_account`**, naming the `/api/admin/clients/{id}/…`
|
||||
route that reads the same data. Their own `/api/auth/*` keeps working: a session
|
||||
is not a company's data, and revoking a lost device must not depend on having a
|
||||
tenant.
|
||||
|
||||
---
|
||||
|
||||
## The onboarding chain — who creates whom
|
||||
@@ -469,6 +480,34 @@ deactivate them (§4); that revokes every session they hold.
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/auth/password` — any signed-in account
|
||||
|
||||
Change your own password. Works for **every** account including a platform
|
||||
admin, who has no company and therefore cannot be reached by the team routes.
|
||||
|
||||
```json
|
||||
{ "current_password": "...", "new_password": "..." }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "changed": true, "sessions_revoked": 3 }
|
||||
```
|
||||
|
||||
- **The current password is required.** An access token lives twelve hours and
|
||||
travels on shop-floor PCs and staff phones; without this a stolen one would
|
||||
own the account permanently rather than until it expires. Wrong current
|
||||
password is **403 `wrong_password`** and changes nothing.
|
||||
- **Every other session is revoked; the caller's is kept.** Somebody changing
|
||||
their password because they think it is known must not wonder whether the
|
||||
device that already had it is still signed in — and must not be signed out of
|
||||
the one in their hand while dealing with it.
|
||||
- A new password under the floor is **400**, and so is reusing the current one.
|
||||
|
||||
This is the route to use rather than asking an administrator. `POST
|
||||
/api/team/{id}/password` remains what a manager uses on somebody *else*.
|
||||
|
||||
---
|
||||
|
||||
## 4. The team
|
||||
|
||||
### `GET /api/team` — anyone in the company
|
||||
@@ -687,6 +726,105 @@ record of what you were permitted to do is what an auditor asks for.
|
||||
Links a sale to a customer so the conversion report can say who bought. **One
|
||||
currency per report** — see §9.
|
||||
|
||||
### `GET /api/sales` — anyone in the company
|
||||
|
||||
The purchases behind the conversion report. That report has always summed this
|
||||
table; until 28 Sep nothing could read a row of it, so "revenue was 41,000"
|
||||
could not be checked against a till.
|
||||
|
||||
Takes the same window as a report: `from`, `to` (`YYYY-MM-DD`), `site` or
|
||||
`site_id` (slug or uuid), plus `customer` (`V-42` or a uuid) and `limit`
|
||||
(default 50, max 200). An unknown shop or customer is a **400**, not a silently
|
||||
ignored filter.
|
||||
|
||||
```json
|
||||
[{ "id": "8525ef18-…", "occurred_at": "2026-09-19T10:48:44Z",
|
||||
"site_id": "93d0565f-…", "site": "TeNext Coimbatore", "site_slug": "chennai",
|
||||
"amount": 1000, "currency": "INR",
|
||||
"visitor_id": "ba5e5d2e-…", "visitor_ref": "V-1", "visitor_label": "Visitor 1",
|
||||
"visit_id": "3bcd53ca-…", "items": ["Headphones"], "source": "manual" }]
|
||||
```
|
||||
|
||||
- **A sale with no `visitor_id` is listed**, not joined away. An unidentified
|
||||
walk-in is still revenue, and hiding it would make this disagree with the
|
||||
conversion report computed over the same rows.
|
||||
- `items` is always an array, never `null`.
|
||||
- **No cursor, deliberately.** A keyset cursor needs a monotonic
|
||||
server-assigned column and `purchases` has none; ordering by
|
||||
`(occurred_at, id)` with a random uuid tie-break is the shape that silently
|
||||
dropped visits from the arrivals feed before `visits.seq` existed. Narrow by
|
||||
date and `limit` instead.
|
||||
|
||||
### `GET /api/sales/{id}`
|
||||
|
||||
One sale, same shape. Another company's sale is **404**.
|
||||
|
||||
### `GET /api/dashboard/summary` — anyone in the company
|
||||
|
||||
The home screen in one call, so a client does not combine four.
|
||||
|
||||
Takes `site`/`site_id` and `tz` (IANA, default the company's). "Today" is cut
|
||||
in **that timezone** — a dashboard that says today and means UTC is five and a
|
||||
half hours wrong in India.
|
||||
|
||||
```json
|
||||
{ "date": "2026-09-28", "visitors": 0, "visits": 0,
|
||||
"sites_total": 4, "sites_online": 0, "cameras_total": 1, "cameras_up": 1,
|
||||
"fraction_below_gate": 0.59, "worst_site": "TeNext Coimbatore",
|
||||
"timezone": "Asia/Kolkata" }
|
||||
```
|
||||
|
||||
`visitors` is unique people and `visits` is arrivals — **do not add the daily
|
||||
bars of a footfall report to get either.** `fraction_below_gate` travels with
|
||||
them because it is what says whether the count is a number or a floor.
|
||||
|
||||
### `POST /api/customers` — staff and above
|
||||
|
||||
Register a customer before any camera has seen them — somebody standing at the
|
||||
counter. Body is a profile; **at least a name or a phone** is required, since a
|
||||
record with neither is a number nobody can search for.
|
||||
|
||||
```json
|
||||
{ "id": "…", "ref": "V-7", "label": "Asha Menon", "visit_count": 0, "has_profile": true }
|
||||
```
|
||||
|
||||
They get a `V-` reference from the same counter an enrolled customer does, so a
|
||||
hand-created record is indistinguishable from one the engine made.
|
||||
|
||||
**Know what this implies.** They have no face template, so when a camera sees
|
||||
that person later the matcher has nothing to compare against and enrols them
|
||||
again — by design, not by failure. Join the two with the merge below.
|
||||
|
||||
### `POST /api/visitors/{id}/merge` — manager and above
|
||||
|
||||
Fold the customer in the path **into** the one named in the body, and delete
|
||||
the source. `into` takes a uuid or a `V-` reference.
|
||||
|
||||
```json
|
||||
{ "into": "V-12" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "visitor_id": "…", "ref": "V-12", "label": "Asha Menon",
|
||||
"visits": 2, "purchases": 1, "embeddings": 1, "consents": 1,
|
||||
"retired_ref": "V-7",
|
||||
"discarded": ["phone: 9000000001"] }
|
||||
```
|
||||
|
||||
- **Irreversible**, which is why it is manager-and-above. Two different people
|
||||
welded together cannot be separated: nothing records which visit came from
|
||||
whom. It logs at WARNING and writes an audit row.
|
||||
- **`retired_ref` is the reference that has stopped resolving.** Staff write
|
||||
these on cards; asking for it afterwards returns 404.
|
||||
- **Nothing is discarded silently.** The survivor keeps its own profile values
|
||||
and its blanks are filled from the source; anything that loses is listed in
|
||||
`discarded` **and** appended to the survivor's notes — and `GET /api/visitors?q=`
|
||||
searches notes, so a customer reached by their old phone number is still found.
|
||||
- Visits, purchases, templates and consents all move. `visit_count` is
|
||||
recomputed by counting rows, and `first_seen_at` takes the earlier of the two.
|
||||
- Merging a customer into themselves is **400**; an unknown or another tenant's
|
||||
customer is **404**.
|
||||
|
||||
### `DELETE /api/visitors/{id}` — **erasure**, manager and above
|
||||
|
||||
Destroys the face template and the photo outright. Keeps the visit rows,
|
||||
@@ -1091,6 +1229,57 @@ first — a failure there is `502 storage_error` and nothing else is touched —
|
||||
then the shop PCs' broker logins, then every row (templates, visits, users,
|
||||
sessions, cameras) by cascade.
|
||||
|
||||
### The drill-down: `GET /api/admin/clients/{id}` and below
|
||||
|
||||
A platform admin has **no company**, so the tenant routes cannot serve this —
|
||||
they scope by the signed-in account's client, and an admin has none. These take
|
||||
the merchant in the path instead. `{site}` accepts a slug or a uuid; `{camera}`
|
||||
accepts the engine's camera id or a uuid.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /api/admin/clients/{id}` | the list row plus `owner_email`, `owner_name` |
|
||||
| `GET …/{id}/sites` | same shape as a tenant's `GET /api/sites` |
|
||||
| `GET …/{id}/sites/{site}` | one of them |
|
||||
| `GET …/{id}/sites/{site}/cameras` | **redacted** — see below |
|
||||
| `GET …/{id}/sites/{site}/cameras/{camera}` | one of them |
|
||||
| `GET /api/admin/monitoring/summary` | `cameras_total`, `cameras_online`, `merchants_active`, `sites_total`, `events_today`, `as_of` |
|
||||
|
||||
**Camera rows here are a different shape from `GET /api/cameras`** and carry no
|
||||
`host`, `port`, `path`, `username` or `has_password`. A company seeing those
|
||||
for its own camera is correct; a platform admin browsing somebody else's estate
|
||||
is a different question, and an RTSP host with a username beside it is most of
|
||||
a live path into that customer's camera.
|
||||
|
||||
```json
|
||||
[{ "id": "3a96a742-…", "site_id": "93d0565f-…", "site": "TeNext Coimbatore",
|
||||
"camera_id": "cam2", "label": "Open office", "enabled": true,
|
||||
"connected": true, "last_seen_at": "2026-09-24T08:33:10Z",
|
||||
"snapshot_at": "2026-09-24T08:33:10Z", "check": { … } }]
|
||||
```
|
||||
|
||||
`connected` is still three states: `null` = no shop PC has reported yet,
|
||||
`false` = not connecting, `true` = up.
|
||||
|
||||
A shop or camera belonging to a **different** merchant is **404**, never an
|
||||
empty list — `[]` would say "this shop has no cameras" when the truth is "not
|
||||
your shop". A suspended merchant stays readable; that is what an admin opens
|
||||
the console to see. Every read below the merchant list is written to
|
||||
`audit_log`; the counts-only summary is not.
|
||||
|
||||
**Not built, and each is a decision rather than a missing handler:**
|
||||
|
||||
- `…/events` and `…/alerts` — there is no events table and the shop PC
|
||||
deliberately does not send diagnostics (`camera.up`, `person.missed`) to the
|
||||
server. This needs that decision, a table and a retention policy first; an
|
||||
endpoint now would return `[]` forever.
|
||||
- `POST /api/admin/assistant` — the assistant's tools take no client id by
|
||||
design, which is what makes cross-tenant access impossible rather than merely
|
||||
disallowed. An admin one needs a principal scoped to the merchant being
|
||||
viewed, which weakens that. Deliberately not done quietly.
|
||||
|
||||
---
|
||||
|
||||
## 12. Errors
|
||||
|
||||
```json
|
||||
@@ -1105,7 +1294,7 @@ rewritten freely.
|
||||
|---|---|
|
||||
| 400 | the request was wrong; `message` says how — including an unknown reference in a query filter |
|
||||
| 401 | not signed in, or `token_expired` → refresh once and retry |
|
||||
| 403 | `forbidden` — signed in, but this role may not; `message` says who can |
|
||||
| 403 | `forbidden` — signed in, but this role may not; `message` says who can. Also `not_a_tenant_account`: a **platform admin** calling a company's own route, who reads that data through `/api/admin/clients/{id}/…` instead |
|
||||
| 404 | not found — **also** what another tenant's data returns, always; and what admin routes return to non-admins |
|
||||
| 409 | a conflict `message` explains (`last_owner`, duplicate address) |
|
||||
| 429 | `too_many_attempts` |
|
||||
@@ -1126,3 +1315,7 @@ a user session is refused.
|
||||
|
||||
A web or mobile client never calls them. They are listed here so nobody wonders
|
||||
what they are.
|
||||
|
||||
One field name, because it is the only one in the product that is easy to guess
|
||||
wrong: `POST /api/agent/enrol` takes **`site_token`** (the installation code),
|
||||
not `code`, plus an optional `device`. Verified against production.
|
||||
|
||||
417
CLAUDE.md
417
CLAUDE.md
@@ -2151,6 +2151,423 @@ correctly calls them one person. Tests that need several different people use
|
||||
`distinctFace(i)`, which is orthogonal per index.
|
||||
|
||||
|
||||
## It recognises people, and that is now measured rather than assumed
|
||||
|
||||
Verified against **production** on 2026-09-24, reading what the live system had
|
||||
already recorded from the office cameras on 15-19 September. Until this, every
|
||||
claim about recognition rested on the August engine tests; the whole chain -
|
||||
camera to engine to agent to broker to server to a screen - had never once
|
||||
carried a real person.
|
||||
|
||||
```
|
||||
6 customers, 36 visits, 1,211 events accepted, 0 dropped, 0 duplicates
|
||||
Visitor 1 15 visits Visitor 3 8 visits Visitor 5 7 visits
|
||||
same-person similarity on returning matches: 0.437 - 0.716
|
||||
attributes travelling with each arrival: age 37 (spread 2), gender Male
|
||||
```
|
||||
|
||||
Those similarities are the point. The August measurement on this site said
|
||||
`match_threshold: 0.42` sat *inside* the same-person distribution for the
|
||||
overhead camera and no threshold could separate a person from themselves. On
|
||||
`cam2` the same code now returns 0.54, 0.64, 0.68, 0.72 for repeat sightings of
|
||||
the same people - a distribution the threshold sits clearly below. The camera
|
||||
that produced them is the open-office one at roughly head height, which is
|
||||
precisely the fix this file has recommended since August.
|
||||
|
||||
What is still true and unflattering: `fraction_below_gate` on that site is
|
||||
**0.59**, reported with `worst_site` beside it. Well over half the faces those
|
||||
two cameras see are still too poor to enrol. The footfall figure of 36 visits
|
||||
is therefore a floor, not a count, and the report says so in the same response -
|
||||
which is the entire reason that number travels with the one it qualifies.
|
||||
|
||||
### The image chain works; the engine is simply not capturing
|
||||
|
||||
Exercised on production exactly as a shop PC does it, end to end:
|
||||
|
||||
```
|
||||
enrolment code -> agent token 200
|
||||
POST /api/agent/upload-url 200 behavision/v2/<client>/<site>/2026/09/24/<random>.jpg
|
||||
PUT <presigned url> 200 JPEG in DigitalOcean Spaces
|
||||
anonymous GET of that same object 403 private, as the signature demands
|
||||
staff GET /api/visitors/{id}/image 404 "No photo was captured for this visit."
|
||||
```
|
||||
|
||||
Every server-side link holds: the key is minted by the server and scoped to one
|
||||
site, the ACL is inside the signature, and an unauthenticated read is refused.
|
||||
The 404 at the end is not a fault - it is `app.store_faces: false`, the shipped
|
||||
default, so the engine never wrote a crop for the agent to upload. Turning
|
||||
images on is one setting on the shop PC and a deliberate change to what the
|
||||
system is under GDPR and India's DPDP, which is why it is off until somebody
|
||||
decides otherwise rather than on until somebody notices.
|
||||
|
||||
### The mobile API, walked as a phone would
|
||||
|
||||
Sixteen checks against production as a **staff** account: login, arrivals feed
|
||||
with cursor, arrivals carrying `visitor_id` and `site_slug`, customer search,
|
||||
customer history, photo endpoint, profile write, purchase, device list, token
|
||||
rotation (the retired access token correctly 401s), team read allowed, invite
|
||||
refused 403, admin surface 404, SSE stream delivering rows, and Loya answering.
|
||||
All pass.
|
||||
|
||||
Three things that looked like product bugs and were not, recorded so the next
|
||||
person does not re-file them:
|
||||
|
||||
- `POST /api/purchases` takes `items` as a **list of strings**, not a count.
|
||||
API.md had it right; the test was wrong.
|
||||
- `new` and `returning` live **inside each bucket** of a footfall report, not at
|
||||
the top level. `total`, `visits`, `fraction_below_gate` and `worst_site` are
|
||||
the top-level fields.
|
||||
- `?range=7d` is not a parameter. Reports take `from` / `to` / `bucket` / `site`,
|
||||
and an unknown query parameter is silently ignored - so an invented one
|
||||
returns the default 30-day window rather than an error. That hazard is
|
||||
already recorded above for `site` vs `site_id`; it applies here too.
|
||||
|
||||
`POST /api/agent/enrol` takes **`site_token`**, not `code` - the only field name
|
||||
in the product a caller could reasonably guess wrong, and it is a route no
|
||||
client app should ever call.
|
||||
|
||||
## CPU: the engine was searching an empty room 15 times a second
|
||||
|
||||
Measured on this Mac against the office camera, because "it feels hot" is not
|
||||
a number. The first reading was **214% of a core**, and the first guess -
|
||||
H.265 decode - was wrong. Wall-clock time said decode cost 58 ms a frame, but
|
||||
`cap.read()` BLOCKS until the next frame arrives, so that number was the frame
|
||||
interval, not work. Measured as CPU time instead:
|
||||
|
||||
```
|
||||
wall/frame CPU/frame at 15 fps
|
||||
H.265 decode 58.8 ms 3.7 ms 5% of a core
|
||||
YuNet detect 8.9 ms 31.0 ms 47% of a core
|
||||
```
|
||||
|
||||
Detection costs three times its wall time because OpenCV spreads it over eight
|
||||
threads. Decode is nearly free. So the cost is detection, and it was running on
|
||||
**every frame whether or not anything was in front of the camera** - 6,649 of
|
||||
8,634 frames searched, with `faces_seen: 0` and `active_tracks: 0` throughout.
|
||||
|
||||
Two changes, both measured:
|
||||
|
||||
- **`app.detect_threads: 1`.** OpenCV sizes its pool for one big job on an idle
|
||||
machine; this is a small job repeated forever on a machine also running the
|
||||
recogniser, the tracker and possibly three other cameras. One thread costs
|
||||
15.3 ms of CPU against the default's 31.0 ms, for 6 ms more wall time against
|
||||
a 66 ms frame budget. Half the CPU, no latency that matters.
|
||||
- **`app.motion_gate`.** A 160x90 greyscale thumbnail and an `absdiff`: 0.1 ms
|
||||
against detection's 15 ms, ninety times cheaper. A shop is empty most of the
|
||||
day and an empty room costs exactly as much to search as a busy one.
|
||||
|
||||
Together: **80% -> 16% of a core**, with detection skipped on 92% of frames.
|
||||
Against the original main-stream reading that is 214% -> 16%.
|
||||
|
||||
### Why the gate cannot lose a face
|
||||
|
||||
Cheapness is easy; this is the part that makes it acceptable, and
|
||||
`tests/test_motion_gate.py` is the argument written down rather than asserted.
|
||||
|
||||
- It is only consulted while **no track is open**, so a person already being
|
||||
followed is never subject to it.
|
||||
- `motion_max_skip` forces a real detection about once a second whatever the
|
||||
thumbnail says. The test asserts the longest *run* of skips, not the total:
|
||||
what matters is the worst case a person can fall into, and counting the total
|
||||
would pass a gate that skipped forty frames and then looked forty times.
|
||||
- The comparison is against the last frame actually **searched**, not the last
|
||||
frame seen, so a slow drift accumulates and trips the gate instead of sliding
|
||||
under it one frame at a time. Someone easing into view slowly would otherwise
|
||||
be invisible indefinitely.
|
||||
- The threshold (1.0 mean absolute difference) sits above this camera's
|
||||
measured noise floor (~0.3) and far below a person. Anything ambiguous falls
|
||||
through to detection: when in doubt it looks.
|
||||
|
||||
Verified on the live camera after the change: `faces_seen: 0` - and the gate is
|
||||
not why. Running the detector directly over the same frames finds **0 faces at
|
||||
threshold 0.50**, let alone 0.82. The people in view are seated, side-on and
|
||||
far away, which is the same `fraction_below_gate: 0.59` this file already
|
||||
records. The placement is still the limit; the CPU was simply being spent to
|
||||
discover that 15 times a second.
|
||||
|
||||
## Three states that looked like health from outside
|
||||
|
||||
Found by auditing the engine for what it does when something goes wrong,
|
||||
rather than when it goes right. Each of these left the process healthy, the
|
||||
dashboard green and the product not working - the class of bug this file
|
||||
already calls a headcount wrong in a way nobody can detect.
|
||||
`tests/test_reliability.py` covers all three.
|
||||
|
||||
### A gallery the running encoder cannot read
|
||||
|
||||
The fallback chain exists so a memory-starved box still starts, and
|
||||
CLAUDE.md already warned that "on a memory-starved box the big model silently
|
||||
loses the chain". What it did not say is what that **costs**: embeddings are
|
||||
model-tagged, so every vector the previous encoder wrote becomes invisible.
|
||||
The shop keeps its whole customer list and recognises nobody on it. Every
|
||||
regular is greeted as a stranger and enrolled a second time. Footfall stays
|
||||
correct, which is precisely why nothing looks wrong.
|
||||
|
||||
The only evidence was an INFO line reading `gallery ready: 0 embeddings
|
||||
(model 'w600k_mbf') across 21 identities` - a sentence that says the disaster
|
||||
and calls it ready. Run against the real 87-embedding gallery with the
|
||||
fallback model forced, it now says:
|
||||
|
||||
```
|
||||
WARNING gallery: 87 of 87 stored embeddings were written by a DIFFERENT
|
||||
encoder (w600k_r50) and cannot be searched - 21 known people are
|
||||
unrecognisable under the running model 'w600k_mbf'.
|
||||
```
|
||||
|
||||
`Gallery.health` carries the same numbers to `/api/stats` and
|
||||
`gallery_unreadable_embeddings` to `/api/health`, because a log line on a shop
|
||||
PC is read by nobody. It travels for the same reason `fraction_below_gate`
|
||||
does: beside the number it qualifies. `identities_stranded` is the figure that
|
||||
matters - **people lost, not vectors** - and an empty gallery reports zero
|
||||
rather than raising an alarm on a fresh install.
|
||||
|
||||
### Connected, and sending nothing
|
||||
|
||||
`connected` meant *the socket opened*. A stream that opens and then goes quiet
|
||||
kept it `true` while `last_frame_age_s` climbed, so the heartbeat told head
|
||||
office the camera was up. OpenCV breaks a blocked read after 30s and we
|
||||
reconnect - but a camera trickling one frame every 20s never trips that at
|
||||
all, so it never reconnects and never recovers.
|
||||
|
||||
`stalled()` and `streaming` are reported beside `connected`, and the local
|
||||
dashboard now says **live / stalled / offline** rather than live / offline.
|
||||
Three states because two of them need opposite actions: offline sends you to
|
||||
the network, stalled says the camera is answering and sending nothing. Same
|
||||
rule as `artifact` vs `no_faces` in the commissioning verdicts.
|
||||
|
||||
`STALL_AFTER_S = 10` is not a preference. The tracker abandons a face after
|
||||
`max_misses` (25 frames, ~1.7s at 15 fps), so by 10s every track is long gone
|
||||
and 150 frames are missing: whatever this is, recognition cannot use it.
|
||||
|
||||
### The 5-second RTSP timeout that never existed
|
||||
|
||||
`capture.py` set `stimeout;5000000` with a comment claiming "a 5s socket
|
||||
timeout so a dead camera is noticed". Measured against this build (OpenCV
|
||||
4.11, FFmpeg 7.1) on a socket that accepts the connection and then says
|
||||
nothing:
|
||||
|
||||
```
|
||||
stimeout;5000000 -> 30.0s timeout;5000000 -> 30.0s
|
||||
stimeout;2000000 -> 30.5s timeout;2000000 -> 30.4s
|
||||
no timeout option at all -> 30.3s
|
||||
```
|
||||
|
||||
Identical with the option absent, under either name, so it was never honoured
|
||||
through this path - `stimeout` was renamed `timeout` in FFmpeg 5.0 and neither
|
||||
reaches the RTSP protocol here. The real bound is OpenCV's own interrupt
|
||||
callback, a compile-time constant we do not control. Both names are still set
|
||||
(harmless, and right on a build where they do work), but **nothing depends on
|
||||
them**.
|
||||
|
||||
What replaces it is `_tcp_reachable` in `_open()` - the pre-flight
|
||||
`probe_source` already used, in code we own. It matters beyond speed: the
|
||||
`cv2.VideoCapture` constructor is not interruptible, so `stop()` could not cut
|
||||
it short and a camera removed from head office left a daemon thread holding a
|
||||
socket for half a minute. Measured:
|
||||
|
||||
```
|
||||
unroutable address 30.3s -> 2.02s "no response from ... within 2s"
|
||||
host up, port closed 30.3s -> 0.00s "cannot reach ... Connection refused"
|
||||
wrong port, real cam 30.3s -> 1.01s "cannot reach ... Connection refused"
|
||||
```
|
||||
|
||||
`last_error` is reported with the camera, because `connected: false` alone
|
||||
cannot tell a wrong IP from a wrong password, and those are different jobs.
|
||||
|
||||
## The admin console could list merchants and see nothing inside them
|
||||
|
||||
`GET /api/admin/clients/{id}` and, under it, `/sites`, `/sites/{site}`,
|
||||
`/sites/{site}/cameras`, `/sites/{site}/cameras/{camera}`, plus
|
||||
`/api/admin/monitoring/summary`. The head-office console drills down
|
||||
merchant -> store -> camera and every level below the first showed *"Backend
|
||||
integration required"*.
|
||||
|
||||
They cannot be the tenant routes, and the reason is structural. Every tenant
|
||||
handler derives the client from the **session** - that is what makes
|
||||
cross-tenant access impossible rather than merely disallowed - and a platform
|
||||
admin has no client at all. The three workarounds each make it worse: passing
|
||||
a company id to a tenant route puts a caller-chosen tenant back in the one
|
||||
place this system refuses to take one, filtering the estate in the browser
|
||||
ships every merchant's data to render one, and signing in as the owner audits
|
||||
the wrong person. So the tenant STORE functions are reused with an explicit
|
||||
client id - they already take one - and the scoping the tenant handlers get
|
||||
from the session happens in the handler instead.
|
||||
|
||||
- **`AdminCamera` is a separate type from `Camera`**, for the same reason
|
||||
`AgentCamera` is: it cannot carry `host`, `port`, `path`, `username` or
|
||||
`has_password`. A tenant seeing those for their own camera is correct; a
|
||||
platform admin browsing another company's estate is a different question,
|
||||
and an RTSP host with a username beside it is most of a live path into a
|
||||
customer's camera. Blanking fields on a shared struct leaves "remember to
|
||||
redact, on every path, forever" as the only thing preventing a leak. The
|
||||
test asserts on the **raw JSON**, because decoding into the struct would
|
||||
discard exactly what it is looking for.
|
||||
- **An unowned site is 404, never an empty list.** `[]` says "this shop has no
|
||||
cameras" when the truth is "not your shop".
|
||||
- **Every read below the merchant list writes an audit row.** An admin is the
|
||||
one account for which nothing else here leaves a trace. The counts-only
|
||||
summary does not: a console refreshes it on a timer, and logging that buries
|
||||
the reads worth finding.
|
||||
- **A suspended merchant stays readable** - that is precisely what an admin
|
||||
opens the console to look at.
|
||||
|
||||
Alongside it, the two merchant-side reads that were only ever aggregates:
|
||||
`GET /api/sales` and `/api/sales/{id}` over the `purchases` table the
|
||||
conversion report has summed since it existed, and
|
||||
`GET /api/dashboard/summary`. No cursor on the sales list, deliberately: a
|
||||
keyset cursor needs a monotonic server-assigned column and `purchases` has
|
||||
none, so ordering by `(occurred_at, id)` with a random uuid tie-break is
|
||||
exactly the shape that dropped four of six simultaneous visits before
|
||||
`visits.seq` existed. Offering one would imply a delivery guarantee this table
|
||||
cannot make.
|
||||
|
||||
### What the fake could not catch, and the database did immediately
|
||||
|
||||
Both of these passed every in-memory test and failed on the first real call.
|
||||
|
||||
**A wrong URL answered 500.** `c.id = $1::uuid` makes Postgres cast the path
|
||||
segment, and casting a malformed string - or the empty one a shape check hands
|
||||
back in its place - is an **error**, not a miss. `c.id::text = $1` cannot fail.
|
||||
The two sibling resolvers were already written that way and correctly 404'd the
|
||||
same input: the rule was applied to two of three places, which is the shape of
|
||||
a rule that holds until somebody adds the next one. `api_admin_monitor_live_test.go`
|
||||
asserts it where the property actually lives.
|
||||
|
||||
**Five live endpoints answered 500 to a platform admin** - `/api/visits`,
|
||||
`/api/cameras`, `/api/sites`, `/api/visitors`, `/api/reports/footfall` - and had
|
||||
done since they shipped. Same cause one level up: a platform admin has no
|
||||
client, every tenant query scopes on `client_id = $1::uuid`, and `''::uuid` is
|
||||
a cast error. `tenantOnly` is the guard, beside `adminOnly` and for the
|
||||
opposite audience. **403, not 404**, because the two hide opposite things: a
|
||||
tenant must not learn a platform surface exists, while a platform admin already
|
||||
knows the tenant surface does - so the refusal names the route to use instead.
|
||||
`/api/auth/*` stays ungated: a session is not a company's data, and revoking a
|
||||
lost device must work for an account with no tenant.
|
||||
|
||||
Guarding at the chokepoint rather than per query is the point. A per-query cast
|
||||
is a fix the next query forgets, and the next query would 500 in production
|
||||
exactly as these did.
|
||||
|
||||
### Two bugs in the deploy script, both found by running it
|
||||
|
||||
- **`go: command not found` at step 1**, on the machine the script was written
|
||||
on. Go sits in a directory `.zprofile` adds and a script does not inherit. A
|
||||
deploy that needs the operator to fix their environment first is a deploy
|
||||
that gets skipped, which is the failure this script exists to end.
|
||||
- **Step 3 reported the wrong backup.** `ls | tail -1` sorts alphabetically, so
|
||||
`pre-...-demo-12` sorts before `pre-...-demo-6` and it printed a dump from
|
||||
four days earlier. A deploy that names the wrong safety net is worse than one
|
||||
that names none - that is the file somebody reaches for at the worst moment.
|
||||
- Step 7 verified five routes and none of them were the nine that had just
|
||||
shipped. It checks all of them now and treats **401 as a pass**: an
|
||||
unauthenticated call to a route that exists is refused, while one the binary
|
||||
never registered is a 404. That makes the step prove the *routing*, which is
|
||||
what a deploy gets wrong, and a missing route now fails the deploy loudly.
|
||||
|
||||
Verified live on 2026-09-28 against production (`v0.4.8-demo-14-g830c1c1`):
|
||||
merchant detail with its owner, the drill-down by slug and by uuid, camera rows
|
||||
carrying no host or username, another merchant's shop and camera both 404,
|
||||
malformed identifiers 404 rather than 500, one real sale (INR 1000, V-1) read
|
||||
back by id, and every tenant route still 200 for an ordinary tenant account.
|
||||
|
||||
## Nobody could change their own password
|
||||
|
||||
`POST /api/auth/password`. The cost of its absence was measured rather than
|
||||
argued. Rotating the three production accounts took a shell on the host, three
|
||||
round trips, and briefly left the **platform admin** — the account that reads
|
||||
every company on the estate — with the password `PASTE_IT_HERE`, because a
|
||||
placeholder in a pasted command was taken literally and there was no way to
|
||||
correct it from the product itself.
|
||||
|
||||
A manager could always reset somebody *else's* password. A platform admin could
|
||||
be reset by nobody: they have no client, so the team routes are not theirs, and
|
||||
`provision user` on the host was the only route. For software that puts
|
||||
accounts on shop-floor PCs and staff phones, this is not a feature — it is what
|
||||
makes every other credential decision recoverable.
|
||||
|
||||
- **`authed`, not `tenantOnly`.** A session is not a company's data, and the
|
||||
account with no company is precisely the one that had no route. Scoping it by
|
||||
client would have reproduced the hole it exists to close — which is also why
|
||||
`SetUserPassword` is not client-scoped the way `ResetMemberPassword` beside
|
||||
it is. The user id comes from the verified session, never the request.
|
||||
- **The current password is required**, or an access token alone takes an
|
||||
account over permanently instead of for the rest of the day.
|
||||
- **Every other session is revoked and the caller's is kept.** A failure there
|
||||
is logged, not returned: the password is already changed, and an error would
|
||||
send the user to retry with a current password that no longer exists.
|
||||
|
||||
Verified live against production: wrong current password 403 and nothing
|
||||
changed, a change revoking **45** stale sessions while the caller's own
|
||||
survived, the new password in and the old one out, then changed back.
|
||||
|
||||
### And a shell quoting trap worth not repeating
|
||||
|
||||
The first attempt to rotate the admin password from the operator's terminal
|
||||
ran with the literal string `PASTE_IT_HERE`. The second, reading the value out
|
||||
of a file, produced **no output at all** and changed nothing — `~` was not
|
||||
expanded in that eval context, `awk` could not open the file, returned
|
||||
non-zero, and `&&` short-circuited silently. Absolute paths and `;` instead of
|
||||
`&&` fixed it, and echoing the password *length* first is what proved the
|
||||
third attempt was about to set something real. A command handed to somebody to
|
||||
paste should contain nothing to edit and should fail loudly.
|
||||
|
||||
## A customer nobody has photographed, and the way back
|
||||
|
||||
`POST /api/customers` and `POST /api/visitors/{id}/merge`, shipped together
|
||||
because the first creates the need for the second. A customer typed in at a
|
||||
counter has **no face template**, so when a camera sees that person later the
|
||||
matcher has nothing to compare against and enrols them as somebody new. That is
|
||||
the design working, not failing — and it means every hand-created customer is a
|
||||
duplicate waiting to happen. Shipping the create alone would have manufactured
|
||||
duplicates into the state this file already flagged: *"there is no merge
|
||||
endpoint server-side, so its duplicates would be unrecoverable."*
|
||||
|
||||
The number comes from `clients.visitor_seq`, taken exactly as `RecordVisit`
|
||||
takes it. Two sources of visitor numbers that could disagree would be worse
|
||||
than none — `V-42` has to mean one person whichever way they arrived.
|
||||
|
||||
**The merge is one transaction over five tables, and the count is the point.**
|
||||
`visits`, `purchases`, `visitor_embeddings`, `consents` and `visitor_profiles`
|
||||
all reference visitors `ON DELETE CASCADE`, so a table it forgets to re-point
|
||||
is not an error: those rows are destroyed with the source and nobody finds out
|
||||
until a customer's history is short.
|
||||
|
||||
Policies carried from the edge gallery's merge, which settled them once
|
||||
already: a human name outranks an auto `Visitor N` whichever direction the
|
||||
operator merged; `visit_count` is recomputed with `COUNT(*)` and never summed;
|
||||
`first_seen_at` takes the earlier. Two that are this side's own: the source is
|
||||
**deleted for real** (a tombstone would leave its number resolving to a record
|
||||
holding nothing, which reads as *"exists and has never been here"*), and the
|
||||
response names the **retired reference**, because staff write `V-42` on cards.
|
||||
|
||||
### It lost a phone number on its first live run
|
||||
|
||||
Found by walking the scenario against production, not by a test. Two records
|
||||
each with a phone; the survivor kept its own and the source's stopped existing.
|
||||
|
||||
The first rule was *"fill the survivor's blanks, never overwrite"* — correct
|
||||
about which value **wins** and silent about the one that loses. One person can
|
||||
have two numbers. A merge that quietly deletes one is the same data loss this
|
||||
file already refuses: *"silently turning Alice back into Visitor 3 is data loss
|
||||
the operator cannot see happen."*
|
||||
|
||||
The profile is reconciled field by field in Go now, because the interesting
|
||||
case was never the winner. Every losing value is returned in `discarded` **and**
|
||||
appended to the survivor's notes — the response is read once, the record is read
|
||||
forever. Notes are additive rather than a winner: two people writing about one
|
||||
customer wrote two different true things.
|
||||
|
||||
And retained was not enough. `SearchVisitors` did not look at notes, so the
|
||||
number was kept and **unfindable** — the letter of "nothing is lost" without the
|
||||
point of it. Search covers notes now, which is what makes a customer reached by
|
||||
their old number the one who comes back.
|
||||
|
||||
One bug caught in that same patch and worth the warning: the new clause was
|
||||
written `ESCAPE` with two backslashes where the four beside it use one. In a Go
|
||||
raw string that is two literal characters and Postgres requires exactly one —
|
||||
it would have failed the **whole** customer search at runtime, on a query no
|
||||
in-memory test executes.
|
||||
|
||||
## Setting up on a new machine
|
||||
|
||||
1. Copy the `Behavision` folder **including `.env`** (gitignored, holds
|
||||
|
||||
@@ -92,6 +92,12 @@ func run() error {
|
||||
}
|
||||
}
|
||||
|
||||
if running := behavisionRunning(); running != "" {
|
||||
return fmt.Errorf("%s is running. Quit Behavision from the tray icon first, then run setup again.\n\n"+
|
||||
"Setting up underneath a running copy starts a second engine on the same port and, in a demo,\n"+
|
||||
"re-claims the shop while the open app still holds the old credentials.", running)
|
||||
}
|
||||
|
||||
py, ver, err := findPython()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -136,6 +142,16 @@ func run() error {
|
||||
// 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.
|
||||
// Joining a shop: head office supplies the cameras, so any left on this PC
|
||||
// from an earlier install go first. Otherwise the reconciler offers them UP
|
||||
// to head office - without their passwords, which the engine never returns
|
||||
// - and the shop ends up with the same lens listed twice, one copy of which
|
||||
// can never be pushed to another PC. Measured on the first claimed demo.
|
||||
if bundle != nil && bundle.EnrolCode != "" {
|
||||
if err := os.Remove(paths.CamerasFile()); err == nil {
|
||||
step("Earlier cameras", "removed - head office supplies them now")
|
||||
}
|
||||
}
|
||||
if err := smokeTest(vpy, demoCams); err != nil {
|
||||
return fmt.Errorf("the engine installed but would not start: %w", err)
|
||||
}
|
||||
@@ -200,6 +216,22 @@ func engineSource() (string, error) {
|
||||
// `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.
|
||||
// behavisionRunning names a Behavision process if one is up. Windows only -
|
||||
// that is the platform setup ships on - and by image name via tasklist, which
|
||||
// needs no extra privilege.
|
||||
func behavisionRunning() string {
|
||||
if runtime.GOOS != "windows" {
|
||||
return ""
|
||||
}
|
||||
for _, name := range []string{"Behavision.exe", "behavision-agent.exe"} {
|
||||
out, err := exec.Command("tasklist", "/FI", "IMAGENAME eq "+name, "/NH").Output()
|
||||
if err == nil && strings.Contains(strings.ToLower(string(out)), strings.ToLower(name)) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findPython() (string, string, error) {
|
||||
type cand struct {
|
||||
exe string
|
||||
@@ -523,6 +555,7 @@ func claimShop(code, base string) (string, error) {
|
||||
cfg.AgentToken = b.AgentToken
|
||||
cfg.CloudBase = base
|
||||
cfg.Standalone = false
|
||||
cfg.SessionToken, cfg.SessionRefresh, cfg.SessionEmail = "", "", ""
|
||||
caPath, err := enrol.SaveCA(b.CACert, paths.BrokerCA())
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -119,6 +119,7 @@ func cmdClaim(args []string) error {
|
||||
cfg.BrokerPassword = b.MQTTPass
|
||||
cfg.AgentToken = b.AgentToken
|
||||
cfg.CloudBase = base
|
||||
cfg.SessionToken, cfg.SessionRefresh, cfg.SessionEmail = "", "", ""
|
||||
caPath, err := enrol.SaveCA(b.CACert, paths.BrokerCA())
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -160,6 +161,7 @@ func cmdStatus() error {
|
||||
// which is the default. Reading it here is what stops every call the agent
|
||||
// makes to the engine coming back 401 on a stock install.
|
||||
cfg = cfg.WithEngineCredentials(paths.APICredentials())
|
||||
creds := config.NewCreds(paths.APICredentials(), cfg.APIUser, cfg.APIPassword)
|
||||
q, err := spool.Open(paths.SpoolDir(), cfg.SpoolMax)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -168,7 +170,7 @@ func cmdStatus() error {
|
||||
Command: func(context.Context) *exec.Cmd { return nil },
|
||||
HealthURL: strings.TrimRight(cfg.APIBase, "/") + "/api/health",
|
||||
StatsURL: strings.TrimRight(cfg.APIBase, "/") + "/api/stats",
|
||||
User: cfg.APIUser, Password: cfg.APIPassword,
|
||||
User: cfg.APIUser, Password: cfg.APIPassword, Creds: creds,
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -203,6 +205,7 @@ func cmdRun() error {
|
||||
// which is the default. Reading it here is what stops every call the agent
|
||||
// makes to the engine coming back 401 on a stock install.
|
||||
cfg = cfg.WithEngineCredentials(paths.APICredentials())
|
||||
creds := config.NewCreds(paths.APICredentials(), cfg.APIUser, cfg.APIPassword)
|
||||
// Opened before the engine starts: detections arriving in the first second
|
||||
// must have somewhere to land.
|
||||
q, err := spool.Open(paths.SpoolDir(), cfg.SpoolMax)
|
||||
@@ -228,6 +231,14 @@ func cmdRun() error {
|
||||
sup := engine.New(engine.Options{
|
||||
Command: func(ctx context.Context) *exec.Cmd {
|
||||
cmd := exec.CommandContext(ctx, exe, cfg.EngineArgs...)
|
||||
// Run the engine FROM a known directory rather than from whatever
|
||||
// happened to launch us. A double-clicked bundle hands its child
|
||||
// "/", and an engine invoked as `-m behavision` then cannot find
|
||||
// itself - measured on macOS, where it retried forever.
|
||||
cmd.Dir = cfg.EngineDir
|
||||
if cmd.Dir == "" {
|
||||
cmd.Dir = paths.InstallRoot()
|
||||
}
|
||||
// How the engine learns where to send detections. The engine's
|
||||
// config already reads `events.webhook_url: ${BEHAVISION_WEBHOOK_URL}`
|
||||
// and python-dotenv does not override a variable the process
|
||||
@@ -243,7 +254,7 @@ func cmdRun() error {
|
||||
LogWriter: logFile,
|
||||
HealthURL: strings.TrimRight(cfg.APIBase, "/") + "/api/health",
|
||||
StatsURL: strings.TrimRight(cfg.APIBase, "/") + "/api/stats",
|
||||
User: cfg.APIUser, Password: cfg.APIPassword,
|
||||
User: cfg.APIUser, Password: cfg.APIPassword, Creds: creds,
|
||||
})
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(),
|
||||
@@ -278,6 +289,7 @@ func cmdRun() error {
|
||||
cloud := cameras.NewCloudClient(cfg.CloudBase, cfg.AgentToken)
|
||||
cloud.Upload = uploader.UploadBytes
|
||||
eng := cameras.NewEngineClient(cfg.APIBase, cfg.APIUser, cfg.APIPassword)
|
||||
eng.Creds = creds
|
||||
go cameras.New(eng, cloud, logger).Run(ctx)
|
||||
// The live relay, which uploads nothing until somebody at head office is
|
||||
// actually watching a camera.
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/bridge"
|
||||
"github.com/loyaly/behavision-agent/pkg/config"
|
||||
)
|
||||
|
||||
// EngineClient talks to the recognition engine on this PC's loopback.
|
||||
@@ -21,7 +22,12 @@ type EngineClient struct {
|
||||
Base string
|
||||
User string
|
||||
Password string
|
||||
Client *http.Client
|
||||
// Creds re-reads the engine's generated credential when one is rejected.
|
||||
// Without it a fresh install is 401 for the life of the process: the agent
|
||||
// starts the engine, and the engine writes its credential file seconds
|
||||
// after the agent has already read (and failed to find) it.
|
||||
Creds *config.Creds
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func NewEngineClient(base, user, password string) *EngineClient {
|
||||
@@ -50,14 +56,24 @@ func (e *EngineClient) do(ctx context.Context, method, path string, body, out an
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if e.User != "" {
|
||||
req.SetBasicAuth(e.User, e.Password)
|
||||
user, pass := e.User, e.Password
|
||||
if e.Creds != nil {
|
||||
user, pass = e.Creds.Get()
|
||||
}
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := e.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized && e.Creds != nil && e.Creds.Refresh() {
|
||||
// The engine generated its credential after we last looked. Read it
|
||||
// and try once more rather than failing for the life of the process.
|
||||
resp.Body.Close()
|
||||
return e.do(ctx, method, path, body, out)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// The engine's message, not just a status. "camera stored but failed to
|
||||
// start: connection refused" is something an operator can act on;
|
||||
|
||||
@@ -37,7 +37,18 @@ type Config struct {
|
||||
BrokerCAFile string `json:"broker_ca_file"`
|
||||
|
||||
// Engine process.
|
||||
EngineExe string `json:"engine_exe"`
|
||||
EngineExe string `json:"engine_exe"`
|
||||
// EngineDir is the working directory the engine is launched IN. Empty
|
||||
// means the install root.
|
||||
//
|
||||
// It exists because nothing set it, so the engine inherited whatever
|
||||
// launched the app - and for an app started by double-clicking its
|
||||
// bundle that is "/", not anywhere useful. The symptom on macOS was
|
||||
// `python: No module named behavision` repeating forever: the dev engine
|
||||
// is `-m behavision`, which resolves against the working directory. The
|
||||
// same app started from a terminal in the repo worked, which is exactly
|
||||
// the shape of a bug that survives every test run by a developer.
|
||||
EngineDir string `json:"engine_dir,omitempty"`
|
||||
EngineArgs []string `json:"engine_args"`
|
||||
APIBase string `json:"api_base"`
|
||||
APIUser string `json:"api_user"`
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// EngineCredentials reads the Basic credentials the engine generated for
|
||||
@@ -60,3 +61,60 @@ func (c Config) WithEngineCredentials(path string) Config {
|
||||
c.APIUser, c.APIPassword = EngineCredentials(path)
|
||||
return c
|
||||
}
|
||||
|
||||
// Creds resolves the engine's Basic credentials, re-reading the file when it
|
||||
// has none.
|
||||
//
|
||||
// Reading once at startup is wrong on a fresh install, and that is the case
|
||||
// that matters: the agent starts the engine, the engine generates its
|
||||
// credential and writes the file a few seconds later, and an agent that read
|
||||
// the file before that holds "" forever. Every call it makes - health, stats,
|
||||
// camera sync, the embedding for a visit - then comes back 401 for the life of
|
||||
// the process, on a brand new shop PC, with the tray showing a red engine that
|
||||
// is running perfectly. Measured on a fresh state directory: three 401s and no
|
||||
// camera ever reconciled.
|
||||
//
|
||||
// A configured credential is never re-read: an operator who set
|
||||
// BEHAVISION_API_USER means it.
|
||||
type Creds struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
user string
|
||||
pass string
|
||||
fixed bool
|
||||
}
|
||||
|
||||
// NewCreds takes whatever the config already has. Non-empty means configured,
|
||||
// and is used unchanged.
|
||||
func NewCreds(path, user, password string) *Creds {
|
||||
c := &Creds{path: path, user: user, pass: password}
|
||||
c.fixed = user != "" || password != ""
|
||||
return c
|
||||
}
|
||||
|
||||
// Get returns the current pair, reading the file if it has nothing yet.
|
||||
func (c *Creds) Get() (string, string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.user == "" && !c.fixed {
|
||||
c.user, c.pass = EngineCredentials(c.path)
|
||||
}
|
||||
return c.user, c.pass
|
||||
}
|
||||
|
||||
// Refresh re-reads the file after a rejection and reports whether the pair
|
||||
// changed. Callers retry once when it did - which covers both the fresh-install
|
||||
// race and a credential the engine regenerated under a running agent.
|
||||
func (c *Creds) Refresh() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.fixed {
|
||||
return false
|
||||
}
|
||||
u, p := EngineCredentials(c.path)
|
||||
if u == c.user && p == c.pass {
|
||||
return false
|
||||
}
|
||||
c.user, c.pass = u, p
|
||||
return u != ""
|
||||
}
|
||||
|
||||
77
agent/pkg/config/credentials_firstrun_test.go
Normal file
77
agent/pkg/config/credentials_firstrun_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The sequence on a brand new shop PC, in order:
|
||||
//
|
||||
// agent starts -> file does not exist yet
|
||||
// agent starts the engine
|
||||
// engine generates its credential and writes the file
|
||||
// agent calls the engine -> must now succeed
|
||||
//
|
||||
// Read once at startup, the agent holds "" for the life of the process and
|
||||
// every engine call is 401: health, stats, camera sync, the embedding for a
|
||||
// visit. The tray shows a red engine that is running perfectly, and nothing
|
||||
// says why. Measured on a fresh state directory before this existed.
|
||||
func TestCredentialsArriveAfterTheAgentHasAlreadyLooked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "api_credentials.txt")
|
||||
|
||||
creds := NewCreds(path, "", "") // nothing configured, file not there yet
|
||||
if u, _ := creds.Get(); u != "" {
|
||||
t.Fatalf("expected no credential before the engine has written one, got %q", u)
|
||||
}
|
||||
|
||||
// the engine starts and writes its credential
|
||||
if err := os.WriteFile(path, []byte("username=behavision\npassword=s3cret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// a 401 makes the agent look again
|
||||
if !creds.Refresh() {
|
||||
t.Fatal("Refresh did not pick up the credential the engine just wrote")
|
||||
}
|
||||
u, p := creds.Get()
|
||||
if u != "behavision" || p != "s3cret" {
|
||||
t.Fatalf("got %q/%q", u, p)
|
||||
}
|
||||
}
|
||||
|
||||
// An operator who set BEHAVISION_API_USER means it, and a file must never
|
||||
// override them.
|
||||
func TestAConfiguredCredentialIsNeverReplacedByTheFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "api_credentials.txt")
|
||||
if err := os.WriteFile(path, []byte("username=generated\npassword=nope\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
creds := NewCreds(path, "chosen", "byhand")
|
||||
if u, p := creds.Get(); u != "chosen" || p != "byhand" {
|
||||
t.Fatalf("configured credential was replaced: %q/%q", u, p)
|
||||
}
|
||||
if creds.Refresh() {
|
||||
t.Fatal("Refresh overrode a configured credential")
|
||||
}
|
||||
}
|
||||
|
||||
// A credential the engine regenerates under a running agent is picked up too -
|
||||
// the same mechanism, and the reason paths.APICredentials says the agent reads
|
||||
// the file "rather than storing a second copy".
|
||||
func TestARegeneratedCredentialIsPickedUp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "api_credentials.txt")
|
||||
os.WriteFile(path, []byte("username=behavision\npassword=old\n"), 0o600)
|
||||
creds := NewCreds(path, "", "")
|
||||
creds.Get()
|
||||
os.WriteFile(path, []byte("username=behavision\npassword=new\n"), 0o600)
|
||||
if !creds.Refresh() {
|
||||
t.Fatal("a regenerated password was not picked up")
|
||||
}
|
||||
if _, p := creds.Get(); p != "new" {
|
||||
t.Fatalf("still holding %q", p)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,12 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/config"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -56,6 +61,9 @@ type Options struct {
|
||||
// no captured output is undiagnosable, which on a customer site means a
|
||||
// site visit.
|
||||
LogWriter io.Writer
|
||||
// Creds re-reads the engine's generated credential when one is rejected,
|
||||
// which is the ordinary case on a first run.
|
||||
Creds *config.Creds
|
||||
// HealthURL, StatsURL, User, Password address the engine's own API.
|
||||
HealthURL string
|
||||
StatsURL string
|
||||
@@ -76,6 +84,7 @@ type Supervisor struct {
|
||||
restarts int
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
progress Progress
|
||||
}
|
||||
|
||||
func New(opts Options) *Supervisor {
|
||||
@@ -109,6 +118,37 @@ func (s *Supervisor) Start() {
|
||||
go s.supervise(ctx, done)
|
||||
}
|
||||
|
||||
// Progress is what the engine is busy with before it answers - on first run,
|
||||
// downloading ~275 MB of models. Empty once the engine is up.
|
||||
type Progress struct {
|
||||
What string `json:"what"`
|
||||
Percent int `json:"percent"`
|
||||
}
|
||||
|
||||
var progressRe = regexp.MustCompile(`download: (.+?) (\d{1,3})%`)
|
||||
|
||||
func (s *Supervisor) noteProgress(line string) {
|
||||
m := progressRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
pct, _ := strconv.Atoi(m[2])
|
||||
s.mu.Lock()
|
||||
if pct >= 100 {
|
||||
s.progress = Progress{}
|
||||
} else {
|
||||
s.progress = Progress{What: m[1], Percent: pct}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Progress reports the current first-run download, if any.
|
||||
func (s *Supervisor) Progress() Progress {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.progress
|
||||
}
|
||||
|
||||
// Stop asks the engine to exit and waits for it.
|
||||
func (s *Supervisor) Stop() {
|
||||
s.mu.Lock()
|
||||
@@ -218,18 +258,35 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
|
||||
kill = k
|
||||
defer release()
|
||||
|
||||
// The last few lines the engine printed travel with the failure, because
|
||||
// "engine exited: exit status 1" sends somebody to a log file on a shop
|
||||
// PC, and the one line that matters - "port 8010 is already in use" - was
|
||||
// right there.
|
||||
var tailMu sync.Mutex
|
||||
var tail []string
|
||||
pumped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(pumped)
|
||||
sc := bufio.NewScanner(stdout)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
fmt.Fprintln(s.opts.LogWriter, sc.Text())
|
||||
line := sc.Text()
|
||||
fmt.Fprintln(s.opts.LogWriter, line)
|
||||
s.noteProgress(line)
|
||||
tailMu.Lock()
|
||||
tail = append(tail, line)
|
||||
if len(tail) > 12 {
|
||||
tail = tail[1:]
|
||||
}
|
||||
tailMu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
s.setState(Running, nil)
|
||||
waitErr := cmd.Wait()
|
||||
s.mu.Lock()
|
||||
s.progress = Progress{}
|
||||
s.mu.Unlock()
|
||||
<-pumped
|
||||
|
||||
// A context cancel terminates the child through exec's own handling; the
|
||||
@@ -238,6 +295,12 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
if waitErr != nil {
|
||||
tailMu.Lock()
|
||||
reason := explain(tail)
|
||||
tailMu.Unlock()
|
||||
if reason != "" {
|
||||
return fmt.Errorf("%s (%v)", reason, waitErr)
|
||||
}
|
||||
return fmt.Errorf("engine exited: %w", waitErr)
|
||||
}
|
||||
return errors.New("engine exited unexpectedly with status 0")
|
||||
@@ -351,14 +414,24 @@ func (s *Supervisor) getJSON(ctx context.Context, url string, out any) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.opts.User != "" {
|
||||
req.SetBasicAuth(s.opts.User, s.opts.Password)
|
||||
user, pass := s.opts.User, s.opts.Password
|
||||
if s.opts.Creds != nil {
|
||||
user, pass = s.opts.Creds.Get()
|
||||
}
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized && s.opts.Creds != nil && s.opts.Creds.Refresh() {
|
||||
// See cameras.EngineClient: on a fresh install the engine writes its
|
||||
// credential after the agent has already read for one.
|
||||
resp.Body.Close()
|
||||
return s.getJSON(ctx, url, out)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s returned %s", url, resp.Status)
|
||||
}
|
||||
@@ -373,3 +446,28 @@ func LogFile(path string) (*os.File, error) {
|
||||
}
|
||||
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
}
|
||||
|
||||
// explain turns the engine's last output into the sentence the tray shows.
|
||||
// The cases are the ones seen on real installs; anything else shows the last
|
||||
// non-empty line verbatim.
|
||||
func explain(tail []string) string {
|
||||
last := ""
|
||||
for _, l := range tail {
|
||||
low := strings.ToLower(l)
|
||||
switch {
|
||||
case strings.Contains(low, "address already in use") || strings.Contains(low, "only one usage of each socket address"):
|
||||
return "port 8010 is already in use - another Behavision or its engine is still running"
|
||||
case strings.Contains(low, "no module named behavision"):
|
||||
return "the engine is not installed in this Python - run behavision-setup again"
|
||||
case strings.Contains(low, "modulenotfounderror") || strings.Contains(low, "importerror"):
|
||||
return "the engine is missing a library - run behavision-setup again"
|
||||
}
|
||||
if strings.TrimSpace(l) != "" {
|
||||
last = strings.TrimSpace(l)
|
||||
}
|
||||
}
|
||||
if len(last) > 120 {
|
||||
last = last[:120] + "…"
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
@@ -70,6 +70,12 @@ func APICredentials() string {
|
||||
return filepath.Join(StateRoot(), "data", "api_credentials.txt")
|
||||
}
|
||||
|
||||
// CamerasFile is the engine's own camera store. The agent never edits it -
|
||||
// cameras go through the engine's API so passwords are sealed - but setup
|
||||
// removes it when a PC joins a shop, because head office is the source of
|
||||
// truth from then on.
|
||||
func CamerasFile() string { return filepath.Join(StateRoot(), "data", "cameras.json") }
|
||||
|
||||
// EnsureState creates the writable tree. Called before anything opens a file
|
||||
// under it, so a first run on a fresh machine does not fail on a missing dir.
|
||||
func EnsureState() error {
|
||||
|
||||
@@ -21,6 +21,17 @@ def cmd_run(args: argparse.Namespace) -> int:
|
||||
|
||||
cfg = load_config(args.config)
|
||||
setup_logging(cfg.app.log_level, cfg.app.data_dir)
|
||||
if cfg.app.detect_threads > 0:
|
||||
# OpenCV sizes its pool for one big job on an idle machine. This is a
|
||||
# small job repeated forever on a machine also running the recogniser,
|
||||
# the tracker and possibly three other cameras, so the default costs
|
||||
# twice the CPU for no useful latency. Measured: 31 ms CPU/frame at the
|
||||
# default against 15 ms at one thread, for 6 ms more wall time against
|
||||
# a 66 ms budget.
|
||||
import cv2
|
||||
cv2.setNumThreads(cfg.app.detect_threads)
|
||||
log.info("detection threads: %d (OpenCV default was %d)",
|
||||
cfg.app.detect_threads, cv2.getNumThreads())
|
||||
missing = setup_models(cfg.app.models_dir)
|
||||
if missing:
|
||||
log.error("required models missing: %s", ", ".join(missing))
|
||||
|
||||
@@ -167,8 +167,13 @@ def create_app(engine: Engine) -> FastAPI:
|
||||
@app.get("/api/health")
|
||||
def health() -> dict:
|
||||
from .paths import describe
|
||||
# A gallery the running encoder cannot read is the failure most
|
||||
# worth catching from outside: the process is healthy, the cameras
|
||||
# are up, and the shop recognises nobody it already knows.
|
||||
stranded = engine.gallery.health["stranded"]
|
||||
return {"status": "ok" if engine.started_at else "starting",
|
||||
"recognition_model": engine.encoder.model_name,
|
||||
"gallery_unreadable_embeddings": stranded,
|
||||
# "where is my database" must be answerable from the API: the
|
||||
# tray, the installer and support all need it, and installed
|
||||
# it is not next to the code.
|
||||
@@ -332,6 +337,17 @@ def create_app(engine: Engine) -> FastAPI:
|
||||
worker.commission.cancel()
|
||||
return {"cancelled": camera_id}
|
||||
|
||||
@app.get("/api/cameras/discover")
|
||||
def discover_cameras() -> dict:
|
||||
"""Cameras on this PC's network, for the add-camera form to pick from.
|
||||
|
||||
A sync def so FastAPI runs it in the threadpool: it holds a socket
|
||||
open for a couple of seconds and sweeps a /24, and the event loop
|
||||
must keep serving the live picture meanwhile.
|
||||
"""
|
||||
from .discover import discover
|
||||
return discover()
|
||||
|
||||
@app.post("/api/cameras/test")
|
||||
def test_camera(payload: CameraPayload) -> dict:
|
||||
"""Try a camera WITHOUT saving it - the UI's Test button.
|
||||
|
||||
@@ -20,7 +20,26 @@ log = logging.getLogger(__name__)
|
||||
# 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.
|
||||
# Wi-Fi.
|
||||
#
|
||||
# The timeout here is NOT what bounds a dead camera, and the comment that
|
||||
# once said it did was wrong. Measured against OpenCV 4.11 / FFmpeg 7.1 on a
|
||||
# socket that accepts the connection and then says nothing:
|
||||
#
|
||||
# stimeout;5000000 -> 30.0s timeout;5000000 -> 30.0s
|
||||
# stimeout;2000000 -> 30.5s timeout;2000000 -> 30.4s
|
||||
# no timeout option at all -> 30.3s
|
||||
#
|
||||
# Identical with the option absent, so it is not being honoured under either
|
||||
# name through this path. `stimeout` was renamed `timeout` in FFmpeg 5.0, and
|
||||
# neither reaches the RTSP protocol here. What actually bounds it is
|
||||
# OpenCV's own interrupt callback (30s for open, 30s for read), which is a
|
||||
# compile-time constant we do not control.
|
||||
#
|
||||
# Both names are still set, because on a build where they DO take effect the
|
||||
# shorter bound is what we want and an unrecognised option is ignored. But
|
||||
# nothing may depend on it: a wrong address is caught by _tcp_reachable
|
||||
# below, in code we own, in under a second.
|
||||
#
|
||||
# fflags=nobuffer and flags=low_delay: without them ffmpeg's RTSP demuxer
|
||||
# holds a comfortable queue of frames before handing over the first, which
|
||||
@@ -30,10 +49,25 @@ log = logging.getLogger(__name__)
|
||||
# reorder wait for the same reason.
|
||||
os.environ.setdefault(
|
||||
"OPENCV_FFMPEG_CAPTURE_OPTIONS",
|
||||
"rtsp_transport;tcp|stimeout;5000000|fflags;nobuffer|flags;low_delay|max_delay;200000",
|
||||
"rtsp_transport;tcp|stimeout;5000000|timeout;5000000"
|
||||
"|fflags;nobuffer|flags;low_delay|max_delay;200000",
|
||||
)
|
||||
|
||||
|
||||
# A stream can stay open and stop delivering. OpenCV breaks a blocked read
|
||||
# after 30s and we reconnect, but for those 30s `connected` is True and the
|
||||
# camera is dead — and a stream that trickles a frame every 20s never trips
|
||||
# that timeout at all, so it never reconnects and never recovers either.
|
||||
#
|
||||
# 10s is not a preference. The tracker gives up on a face after `max_misses`
|
||||
# (25 frames, ~1.7s at 15 fps), so by 10s every track is long gone and 150
|
||||
# frames are missing: whatever this is, it is not something recognition can
|
||||
# work with. Reported separately from `connected` because the two need
|
||||
# opposite actions — one says check the network, the other says the camera
|
||||
# is answering but sending nothing.
|
||||
STALL_AFTER_S = 10.0
|
||||
|
||||
|
||||
def _tcp_reachable(source: "str | int", timeout: float
|
||||
) -> "tuple[bool, str]":
|
||||
"""Cheap pre-flight for an rtsp:// URL. Non-URL sources pass through."""
|
||||
@@ -169,6 +203,10 @@ class VideoSource(threading.Thread):
|
||||
self.frames_total = 0
|
||||
self.reconnects = 0
|
||||
self._ever_connected = False
|
||||
# Why the last open failed, in the words an installer can act on.
|
||||
# Without it a camera that never connects reports only `connected:
|
||||
# false`, which cannot distinguish a wrong IP from a wrong password.
|
||||
self.last_error = ""
|
||||
|
||||
# -- public ---------------------------------------------------------
|
||||
def latest(self) -> "tuple[Optional[np.ndarray], float]":
|
||||
@@ -199,11 +237,23 @@ class VideoSource(threading.Thread):
|
||||
def stop(self) -> None:
|
||||
self._stopping.set()
|
||||
|
||||
def stalled(self) -> bool:
|
||||
"""Open, but not delivering. See STALL_AFTER_S."""
|
||||
if not self.connected or not self._frame_ts:
|
||||
return False
|
||||
return (time.time() - self._frame_ts) > STALL_AFTER_S
|
||||
|
||||
def stats(self) -> dict:
|
||||
return {
|
||||
"camera_id": self.camera_id,
|
||||
"url": self._display_url,
|
||||
"connected": self.connected,
|
||||
# Connected AND delivering. `connected` alone stays true through
|
||||
# a stall, so it is the wrong thing for a dashboard to colour a
|
||||
# camera green on.
|
||||
"streaming": self.connected and not self.stalled(),
|
||||
"stalled": self.stalled(),
|
||||
"last_error": self.last_error,
|
||||
"frames_total": self.frames_total,
|
||||
"reconnects": self.reconnects,
|
||||
"last_frame_age_s": round(time.time() - self._frame_ts, 1)
|
||||
@@ -260,6 +310,19 @@ class VideoSource(threading.Thread):
|
||||
log.info("[%s] capture stopped", self.camera_id)
|
||||
|
||||
def _open(self) -> Optional[cv2.VideoCapture]:
|
||||
# Pre-flight the socket, exactly as probe_source does. Without it a
|
||||
# camera that is off, moved or mistyped costs 30s per attempt inside
|
||||
# the VideoCapture constructor (measured; it is OpenCV's interrupt
|
||||
# timeout, not ours to shorten) — and the constructor is not
|
||||
# interruptible, so stop() cannot cut it short and a removed camera
|
||||
# leaves a daemon thread holding a socket for half a minute. A
|
||||
# refused or unroutable address answers in well under a second, which
|
||||
# is also what lets the backoff below mean what it says.
|
||||
reachable, why = _tcp_reachable(self._source, 2.0)
|
||||
if not reachable:
|
||||
log.debug("[%s] %s", self.camera_id, why)
|
||||
self.last_error = why
|
||||
return None
|
||||
try:
|
||||
if isinstance(self._source, int):
|
||||
cap = cv2.VideoCapture(self._source)
|
||||
@@ -268,8 +331,12 @@ class VideoSource(threading.Thread):
|
||||
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||
if not cap.isOpened():
|
||||
cap.release()
|
||||
self.last_error = ("reachable, but the stream would not open "
|
||||
"- check the path and credentials")
|
||||
return None
|
||||
self.last_error = ""
|
||||
return cap
|
||||
except cv2.error:
|
||||
log.exception("[%s] VideoCapture error", self.camera_id)
|
||||
self.last_error = "VideoCapture error - see the engine log"
|
||||
return None
|
||||
|
||||
@@ -132,6 +132,29 @@ class AppSection(BaseModel):
|
||||
# on changes what the system is under GDPR and India's DPDP, so it has to
|
||||
# be a decision somebody makes rather than one they inherit.
|
||||
store_faces: bool = False
|
||||
# How many threads OpenCV may use for detection. Measured on the office
|
||||
# camera (800x448 sub-stream): the default of 8 costs 31 ms of CPU per
|
||||
# frame for 8.9 ms of wall time, while ONE thread costs 15.3 ms of CPU for
|
||||
# 15.3 ms of wall - half the CPU for 6 ms more latency, against a 66 ms
|
||||
# frame budget at 15 fps. The default is wrong here because OpenCV sizes it
|
||||
# for one big job on an idle machine, and this is a small job repeated
|
||||
# forever on a machine also running the recogniser, the tracker and three
|
||||
# other cameras. 0 leaves OpenCV's own default alone.
|
||||
detect_threads: int = 1
|
||||
# Skip detection on frames where nothing has changed and nothing is being
|
||||
# tracked. A shop is empty most of the day and a frame of an empty room
|
||||
# costs exactly as much to search as a busy one. See CameraWorker.run for
|
||||
# why this cannot lose a face.
|
||||
motion_gate: bool = True
|
||||
# Mean absolute difference, 0-255, over a 160x90 greyscale thumbnail. 1.0
|
||||
# is well below the noise floor of a real camera - measured on this one,
|
||||
# an empty room varies by ~0.3 between frames - so it triggers on movement
|
||||
# rather than on sensor noise, and anything ambiguous detects.
|
||||
motion_threshold: float = 1.0
|
||||
# Detect at least this often regardless of the gate, so a change the
|
||||
# thumbnail cannot see - someone entering at the far edge, a slow lean into
|
||||
# frame - is still found within a second.
|
||||
motion_max_skip: int = 12
|
||||
|
||||
|
||||
class ApiSection(BaseModel):
|
||||
|
||||
206
behavision/discover.py
Normal file
206
behavision/discover.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""Find the cameras on the shop's network, so nobody has to type an address.
|
||||
|
||||
The add-camera form asked for an IP address, and a shop owner does not know
|
||||
their camera's IP address. It is on a sticker under the camera, if at all, or
|
||||
inside the camera's own app under a menu called something different for every
|
||||
make. That one field is where onboarding stopped for anyone who was not an
|
||||
installer.
|
||||
|
||||
Two probes, merged:
|
||||
|
||||
- **WS-Discovery** (ONVIF's discovery protocol): one multicast to
|
||||
239.255.255.250:3702 and every ONVIF camera on the LAN answers with its
|
||||
address and, usually, its make and model. Cheap, fast, and names the device
|
||||
- but only cameras that speak ONVIF answer, and some cheap ones do not.
|
||||
- **A TCP sweep of port 554** across the local /24: anything listening on the
|
||||
RTSP port is very probably a camera or a recorder. Names nothing, misses
|
||||
nothing that streams.
|
||||
|
||||
A host found by either is a candidate; one found by both is a camera with a
|
||||
name. The result is a list to pick from, not a decision: the person still
|
||||
supplies the password, and Test still proves the stream opens.
|
||||
|
||||
Stdlib only. This runs inside the engine, which ships as a small wheel, and a
|
||||
network-scanning dependency would be a large thing to add for two sockets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import socket
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Iterable
|
||||
|
||||
# ONVIF WS-Discovery probe. The MessageID must be unique per probe; devices
|
||||
# ignore a repeat.
|
||||
_PROBE = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<e:Envelope xmlns:e="http://www.w3.org/2003/05/soap-envelope" '
|
||||
'xmlns:w="http://schemas.xmlsoap.org/ws/2004/08/addressing" '
|
||||
'xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery" '
|
||||
'xmlns:dn="http://www.onvif.org/ver10/network/wsdl">'
|
||||
'<e:Header><w:MessageID>uuid:{mid}</w:MessageID>'
|
||||
'<w:To e:mustUnderstand="true">urn:schemas-xmlsoap-org:ws:2005:04:discovery</w:To>'
|
||||
'<w:Action e:mustUnderstand="true">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</w:Action>'
|
||||
'</e:Header><e:Body><d:Probe><d:Types>dn:NetworkVideoTransmitter</d:Types></d:Probe></e:Body>'
|
||||
'</e:Envelope>'
|
||||
)
|
||||
_MCAST = ("239.255.255.250", 3702)
|
||||
|
||||
# Makes we can name from an ONVIF scope or hostname, mapped to the ids the
|
||||
# camera-make picker uses so the form can preselect the stream path.
|
||||
_MAKES = (
|
||||
("hikvision", "hikvision"), ("hik", "hikvision"), ("dahua", "dahua"),
|
||||
("cp plus", "cpplus"), ("cpplus", "cpplus"), ("cp-plus", "cpplus"),
|
||||
("uniview", "uniview"), ("unv", "uniview"), ("tapo", "tplink"),
|
||||
("tp-link", "tplink"), ("reolink", "reolink"), ("amcrest", "amcrest"),
|
||||
("axis", "axis"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Found:
|
||||
host: str
|
||||
rtsp: bool = False # port 554 answered
|
||||
onvif: bool = False # answered WS-Discovery
|
||||
name: str = "" # from ONVIF scopes, e.g. "Hikvision DS-2CD2043"
|
||||
make: str = "" # picker id, when it can be guessed
|
||||
onvif_url: str = ""
|
||||
sources: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def local_networks() -> list[ipaddress.IPv4Network]:
|
||||
"""The /24s this machine sits on, best effort and without dependencies.
|
||||
|
||||
Interface masks are not portable in the stdlib, so this assumes /24 - the
|
||||
shape of nearly every shop's router - for each local IPv4 address it can
|
||||
find. A bigger network would need a scan anyway that this should not run
|
||||
unasked.
|
||||
"""
|
||||
addrs: set[str] = set()
|
||||
try:
|
||||
# The address the OS would use to reach the internet: the LAN we care about.
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.settimeout(0.5)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
addrs.add(s.getsockname()[0])
|
||||
s.close()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
for a in socket.gethostbyname_ex(socket.gethostname())[2]:
|
||||
addrs.add(a)
|
||||
except OSError:
|
||||
pass
|
||||
nets = []
|
||||
for a in addrs:
|
||||
try:
|
||||
ip = ipaddress.IPv4Address(a)
|
||||
except ValueError:
|
||||
continue
|
||||
if ip.is_loopback or ip.is_link_local:
|
||||
continue
|
||||
nets.append(ipaddress.IPv4Network(f"{a}/24", strict=False))
|
||||
return sorted(set(nets), key=str)
|
||||
|
||||
|
||||
def ws_discover(timeout: float = 2.5) -> list[Found]:
|
||||
"""One ONVIF probe, every answer within `timeout` seconds."""
|
||||
out: dict[str, Found] = {}
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
||||
sock.settimeout(timeout)
|
||||
sock.sendto(_PROBE.format(mid=uuid.uuid4()).encode(), _MCAST)
|
||||
except OSError:
|
||||
return []
|
||||
import time
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
data, (host, _) = sock.recvfrom(65535)
|
||||
except socket.timeout:
|
||||
break
|
||||
except OSError:
|
||||
break
|
||||
f = parse_probe_match(data.decode("utf-8", "replace"), host)
|
||||
if f:
|
||||
out[f.host] = f
|
||||
sock.close()
|
||||
return list(out.values())
|
||||
|
||||
|
||||
_XADDR = re.compile(r"<[^>]*XAddrs[^>]*>([^<]+)<")
|
||||
_SCOPES = re.compile(r"<[^>]*Scopes[^>]*>([^<]+)<")
|
||||
|
||||
|
||||
def parse_probe_match(xml: str, host: str) -> Found | None:
|
||||
"""Pull the address and the human-readable scopes out of a ProbeMatch.
|
||||
|
||||
A regex rather than an XML parser on purpose: cameras emit every namespace
|
||||
prefix imaginable and some emit XML that is not quite well-formed, and the
|
||||
two fields wanted are flat text.
|
||||
"""
|
||||
xaddrs = _XADDR.search(xml)
|
||||
scopes = _SCOPES.search(xml)
|
||||
if not xaddrs and not scopes:
|
||||
return None
|
||||
url = xaddrs.group(1).split()[0] if xaddrs else ""
|
||||
# Prefer the host from the XAddrs URL: a device with several interfaces
|
||||
# answers from the one it heard us on, which is the one we can reach.
|
||||
m = re.match(r"https?://([^/:]+)", url)
|
||||
ip = m.group(1) if m else host
|
||||
f = Found(host=ip, onvif=True, onvif_url=url, sources=["onvif"])
|
||||
if scopes:
|
||||
words = []
|
||||
for s in scopes.group(1).split():
|
||||
if "/name/" in s or "/hardware/" in s:
|
||||
from urllib.parse import unquote
|
||||
words.append(unquote(s.rsplit("/", 1)[-1]))
|
||||
f.name = " ".join(dict.fromkeys(words)) # dedupe, keep order
|
||||
f.make = guess_make(f.name)
|
||||
return f
|
||||
|
||||
|
||||
def guess_make(text: str) -> str:
|
||||
low = text.lower()
|
||||
for needle, make in _MAKES:
|
||||
if needle in low:
|
||||
return make
|
||||
return ""
|
||||
|
||||
|
||||
def rtsp_sweep(nets: Iterable[ipaddress.IPv4Network], timeout: float = 0.5,
|
||||
workers: int = 128) -> list[str]:
|
||||
"""Every host in `nets` with port 554 open. ~254 hosts in about a second."""
|
||||
def probe(ip: str) -> str | None:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(timeout)
|
||||
try:
|
||||
return ip if s.connect_ex((ip, 554)) == 0 else None
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
s.close()
|
||||
hosts = [str(h) for n in nets for h in n.hosts()]
|
||||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
return [ip for ip in ex.map(probe, hosts) if ip]
|
||||
|
||||
|
||||
def discover(timeout: float = 2.5) -> dict:
|
||||
"""Both probes, merged, as the API returns it."""
|
||||
nets = local_networks()
|
||||
found: dict[str, Found] = {f.host: f for f in ws_discover(timeout)}
|
||||
for ip in rtsp_sweep(nets):
|
||||
f = found.setdefault(ip, Found(host=ip))
|
||||
f.rtsp = True
|
||||
f.sources.append("rtsp")
|
||||
cams = sorted(found.values(), key=lambda f: (not (f.rtsp and f.onvif), not f.rtsp,
|
||||
ipaddress.IPv4Address(f.host)))
|
||||
return {
|
||||
"networks": [str(n) for n in nets],
|
||||
"cameras": [asdict(c) for c in cams],
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import numpy as np
|
||||
|
||||
from .attributes import AttributeEstimator, aggregate as aggregate_attrs
|
||||
from .cameras import CameraStore
|
||||
from . import capture
|
||||
from .capture import VideoSource
|
||||
from .faces import FaceOutbox
|
||||
from .commission import CommissionRun
|
||||
@@ -169,7 +170,13 @@ class CameraWorker(threading.Thread):
|
||||
self._overlay_ts = 0.0
|
||||
self._last_frame_ts = 0.0
|
||||
self._was_connected = False
|
||||
self._was_stalled = False
|
||||
self.frames_processed = 0
|
||||
# Motion gate state: a 160x90 greyscale thumbnail of the last frame we
|
||||
# actually searched, and how many frames we have skipped since.
|
||||
self._motion_prev = None
|
||||
self._motion_skipped = 0
|
||||
self.frames_skipped = 0
|
||||
self.faces_seen = 0
|
||||
self.pipeline = PipelineStats()
|
||||
# One outbox per worker, all writing into the same directory. Files are
|
||||
@@ -236,6 +243,7 @@ class CameraWorker(threading.Thread):
|
||||
return {
|
||||
**self.source.stats(),
|
||||
"frames_processed": self.frames_processed,
|
||||
"frames_skipped": self.frames_skipped,
|
||||
"faces_seen": self.faces_seen,
|
||||
"active_tracks": len(self.tracker.tracks),
|
||||
"pipeline": self.pipeline.snapshot(self.rcfg.min_enroll_quality),
|
||||
@@ -244,6 +252,43 @@ class CameraWorker(threading.Thread):
|
||||
"enroll_threshold": self.rcfg.enroll_threshold},
|
||||
}
|
||||
|
||||
def _nothing_moved(self, frame) -> bool:
|
||||
"""True when this frame is close enough to the last searched one that
|
||||
searching it again would find the same nothing.
|
||||
|
||||
It cannot lose a face, and that property is what makes it acceptable
|
||||
rather than merely cheap. Three guards, in order:
|
||||
|
||||
* the caller only asks while NO track is open, so a person already
|
||||
being followed is never affected by it;
|
||||
* `motion_max_skip` forces a real detection about once a second
|
||||
whatever the thumbnail says, which covers a change too small or too
|
||||
gradual for it - someone easing into frame at the far edge;
|
||||
* the threshold sits well above measured sensor noise and well below
|
||||
a person, and anything ambiguous falls through to detection. When
|
||||
in doubt it looks.
|
||||
|
||||
Cost is 0.1 ms against detection's 15 ms, so an empty shop stops paying
|
||||
for a search of an empty room ~90 times a second.
|
||||
"""
|
||||
import cv2 as _cv2
|
||||
small = _cv2.resize(_cv2.cvtColor(frame, _cv2.COLOR_BGR2GRAY), (160, 90),
|
||||
interpolation=_cv2.INTER_AREA)
|
||||
prev, self._motion_prev = self._motion_prev, small
|
||||
if prev is None:
|
||||
return False
|
||||
if self._motion_skipped >= self.cfg.app.motion_max_skip:
|
||||
self._motion_skipped = 0
|
||||
return False
|
||||
if float(_cv2.absdiff(small, prev).mean()) >= self.cfg.app.motion_threshold:
|
||||
self._motion_skipped = 0
|
||||
# Keep the thumbnail we just searched against, not this one, so a
|
||||
# slow drift cannot creep past the threshold one frame at a time.
|
||||
return False
|
||||
self._motion_prev = prev
|
||||
self._motion_skipped += 1
|
||||
return True
|
||||
|
||||
# -- thread ---------------------------------------------------------
|
||||
def run(self) -> None:
|
||||
tcfg = self.cfg.tracking
|
||||
@@ -256,6 +301,15 @@ class CameraWorker(threading.Thread):
|
||||
continue
|
||||
self._last_frame_ts = ts
|
||||
|
||||
# An empty room costs exactly as much to search as a busy one,
|
||||
# and a shop is empty most of the day. Only ever while nothing
|
||||
# is being tracked - see _nothing_moved.
|
||||
if (self.cfg.app.motion_gate and not self.tracker.tracks
|
||||
and self._nothing_moved(frame)):
|
||||
self.frames_skipped += 1
|
||||
self._remember_tracks([])
|
||||
continue
|
||||
|
||||
detections = self.detector.detect(frame)
|
||||
for det in detections:
|
||||
det.quality = face_quality(frame, det.box, det.kps)
|
||||
@@ -294,6 +348,20 @@ class CameraWorker(threading.Thread):
|
||||
type="camera.up" if connected else "camera.down",
|
||||
camera_id=self.cam_cfg.id))
|
||||
|
||||
# A stall is not a disconnect and must not be reported as one: the
|
||||
# socket is fine, the camera is answering, and nothing is arriving.
|
||||
# Logged on the transition only — a per-frame warning would bury the
|
||||
# one line that matters under thousands of copies of itself.
|
||||
stalled = self.source.stalled()
|
||||
if stalled != self._was_stalled:
|
||||
self._was_stalled = stalled
|
||||
if stalled:
|
||||
log.warning("[%s] connected but no frame for over %.0fs - the "
|
||||
"camera is answering and sending nothing",
|
||||
self.cam_cfg.id, capture.STALL_AFTER_S)
|
||||
else:
|
||||
log.info("[%s] frames resumed", self.cam_cfg.id)
|
||||
|
||||
def _finish_track(self, track: Track, ts: float) -> None:
|
||||
"""Record what became of a track, once, as it ends.
|
||||
|
||||
@@ -629,7 +697,10 @@ class Engine:
|
||||
"age_model": ("genderage" if self.attributes is not None
|
||||
and self.attributes.has_genderage else "caffe/none"),
|
||||
},
|
||||
"gallery": self.store.stats(),
|
||||
# Counts, plus whether the running encoder can actually SEARCH
|
||||
# them. A gallery of 21 identities that the loaded model cannot
|
||||
# read is the silent version of an empty one.
|
||||
"gallery": {**self.store.stats(), **self.gallery.health},
|
||||
"cameras": [w.stats() for w in self.snapshot_workers()],
|
||||
}
|
||||
|
||||
|
||||
@@ -53,10 +53,56 @@ class Gallery:
|
||||
# vectors from a different model are numerically incompatible.
|
||||
ids, vecs = store.all_embeddings(index.dim, model=model_name)
|
||||
index.add(ids, vecs)
|
||||
self.health = self._assess(len(ids))
|
||||
if self.health["stranded"]:
|
||||
# Not an INFO line. The encoder fallback chain exists so a
|
||||
# memory-starved box still runs, and when it fires every vector
|
||||
# written by the previous encoder becomes invisible: the shop
|
||||
# keeps its customer list and recognises nobody on it, greeting
|
||||
# every regular as new and enrolling them a second time. Footfall
|
||||
# stays right, which is exactly why nothing looks wrong. The old
|
||||
# message for that state was "gallery ready: 0 embeddings".
|
||||
log.warning(
|
||||
"gallery: %d of %d stored embeddings were written by a "
|
||||
"DIFFERENT encoder (%s) and cannot be searched - %d known "
|
||||
"%s unrecognisable under the running model '%s'. Either "
|
||||
"restore that model or accept that these identities start "
|
||||
"over.",
|
||||
self.health["stranded"], self.health["stored"],
|
||||
", ".join(sorted(self.health["other_models"])),
|
||||
self.health["identities_stranded"],
|
||||
"person is" if self.health["identities_stranded"] == 1
|
||||
else "people are",
|
||||
model_name)
|
||||
log.info("gallery ready: %d embeddings (model '%s') across %d "
|
||||
"identities", len(ids), model_name,
|
||||
store.stats()["identities"])
|
||||
|
||||
def _assess(self, usable: int) -> dict:
|
||||
"""What share of the gallery the running encoder can actually reach.
|
||||
|
||||
Reported rather than merely logged, because a log line on a shop PC
|
||||
is read by nobody: this travels to head office the same way
|
||||
`fraction_below_gate` does, beside the number it qualifies.
|
||||
"""
|
||||
counts = self.store.model_counts()
|
||||
stored = sum(counts.values())
|
||||
others = {m: n for m, n in counts.items() if m != self.model_name}
|
||||
identities = self.store.stats()["identities"]
|
||||
return {
|
||||
"model": self.model_name,
|
||||
"stored": stored,
|
||||
"usable": usable,
|
||||
"stranded": sum(others.values()),
|
||||
"other_models": sorted(others),
|
||||
"identities": identities,
|
||||
"identities_usable": self.store.identities_with_model(
|
||||
self.model_name),
|
||||
"identities_stranded": max(
|
||||
0, identities - self.store.identities_with_model(
|
||||
self.model_name)),
|
||||
}
|
||||
|
||||
def resolve(self, embedding: np.ndarray, quality: float, camera_id: str,
|
||||
ts: "float | None" = None,
|
||||
attributes: "dict | None" = None,
|
||||
|
||||
@@ -281,6 +281,31 @@ class IdentityStore:
|
||||
return None
|
||||
return np.frombuffer(row["vector"], dtype=np.float32), float(row["quality"])
|
||||
|
||||
def model_counts(self) -> "dict[str, int]":
|
||||
"""How many stored embeddings each encoder produced.
|
||||
|
||||
The gallery only ever searches vectors tagged with the *running*
|
||||
encoder, so this is what says whether the rest of the gallery is
|
||||
reachable at all. See `Gallery.health` for why that matters.
|
||||
"""
|
||||
with self._lock:
|
||||
rows = self._db.execute(
|
||||
"SELECT model, COUNT(*) AS n FROM embeddings "
|
||||
"GROUP BY model").fetchall()
|
||||
return {str(r["model"]): int(r["n"]) for r in rows}
|
||||
|
||||
def identities_with_model(self, model: str) -> int:
|
||||
"""Identities holding at least one embedding from this encoder.
|
||||
|
||||
Not the same as the identity count: an identity whose only vectors
|
||||
came from a previous encoder still exists, and is unrecognisable.
|
||||
"""
|
||||
with self._lock:
|
||||
row = self._db.execute(
|
||||
"SELECT COUNT(DISTINCT identity_id) AS n FROM embeddings "
|
||||
"WHERE model=?", (model,)).fetchone()
|
||||
return int(row["n"]) if row else 0
|
||||
|
||||
def embedding_owners(self, model: "str | None" = None) -> "dict[int, int]":
|
||||
"""embedding_id -> identity_id, for turning index hits into identity
|
||||
pairs without a round trip to SQLite per hit."""
|
||||
|
||||
@@ -35,6 +35,31 @@ _COPY_MAP = {
|
||||
}
|
||||
|
||||
|
||||
def _fetch(url: str, dest: Path, label: str) -> None:
|
||||
"""Download with progress on stdout the supervisor can read.
|
||||
|
||||
On first run this is minutes of nothing: the API is not up yet, so the
|
||||
app cannot ask the engine what it is doing, and a shop PC that shows a
|
||||
stopped engine for five minutes after install looks broken. The
|
||||
supervisor watches for `download: <label> <n>%` and puts the number in
|
||||
the tray and the window. Logged every 5 points, not every chunk, so the
|
||||
log file does not fill with a progress bar.
|
||||
"""
|
||||
last = -5
|
||||
|
||||
def hook(blocks: int, block_size: int, total: int) -> None:
|
||||
nonlocal last
|
||||
if total <= 0:
|
||||
return
|
||||
pct = min(100, blocks * block_size * 100 // total)
|
||||
if pct >= last + 5:
|
||||
last = pct
|
||||
log.info("download: %s %d%%", label, pct)
|
||||
|
||||
urllib.request.urlretrieve(url, dest, hook)
|
||||
log.info("download: %s 100%%", label)
|
||||
|
||||
|
||||
def setup_models(models_dir: Path) -> "list[str]":
|
||||
"""Ensure all model files exist in models_dir. Returns missing ones."""
|
||||
models_dir = Path(models_dir)
|
||||
@@ -44,7 +69,7 @@ def setup_models(models_dir: Path) -> "list[str]":
|
||||
if not yunet.exists():
|
||||
log.info("downloading YuNet face detector (~230 KB)...")
|
||||
tmp = yunet.with_suffix(".part")
|
||||
urllib.request.urlretrieve(YUNET_URL, tmp)
|
||||
_fetch(YUNET_URL, tmp, "face detector")
|
||||
tmp.rename(yunet)
|
||||
log.info("YuNet saved to %s", yunet)
|
||||
|
||||
@@ -88,7 +113,7 @@ def setup_models(models_dir: Path) -> "list[str]":
|
||||
import zipfile
|
||||
|
||||
tmp = models_dir / "buffalo_l.zip.part"
|
||||
urllib.request.urlretrieve(BUFFALO_L_URL, tmp)
|
||||
_fetch(BUFFALO_L_URL, tmp, "recognition models")
|
||||
with zipfile.ZipFile(tmp) as zf:
|
||||
for name, target in wanted.items():
|
||||
member = next((n for n in zf.namelist()
|
||||
|
||||
@@ -514,11 +514,24 @@ async function refresh() {
|
||||
]);
|
||||
renderFeeds(camList);
|
||||
renderCameras(camList);
|
||||
// 'stalled' is its own word on purpose: connected and offline send you
|
||||
// to the network, a camera that is answering and sending nothing does
|
||||
// not. Three states, because two of them need opposite actions.
|
||||
const cams = stats.cameras.map(c =>
|
||||
`${c.camera_id}: ${c.connected ? 'live' : 'offline'}`).join(' · ');
|
||||
`${c.camera_id}: ${c.streaming ? 'live' : c.connected ? 'stalled' : 'offline'}`
|
||||
).join(' · ');
|
||||
// The one failure that otherwise looks like perfect health: the encoder
|
||||
// that loaded cannot read the embeddings already stored, so every known
|
||||
// customer is a stranger. Counts stay right, which is why it needs saying.
|
||||
const stranded = stats.gallery.stranded || 0;
|
||||
const warn = stranded
|
||||
? ` · ⚠ ${stats.gallery.identities_stranded} people unrecognisable `
|
||||
+ `(${stranded} embeddings from ${stats.gallery.other_models.join(', ')}, `
|
||||
+ `running ${stats.gallery.model})`
|
||||
: '';
|
||||
// textContent, not innerHTML — no escaping needed here.
|
||||
document.getElementById('status').textContent =
|
||||
`${cams} · ${stats.gallery.identities} people · ${stats.gallery.sightings} sightings`;
|
||||
`${cams} · ${stats.gallery.identities} people · ${stats.gallery.sightings} sightings${warn}`;
|
||||
|
||||
document.getElementById('events').innerHTML = events.map(e => {
|
||||
const cls = e.type === 'person.new' ? 'new'
|
||||
|
||||
258
demo/console.html
Normal file
258
demo/console.html
Normal file
@@ -0,0 +1,258 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Behavision — live demo</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0A0E12; --s1:#11171C; --s2:#161D24; --s3:#1D262E;
|
||||
--line:#24303A; --line2:#1B242C;
|
||||
--ink:#E8EEF3; --ink2:#9FB0BD; --ink3:#6B7E8C;
|
||||
--accent:#3DD0C4; --accent-dim:#123039;
|
||||
--ok:#3FBF7F; --warn:#E0A33A; --bad:#E15B4C;
|
||||
--mono:'SF Mono',ui-monospace,Menlo,monospace;
|
||||
--font:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--ink);font-family:var(--font);font-size:14px;line-height:1.5;
|
||||
-webkit-font-smoothing:antialiased;padding:18px;min-height:100vh}
|
||||
.top{display:flex;align-items:center;gap:14px;margin-bottom:16px;flex-wrap:wrap}
|
||||
.brand{display:flex;align-items:center;gap:10px;margin-right:auto}
|
||||
.brand img{width:26px;height:26px;object-fit:contain}
|
||||
.brand b{font-size:16px;letter-spacing:-.01em}
|
||||
.brand span{color:var(--ink3);font-size:12px}
|
||||
.pill{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:99px;
|
||||
border:1px solid var(--line);background:var(--s1);font-size:12px;color:var(--ink2)}
|
||||
.dot{width:7px;height:7px;border-radius:99px;background:var(--ink3);flex:none}
|
||||
.dot.ok{background:var(--ok);box-shadow:0 0 0 3px rgba(63,191,127,.16)}
|
||||
.dot.bad{background:var(--bad);box-shadow:0 0 0 3px rgba(225,91,76,.16)}
|
||||
.dot.warn{background:var(--warn);box-shadow:0 0 0 3px rgba(224,163,58,.16)}
|
||||
|
||||
.grid{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);gap:14px;align-items:start}
|
||||
@media(max-width:1100px){.grid{grid-template-columns:minmax(0,1fr)}}
|
||||
.card{background:var(--s1);border:1px solid var(--line);border-radius:12px;overflow:hidden}
|
||||
.card h2{font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--ink3);
|
||||
padding:12px 16px;border-bottom:1px solid var(--line2);display:flex;align-items:center;gap:10px}
|
||||
.card h2 .grow{margin-left:auto;font-weight:500;letter-spacing:0;text-transform:none;font-size:12px;color:var(--ink3)}
|
||||
.pad{padding:16px}
|
||||
|
||||
.cam{position:relative;aspect-ratio:16/9;background:#05090C}
|
||||
.cam img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
.cam .none{position:absolute;inset:0;display:grid;place-items:center;color:var(--ink3);font-size:13px;text-align:center;padding:20px}
|
||||
.cam .tag{position:absolute;top:10px;left:10px;background:rgba(10,14,18,.78);backdrop-filter:blur(8px);
|
||||
border:1px solid var(--line);border-radius:8px;padding:5px 10px;font-size:11.5px;font-family:var(--mono)}
|
||||
|
||||
/* the chain */
|
||||
.chain{display:flex;flex-direction:column;gap:0}
|
||||
.step{display:grid;grid-template-columns:26px 1fr auto;gap:12px;align-items:start;padding:11px 16px;
|
||||
border-bottom:1px solid var(--line2);opacity:.38;transition:opacity .25s}
|
||||
.step:last-child{border-bottom:0}
|
||||
.step.on{opacity:1}
|
||||
.step .n{width:22px;height:22px;border-radius:99px;display:grid;place-items:center;font-size:11px;font-weight:600;
|
||||
background:var(--s3);color:var(--ink3);border:1px solid var(--line);margin-top:1px}
|
||||
.step.on .n{background:var(--accent);color:#04161B;border-color:transparent}
|
||||
.step b{font-size:13.5px;font-weight:550;display:block}
|
||||
.step small{color:var(--ink2);font-size:12px;display:block;margin-top:1px;font-family:var(--mono)}
|
||||
.step .ms{font-family:var(--mono);font-size:11.5px;color:var(--accent);white-space:nowrap;margin-top:2px}
|
||||
|
||||
.empty{padding:34px 16px;text-align:center;color:var(--ink3);font-size:13px;line-height:1.6}
|
||||
.empty b{display:block;color:var(--ink2);font-size:14px;margin-bottom:5px}
|
||||
|
||||
/* customer */
|
||||
.who{display:flex;gap:13px;align-items:center;padding:16px;border-bottom:1px solid var(--line2)}
|
||||
.av{width:50px;height:50px;border-radius:10px;background:var(--s3);border:1px solid var(--line);
|
||||
display:grid;place-items:center;font-weight:600;font-size:17px;color:var(--ink2);flex:none;overflow:hidden}
|
||||
.av img{width:100%;height:100%;object-fit:cover}
|
||||
.who .n{font-size:16px;font-weight:600;letter-spacing:-.01em}
|
||||
.who .m{color:var(--ink3);font-size:12.5px;margin-top:2px}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:99px;font-size:10.5px;font-weight:600;
|
||||
letter-spacing:.04em;text-transform:uppercase}
|
||||
.badge.new{background:var(--accent-dim);color:var(--accent)}
|
||||
.badge.seen{background:rgba(63,191,127,.14);color:var(--ok)}
|
||||
label{display:block;font-size:11.5px;font-weight:550;color:var(--ink2);margin-bottom:5px}
|
||||
input{width:100%;background:var(--s2);border:1px solid var(--line);border-radius:7px;padding:9px 11px;
|
||||
color:var(--ink);font:inherit;font-size:13.5px}
|
||||
input:focus{outline:none;border-color:var(--accent)}
|
||||
.row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px}
|
||||
button{background:var(--accent);color:#04161B;border:0;border-radius:7px;padding:9px 16px;
|
||||
font:inherit;font-size:13px;font-weight:600;cursor:pointer}
|
||||
button:disabled{opacity:.45;cursor:default}
|
||||
button.sec{background:var(--s3);color:var(--ink);border:1px solid var(--line)}
|
||||
.saved{color:var(--ok);font-size:12.5px;margin-top:9px;display:flex;align-items:center;gap:6px}
|
||||
|
||||
/* raw json */
|
||||
pre{font-family:var(--mono);font-size:11px;line-height:1.55;color:var(--ink2);
|
||||
background:#080C10;border-top:1px solid var(--line2);padding:13px 16px;margin:0;
|
||||
max-height:230px;overflow:auto;white-space:pre-wrap;word-break:break-word}
|
||||
.req{font-family:var(--mono);font-size:11.5px;color:var(--accent);padding:10px 16px;background:var(--s2)}
|
||||
.req .st{float:right;color:var(--ink3)}
|
||||
.hint{color:var(--ink3);font-size:12px;padding:10px 16px 14px;line-height:1.55}
|
||||
.stack{display:flex;flex-direction:column;gap:14px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div class="brand">
|
||||
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDIxcy04LTQuNS04LTEwYTQuNSA0LjUgMCAwIDEgOC0yLjggNC41IDQuNSAwIDAgMSA4IDIuOGMwIDUuNS04IDEwLTggMTB6IiBmaWxsPSIjRjJDMTFGIi8+PC9zdmc+" alt="">
|
||||
<div><b>Behavision</b> <span id="site">— live demo</span></div>
|
||||
</div>
|
||||
<span class="pill"><i class="dot" id="d-eng"></i><span id="t-eng">engine…</span></span>
|
||||
<span class="pill"><i class="dot" id="d-cam"></i><span id="t-cam">camera…</span></span>
|
||||
<span class="pill"><i class="dot" id="d-cloud"></i><span id="t-cloud">cloud…</span></span>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="stack">
|
||||
<div class="card">
|
||||
<h2>The camera <span class="grow" id="camname"></span></h2>
|
||||
<div class="cam">
|
||||
<img id="feed" alt="" style="display:none">
|
||||
<div class="none" id="feednone">waiting for the camera…</div>
|
||||
<div class="tag" id="camtag" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>The customer <span class="grow">type a name, then walk past again</span></h2>
|
||||
<div id="cust">
|
||||
<div class="empty"><b>Nobody yet</b>Walk in front of the camera.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<div class="card">
|
||||
<h2>What just happened <span class="grow" id="lat"></span></h2>
|
||||
<div id="chain"><div class="empty"><b>Waiting</b>Every step below lights up as it really happens.</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>What the mobile app receives</h2>
|
||||
<div class="req" id="m-req">GET /api/visits<span class="st" id="m-st"></span></div>
|
||||
<pre id="m-body">…</pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>What the dashboard receives</h2>
|
||||
<div class="req" id="d-req">GET /api/reports/footfall<span class="st" id="d-st"></span></div>
|
||||
<pre id="d-body">…</pre>
|
||||
<div class="hint">Both of these are the real production API at mcp.loyaly.ai, called from this
|
||||
machine with a staff login — not a mock, and not the local engine.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
let camStarted = null, current = null, savedFor = null;
|
||||
|
||||
function setPill(dot, text, tone, label){
|
||||
$(dot).className = 'dot' + (tone ? ' ' + tone : '');
|
||||
$(text).textContent = label;
|
||||
}
|
||||
|
||||
function initials(name, ref){
|
||||
const m = /^Visitor (\d+)$/.exec((name||'').trim());
|
||||
if (m) return m[1];
|
||||
const w = (name||'').trim().split(/\s+/).filter(Boolean);
|
||||
if (!w.length) return '?';
|
||||
return (w[0][0] + (w[1]?.[0] ?? '')).toUpperCase();
|
||||
}
|
||||
|
||||
function drawChain(e){
|
||||
if (!e){ $('#chain').innerHTML = '<div class="empty"><b>Waiting</b>Every step below lights up as it really happens.</div>'; $('#lat').textContent=''; return; }
|
||||
const g = e.engine, c = e.cloud;
|
||||
const steps = [
|
||||
[true, 'Camera saw a face', g.quality != null ? `quality ${(+g.quality).toFixed(2)} · camera ${g.camera}` : `camera ${g.camera}`, ''],
|
||||
[true, e.kind === 'new' ? 'Engine: nobody it knows → enrolled' : 'Engine: matched a returning customer',
|
||||
(g.label || '') + (g.similarity != null && g.similarity >= 0 ? ` · similarity ${(+g.similarity).toFixed(2)}` : '') , ''],
|
||||
[true, 'Agent queued the visit', 'durable on this disk until the broker confirms', ''],
|
||||
[!!c, 'Broker delivered it', 'MQTT over TLS to mcp.loyaly.ai', ''],
|
||||
[!!c, 'Server recorded it', c ? `${c.site} · ${c.is_new ? 'new customer' : 'returning'}` : 'waiting…', ''],
|
||||
[!!c, 'Mobile + dashboard can see it', c ? `visit ${String(c.visit_id).slice(0,8)}` : 'waiting…',
|
||||
e.latency != null ? `+${e.latency}s` : ''],
|
||||
];
|
||||
$('#chain').innerHTML = steps.map(([on,title,sub,ms],i)=>
|
||||
`<div class="step ${on?'on':''}"><div class="n">${i+1}</div><div><b>${title}</b><small>${sub}</small></div><div class="ms">${ms}</div></div>`
|
||||
).join('');
|
||||
$('#lat').textContent = e.latency != null ? `camera → cloud in ${e.latency}s` : '';
|
||||
}
|
||||
|
||||
function drawCustomer(e){
|
||||
const c = e && e.cloud;
|
||||
if (!c){ if(!current) $('#cust').innerHTML = '<div class="empty"><b>Nobody yet</b>Walk in front of the camera.</div>'; return; }
|
||||
const changed = !current || current.visitor_id !== c.visitor_id || current.visit_id !== c.visit_id;
|
||||
if (!changed) return;
|
||||
current = c;
|
||||
const name = c.label || 'Unrecognised';
|
||||
const img = c.image && c.image.available && c.image.url;
|
||||
$('#cust').innerHTML = `
|
||||
<div class="who">
|
||||
<div class="av">${img ? `<img src="${img}">` : initials(name)}</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="n">${name}</div>
|
||||
<div class="m">${c.ref ? c.ref + ' · ' : ''}${c.is_new ? 'first time here' : 'returning'}${c.similarity>0 ? ' · match ' + (+c.similarity).toFixed(2) : ''}</div>
|
||||
</div>
|
||||
<span class="badge ${c.is_new?'new':'seen'}">${c.is_new?'new':'returning'}</span>
|
||||
</div>
|
||||
<div class="pad">
|
||||
<div class="row">
|
||||
<div><label>Name</label><input id="f-name" placeholder="e.g. Suriya" value=""></div>
|
||||
<div><label>Phone</label><input id="f-phone" placeholder="+91…" value=""></div>
|
||||
</div>
|
||||
<button id="save">Save to the customer record</button>
|
||||
<div id="savedmsg"></div>
|
||||
<div class="hint" style="padding:12px 0 0">This writes to the production API. Walk past again and
|
||||
the name comes back through the cloud instead of “${name}”.</div>
|
||||
</div>`;
|
||||
$('#save').onclick = async () => {
|
||||
const b = $('#save'); b.disabled = true; b.textContent = 'Saving…';
|
||||
const r = await fetch('/api/profile', {method:'POST', headers:{'content-type':'application/json'},
|
||||
body: JSON.stringify({id: c.visitor_id, full_name: $('#f-name').value, phone: $('#f-phone').value})});
|
||||
const d = await r.json();
|
||||
b.disabled = false; b.textContent = 'Save to the customer record';
|
||||
$('#savedmsg').innerHTML = (d.status===200||d.status===204)
|
||||
? '<div class="saved">✓ Saved — PUT /api/visitors/'+String(c.visitor_id).slice(0,8)+'…/profile → '+d.status+'</div>'
|
||||
: '<div class="saved" style="color:var(--bad)">'+(d.body&&d.body.message||('HTTP '+d.status))+'</div>';
|
||||
savedFor = c.visitor_id;
|
||||
};
|
||||
}
|
||||
|
||||
async function tick(){
|
||||
let s;
|
||||
try { s = await (await fetch('/api/snapshot')).json(); } catch { return; }
|
||||
|
||||
const eng = s.engine || {};
|
||||
setPill('#d-eng','#t-eng', eng.up ? 'ok' : 'bad', eng.up ? ('engine · ' + (eng.model||'starting')) : 'engine starting…');
|
||||
const cam = (eng.cameras||[])[0];
|
||||
setPill('#d-cam','#t-cam', cam && cam.connected ? 'ok' : 'warn',
|
||||
cam ? (cam.connected ? `camera live · ${cam.frames||0} frames` : 'camera connecting…') : 'no camera yet');
|
||||
setPill('#d-cloud','#t-cloud', s.cloud_ok ? 'ok' : 'bad', s.cloud_ok ? 'cloud connected' : 'cloud unreachable');
|
||||
$('#camname').textContent = cam ? cam.id : '';
|
||||
|
||||
if (cam && cam.connected){
|
||||
if (camStarted !== cam.id){
|
||||
camStarted = cam.id;
|
||||
$('#feed').style.display = 'block'; $('#feednone').style.display = 'none';
|
||||
$('#camtag').style.display = 'block';
|
||||
// A polled still rather than an MJPEG stream: the engine re-serves its
|
||||
// latest frame anyway, and a multipart stream through a proxy is one
|
||||
// more thing to fail in front of an audience.
|
||||
setInterval(() => { $('#feed').src = '/camera.jpg?id=' +
|
||||
encodeURIComponent(camStarted) + '&t=' + Date.now(); }, 350);
|
||||
}
|
||||
$('#camtag').textContent = cam.id + (cam.tracks ? ` · ${cam.tracks} in frame` : '');
|
||||
}
|
||||
|
||||
const e = (s.chain||[])[0];
|
||||
drawChain(e); drawCustomer(e);
|
||||
|
||||
$('#m-st').textContent = s.mobile.status;
|
||||
$('#m-body').textContent = JSON.stringify(s.mobile.body, null, 1).slice(0, 2600);
|
||||
$('#d-st').textContent = s.dashboard.status;
|
||||
$('#d-body').textContent = JSON.stringify(s.dashboard.body, null, 1).slice(0, 1800);
|
||||
}
|
||||
tick(); setInterval(tick, 1500);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
330
demo/console.py
Normal file
330
demo/console.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""A one-screen live demo of the whole Behavision chain, for showing someone.
|
||||
|
||||
Run it on the shop PC (here, this Mac) while the engine and agent are running.
|
||||
It holds every credential itself and the browser holds none, so the page can be
|
||||
put on a projector without putting a token on it.
|
||||
|
||||
What it shows, and why each part is there:
|
||||
|
||||
- the live camera, so the person walking past sees themselves;
|
||||
- the CHAIN, measured rather than described: the engine recognised a face at
|
||||
this instant, the same visit appeared in the cloud API this many seconds
|
||||
later. That number is the product's claim, and it is computed here from two
|
||||
independent sources rather than asserted;
|
||||
- the customer, editable - type a name, walk past again, watch the name come
|
||||
back through the cloud instead of "Visitor 5";
|
||||
- the raw JSON a phone and a dashboard receive, side by side, because a
|
||||
colleague's real question is "is this actually wired up or is it a mock".
|
||||
|
||||
.venv/bin/python demo/console.py # http://127.0.0.1:8099
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
STATE = Path(os.environ.get("BEHAVISION_DATA_DIR", ROOT / ".demo"))
|
||||
CLOUD = os.environ.get("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")
|
||||
ENGINE = "http://127.0.0.1:8010"
|
||||
PORT = int(os.environ.get("DEMO_PORT", "8099"))
|
||||
|
||||
# Whoever the demo signs in as. Staff on purpose: it is the weakest role that
|
||||
# can do everything the shop floor does, so nothing here is only possible
|
||||
# because we used an owner.
|
||||
EMAIL = os.environ.get("DEMO_EMAIL", "staff.demo@tenext.in")
|
||||
PASSWORD = os.environ.get("DEMO_PASSWORD", "admin@123")
|
||||
|
||||
|
||||
def engine_auth() -> str:
|
||||
"""The engine invents a Basic credential when none is configured, and
|
||||
writes it here. Read it rather than keeping a second copy."""
|
||||
f = STATE / "data" / "api_credentials.txt"
|
||||
if not f.exists():
|
||||
return ""
|
||||
user = pw = ""
|
||||
for line in f.read_text().splitlines():
|
||||
# `key=value`, and `key: value` too - the engine writes one and people
|
||||
# read the other, and which is which is not worth a support call.
|
||||
if "=" in line or ":" in line:
|
||||
k, v = line.split("=", 1) if "=" in line else line.split(":", 1)
|
||||
if k.strip().lower() == "username":
|
||||
user = v.strip()
|
||||
elif k.strip().lower() == "password":
|
||||
pw = v.strip()
|
||||
if not user:
|
||||
return ""
|
||||
return "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
|
||||
|
||||
|
||||
def fetch(url: str, *, headers=None, body=None, method="GET", timeout=20):
|
||||
req = urllib.request.Request(url, method=method,
|
||||
data=json.dumps(body).encode() if body is not None else None,
|
||||
headers={k: v for k, v in (headers or {}).items() if v})
|
||||
if body is not None:
|
||||
req.add_header("content-type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
raw = r.read()
|
||||
return r.status, (json.loads(raw) if raw and r.headers.get("content-type", "").startswith("application/json") else raw)
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
try:
|
||||
return e.code, json.loads(raw or b"{}")
|
||||
except Exception:
|
||||
return e.code, raw[:400]
|
||||
except Exception as e:
|
||||
return 0, {"error": str(e)}
|
||||
|
||||
|
||||
class Cloud:
|
||||
"""The signed-in session, refreshed when it expires."""
|
||||
|
||||
def __init__(self):
|
||||
self.token = ""
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def sign_in(self) -> bool:
|
||||
st, d = fetch(f"{CLOUD}/api/auth/login", method="POST",
|
||||
body={"email": EMAIL, "password": PASSWORD, "device": "Demo console"})
|
||||
if st == 200 and isinstance(d, dict):
|
||||
self.token = d.get("access_token", "")
|
||||
return True
|
||||
return False
|
||||
|
||||
def call(self, path, method="GET", body=None, retry=True):
|
||||
with self.lock:
|
||||
if not self.token and not self.sign_in():
|
||||
return 0, {"error": "cannot sign in to the platform"}
|
||||
tok = self.token
|
||||
st, d = fetch(f"{CLOUD}{path}", method=method, body=body,
|
||||
headers={"authorization": f"Bearer {tok}"})
|
||||
if st == 401 and retry:
|
||||
with self.lock:
|
||||
self.sign_in()
|
||||
return self.call(path, method, body, retry=False)
|
||||
return st, d
|
||||
|
||||
|
||||
cloud = Cloud()
|
||||
|
||||
# The chain, as the watcher builds it. One dict per recognition, newest first.
|
||||
events: list[dict] = []
|
||||
events_lock = threading.Lock()
|
||||
|
||||
|
||||
def watch():
|
||||
"""Poll the engine's own event log and the cloud feed, and join them.
|
||||
|
||||
They are joined on the identity and the second, not on a shared id,
|
||||
because the engine numbers identities locally and the server numbers them
|
||||
per tenant - the two are deliberately different (see CLAUDE.md). What
|
||||
matters for the demo is the LATENCY between one seeing a person and the
|
||||
other, and that only needs the same person and the same moment.
|
||||
"""
|
||||
seen_local: set[str] = set()
|
||||
while True:
|
||||
try:
|
||||
auth = engine_auth()
|
||||
st, d = fetch(f"{ENGINE}/api/events?limit=25", headers={"authorization": auth})
|
||||
# The engine returns a bare list; a dict with "events" is accepted
|
||||
# too so this survives either shape.
|
||||
evs = d if isinstance(d, list) else (d or {}).get("events", []) if isinstance(d, dict) else []
|
||||
if st == 200:
|
||||
for e in evs:
|
||||
if e.get("type") not in ("person.new", "person.seen"):
|
||||
continue
|
||||
key = f"{e.get('ts')}|{e.get('camera_id')}|{(e.get('data') or {}).get('identity_id')}"
|
||||
if key in seen_local:
|
||||
continue
|
||||
seen_local.add(key)
|
||||
data = e.get("data") or {}
|
||||
with events_lock:
|
||||
events.insert(0, {
|
||||
"key": key,
|
||||
"at": time.time(),
|
||||
"kind": "new" if e["type"] == "person.new" else "seen",
|
||||
"engine": {
|
||||
"label": data.get("label"),
|
||||
"identity_id": data.get("identity_id"),
|
||||
"similarity": data.get("similarity"),
|
||||
"quality": data.get("quality"),
|
||||
"gender": data.get("gender"),
|
||||
"age": data.get("age"),
|
||||
"camera": e.get("camera_id"),
|
||||
"ts": e.get("ts"),
|
||||
},
|
||||
"cloud": None,
|
||||
"latency": None,
|
||||
})
|
||||
del events[40:]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# the other half: has the cloud got it yet?
|
||||
try:
|
||||
with events_lock:
|
||||
pending = [e for e in events if e["cloud"] is None][:6]
|
||||
if pending:
|
||||
st, d = cloud.call("/api/visits?limit=12")
|
||||
arrivals = (d or {}).get("arrivals", []) if isinstance(d, dict) else []
|
||||
for e in pending:
|
||||
for a in arrivals:
|
||||
# same camera, and the cloud's visit is not older than
|
||||
# the engine's sighting
|
||||
if a.get("camera_id") != e["engine"]["camera"]:
|
||||
continue
|
||||
if a.get("visit_id") in [x["cloud"].get("visit_id") for x in events if x["cloud"]]:
|
||||
continue
|
||||
with events_lock:
|
||||
e["cloud"] = {
|
||||
"visit_id": a.get("visit_id"),
|
||||
"visitor_id": a.get("visitor_id"),
|
||||
"label": a.get("label"),
|
||||
"ref": a.get("customer_ref") or a.get("ref"),
|
||||
"is_new": a.get("is_new_visitor"),
|
||||
"similarity": a.get("similarity"),
|
||||
"site": a.get("site"),
|
||||
"occurred_at": a.get("occurred_at"),
|
||||
"image": a.get("image"),
|
||||
}
|
||||
e["latency"] = round(time.time() - e["at"], 1)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
"""Everything the page draws, in one reply."""
|
||||
auth = engine_auth()
|
||||
_, health = fetch(f"{ENGINE}/api/health", headers={"authorization": auth})
|
||||
_, stats = fetch(f"{ENGINE}/api/stats", headers={"authorization": auth})
|
||||
st_v, visits = cloud.call("/api/visits?limit=3")
|
||||
st_f, foot = cloud.call("/api/reports/footfall?from=%s&to=%s"
|
||||
% (time.strftime("%Y-%m-%d", time.localtime(time.time() - 7 * 86400)),
|
||||
time.strftime("%Y-%m-%d")))
|
||||
with events_lock:
|
||||
chain = json.loads(json.dumps(events[:8]))
|
||||
cams = (stats or {}).get("cameras", []) if isinstance(stats, dict) else []
|
||||
return {
|
||||
"engine": {
|
||||
"up": isinstance(health, dict) and bool(health.get("status")),
|
||||
"model": (health or {}).get("recognition_model") if isinstance(health, dict) else None,
|
||||
"cameras": [{"id": c.get("camera_id"), "connected": c.get("connected"),
|
||||
"frames": c.get("frames_processed") or c.get("frames_total"),
|
||||
"faces": c.get("faces_seen"),
|
||||
"tracks": c.get("active_tracks")} for c in cams],
|
||||
},
|
||||
"cloud_ok": st_v == 200,
|
||||
"chain": chain,
|
||||
"mobile": {"request": "GET /api/visits?limit=3", "status": st_v, "body": visits},
|
||||
"dashboard": {"request": "GET /api/reports/footfall?from=…&to=…", "status": st_f, "body": foot},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
page = (Path(__file__).parent / "console.html").read_bytes()
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a): # quiet
|
||||
pass
|
||||
|
||||
def _send(self, code, body, ctype="application/json"):
|
||||
self.send_response(code)
|
||||
self.send_header("content-type", ctype)
|
||||
self.send_header("content-length", str(len(body)))
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
u = urlparse(self.path)
|
||||
if u.path == "/":
|
||||
return self._send(200, page, "text/html; charset=utf-8")
|
||||
if u.path == "/api/snapshot":
|
||||
return self._send(200, json.dumps(snapshot()).encode())
|
||||
if u.path == "/api/customer":
|
||||
vid = parse_qs(u.query).get("id", [""])[0]
|
||||
if not vid:
|
||||
return self._send(400, b'{"error":"no id"}')
|
||||
st, d = cloud.call(f"/api/visitors/{vid}/history?limit=8")
|
||||
return self._send(200, json.dumps({"status": st, "history": d}).encode())
|
||||
if u.path == "/camera.jpg":
|
||||
# A polled still, not the MJPEG stream. The engine re-serves its
|
||||
# latest frame until the pipeline produces a new one, so polling
|
||||
# shows the same picture - and a multipart stream through a
|
||||
# proxy is one more thing to fail in front of an audience.
|
||||
cam = parse_qs(u.query).get("id", [""])[0]
|
||||
st, body = fetch(f"{ENGINE}/api/cameras/{cam}/frame.jpg",
|
||||
headers={"authorization": engine_auth()}, timeout=15)
|
||||
if st != 200 or not isinstance(body, (bytes, bytearray)):
|
||||
return self._send(502, b'{"error":"no frame"}')
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", "image/jpeg")
|
||||
self.send_header("cache-control", "no-store")
|
||||
self.send_header("content-length", str(len(body)))
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
return
|
||||
self._send(404, b'{"error":"no"}')
|
||||
|
||||
def do_POST(self):
|
||||
u = urlparse(self.path)
|
||||
n = int(self.headers.get("content-length", 0))
|
||||
body = json.loads(self.rfile.read(n) or b"{}")
|
||||
if u.path == "/api/profile":
|
||||
vid = body.pop("id", "")
|
||||
st, d = cloud.call(f"/api/visitors/{vid}/profile", method="PUT", body=body)
|
||||
return self._send(200, json.dumps({"status": st, "body": d}).encode())
|
||||
self._send(404, b'{"error":"no"}')
|
||||
|
||||
def _proxy_stream(self, url):
|
||||
"""The engine's MJPEG, relayed so the browser needs no credential.
|
||||
|
||||
The engine's API is Basic-authenticated with a credential it
|
||||
generated locally; putting that in a page would hand the whole
|
||||
biometric API to anyone who opened it.
|
||||
"""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"authorization": engine_auth()})
|
||||
up = urllib.request.urlopen(req, timeout=20)
|
||||
except Exception:
|
||||
return self._send(502, b'{"error":"camera not available"}')
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", up.headers.get("content-type", "multipart/x-mixed-replace"))
|
||||
self.end_headers()
|
||||
try:
|
||||
while True:
|
||||
chunk = up.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
self.wfile.flush()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
up.close()
|
||||
|
||||
threading.Thread(target=watch, daemon=True).start()
|
||||
print(f"\n Demo console → http://127.0.0.1:{PORT}\n")
|
||||
print(f" engine {ENGINE} · cloud {CLOUD} · signed in as {EMAIL}\n")
|
||||
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -66,7 +66,7 @@ func NewApp() *App {
|
||||
return &App{
|
||||
cfg: cfg,
|
||||
cloud: cloud.New(envOr("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")),
|
||||
local: local.New(base, cfg.APIUser, cfg.APIPassword),
|
||||
local: localWithCreds(base, cfg),
|
||||
proxy: newStreamProxy(),
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,14 @@ func (a *App) startup(ctx context.Context) {
|
||||
a.sup = agentengine.New(agentengine.Options{
|
||||
Command: func(c context.Context) *exec.Cmd {
|
||||
cmd := exec.CommandContext(c, exe, a.cfg.EngineArgs...)
|
||||
// Run the engine FROM a known directory rather than from whatever
|
||||
// happened to launch us. A double-clicked bundle hands its child
|
||||
// "/", and an engine invoked as `-m behavision` then cannot find
|
||||
// itself - measured on macOS, where it retried forever.
|
||||
cmd.Dir = a.cfg.EngineDir
|
||||
if cmd.Dir == "" {
|
||||
cmd.Dir = agentpaths.InstallRoot()
|
||||
}
|
||||
// How the engine learns where to post its detections. Its config
|
||||
// already reads `events.webhook_url: ${BEHAVISION_WEBHOOK_URL}`,
|
||||
// and python-dotenv does not override a variable the process
|
||||
@@ -125,6 +133,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
})
|
||||
|
||||
a.startPipeline(ctx)
|
||||
go a.watchConfig(ctx)
|
||||
|
||||
// Recognition starts with the app. Until this, the engine only ever
|
||||
// started when somebody pressed Start - which meant a till that rebooted
|
||||
@@ -435,6 +444,9 @@ func (a *App) Claim(code string) (SessionInfo, error) {
|
||||
a.cfg.BrokerPassword = b.MQTTPass
|
||||
a.cfg.AgentToken = b.AgentToken
|
||||
a.cfg.CloudBase = a.cloud.Base
|
||||
// A new head office: whoever was signed in was signed in somewhere else.
|
||||
a.cfg.SessionToken, a.cfg.SessionRefresh, a.cfg.SessionEmail = "", "", ""
|
||||
a.cloud.Clear()
|
||||
caPath, err := enrol.SaveCA(b.CACert, agentpaths.BrokerCA())
|
||||
if err != nil {
|
||||
return SessionInfo{}, err
|
||||
@@ -464,6 +476,62 @@ func (a *App) Claim(code string) (SessionInfo, error) {
|
||||
// the current config. Only Claim needs it today; it exists as its own method
|
||||
// because "stop everything that reads the config, then start it" is the part
|
||||
// that is easy to get half right.
|
||||
// watchConfig reloads agent.json when something else writes it.
|
||||
//
|
||||
// behavision-setup re-run on a PC with the app open re-claims the shop and
|
||||
// rotates its API token; the running app kept the old one and every camera
|
||||
// sync was refused from then on - heartbeats still flowed, so head office
|
||||
// looked fine while the cameras went stale. A claim from `behavision-agent
|
||||
// claim` does the same. Rather than ask people to restart the app, the app
|
||||
// watches the file and picks the new credentials up itself.
|
||||
func (a *App) watchConfig(ctx context.Context) {
|
||||
path := agentpaths.AgentConfig()
|
||||
last := mtime(path)
|
||||
t := time.NewTicker(10 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
now := mtime(path)
|
||||
if now.IsZero() || now.Equal(last) {
|
||||
continue
|
||||
}
|
||||
last = now
|
||||
fresh, err := agentcfg.Load(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fresh = fresh.WithEngineCredentials(agentpaths.APICredentials())
|
||||
a.mu.Lock()
|
||||
changed := fresh.AgentToken != a.cfg.AgentToken || fresh.SiteID != a.cfg.SiteID ||
|
||||
fresh.BrokerPassword != a.cfg.BrokerPassword || fresh.CloudBase != a.cfg.CloudBase ||
|
||||
fresh.Standalone != a.cfg.Standalone
|
||||
if changed {
|
||||
// Keep this process's live session; a claim clears it in the file
|
||||
// deliberately, and that is honoured too.
|
||||
a.cfg = fresh
|
||||
if fresh.SessionToken == "" {
|
||||
a.cloud.Clear()
|
||||
}
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if changed {
|
||||
a.restartPipeline()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mtime(path string) time.Time {
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return st.ModTime()
|
||||
}
|
||||
|
||||
func (a *App) restartPipeline() {
|
||||
if a.stopBridge != nil {
|
||||
a.stopBridge()
|
||||
@@ -501,6 +569,8 @@ type EngineStatus struct {
|
||||
Reachable bool `json:"reachable"`
|
||||
Model string `json:"recognition_model,omitempty"`
|
||||
Cameras map[string]bool `json:"cameras,omitempty"`
|
||||
// Progress is the first-run model download, when one is happening.
|
||||
Progress *agentengine.Progress `json:"progress,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) EngineStatus() EngineStatus {
|
||||
@@ -511,6 +581,9 @@ func (a *App) EngineStatus() EngineStatus {
|
||||
st, err := a.sup.State()
|
||||
out.State = string(st)
|
||||
out.Restarts = a.sup.Restarts()
|
||||
if p := a.sup.Progress(); p.What != "" {
|
||||
out.Progress = &p
|
||||
}
|
||||
if err != nil {
|
||||
out.Error = err.Error()
|
||||
}
|
||||
@@ -549,6 +622,14 @@ func (a *App) Cameras() ([]map[string]any, error) {
|
||||
return a.local.Cameras(ctx)
|
||||
}
|
||||
|
||||
// DiscoverCameras lists the cameras on this PC's network, so the add-camera
|
||||
// form is a pick-list and not a request for an IP address nobody knows.
|
||||
func (a *App) DiscoverCameras() (map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
return a.local.DiscoverCameras(ctx)
|
||||
}
|
||||
|
||||
func (a *App) TestCamera(cam map[string]any) (map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
@@ -742,3 +823,12 @@ func envOr(key, def string) string {
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// localWithCreds builds the engine client with a credential resolver, so a
|
||||
// first run - where the engine writes its credential after the app has looked
|
||||
// for it - recovers by itself instead of 401ing for the life of the process.
|
||||
func localWithCreds(base string, cfg agentcfg.Config) *local.Client {
|
||||
c := local.New(base, cfg.APIUser, cfg.APIPassword)
|
||||
c.Creds = agentcfg.NewCreds(agentpaths.APICredentials(), cfg.APIUser, cfg.APIPassword)
|
||||
return c
|
||||
}
|
||||
|
||||
25
desktop/darwin_link.go
Normal file
25
desktop/darwin_link.go
Normal file
@@ -0,0 +1,25 @@
|
||||
//go:build darwin
|
||||
|
||||
// Link the framework Wails' darwin frontend forgets.
|
||||
//
|
||||
// It references UTType (UniformTypeIdentifiers) without linking it, so a macOS
|
||||
// build fails at the LINK step with `Undefined symbols: _OBJC_CLASS_$_UTType`
|
||||
// - after compiling everything successfully, which makes it read like a broken
|
||||
// toolchain rather than one missing flag. That is why there was no Mac build:
|
||||
// not a design limit, a link error nobody had chased.
|
||||
//
|
||||
// Declared in the source rather than passed as CGO_LDFLAGS on the command
|
||||
// line, for the same reason deploy.sh now finds Go itself: a build that needs
|
||||
// the operator to know an incantation is a build that does not happen. Plain
|
||||
// `go build` and `wails build` both work on a Mac with this file present, and
|
||||
// the build tag makes it inert everywhere else.
|
||||
//
|
||||
// Note for anyone editing: the comment directly above `import "C"` is cgo's C
|
||||
// PREAMBLE, not documentation. This paragraph sits above `package main` on
|
||||
// purpose - put it there and the prose is compiled as C, which is how the
|
||||
// first attempt failed.
|
||||
|
||||
package main
|
||||
|
||||
// #cgo LDFLAGS: -framework UniformTypeIdentifiers
|
||||
import "C"
|
||||
40
desktop/frontend/dist/assets/index-Be_Iv2Nz.js
vendored
Normal file
40
desktop/frontend/dist/assets/index-Be_Iv2Nz.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
40
desktop/frontend/dist/assets/index-upywadx9.js
vendored
40
desktop/frontend/dist/assets/index-upywadx9.js
vendored
File diff suppressed because one or more lines are too long
4
desktop/frontend/dist/index.html
vendored
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-upywadx9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-Z_jL3Bie.css">
|
||||
<script type="module" crossorigin src="./assets/index-Be_Iv2Nz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-lhDNZRcC.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -40,10 +40,20 @@ export default function App() {
|
||||
const [helping, setHelping] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try { setSession(await api.session()) } catch { setSession(null) }
|
||||
setBooting(false)
|
||||
})()
|
||||
let alive = true
|
||||
const load = async () => {
|
||||
try {
|
||||
const s = await api.session()
|
||||
if (alive) setSession(prev => JSON.stringify(prev) === JSON.stringify(s) ? prev : s)
|
||||
} catch { if (alive) setSession(null) }
|
||||
if (alive) setBooting(false)
|
||||
}
|
||||
load()
|
||||
// Re-read every few seconds: a session the server has ended - or one
|
||||
// that never belonged to this head office - must put Login back on
|
||||
// screen, not leave "session expired" banners on every page.
|
||||
const id = setInterval(load, 8000)
|
||||
return () => { alive = false; clearInterval(id) }
|
||||
}, [])
|
||||
|
||||
if (!isDesktop()) {
|
||||
@@ -123,7 +133,7 @@ export default function App() {
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main">
|
||||
<Current session={session} />
|
||||
<Current session={session} onNavigate={setView} />
|
||||
{/* Loya's door, top right of every screen. A buddy you have to find in
|
||||
a sidebar is not around; one in the corner is. */}
|
||||
{!helping && (
|
||||
@@ -162,6 +172,7 @@ function EngineBox() {
|
||||
// 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 && s.progress) { tone = 'warn'; text = `Downloading ${s.progress.what}… ${s.progress.percent}%` }
|
||||
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' }
|
||||
|
||||
@@ -32,6 +32,7 @@ export const api = {
|
||||
|
||||
cameras: () => call('Cameras'),
|
||||
testCamera: (cam) => call('TestCamera', cam),
|
||||
discoverCameras: () => call('DiscoverCameras'),
|
||||
saveCamera: (id, cam) => call('SaveCamera', id, cam),
|
||||
deleteCamera: (id) => call('DeleteCamera', id),
|
||||
startPlacement: (id, seconds) => call('StartPlacementCheck', id, seconds),
|
||||
|
||||
@@ -47,6 +47,11 @@ if (scenario) {
|
||||
StartEngine: () => delay({state: 'running', reachable: true}),
|
||||
StopEngine: () => delay({state: 'stopped', reachable: false}),
|
||||
Cameras: () => delay(st.cameras.map(c => ({...c, connected: true, frames: 1200, faces: 9}))),
|
||||
DiscoverCameras: () => delay({networks: ['192.168.1.0/24'], cameras: [
|
||||
{host: '192.168.1.122', rtsp: true, onvif: true, name: 'HIKVISION DS-2CD2043G2', make: 'hikvision'},
|
||||
{host: '192.168.1.121', rtsp: true, onvif: true, name: 'IPC-model IPC', make: ''},
|
||||
{host: '192.168.1.40', rtsp: true, onvif: false, name: '', make: ''},
|
||||
]}, 2500),
|
||||
TestCamera: (cam) => delay({ok: Boolean(cam.host), width: 800, height: 448, codec: 'hevc', error: cam.host ? '' : 'no host'}, 1500),
|
||||
SaveCamera: (id, cam) => { const c = {id: id || cam.id || 'cam' + (st.cameras.length + 1), ...cam, has_password: Boolean(cam.password)}; delete c.password; st.cameras = [...st.cameras.filter(x => x.id !== c.id), c]; return delay(c) },
|
||||
DeleteCamera: (id) => { st.cameras = st.cameras.filter(c => c.id !== id); return delay(null) },
|
||||
|
||||
@@ -598,3 +598,39 @@ tr.click { cursor: pointer; } tr.click:hover td { background: var(--s2); }
|
||||
box-shadow: var(--shadow-lg); cursor: pointer; font-size: 13px; font-weight: 600; }
|
||||
.loya-fab img { width: 20px; height: 20px; object-fit: contain; }
|
||||
.loya-fab:hover { background: var(--accent-3); border-color: var(--accent); }
|
||||
|
||||
/* Camera finder: the pick-list that replaces "type an IP address". */
|
||||
.finder { display: flex; flex-direction: column; gap: var(--sp-3); align-items: flex-start; padding: var(--sp-3) var(--sp-4); border: 1px dashed var(--line); border-radius: var(--r); background: var(--s2); }
|
||||
.finder p { font-size: 13px; color: var(--ink-2); line-height: 1.5; margin: 0; }
|
||||
.finder.busy { flex-direction: row; align-items: center; color: var(--ink-2); font-size: 13px; border-style: solid; }
|
||||
.foundlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.foundlist li button { width: 100%; display: flex; align-items: center; gap: var(--sp-3); padding: 10px 12px; border-radius: var(--r); border: 1px solid var(--line); background: var(--s2); color: var(--ink); cursor: pointer; text-align: left; font-size: 13px; }
|
||||
.foundlist li button:hover { border-color: var(--accent); }
|
||||
.foundlist li.picked button { border-color: var(--accent); background: var(--accent-3); }
|
||||
.foundlist .mono { font-family: var(--font-mono); font-size: 12.5px; min-width: 120px; }
|
||||
.foundlist .what { flex: 1; color: var(--ink-2); }
|
||||
.foundlist li.rescan button { width: auto; border: 0; background: none; padding: 4px 0; color: var(--ink-3); }
|
||||
|
||||
/* Welcome: two paths, each a card. */
|
||||
.login .box.wide { max-width: 640px; }
|
||||
.choices { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); margin-top: var(--sp-2); }
|
||||
@media (max-width: 720px) { .choices { grid-template-columns: 1fr; } }
|
||||
.choice { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; text-align: left; padding: var(--sp-4);
|
||||
border-radius: var(--r-lg); border: 1px solid var(--line); background: var(--s2); color: var(--ink); cursor: pointer; }
|
||||
.choice:hover { border-color: var(--accent); background: var(--accent-3); }
|
||||
.choice b { font-size: 14px; }
|
||||
.choice span { font-size: 12.5px; color: var(--ink-2); line-height: 1.5; }
|
||||
.choice em { font-family: var(--font-mono); font-style: normal; font-size: 11.5px; }
|
||||
.choice svg { color: var(--accent); }
|
||||
.login .foot em { font-style: normal; color: var(--ink-2); }
|
||||
.linkbtn { display: inline-flex; align-items: center; gap: 6px; }
|
||||
|
||||
/* Getting started */
|
||||
.starter { margin-bottom: var(--sp-4); }
|
||||
.starter .panelhead { padding: var(--sp-3) var(--sp-4); }
|
||||
.starter .steps { margin: 0; padding: var(--sp-3) var(--sp-4) 0; }
|
||||
.starter .steps.compact li { padding: var(--sp-3) var(--sp-4) var(--sp-3) 52px; }
|
||||
.starter .steps li .btn { margin-top: var(--sp-2); }
|
||||
.starter .note { padding: var(--sp-3) var(--sp-4); font-size: 12px; }
|
||||
.btn.ghost { background: none; border-color: transparent; color: var(--ink-3); }
|
||||
.btn.ghost:hover { color: var(--ink); }
|
||||
|
||||
@@ -36,7 +36,12 @@ export default function Assistant({ session, onClose }) {
|
||||
const a = await api.ask(history.map(t => ({ role: t.role, text: t.text })))
|
||||
setTurns([...history, { role: 'assistant', text: a.text, used: a.used ?? [] }])
|
||||
} catch (e) {
|
||||
setError(message(e))
|
||||
const m = message(e)
|
||||
// Her only failure that is not hers: the login is gone. Say what to do,
|
||||
// not "session expired" - the shell returns to Login within seconds.
|
||||
setError(/session expired|unauthori[sz]ed/i.test(m)
|
||||
? 'You’re signed out of head office, so I can’t look anything up. Sign in again and ask me once more.'
|
||||
: m)
|
||||
setTurns(turns) // the question stays in the box, not in the transcript
|
||||
setDraft(q)
|
||||
} finally { setBusy(false) }
|
||||
|
||||
@@ -165,6 +165,24 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
||||
|
||||
const chosen = makeById(make)
|
||||
|
||||
// The camera is picked from a scan of the shop's network rather than typed.
|
||||
// Nobody knows their camera's address; the sticker is under the camera and
|
||||
// the menu is different in every make's app. The scan names ONVIF cameras
|
||||
// and lists anything with the RTSP port open; picking one fills the
|
||||
// address and, when the make is recognisable, the stream path too.
|
||||
const [scan, setScan] = useState(null) // null | 'busy' | {cameras, networks} | {error}
|
||||
async function findCameras() {
|
||||
setScan('busy')
|
||||
try { setScan(await api.discoverCameras()) } catch (e) { setScan({ error: message(e) }) }
|
||||
}
|
||||
function pick(c) {
|
||||
const m = c.make ? makeById(c.make) : null
|
||||
setF(prev => ({ ...prev, host: c.host, path: m?.path || prev.path,
|
||||
id: prev.id || (m ? '' : ''), }))
|
||||
if (m) setMake(m.id)
|
||||
setTest(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="sheet">
|
||||
@@ -176,6 +194,40 @@ function CameraSheet({ cam, onClose, onSaved }) {
|
||||
<p className="lead">Three things from the camera: its address, its make, and its password. Test before you save — a wrong address is the most common mistake.</p>
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
|
||||
{isNew && (
|
||||
<section className="formsection">
|
||||
<h4>Find it</h4>
|
||||
{scan === null && (
|
||||
<div className="finder">
|
||||
<p>Behavision can look for cameras on this shop’s network.</p>
|
||||
<button type="button" className="btn primary" onClick={findCameras}><Icon.Search size={14} />Find cameras on this network</button>
|
||||
</div>
|
||||
)}
|
||||
{scan === 'busy' && <div className="finder busy"><span className="spinner" />Looking on the network… a few seconds.</div>}
|
||||
{scan?.error && <div className="err"><Icon.Warning size={15} />{scan.error}</div>}
|
||||
{scan?.cameras && (
|
||||
scan.cameras.length === 0
|
||||
? <div className="finder">
|
||||
<p>Nothing answered on {scan.networks?.join(', ') || 'this network'}. The camera may be on a different network, switched off, or not yet connected — check its cable and power, then try again. You can still type its address below.</p>
|
||||
<button type="button" className="btn" onClick={findCameras}>Try again</button>
|
||||
</div>
|
||||
: <ul className="foundlist">
|
||||
{scan.cameras.map(c => (
|
||||
<li key={c.host} className={f.host === c.host ? 'picked' : ''}>
|
||||
<button type="button" onClick={() => pick(c)}>
|
||||
<span className="mono">{c.host}</span>
|
||||
<span className="what">{c.name || (c.rtsp ? 'Streams video (RTSP)' : 'Answers ONVIF')}</span>
|
||||
{c.make && <span className="tag seen">{makeById(c.make).label}</span>}
|
||||
{f.host === c.host && <Icon.Check size={16} />}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li className="rescan"><button type="button" className="btn sm" onClick={findCameras}>Scan again</button></li>
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="formsection">
|
||||
<h4>The camera</h4>
|
||||
{isNew && (
|
||||
|
||||
@@ -16,7 +16,7 @@ import * as Icon from '../ui/icons.jsx'
|
||||
// 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() {
|
||||
export default function Live({ onNavigate }) {
|
||||
const { data, error } = usePolled(() => api.live(), 3000)
|
||||
const { data: pipe } = usePolled(() => api.pipelineStatus(), 5000)
|
||||
|
||||
@@ -47,6 +47,8 @@ export default function Live() {
|
||||
|
||||
<PipelineStrip pipe={pipe} cameras={cameras} up={up} />
|
||||
|
||||
<GettingStarted cameras={cameras} arrivals={arrivals} onNavigate={onNavigate} />
|
||||
|
||||
<div className="panel arrivals-panel">
|
||||
<div className="panelhead">
|
||||
<h3>Who just walked in</h3>
|
||||
@@ -82,6 +84,65 @@ export default function Live() {
|
||||
)
|
||||
}
|
||||
|
||||
// The first five minutes, as a checklist that ticks itself.
|
||||
//
|
||||
// Before this the Live screen after install was "Nobody yet" over a row of
|
||||
// dashes, with an amber "No cameras" in the far corner. Nothing said what to
|
||||
// do next. This says the three things, in order, and each step reads its own
|
||||
// state from the engine: recognition ready, a camera added, the camera
|
||||
// proven by a walk-past. It disappears on its own once someone has actually
|
||||
// been recognised, because at that point the product has explained itself.
|
||||
function GettingStarted({ cameras, arrivals, onNavigate }) {
|
||||
const { data: eng } = usePolled(() => api.engineStatus(), 4000)
|
||||
const [hidden, setHidden] = useState(() => { try { return localStorage.getItem('bv.gettingStarted') === 'done' } catch { return false } })
|
||||
const hasCamera = cameras.length > 0
|
||||
const anyUp = cameras.some(c => c.connected)
|
||||
const { data: checks } = usePolled(async () => {
|
||||
const out = {}
|
||||
for (const c of cameras) { try { out[c.camera_id] = await api.placementResult(c.camera_id) } catch { /* not yet */ } }
|
||||
return out
|
||||
}, 10000, [cameras.map(c => c.camera_id).join('|')])
|
||||
const proven = Object.values(checks ?? {}).some(r => r && !r.running && r.verdict === 'good')
|
||||
const recognised = arrivals.length > 0
|
||||
|
||||
if (hidden || recognised) return null
|
||||
const engineReady = Boolean(eng?.reachable && eng?.recognition_model)
|
||||
const progress = eng?.progress
|
||||
|
||||
const steps = [
|
||||
{ done: engineReady, now: !engineReady,
|
||||
title: engineReady ? `Recognition ready (${eng.recognition_model})` : progress ? `Downloading ${progress.what}… ${progress.percent}%` : 'Starting recognition…',
|
||||
text: engineReady ? null : 'First start downloads about 275 MB of recognition models. A few minutes on a normal connection; nothing to do meanwhile.' },
|
||||
{ done: hasCamera && anyUp, now: engineReady && !(hasCamera && anyUp),
|
||||
title: hasCamera ? (anyUp ? 'Camera connected' : 'Camera added — not connecting yet') : 'Add your camera',
|
||||
text: hasCamera ? (anyUp ? null : 'Check its password and stream path under Cameras → Edit.') : 'Behavision can find it on the network; you type only its password.',
|
||||
action: hasCamera ? null : { label: 'Add camera', go: 'cameras' } },
|
||||
{ done: proven, now: hasCamera && anyUp && !proven,
|
||||
title: proven ? 'Camera proven — it can recognise faces' : 'Walk past the camera',
|
||||
text: proven ? null : 'Run Check placement and walk past like a customer for 25 seconds. Only a “good” verdict means it will recognise people.',
|
||||
action: hasCamera && anyUp && !proven ? { label: 'Check placement', go: 'cameras' } : null },
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="panel starter">
|
||||
<div className="panelhead">
|
||||
<h3>Getting started</h3>
|
||||
<button className="btn sm ghost" onClick={() => { try { localStorage.setItem('bv.gettingStarted', 'done') } catch {} ; setHidden(true) }}>Hide</button>
|
||||
</div>
|
||||
<ol className="steps compact">
|
||||
{steps.map((st, i) => (
|
||||
<li key={i} className={st.done ? 'done' : st.now ? 'now' : ''}>
|
||||
<b>{st.title}</b>
|
||||
{st.text && <span>{st.text}</span>}
|
||||
{st.action && <button className="btn sm primary" onClick={() => onNavigate?.(st.action.go)}>{st.action.label}</button>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="note">The moment a customer is recognised, this list goes away.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// One customer, big enough to match against the person in front of you.
|
||||
function Arrival({ e, fresh }) {
|
||||
const isNew = e.type === 'person.new'
|
||||
|
||||
@@ -15,7 +15,6 @@ export default function Setup({ onDone, onCancel }) {
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [alone, setAlone] = useState(false)
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
@@ -40,13 +39,66 @@ export default function Setup({ onDone, onCancel }) {
|
||||
}
|
||||
}
|
||||
|
||||
// First launch: a choice, not a code box. Two thirds of the people who
|
||||
// open this have no idea what an installation code is; the other third has
|
||||
// one in their hand. Both must see their own path in the first second.
|
||||
const [path, setPath] = useState(onCancel ? 'code' : null)
|
||||
|
||||
if (path === null) {
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="box wide">
|
||||
<span className="mark"><img src={logo} alt="" /></span>
|
||||
<h1>Welcome to Behavision</h1>
|
||||
<p className="lead">
|
||||
This PC will watch your shop’s cameras and recognise returning customers.
|
||||
First, one question: is this shop managed from a head office?
|
||||
</p>
|
||||
<div className="choices">
|
||||
<button type="button" className="choice" onClick={() => setPath('code')}>
|
||||
<Icon.Cloud size={22} />
|
||||
<b>Yes — I have an installation code</b>
|
||||
<span>Head office gave you a code like <em>ABCDEF-123456-…</em>. This PC joins that shop and gets its cameras from there.</span>
|
||||
</button>
|
||||
<button type="button" className="choice" onClick={() => setPath('alone')}>
|
||||
<Icon.Shield size={22} />
|
||||
<b>No — set up on this PC only</b>
|
||||
<span>Cameras, customers and recognition stay on this PC. Nothing is sent anywhere. You can link to a head office later.</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (path === 'alone') {
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="box">
|
||||
<span className="mark"><img src={logo} alt="" /></span>
|
||||
<h1>On this PC only</h1>
|
||||
<p className="lead">
|
||||
Behavision will run entirely here. Next you’ll add your camera — it can find it on the network for you — and walk past it once so it can prove it works.
|
||||
</p>
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
<button type="button" className="btn primary" disabled={!!busy} onClick={standalone}>
|
||||
{busy === 'alone' ? 'Setting up…' : 'Continue'}
|
||||
</button>
|
||||
<div className="alt">
|
||||
<button type="button" className="linkbtn" onClick={() => setPath(null)}><Icon.Back size={14} /> Back</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
<h1>{onCancel ? 'Link to head office' : 'Join your shop'}</h1>
|
||||
<p className="lead">
|
||||
Type the installation code for this shop. You only do this once.
|
||||
Type the installation code head office gave you. It works once, and this PC becomes that shop.
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
@@ -67,38 +119,12 @@ export default function Setup({ onDone, onCancel }) {
|
||||
</button>
|
||||
</form>
|
||||
<p className="foot">
|
||||
The code works once. Ask whoever manages your shops for it — they can
|
||||
create one from the Behavision platform, under the shop.
|
||||
Don’t have one? Whoever runs head office creates it under the shop: <em>Shops → the shop → Set up a shop PC</em>.
|
||||
</p>
|
||||
|
||||
{/* The second way out of this screen, and the reason it exists.
|
||||
Recognition, the cameras and this shop's own gallery all run on
|
||||
this PC and need no server, so a shop with one till and no head
|
||||
office was being blocked from adding a camera until somebody
|
||||
issued it a code — the software refusing to do the thing it is
|
||||
for. Linking later is still one click away, and it keeps the
|
||||
visits already recorded here. */}
|
||||
<div className="alt">
|
||||
{onCancel
|
||||
? <button type="button" className="linkbtn" onClick={onCancel}>
|
||||
Not now — go back
|
||||
</button>
|
||||
: !alone
|
||||
? <button type="button" className="linkbtn" onClick={() => setAlone(true)}>
|
||||
No head office — set this PC up on its own
|
||||
</button>
|
||||
: <>
|
||||
<p className="note">
|
||||
This PC will watch its cameras and recognise returning
|
||||
customers on its own. Nothing is sent anywhere. You can link
|
||||
it to head office later without losing anything recorded
|
||||
here.
|
||||
</p>
|
||||
<button type="button" className="btn" disabled={!!busy}
|
||||
onClick={standalone}>
|
||||
{busy === 'alone' ? 'Setting up…' : 'Use this PC on its own'}
|
||||
</button>
|
||||
</>}
|
||||
? <button type="button" className="linkbtn" onClick={onCancel}>Not now — go back</button>
|
||||
: <button type="button" className="linkbtn" onClick={() => setPath(null)}><Icon.Back size={14} /> Back</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,8 +108,16 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err
|
||||
}
|
||||
if rerr := c.Refresh(ctx); rerr != nil {
|
||||
// The refresh token is gone too, so this really is a sign-in, not a
|
||||
// transient failure. Report it as such so the UI shows the login sheet
|
||||
// rather than an error dialog.
|
||||
// transient failure. Forget the session - in memory AND on disk, through
|
||||
// the same callback that persists rotations - so the app goes back to
|
||||
// Login instead of showing "session expired" on every screen until
|
||||
// somebody finds Sign out. Seen on a PC that had been claimed against a
|
||||
// demo head office and then re-claimed against the real one: the old
|
||||
// login sat there, dead, for the whole session.
|
||||
c.Clear()
|
||||
if c.onRefresh != nil {
|
||||
c.onRefresh(Session{})
|
||||
}
|
||||
return ErrUnauthorized
|
||||
}
|
||||
return c.send(ctx, method, path, raw, out)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
agentconfig "github.com/loyaly/behavision-agent/pkg/config"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -20,7 +21,11 @@ type Client struct {
|
||||
Base string
|
||||
User string
|
||||
Password string
|
||||
http *http.Client
|
||||
// Creds re-reads the engine's generated credential when one is rejected.
|
||||
// On a first run the app starts the engine, and the engine writes that
|
||||
// file seconds later - after the app has already looked for it.
|
||||
Creds *agentconfig.Creds
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New(base, user, password string) *Client {
|
||||
@@ -48,8 +53,12 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.User != "" {
|
||||
req.SetBasicAuth(c.User, c.Password)
|
||||
user, pass := c.User, c.Password
|
||||
if c.Creds != nil {
|
||||
user, pass = c.Creds.Get()
|
||||
}
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
@@ -105,6 +114,12 @@ func (c *Client) DeleteCamera(ctx context.Context, id string) error {
|
||||
return c.do(ctx, http.MethodDelete, "/api/cameras/"+id, nil, nil)
|
||||
}
|
||||
|
||||
// DiscoverCameras asks the engine to scan the shop's network. A few seconds.
|
||||
func (c *Client) DiscoverCameras(ctx context.Context) (map[string]any, error) {
|
||||
var out map[string]any
|
||||
return out, c.do(ctx, http.MethodGet, "/api/cameras/discover", nil, &out)
|
||||
}
|
||||
|
||||
func (c *Client) TestCamera(ctx context.Context, cam map[string]any) (map[string]any, error) {
|
||||
var out map[string]any
|
||||
return out, c.do(ctx, http.MethodPost, "/api/cameras/test", cam, &out)
|
||||
|
||||
@@ -12,10 +12,12 @@ import (
|
||||
"context"
|
||||
"embed"
|
||||
"log"
|
||||
runtime2 "runtime"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/mac"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
@@ -43,8 +45,13 @@ func main() {
|
||||
UniqueId: "ai.loyaly.behavision.desktop",
|
||||
OnSecondInstanceLaunch: func(options.SecondInstanceData) {
|
||||
if ctxRef != nil {
|
||||
runtime.Show(ctxRef)
|
||||
runtime.WindowUnminimise(ctxRef)
|
||||
// The same four calls the tray uses, and for the same reasons:
|
||||
// this runs on Wails' own listener goroutine rather than the
|
||||
// window's thread, and the launching process holds the
|
||||
// foreground, so without the flip the window comes back behind
|
||||
// it. Double-clicking the desktop icon while it is already
|
||||
// running is the single most common way anyone reaches this.
|
||||
go openWindow(ctxRef)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -61,15 +68,30 @@ func main() {
|
||||
// Closing the window hides it rather than quitting: the engine must
|
||||
// keep recognising after a shop assistant clicks the X, and the tray
|
||||
// is where they get the window back.
|
||||
HideWindowOnClose: true,
|
||||
// Windows hides on close because the tray is how the window comes
|
||||
// back and how recognition is stopped. macOS has no tray here (see
|
||||
// tray_run_darwin.go), so hiding would leave a running engine with no
|
||||
// window, no tray and no way to reach either - force-quit or nothing.
|
||||
// Closing the window therefore quits, which also stops the engine
|
||||
// through OnShutdown. Same rule as the tray's Quit: never leave it
|
||||
// watching with no visible control.
|
||||
HideWindowOnClose: runtime2.GOOS == "windows",
|
||||
OnStartup: func(ctx context.Context) {
|
||||
ctxRef = ctx
|
||||
app.startup(ctx)
|
||||
tray.start(ctx)
|
||||
},
|
||||
OnBeforeClose: func(ctx context.Context) bool {
|
||||
runtime.Hide(ctx)
|
||||
return true // prevent the close
|
||||
if runtime2.GOOS != "windows" {
|
||||
return false // let it close, and OnShutdown stops the engine
|
||||
}
|
||||
// WindowHide, not Hide. They are different calls on Windows -
|
||||
// WindowHide locks the OS thread for the Win32 work and Hide does
|
||||
// not - and the tray's reopen uses WindowShow, so hiding through
|
||||
// the other one leaves the pair mismatched. Same call, opposite
|
||||
// direction.
|
||||
runtime.WindowHide(ctx)
|
||||
return true // prevent the close; the tray is how it comes back
|
||||
},
|
||||
OnShutdown: func(ctx context.Context) {
|
||||
tray.stop()
|
||||
@@ -77,6 +99,17 @@ func main() {
|
||||
app.StopEngine()
|
||||
},
|
||||
Bind: []any{app},
|
||||
// This block has to EXIST, not merely be empty. Wails computes
|
||||
// `zoomable = !Mac.DisableZoom` inside `if frontendOptions.Mac !=
|
||||
// nil`, and the variable defaults to 0 - so leaving Mac unset does not
|
||||
// mean "defaults", it means the green maximise button is created dead.
|
||||
// There was a Windows block and no Mac one, so the window could not be
|
||||
// zoomed on macOS and nothing anywhere said why.
|
||||
Mac: &mac.Options{
|
||||
WebviewIsTransparent: false,
|
||||
WindowIsTranslucent: false,
|
||||
DisableZoom: false,
|
||||
},
|
||||
Windows: &windows.Options{
|
||||
WebviewIsTransparent: false,
|
||||
WindowIsTranslucent: false,
|
||||
|
||||
@@ -55,9 +55,7 @@ func (t *tray) start(ctx context.Context) {
|
||||
if trayDisabled() {
|
||||
return
|
||||
}
|
||||
t.once.Do(func() {
|
||||
go systray.Run(func() { t.onReady(ctx) }, func() {})
|
||||
})
|
||||
t.once.Do(func() { startSystray(func() { t.onReady(ctx) }) })
|
||||
}
|
||||
|
||||
func (t *tray) stop() {
|
||||
@@ -99,11 +97,15 @@ func (t *tray) onReady(ctx context.Context) {
|
||||
case <-t.quit:
|
||||
return
|
||||
case <-t.mOpen.ClickedCh:
|
||||
runtime.Show(ctx)
|
||||
// In a goroutine, like Start and Stop: this sleeps, and a menu
|
||||
// loop that sleeps is a tray that ignores the next click.
|
||||
go openWindow(ctx)
|
||||
case <-t.mStart.ClickedCh:
|
||||
t.app.StartEngine()
|
||||
// Never on the menu loop itself: a stop waits for the process to
|
||||
// exit, and a menu that is deaf for the duration looks broken.
|
||||
go func() { t.app.StartEngine(); t.refresh() }()
|
||||
case <-t.mStop.ClickedCh:
|
||||
t.app.StopEngine()
|
||||
go func() { t.app.StopEngine(); t.refresh() }()
|
||||
case <-t.mLogs.ClickedCh:
|
||||
runtime.BrowserOpenURL(ctx, "file://"+logsDir())
|
||||
case <-t.mQuit.ClickedCh:
|
||||
@@ -129,23 +131,29 @@ func (t *tray) poll(ctx context.Context) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
s := t.app.EngineStatus()
|
||||
state, label := describe(s)
|
||||
systray.SetIcon(iconFor(state))
|
||||
systray.SetTooltip("Behavision — " + label)
|
||||
if t.mStatus != nil {
|
||||
t.mStatus.SetTitle(label)
|
||||
}
|
||||
running := s.State == "running"
|
||||
if t.mStart != nil && t.mStop != nil {
|
||||
if running {
|
||||
t.mStart.Disable()
|
||||
t.mStop.Enable()
|
||||
} else {
|
||||
t.mStart.Enable()
|
||||
t.mStop.Disable()
|
||||
}
|
||||
}
|
||||
t.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refresh redraws the icon and the menu from EngineStatus - the same source
|
||||
// the window reads, so the two cannot disagree.
|
||||
func (t *tray) refresh() {
|
||||
s := t.app.EngineStatus()
|
||||
state, label := describe(s)
|
||||
systray.SetIcon(iconFor(state))
|
||||
systray.SetTooltip("Behavision — " + label)
|
||||
if t.mStatus != nil {
|
||||
t.mStatus.SetTitle(label)
|
||||
}
|
||||
running := s.State == "running" || s.State == "starting" || s.State == "backoff"
|
||||
if t.mStart != nil && t.mStop != nil {
|
||||
if running {
|
||||
t.mStart.Disable()
|
||||
t.mStop.Enable()
|
||||
} else {
|
||||
t.mStart.Enable()
|
||||
t.mStop.Disable()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,9 +168,13 @@ func describe(s EngineStatus) (state, label string) {
|
||||
case s.State == "stopped":
|
||||
return "stopped", "Stopped"
|
||||
case s.State == "failed":
|
||||
return "error", "Failed — " + firstLine(s.Error)
|
||||
// The supervisor's error is already a sentence (port in use, missing
|
||||
// library); show it whole, because it is the thing to act on.
|
||||
return "error", "Not running — " + firstLine(s.Error)
|
||||
case s.State == "backoff":
|
||||
return "error", fmt.Sprintf("Restarting (%d attempts)", s.Restarts)
|
||||
case !s.Reachable && s.Progress != nil:
|
||||
return "warn", fmt.Sprintf("Downloading %s… %d%%", s.Progress.What, s.Progress.Percent)
|
||||
case !s.Reachable:
|
||||
return "warn", "Starting…"
|
||||
case len(s.Cameras) == 0:
|
||||
@@ -202,3 +214,36 @@ func firstLine(s string) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// openWindow brings the dashboard back, and it takes four calls rather than
|
||||
// the one that was here.
|
||||
//
|
||||
// `runtime.Show` was wrong three times over, and the first is the one that
|
||||
// made it fail rather than merely misbehave:
|
||||
//
|
||||
// 1. WRONG THREAD. Wails implements Show() as a bare `mainWindow.Show()`,
|
||||
// while WindowShow() wraps the same work in runtime.LockOSThread. Win32
|
||||
// window operations have to run on the thread owning the window's message
|
||||
// pump; this is called from the SYSTRAY's goroutine, which is never that
|
||||
// thread. An unlocked call from an arbitrary goroutine is why clicking
|
||||
// "Open dashboard" did nothing reliable.
|
||||
//
|
||||
// 2. Showing is not un-minimising. A hidden window and a minimised one are
|
||||
// different states and Show only fixes the first, so a window the user
|
||||
// minimised stayed minimised.
|
||||
//
|
||||
// 3. Windows will not let a process that is not already in the foreground
|
||||
// take it - the shell refuses, and the window comes back BEHIND whatever
|
||||
// is being looked at. Clicking a tray icon is by definition a moment when
|
||||
// this application is not in the foreground, so that is not an edge case
|
||||
// here, it is every time.
|
||||
//
|
||||
// The always-on-top flip is the ordinary way to ask for the foreground anyway.
|
||||
// It is brief and it is why this cannot run on the menu loop.
|
||||
func openWindow(ctx context.Context) {
|
||||
runtime.WindowUnminimise(ctx)
|
||||
runtime.WindowShow(ctx)
|
||||
runtime.WindowSetAlwaysOnTop(ctx, true)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
runtime.WindowSetAlwaysOnTop(ctx, false)
|
||||
}
|
||||
|
||||
28
desktop/tray_run_darwin.go
Normal file
28
desktop/tray_run_darwin.go
Normal file
@@ -0,0 +1,28 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
// There is no tray on macOS, and that is a decision rather than an omission.
|
||||
//
|
||||
// macOS has exactly ONE main run loop and AppKit insists that windows and
|
||||
// status items are created on it. Wails already owns that loop. Two attempts,
|
||||
// both crashing within a second of launch:
|
||||
//
|
||||
// systray.Run -> SIGTRAP inside cgo: nativeLoop() takes the
|
||||
// main loop for itself, and Wails has it
|
||||
// systray.RunWithExternalLoop -> "NSWindow should only be instantiated on
|
||||
// the main thread!" - it registers in the
|
||||
// existing NSApplication but still builds
|
||||
// AppKit objects, and Wails' OnStartup is not
|
||||
// the main thread
|
||||
//
|
||||
// Making it work needs the status item created through a main-queue dispatch
|
||||
// inside Wails' own lifecycle, which is real work for a build whose entire
|
||||
// purpose is demoing on a developer's Mac. Windows is the platform this ships
|
||||
// to and its tray is the shop manager's only control surface; here the window
|
||||
// is right there in the Dock.
|
||||
//
|
||||
// The consequence is handled rather than left: with no tray there would be no
|
||||
// way back from a hidden window and no way to quit, so on macOS closing the
|
||||
// window stops the engine and exits. See main.go.
|
||||
func startSystray(onReady func()) {}
|
||||
10
desktop/tray_run_windows.go
Normal file
10
desktop/tray_run_windows.go
Normal file
@@ -0,0 +1,10 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "fyne.io/systray"
|
||||
|
||||
// systray.Run owns a message loop, and on Windows it is free to have its own:
|
||||
// the tray lives in its own thread with its own pump, beside the one Wails
|
||||
// runs for the window. A goroutine is all it needs.
|
||||
func startSystray(onReady func()) { go systray.Run(onReady, func() {}) }
|
||||
@@ -38,31 +38,60 @@ function Need($exe, $hint) {
|
||||
}
|
||||
}
|
||||
|
||||
# Run a NATIVE command and stop if it fails.
|
||||
#
|
||||
# $ErrorActionPreference = "Stop" does not do this. It governs PowerShell
|
||||
# errors; a .exe returning non-zero is not one, so the script sails past it.
|
||||
# That is not theoretical here: `npm ci` failing on a fresh Windows box would
|
||||
# have let the build continue, and `go build` would then have embedded the
|
||||
# STALE frontend/dist that is committed to this repository - producing an
|
||||
# installer that works, opens, and shows last month's UI, with nothing
|
||||
# anywhere saying so. The silent-wrong outcome, from the most likely failure.
|
||||
#
|
||||
# Output is NOT swallowed. `| Out-Null` on a failing install is how
|
||||
# run-local.sh once exited with no output at all, which took three runs to
|
||||
# diagnose; the same mistake is not worth repeating in a script that will be
|
||||
# run on a machine nobody is sitting at.
|
||||
function Run($exe) {
|
||||
$rest = $args
|
||||
& $exe @rest
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$exe $($rest -join ' ') failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
Need python "Install Python 3.11+ and tick 'Add to PATH'."
|
||||
Need go "Install Go 1.21+ from https://go.dev/dl/."
|
||||
Need npm "Install Node.js LTS from https://nodejs.org/."
|
||||
|
||||
Step "Python environment"
|
||||
Push-Location $root
|
||||
if (-not (Test-Path ".venv")) { python -m venv .venv }
|
||||
& .\.venv\Scripts\python -m pip install --upgrade pip | Out-Null
|
||||
& .\.venv\Scripts\python -m pip install -r requirements.txt pyinstaller | Out-Null
|
||||
if (-not (Test-Path ".venv")) { Run python -m venv .venv }
|
||||
Run .\.venv\Scripts\python -m pip install --upgrade pip
|
||||
Run .\.venv\Scripts\python -m pip install -r requirements.txt pyinstaller
|
||||
|
||||
Step "Engine tests"
|
||||
# The package is not worth building if the engine is broken, and finding that
|
||||
# out after the installer is signed is the expensive order to do it in.
|
||||
& .\.venv\Scripts\python -m pytest tests -q
|
||||
if ($LASTEXITCODE -ne 0) { throw "engine tests failed" }
|
||||
Run .\.venv\Scripts\python -m pytest tests -q
|
||||
|
||||
Step "Engine (PyInstaller, one-folder)"
|
||||
if (Test-Path (Join-Path $root "build")) { Remove-Item -Recurse -Force (Join-Path $root "build") }
|
||||
& .\.venv\Scripts\pyinstaller behavision.spec --noconfirm --distpath (Join-Path $dist "engine-build")
|
||||
if ($LASTEXITCODE -ne 0) { throw "pyinstaller failed" }
|
||||
Run .\.venv\Scripts\pyinstaller behavision.spec --noconfirm --distpath (Join-Path $dist "engine-build")
|
||||
|
||||
Step "Desktop app (Wails)"
|
||||
Push-Location (Join-Path $root "desktop\frontend")
|
||||
npm ci
|
||||
npm run build
|
||||
# A built dist is COMMITTED to this repository so `go build` type-checks
|
||||
# without npm (the //go:embed directive requires the directory to exist). That
|
||||
# convenience is a trap at package time: a silently failed npm build leaves
|
||||
# the old one in place and it embeds perfectly. So the marker is removed
|
||||
# first, and its reappearance is what proves this build produced the UI being
|
||||
# shipped rather than inheriting one.
|
||||
$marker = Join-Path $root "desktop\frontend\dist\index.html"
|
||||
if (Test-Path $marker) { Remove-Item -Force $marker }
|
||||
Run npm ci
|
||||
Run npm run build
|
||||
if (-not (Test-Path $marker)) { throw "npm run build reported success and produced no dist\index.html" }
|
||||
Pop-Location
|
||||
Push-Location (Join-Path $root "desktop")
|
||||
# Wails v2 talks to WebView2 through pure-Go bindings, so no cgo and no
|
||||
@@ -71,14 +100,17 @@ Push-Location (Join-Path $root "desktop")
|
||||
# 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"
|
||||
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" }
|
||||
Run go build -tags desktop,production -ldflags "-H windowsgui -X main.version=$Version" -o (Join-Path $root "desktop\build\bin\Behavision.exe") .
|
||||
Pop-Location
|
||||
|
||||
Step "Headless agent"
|
||||
Push-Location (Join-Path $root "agent")
|
||||
$env:CGO_ENABLED = "0" # the cgo resolver forces external linking
|
||||
go build -o (Join-Path $root "dist\behavision-agent.exe") .
|
||||
# `go build -o` does not create the target directory, and on a fresh clone
|
||||
# dist\ is gitignored and absent. It exists here only because PyInstaller ran
|
||||
# first and made it - an ordering dependency nothing states, so state it.
|
||||
New-Item -ItemType Directory -Force -Path $dist | Out-Null
|
||||
Run go build -o (Join-Path $root "dist\behavision-agent.exe") .
|
||||
Pop-Location
|
||||
|
||||
Step "WebView2 bootstrapper"
|
||||
@@ -104,8 +136,11 @@ Copy-Item (Join-Path $root "LICENSE") $stage -ErrorAction SilentlyContinue
|
||||
|
||||
$engineExe = Join-Path $stage "engine\behavision.exe"
|
||||
if (-not (Test-Path $engineExe)) { throw "engine exe missing at $engineExe" }
|
||||
& $engineExe paths
|
||||
if ($LASTEXITCODE -ne 0) { throw "the frozen engine cannot start - `paths` failed" }
|
||||
# The frozen engine has to START, not merely exist. A PyInstaller build that
|
||||
# is missing a native DLL links fine and dies on first launch - the classic
|
||||
# "works in the venv, dies in the bundle" - and finding that out on a shop
|
||||
# counter is the expensive order to do it in.
|
||||
Run $engineExe paths
|
||||
|
||||
Pop-Location
|
||||
|
||||
|
||||
@@ -32,7 +32,10 @@ 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 \
|
||||
# -tags desktop,production is what `wails build` passes; without them the
|
||||
# binary starts, shows "Wails applications will not build without the correct
|
||||
# build tags" and exits. Measured on the first Windows install of v0.4.4-demo.
|
||||
(cd desktop && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -tags desktop,production \
|
||||
-ldflags "-H windowsgui -s -w -X main.version=$TAG" -o "../$STAGE/Behavision.exe" .)
|
||||
|
||||
step "2. Agent and setup tool"
|
||||
|
||||
@@ -28,6 +28,17 @@ REMOTE_DIR=/root/behavision
|
||||
PUBLIC=https://mcp.loyaly.ai
|
||||
SSH=(ssh -i "$KEY" -o BatchMode=yes -o ConnectTimeout=10 "$HOST")
|
||||
|
||||
# Go is not always on an interactive shell's PATH - a Homebrew or tarball
|
||||
# install lands in a directory that .zprofile adds but a script does not
|
||||
# inherit, so this failed at step 1 with "go: command not found" on the very
|
||||
# machine it was written on. Found the only way it could be: by somebody
|
||||
# running it. A deploy that needs the operator to fix their environment first
|
||||
# is a deploy that gets skipped.
|
||||
for d in "$HOME/go/bin" /usr/local/go/bin /opt/homebrew/bin; do
|
||||
[ -x "$d/go" ] && case ":$PATH:" in *":$d:"*) ;; *) PATH="$PATH:$d";; esac
|
||||
done
|
||||
command -v go >/dev/null || { echo "go not found - install it or add it to PATH" >&2; exit 1; }
|
||||
|
||||
VERSION=$(git describe --tags --always --dirty)
|
||||
case "$VERSION" in *-dirty) echo "refusing to deploy uncommitted changes ($VERSION)" >&2; exit 1;; esac
|
||||
|
||||
@@ -47,7 +58,19 @@ 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"
|
||||
# The dump's own filename is echoed, not `ls | tail -1`, which reported the
|
||||
# WRONG file: ls sorts alphabetically, so pre-...-demo-12-... sorts before
|
||||
# pre-...-demo-6-... and the line printed a backup from four days earlier. A
|
||||
# deploy that names the wrong safety net is worse than one that names none -
|
||||
# that is the file somebody reaches for at the worst possible moment.
|
||||
#
|
||||
# Bare `-s` on the dump so an empty or failed one cannot be reported as a
|
||||
# backup: pg_dump exiting non-zero already fails the pipeline under pipefail,
|
||||
# but a zero-byte gzip would still satisfy it.
|
||||
"${SSH[@]}" "set -e; f=$REMOTE_DIR/backups/pre-$VERSION-\$(date +%Y%m%d-%H%M%S).sql.gz; \
|
||||
docker exec behavision-db sh -c 'PGPASSWORD=\$POSTGRES_PASSWORD pg_dump -U behavision -d behavision' | gzip > \$f; \
|
||||
[ -s \$f ] || { echo 'backup is empty - refusing to continue' >&2; exit 1; }; \
|
||||
ls -la \$f"
|
||||
|
||||
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"
|
||||
@@ -65,7 +88,33 @@ 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")"
|
||||
# 401 is a PASS, and that distinction is the whole point of this step. An
|
||||
# unauthenticated call to a route that EXISTS is refused; a route the binary
|
||||
# never registered is a 404. So this proves the routing rather than the auth -
|
||||
# which is precisely what a deploy gets wrong, and what otherwise surfaces as a
|
||||
# console showing "Backend integration required" against an API that shipped.
|
||||
#
|
||||
# The uuid matches nothing on purpose: the admin drill-down must answer 401
|
||||
# with no session, never 404.
|
||||
NOBODY=00000000-0000-4000-8000-000000000000
|
||||
fail=0
|
||||
for p in /healthz \
|
||||
/api/admin/clients \
|
||||
"/api/admin/clients/$NOBODY" \
|
||||
"/api/admin/clients/$NOBODY/sites" \
|
||||
"/api/admin/clients/$NOBODY/sites/x/cameras" \
|
||||
/api/admin/monitoring/summary \
|
||||
/api/sales \
|
||||
/api/sales/x \
|
||||
/api/dashboard/summary \
|
||||
/api/team /api/visits /api/cameras; do
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -m 15 "$PUBLIC$p")
|
||||
case "$code" in
|
||||
200|401) verdict="ok" ;;
|
||||
404) verdict="MISSING - this binary does not serve that route"; fail=1 ;;
|
||||
*) verdict="unexpected"; fail=1 ;;
|
||||
esac
|
||||
printf ' %-46s %s %s\n' "$p" "$code" "$verdict"
|
||||
done
|
||||
curl -s -m 15 "$PUBLIC/healthz" | head -c 300; echo
|
||||
[ "$fail" = 0 ] || { echo; echo "VERIFY FAILED - routes above marked MISSING did not ship" >&2; exit 1; }
|
||||
|
||||
@@ -3,13 +3,13 @@ module github.com/loyaly/behavision-server
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/anthropics/anthropic-sdk-go v1.69.0
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
golang.org/x/crypto v0.42.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/anthropics/anthropic-sdk-go v1.69.0 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
315
server/internal/api/admin_monitor_test.go
Normal file
315
server/internal/api/admin_monitor_test.go
Normal file
@@ -0,0 +1,315 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Two merchants, each with a shop and a camera. The whole point of these tests
|
||||
// is that the path names the merchant, so a fixture with only one proves
|
||||
// nothing about scoping.
|
||||
const (
|
||||
merchantA = "11111111-1111-4111-8111-aaaaaaaaaaaa"
|
||||
merchantB = "22222222-2222-4222-8222-bbbbbbbbbbbb"
|
||||
shopA = "33333333-3333-4333-8333-aaaaaaaaaaaa"
|
||||
shopB = "44444444-4444-4444-8444-bbbbbbbbbbbb"
|
||||
)
|
||||
|
||||
func seedTwoMerchants(fs *fakeStore) {
|
||||
seedPlatformAdmin(fs)
|
||||
fs.clientRows = map[string]ClientDetail{
|
||||
merchantA: {ClientRow: ClientRow{ID: merchantA, Slug: "acme", Name: "Acme Retail",
|
||||
Active: true, Sites: 1, Users: 2}, OwnerEmail: "owner@acme.com", OwnerName: "Asha"},
|
||||
merchantB: {ClientRow: ClientRow{ID: merchantB, Slug: "rival", Name: "Rival Stores",
|
||||
Active: true, Sites: 1, Users: 1}, OwnerEmail: "owner@rival.com"},
|
||||
}
|
||||
fs.sites = []SiteHealth{
|
||||
{SiteID: shopA, Slug: "chennai", Name: "Acme Chennai", CamerasUp: 1, CamerasTotal: 1},
|
||||
{SiteID: shopB, Slug: "mumbai", Name: "Rival Mumbai", CamerasUp: 0, CamerasTotal: 1},
|
||||
}
|
||||
fs.siteOwner = map[string]string{shopA: merchantA, shopB: merchantB}
|
||||
yes := true
|
||||
fs.cameras = []Camera{
|
||||
{ID: "cam-a", SiteID: shopA, CameraID: "entrance", Label: "Front door",
|
||||
Host: "192.168.1.121", Port: 554, Path: "/ch0_1.264",
|
||||
Username: "admin", HasPassword: true, Enabled: true, Connected: &yes},
|
||||
{ID: "cam-b", SiteID: shopB, CameraID: "entrance", Label: "Rival door",
|
||||
Host: "10.0.0.9", Port: 554, Username: "root", HasPassword: true},
|
||||
}
|
||||
fs.cameraRefs = map[string]cameraRef{
|
||||
"cam-a": {client: merchantA, site: shopA, engineID: "entrance"},
|
||||
"cam-b": {client: merchantB, site: shopB, engineID: "entrance"},
|
||||
}
|
||||
}
|
||||
|
||||
// adminReads picks out the rows this surface writes. Signing in audits too,
|
||||
// so a bare count would couple these tests to unrelated behaviour.
|
||||
func adminReads(fs *fakeStore) []AuditEntry {
|
||||
var out []AuditEntry
|
||||
for _, a := range fs.audits {
|
||||
if strings.HasPrefix(a.Action, "admin.") {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func adminGet(t *testing.T, s *Server, token, path string) (int, string) {
|
||||
t.Helper()
|
||||
rec := do(t, s, "GET", path, token, nil)
|
||||
return rec.Code, rec.Body.String()
|
||||
}
|
||||
|
||||
// ------------------------------------------------- the surface is invisible
|
||||
|
||||
func TestAMerchantTokenGets404FromEveryAdminDrilldownRoute(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedTwoMerchants(fs)
|
||||
seedUser(fs) // an ordinary manager inside another tenant
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/admin/clients/" + merchantA,
|
||||
"/api/admin/clients/" + merchantA + "/sites",
|
||||
"/api/admin/clients/" + merchantA + "/sites/" + shopA,
|
||||
"/api/admin/clients/" + merchantA + "/sites/" + shopA + "/cameras",
|
||||
"/api/admin/clients/" + merchantA + "/sites/" + shopA + "/cameras/cam-a",
|
||||
"/api/admin/monitoring/summary",
|
||||
} {
|
||||
if code, body := adminGet(t, s, sess.Token, path); code != http.StatusNotFound {
|
||||
t.Errorf("%s: got %d, want 404 (never 403 - a tenant must not learn "+
|
||||
"this surface exists): %s", path, code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- scoping
|
||||
|
||||
func TestSitesAreScopedToTheMerchantInThePath(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedTwoMerchants(fs)
|
||||
sess := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
code, body := adminGet(t, s, sess.Token, "/api/admin/clients/"+merchantA+"/sites")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", code, body)
|
||||
}
|
||||
var rows []SiteHealth
|
||||
if err := json.Unmarshal([]byte(body), &rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].SiteID != shopA {
|
||||
t.Fatalf("got %d rows %+v, want only merchant A's shop", len(rows), rows)
|
||||
}
|
||||
if strings.Contains(body, "Rival") {
|
||||
t.Errorf("another merchant's shop leaked into the response: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The uuid branch is the one that matters. A tenant resolver hands a uuid back
|
||||
// untouched and lets `client_id = $1` downstream do the scoping, which is safe
|
||||
// only because the client id comes from a session. Here the caller names both.
|
||||
func TestAnotherMerchantsShopIs404NotAnEmptyList(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedTwoMerchants(fs)
|
||||
sess := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/admin/clients/" + merchantA + "/sites/" + shopB,
|
||||
"/api/admin/clients/" + merchantA + "/sites/" + shopB + "/cameras",
|
||||
"/api/admin/clients/" + merchantA + "/sites/mumbai/cameras",
|
||||
} {
|
||||
code, body := adminGet(t, s, sess.Token, path)
|
||||
if code != http.StatusNotFound {
|
||||
t.Errorf("%s: got %d, want 404 - an empty list says 'this shop has "+
|
||||
"nothing' when the truth is 'not your shop': %s", path, code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestACameraFromAnotherShopIs404(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedTwoMerchants(fs)
|
||||
sess := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
// cam-b exists, and its engine id "entrance" is the same string as cam-a's
|
||||
// - a camera id is unique per SITE, not per tenant, so the chain has to be
|
||||
// checked rather than the name trusted.
|
||||
code, _ := adminGet(t, s, sess.Token,
|
||||
"/api/admin/clients/"+merchantA+"/sites/"+shopA+"/cameras/cam-b")
|
||||
if code != http.StatusNotFound {
|
||||
t.Errorf("got %d, want 404 for a camera belonging to another shop", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnknownOrMalformedMerchantIs404NotAServerError(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedTwoMerchants(fs)
|
||||
sess := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
for _, id := range []string{
|
||||
"99999999-9999-4999-8999-999999999999", // well formed, no such row
|
||||
"not-a-uuid", // would be a Postgres cast error
|
||||
} {
|
||||
code, body := adminGet(t, s, sess.Token, "/api/admin/clients/"+id+"/sites")
|
||||
if code != http.StatusNotFound {
|
||||
t.Errorf("merchant %q: got %d, want 404: %s", id, code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- redaction
|
||||
|
||||
// The one that would be a real leak. An RTSP host next to a username is most
|
||||
// of a live path into a customer's camera, and a platform admin browsing
|
||||
// another company's estate has no business with either.
|
||||
func TestAdminCameraRowsCarryNoCredentialFields(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedTwoMerchants(fs)
|
||||
sess := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
code, body := adminGet(t, s, sess.Token,
|
||||
"/api/admin/clients/"+merchantA+"/sites/"+shopA+"/cameras")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", code, body)
|
||||
}
|
||||
// Asserted on the raw JSON, not on a struct: decoding into AdminCamera
|
||||
// would discard exactly the fields this test exists to catch.
|
||||
for _, banned := range []string{
|
||||
"host", "192.168.1.121", "port", "554", "path", "ch0_1.264",
|
||||
"username", "admin", "has_password", "password",
|
||||
} {
|
||||
if strings.Contains(body, banned) {
|
||||
t.Errorf("admin camera row contains %q: %s", banned, body)
|
||||
}
|
||||
}
|
||||
// And it still answers the question the screen asks.
|
||||
for _, want := range []string{"entrance", "Front door", "connected"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("admin camera row is missing %q: %s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connected is a pointer for a reason: null means no shop PC has ever reported,
|
||||
// false means it is not connecting, and those send an installer to two
|
||||
// different places. `omitempty` would collapse both into absent.
|
||||
func TestAdminCameraKeepsConnectedAsThreeStates(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedTwoMerchants(fs)
|
||||
fs.cameras[0].Connected = nil
|
||||
sess := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
_, body := adminGet(t, s, sess.Token,
|
||||
"/api/admin/clients/"+merchantA+"/sites/"+shopA+"/cameras")
|
||||
if !strings.Contains(body, `"connected":null`) {
|
||||
t.Errorf(`want "connected":null for a camera no PC has reported on: %s`, body)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- audit
|
||||
|
||||
func TestEveryAdminReadBelowTheMerchantListIsAudited(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedTwoMerchants(fs)
|
||||
sess := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
base := "/api/admin/clients/" + merchantA
|
||||
for _, path := range []string{
|
||||
base + "/sites",
|
||||
base + "/sites/" + shopA,
|
||||
base + "/sites/" + shopA + "/cameras",
|
||||
base + "/sites/" + shopA + "/cameras/cam-a",
|
||||
} {
|
||||
if code, body := adminGet(t, s, sess.Token, path); code != http.StatusOK {
|
||||
t.Fatalf("%s: got %d: %s", path, code, body)
|
||||
}
|
||||
}
|
||||
// Filtered by action: signing in writes its own audit row, and counting
|
||||
// every row would make this test pass or fail on unrelated behaviour.
|
||||
reads := adminReads(fs)
|
||||
if len(reads) != 4 {
|
||||
t.Fatalf("got %d admin read rows, want one per read below the merchant "+
|
||||
"list (all audits: %+v)", len(reads), fs.audits)
|
||||
}
|
||||
for _, a := range reads {
|
||||
if a.ClientID != merchantA {
|
||||
t.Errorf("audit row names client %q, want the merchant being looked at", a.ClientID)
|
||||
}
|
||||
if a.ActorID != "admin-1" || a.ActorKind != "admin" {
|
||||
t.Errorf("audit row must name the admin who looked: %+v", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Counts across the platform name no merchant and no person, and a console
|
||||
// refreshes them on a timer. Logging that would bury the reads worth finding.
|
||||
func TestTheSummaryIsNotAudited(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedTwoMerchants(fs)
|
||||
sess := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
if code, body := adminGet(t, s, sess.Token, "/api/admin/monitoring/summary"); code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", code, body)
|
||||
}
|
||||
if n := len(adminReads(fs)); n != 0 {
|
||||
t.Errorf("got %d admin read rows for a counts-only header strip, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- suspended
|
||||
|
||||
// "This company is suspended" is precisely what an admin opens the console to
|
||||
// look at. Hiding it would make the one screen that can fix it the one screen
|
||||
// that cannot see it.
|
||||
func TestASuspendedMerchantStaysReadable(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedTwoMerchants(fs)
|
||||
row := fs.clientRows[merchantA]
|
||||
row.Active = false
|
||||
fs.clientRows[merchantA] = row
|
||||
sess := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
code, body := adminGet(t, s, sess.Token, "/api/admin/clients/"+merchantA)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("got %d, want a suspended merchant to still read: %s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, `"active":false`) {
|
||||
t.Errorf("the response must say it is suspended: %s", body)
|
||||
}
|
||||
if code, _ := adminGet(t, s, sess.Token, "/api/admin/clients/"+merchantA+"/sites"); code != http.StatusOK {
|
||||
t.Errorf("sites of a suspended merchant: got %d, want 200", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMerchantDetailCarriesTheOwner(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedTwoMerchants(fs)
|
||||
sess := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
code, body := adminGet(t, s, sess.Token, "/api/admin/clients/"+merchantA)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", code, body)
|
||||
}
|
||||
// Who to contact is the whole reason this is not just the list row.
|
||||
if !strings.Contains(body, "owner@acme.com") {
|
||||
t.Errorf("merchant detail must name the owner: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty list must serialise as [] and not null, or a console that maps over
|
||||
// the response breaks on a merchant with no shops - which is every merchant on
|
||||
// the day they are created.
|
||||
func TestAMerchantWithNoShopsReturnsAnEmptyArray(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedTwoMerchants(fs)
|
||||
fs.siteOwner = map[string]string{shopB: merchantB} // A now owns nothing
|
||||
sess := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
code, body := adminGet(t, s, sess.Token, "/api/admin/clients/"+merchantA+"/sites")
|
||||
if code != http.StatusOK || strings.TrimSpace(body) != "[]" {
|
||||
t.Errorf("got %d %q, want 200 []", code, strings.TrimSpace(body))
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ type Store interface {
|
||||
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)
|
||||
SetUserPassword(ctx context.Context, userID, hash string) error
|
||||
|
||||
// --- team and invitations ---
|
||||
// Registration is by invitation: the code carries the address and the role
|
||||
@@ -136,7 +137,19 @@ type Store interface {
|
||||
|
||||
// --- platform administration ---
|
||||
CreateClientWithOwner(ctx context.Context, in NewClientInput) (NewClientResult, error)
|
||||
CreateCustomer(ctx context.Context, clientID string, in Profile, createdBy string) (Customer, error)
|
||||
MergeVisitors(ctx context.Context, clientID, sourceID, targetID string) (MergeResult, error)
|
||||
Sales(ctx context.Context, q SaleQuery) ([]Sale, error)
|
||||
Sale(ctx context.Context, clientID, id string) (Sale, error)
|
||||
|
||||
ListClients(ctx context.Context) ([]ClientRow, error)
|
||||
|
||||
// The admin drill-down. Each takes the merchant's client id explicitly,
|
||||
// because the caller is a platform admin whose session carries none.
|
||||
ClientDetail(ctx context.Context, clientID string) (ClientDetail, error)
|
||||
AdminSiteID(ctx context.Context, clientID, ref string) (string, error)
|
||||
AdminCameraID(ctx context.Context, clientID, siteID, ref string) (string, error)
|
||||
PlatformSummary(ctx context.Context) (PlatformSummary, 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
|
||||
@@ -159,6 +172,9 @@ type Store interface {
|
||||
// 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)
|
||||
// UpdateSite changes what a person reads - the name, the timezone. Never
|
||||
// the slug: the shop PC and the broker ACL are keyed on it.
|
||||
UpdateSite(ctx context.Context, clientID, siteID string, in SiteUpdate) (SiteHealth, error)
|
||||
|
||||
// --- enrolment ---
|
||||
RedeemEnrolment(ctx context.Context, hash []byte) (Enrolment, error)
|
||||
@@ -281,63 +297,84 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
// 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.
|
||||
// Changing your own password. `authed`, not `tenantOnly`: a session is not
|
||||
// a company's data, and a platform admin has no company but must still be
|
||||
// able to do this - they were the account with no route at all.
|
||||
mux.HandleFunc("POST /api/auth/password", s.authed(s.handleChangePassword))
|
||||
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("GET /api/team", s.tenantOnly(s.handleTeam))
|
||||
mux.HandleFunc("PATCH /api/team/{id}", s.tenantOnly(s.handleUpdateTeamMember))
|
||||
mux.HandleFunc("POST /api/team/members", s.tenantOnly(s.handleCreateMember))
|
||||
mux.HandleFunc("POST /api/team/{id}/password", s.tenantOnly(s.handleResetPassword))
|
||||
mux.HandleFunc("GET /api/team/invitations", s.tenantOnly(s.handleInvitations))
|
||||
mux.HandleFunc("POST /api/team/invitations", s.tenantOnly(s.handleInvite))
|
||||
mux.HandleFunc("DELETE /api/team/invitations/{id}",
|
||||
s.authed(s.handleRevokeInvitation))
|
||||
s.tenantOnly(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))
|
||||
mux.HandleFunc("GET /api/reports/footfall", s.tenantOnly(s.handleFootfall))
|
||||
mux.HandleFunc("GET /api/reports/conversion", s.tenantOnly(s.handleConversion))
|
||||
mux.HandleFunc("GET /api/sites", s.tenantOnly(s.handleSites))
|
||||
mux.HandleFunc("POST /api/sites", s.tenantOnly(s.handleCreateSite))
|
||||
mux.HandleFunc("DELETE /api/sites/{site}", s.tenantOnly(s.handleDeleteSite))
|
||||
mux.HandleFunc("PATCH /api/sites/{site}", s.tenantOnly(s.handleUpdateSite))
|
||||
|
||||
// Cameras, onboarded from head office. The shop PC still does the
|
||||
// connecting - it is the only thing on the camera's network - so these
|
||||
// write desired state that its agent pulls and applies.
|
||||
mux.HandleFunc("GET /api/cameras", s.authed(s.handleCameras))
|
||||
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))
|
||||
mux.HandleFunc("GET /api/cameras", s.tenantOnly(s.handleCameras))
|
||||
mux.HandleFunc("POST /api/sites/{site}/cameras", s.tenantOnly(s.handleCreateCamera))
|
||||
mux.HandleFunc("PATCH /api/cameras/{id}", s.tenantOnly(s.handleUpdateCamera))
|
||||
mux.HandleFunc("DELETE /api/cameras/{id}", s.tenantOnly(s.handleDeleteCamera))
|
||||
mux.HandleFunc("GET /api/cameras/{id}/snapshot.jpg", s.tenantOnly(s.handleGetSnapshot))
|
||||
mux.HandleFunc("GET /api/cameras/{id}/live", s.tenantOnly(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
|
||||
// first and fails the second all the time - that is the Office1 case.
|
||||
mux.HandleFunc("POST /api/cameras/{id}/check", s.authed(s.handleRequestCheck))
|
||||
mux.HandleFunc("POST /api/cameras/{id}/check", s.tenantOnly(s.handleRequestCheck))
|
||||
// The end-to-end answer for one shop, assembled from what head office
|
||||
// already knows - so it works even when the shop PC is off, which is one of
|
||||
// the things it reports.
|
||||
mux.HandleFunc("GET /api/sites/{site}/check", s.authed(s.handleSiteCheck))
|
||||
mux.HandleFunc("GET /api/sites/{site}/check", s.tenantOnly(s.handleSiteCheck))
|
||||
mux.HandleFunc("POST /api/sites/{site}/enrolment-code",
|
||||
s.authed(s.handleIssueEnrolmentCode))
|
||||
s.tenantOnly(s.handleIssueEnrolmentCode))
|
||||
|
||||
// The assistant. Every tool it calls runs as the signed-in user, so it can
|
||||
// only ever see what the person asking could already see.
|
||||
mux.HandleFunc("POST /api/assistant", s.authed(s.handleAssistant))
|
||||
mux.HandleFunc("POST /api/assistant", s.tenantOnly(s.handleAssistant))
|
||||
|
||||
// The live feed. `visitors` searches a customer list by name; `visits`
|
||||
// answers the question a shop screen or a mobile app actually asks - who
|
||||
// came through the door just now - and carries each person's photo with
|
||||
// them so rendering four simultaneous arrivals is one request, not nine.
|
||||
mux.HandleFunc("GET /api/visits", s.authed(s.handleArrivals))
|
||||
mux.HandleFunc("GET /api/visits/stream", s.authed(s.handleArrivalStream))
|
||||
mux.HandleFunc("GET /api/visits", s.tenantOnly(s.handleArrivals))
|
||||
mux.HandleFunc("GET /api/visits/stream", s.tenantOnly(s.handleArrivalStream))
|
||||
|
||||
mux.HandleFunc("GET /api/visitors", s.authed(s.handleVisitors))
|
||||
mux.HandleFunc("GET /api/visitors/{id}/history", s.authed(s.handleVisitorHistory))
|
||||
mux.HandleFunc("PUT /api/visitors/{id}/profile", s.authed(s.handleSaveProfile))
|
||||
mux.HandleFunc("POST /api/purchases", s.authed(s.handlePurchase))
|
||||
mux.HandleFunc("GET /api/visitors", s.tenantOnly(s.handleVisitors))
|
||||
mux.HandleFunc("GET /api/visitors/{id}/history", s.tenantOnly(s.handleVisitorHistory))
|
||||
mux.HandleFunc("PUT /api/visitors/{id}/profile", s.tenantOnly(s.handleSaveProfile))
|
||||
|
||||
// A customer registered before any camera has seen them, and the repair
|
||||
// path that creates the need for: with no face template, recognition
|
||||
// cannot match them later and enrols them again.
|
||||
mux.HandleFunc("POST /api/customers", s.tenantOnly(s.handleCreateCustomer))
|
||||
mux.HandleFunc("POST /api/visitors/{id}/merge", s.tenantOnly(s.handleMergeCustomers))
|
||||
mux.HandleFunc("POST /api/purchases", s.tenantOnly(s.handlePurchase))
|
||||
|
||||
// Reading sales, not just aggregating them. /api/reports/conversion has
|
||||
// summed this table since it existed; nothing could read a row of it, so
|
||||
// "revenue was 41,000" could not be checked against a till.
|
||||
mux.HandleFunc("GET /api/sales", s.tenantOnly(s.handleSales))
|
||||
mux.HandleFunc("GET /api/sales/{id}", s.tenantOnly(s.handleSale))
|
||||
|
||||
// The merchant home screen in one call, composed from the functions the
|
||||
// reports already use rather than from new arithmetic.
|
||||
mux.HandleFunc("GET /api/dashboard/summary", s.tenantOnly(s.handleDashboard))
|
||||
|
||||
// Platform administration. Not public registration: an open endpoint that
|
||||
// mints tenants is a far larger thing to secure than one behind an account
|
||||
@@ -349,6 +386,17 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
mux.HandleFunc("POST /api/admin/clients/{id}/owner-password", s.adminOnly(s.handleResetOwnerPassword))
|
||||
mux.HandleFunc("DELETE /api/admin/clients/{id}", s.adminOnly(s.handleDeleteClient))
|
||||
|
||||
// The admin drill-down: merchant -> shop -> camera. Read-only, scoped by
|
||||
// the merchant named in the path rather than by a session that has none,
|
||||
// with every read below the merchant list audited and cameras redacted to
|
||||
// a type that cannot carry an RTSP host or username.
|
||||
mux.HandleFunc("GET /api/admin/clients/{id}", s.adminOnly(s.handleAdminClient))
|
||||
mux.HandleFunc("GET /api/admin/clients/{id}/sites", s.adminOnly(s.handleAdminClientSites))
|
||||
mux.HandleFunc("GET /api/admin/clients/{id}/sites/{site}", s.adminOnly(s.handleAdminClientSite))
|
||||
mux.HandleFunc("GET /api/admin/clients/{id}/sites/{site}/cameras", s.adminOnly(s.handleAdminSiteCameras))
|
||||
mux.HandleFunc("GET /api/admin/clients/{id}/sites/{site}/cameras/{camera}", s.adminOnly(s.handleAdminSiteCamera))
|
||||
mux.HandleFunc("GET /api/admin/monitoring/summary", s.adminOnly(s.handleAdminMonitoringSummary))
|
||||
|
||||
// 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)
|
||||
@@ -367,15 +415,15 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
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))
|
||||
mux.HandleFunc("GET /api/visitors/{id}/image", s.tenantOnly(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))
|
||||
mux.HandleFunc("GET /api/faces/{id}", s.tenantOnly(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))
|
||||
mux.HandleFunc("DELETE /api/visitors/{id}", s.tenantOnly(s.handleForgetVisitor))
|
||||
|
||||
return mux
|
||||
}
|
||||
@@ -393,6 +441,36 @@ func PrincipalFrom(ctx context.Context) auth.Principal {
|
||||
return p
|
||||
}
|
||||
|
||||
// tenantOnly gates the routes that read or write one company's data.
|
||||
//
|
||||
// It exists because a platform admin has NO client - that absence is what
|
||||
// defines them - and every tenant query scopes on `client_id = $1::uuid`.
|
||||
// Handing it the empty string makes Postgres cast ” to a uuid, which is an
|
||||
// ERROR rather than an empty result, so five live endpoints answered 500 to a
|
||||
// signed-in platform admin: /api/visits, /api/cameras, /api/sites,
|
||||
// /api/visitors and /api/reports/footfall. Found by calling them.
|
||||
//
|
||||
// 403 and not 404, unlike adminOnly. The two hide opposite things: a tenant
|
||||
// must not learn that a platform surface exists, while a platform admin
|
||||
// already knows the tenant surface does - they are looking at its data through
|
||||
// /api/admin. Nothing is concealed by pretending otherwise, and "use the admin
|
||||
// routes" is the useful answer.
|
||||
//
|
||||
// Guarding here rather than in each query is deliberate: a per-query fix is
|
||||
// one a new query forgets, and the next one would 500 in production exactly
|
||||
// like these did.
|
||||
func (s *Server) tenantOnly(next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.authed(func(w http.ResponseWriter, r *http.Request) {
|
||||
if PrincipalFrom(r.Context()).ClientID == "" {
|
||||
writeErr(w, http.StatusForbidden, "not_a_tenant_account",
|
||||
"This is a company's own data. A platform administrator "+
|
||||
"reads it through /api/admin/clients/{id}/...")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) authed(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tok := auth.BearerToken(r)
|
||||
@@ -542,6 +620,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")
|
||||
|
||||
// ErrSameVisitor is a merge that names one customer twice. Declared here
|
||||
// rather than in the store for the reason ErrNoSecrets is: the store imports
|
||||
// this package, so a sentinel the other way round is an import cycle.
|
||||
var ErrSameVisitor = errors.New("a customer cannot be merged into themselves")
|
||||
|
||||
// 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.
|
||||
|
||||
148
server/internal/api/customers_test.go
Normal file
148
server/internal/api/customers_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStaffCanRegisterACustomerNobodyHasPhotographed(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/customers", sess.Token, map[string]string{
|
||||
"full_name": "Asha Menon", "phone": "9876543210",
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var c Customer
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A reference a person can say, from the same counter the engine uses.
|
||||
if c.Ref == "" || c.Label != "Asha Menon" {
|
||||
t.Errorf("got ref=%q label=%q, want a V- reference and the typed name",
|
||||
c.Ref, c.Label)
|
||||
}
|
||||
}
|
||||
|
||||
// A record with no name and no phone is a number nobody can search for, and
|
||||
// the customer at the counter is the only source of either.
|
||||
func TestACustomerNeedsANameOrAPhone(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/customers", sess.Token,
|
||||
map[string]string{"notes": "regular, likes the window seat"})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- merge
|
||||
|
||||
// Uuid-shaped on purpose: resolveVisitor takes a uuid or a V- reference and
|
||||
// correctly refuses anything else, so a made-up id would 404 before reaching
|
||||
// the handler under test.
|
||||
const (
|
||||
vTyped = "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa" // typed in at the counter
|
||||
vSeen = "bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb" // enrolled by a camera
|
||||
)
|
||||
|
||||
func seedTwoCustomers(fs *fakeStore) {
|
||||
seedUser(fs)
|
||||
fs.visitors = []Customer{
|
||||
{ID: vTyped, Ref: "V-1", Label: "Asha Menon", FullName: "Asha Menon"},
|
||||
{ID: vSeen, Ref: "V-2", Label: "Visitor 2"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergingFoldsOneCustomerIntoTheOtherAndSaysWhatMoved(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedTwoCustomers(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/visitors/"+vTyped+"/merge", sess.Token,
|
||||
map[string]string{"into": vSeen})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out MergeResult
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The reference that STOPPED resolving has to be named. Staff write these
|
||||
// on cards; discovering it at a counter is the wrong place to find out.
|
||||
if out.RetiredRef != "V-1" || out.Ref != "V-2" {
|
||||
t.Errorf("kept %q retired %q, want V-2 kept and V-1 retired", out.Ref, out.RetiredRef)
|
||||
}
|
||||
}
|
||||
|
||||
// The only irreversible operation on a customer apart from erasure. Two people
|
||||
// welded together cannot be separated: nothing records which visit came from
|
||||
// whom.
|
||||
func TestStaffCannotMerge(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedTwoCustomers(fs)
|
||||
fs.addUser("shopfloor@acme.com", "correct horse battery", UserRecord{
|
||||
ID: "u9", ClientID: "client-acme", Role: "staff", Active: true,
|
||||
})
|
||||
sess := login(t, s, "shopfloor@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/visitors/"+vTyped+"/merge", sess.Token,
|
||||
map[string]string{"into": vSeen})
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("got %d, want 403 for staff: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergingACustomerIntoThemselvesIsRefused(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedTwoCustomers(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", "/api/visitors/"+vTyped+"/merge", sess.Token,
|
||||
map[string]string{"into": vTyped})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergingNeedsATarget(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedTwoCustomers(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
for _, body := range []map[string]string{{}, {"into": " "}, {"into": "V-999"}} {
|
||||
rec := do(t, s, "POST", "/api/visitors/"+vTyped+"/merge", sess.Token, body)
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Errorf("merge with %v succeeded, want a refusal", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every merge leaves a trace: it is destructive and cannot be undone.
|
||||
func TestAMergeIsAudited(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedTwoCustomers(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
do(t, s, "POST", "/api/visitors/"+vTyped+"/merge", sess.Token,
|
||||
map[string]string{"into": vSeen})
|
||||
|
||||
found := false
|
||||
for _, a := range fs.audits {
|
||||
if a.Action == "customer.merge" {
|
||||
found = true
|
||||
if a.Detail["retired_ref"] != "V-1" {
|
||||
t.Errorf("audit must name the retired reference: %+v", a.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("no audit row for a merge")
|
||||
}
|
||||
}
|
||||
@@ -41,13 +41,29 @@ type fakeStore struct {
|
||||
byAccess map[string]string // access hash hex -> session id
|
||||
byRefresh map[string]string
|
||||
|
||||
visitors []Customer
|
||||
history []VisitRow
|
||||
footfall []FootfallPoint
|
||||
totals Totals
|
||||
sales SalesReport
|
||||
sites []SiteHealth
|
||||
enrolment map[string]Enrolment
|
||||
visitors []Customer
|
||||
history []VisitRow
|
||||
footfall []FootfallPoint
|
||||
totals Totals
|
||||
sales SalesReport
|
||||
sites []SiteHealth
|
||||
|
||||
// The admin drill-down is the one surface where the fake MUST know which
|
||||
// merchant owns what. Everywhere else the client id comes from the session
|
||||
// and every query scopes on it, so a fake that ignores it still exercises
|
||||
// the handler. Here the client id comes from the PATH and the scoping is
|
||||
// the thing under test - a fake that ignored it would pass the
|
||||
// cross-merchant tests while returning another company's shops.
|
||||
// Camera ownership already has a home: cameraRefs, read through the
|
||||
// cameraOwner method below.
|
||||
siteOwner map[string]string // site id -> client id
|
||||
salesRows []Sale
|
||||
visitorSeq int64
|
||||
lastMerge [2]string
|
||||
saleOwner map[string]string // sale id -> client id
|
||||
lastSaleQuery SaleQuery
|
||||
clientRows map[string]ClientDetail
|
||||
enrolment map[string]Enrolment
|
||||
|
||||
// Recorded calls, so a test can assert what the handler asked for rather
|
||||
// than only what it returned.
|
||||
@@ -288,8 +304,161 @@ func (f *fakeStore) DeleteNewSite(_ context.Context, _ string, siteID string) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SiteHealth(_ context.Context, _ string) ([]SiteHealth, error) {
|
||||
return f.sites, nil
|
||||
func (f *fakeStore) SiteHealth(_ context.Context, clientID string) ([]SiteHealth, error) {
|
||||
// Scoped only when a test has declared ownership; otherwise every existing
|
||||
// tenant test would have to grow a fixture it does not care about.
|
||||
if f.siteOwner == nil {
|
||||
return f.sites, nil
|
||||
}
|
||||
var out []SiteHealth
|
||||
for _, s := range f.sites {
|
||||
if f.siteOwner[s.SiteID] == clientID {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateCustomer(_ context.Context, clientID string,
|
||||
in Profile, createdBy string) (Customer, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.visitorSeq++
|
||||
label := in.FullName
|
||||
if label == "" {
|
||||
label = fmt.Sprintf("Visitor %d", f.visitorSeq)
|
||||
}
|
||||
c := Customer{
|
||||
ID: fmt.Sprintf("new-%d", f.visitorSeq), Ref: VisitorRef(f.visitorSeq),
|
||||
Label: label, FullName: in.FullName, Phone: in.Phone, Email: in.Email,
|
||||
HasProfile: true,
|
||||
}
|
||||
f.visitors = append(f.visitors, c)
|
||||
f.lastProfile = in
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) MergeVisitors(_ context.Context, clientID, sourceID, targetID string) (
|
||||
MergeResult, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastMerge = [2]string{sourceID, targetID}
|
||||
if sourceID == targetID {
|
||||
return MergeResult{}, ErrSameVisitor
|
||||
}
|
||||
var src, dst *Customer
|
||||
for i := range f.visitors {
|
||||
switch f.visitors[i].ID {
|
||||
case sourceID:
|
||||
src = &f.visitors[i]
|
||||
case targetID:
|
||||
dst = &f.visitors[i]
|
||||
}
|
||||
}
|
||||
if src == nil || dst == nil {
|
||||
return MergeResult{}, pgx.ErrNoRows
|
||||
}
|
||||
out := MergeResult{VisitorID: dst.ID, Ref: dst.Ref, Label: dst.Label,
|
||||
RetiredRef: src.Ref}
|
||||
var kept []Customer
|
||||
for _, c := range f.visitors {
|
||||
if c.ID != sourceID {
|
||||
kept = append(kept, c)
|
||||
}
|
||||
}
|
||||
f.visitors = kept
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetUserPassword(_ context.Context, userID, hash string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for email, u := range f.users {
|
||||
if u.ID == userID {
|
||||
u.PasswordHash = hash
|
||||
f.users[email] = u
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("no such user")
|
||||
}
|
||||
|
||||
func (f *fakeStore) Sales(_ context.Context, q SaleQuery) ([]Sale, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.lastSaleQuery = q
|
||||
var out []Sale
|
||||
for _, sale := range f.salesRows {
|
||||
if q.SiteID != "" && sale.SiteID != q.SiteID {
|
||||
continue
|
||||
}
|
||||
if q.VisitorID != "" && sale.VisitorID != q.VisitorID {
|
||||
continue
|
||||
}
|
||||
out = append(out, sale)
|
||||
}
|
||||
if q.Limit > 0 && len(out) > q.Limit {
|
||||
out = out[:q.Limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Sale(_ context.Context, clientID, id string) (Sale, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, sale := range f.salesRows {
|
||||
// Scoped, so the cross-tenant test is not vacuous.
|
||||
if sale.ID == id && f.saleOwner[id] == clientID {
|
||||
return sale, nil
|
||||
}
|
||||
}
|
||||
return Sale{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ClientDetail(_ context.Context, clientID string) (ClientDetail, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.clientRows[clientID], nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) AdminSiteID(_ context.Context, clientID, ref string) (string, error) {
|
||||
for _, s := range f.sites {
|
||||
if s.SiteID != ref && s.Slug != ref {
|
||||
continue
|
||||
}
|
||||
if f.siteOwner != nil && f.siteOwner[s.SiteID] != clientID {
|
||||
return "", nil // owned by somebody else: a miss, not a match
|
||||
}
|
||||
return s.SiteID, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) AdminCameraID(_ context.Context, clientID, siteID, ref string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, c := range f.cameras {
|
||||
if c.ID != ref && c.CameraID != ref {
|
||||
continue
|
||||
}
|
||||
if c.SiteID != siteID {
|
||||
return "", nil
|
||||
}
|
||||
if f.cameraRefs != nil && f.cameraOwner(c.ID) != clientID {
|
||||
return "", nil
|
||||
}
|
||||
return c.ID, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) PlatformSummary(_ context.Context) (PlatformSummary, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return PlatformSummary{
|
||||
CamerasTotal: len(f.cameras), MerchantsActive: len(f.clientRows),
|
||||
SitesTotal: len(f.sites), AsOf: "2026-09-28T00:00:00Z",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SearchVisitors(_ context.Context, clientID, q string, limit int) (
|
||||
@@ -554,6 +723,23 @@ func (f *fakeStore) DeleteClient(_ context.Context, clientID string) (ClientRow,
|
||||
return ClientRow{}, nil, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpdateSite(_ context.Context, _ string, siteID string, in SiteUpdate) (SiteHealth, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for i := range f.sites {
|
||||
if f.sites[i].SiteID == siteID {
|
||||
if in.Name != nil {
|
||||
f.sites[i].Name = *in.Name
|
||||
}
|
||||
if in.Timezone != nil {
|
||||
f.sites[i].Timezone = *in.Timezone
|
||||
}
|
||||
return f.sites[i], nil
|
||||
}
|
||||
}
|
||||
return SiteHealth{}, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteEmptySite(_ context.Context, _ string, siteID string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
@@ -257,3 +258,57 @@ func (s *Server) handleDeleteSite(w http.ResponseWriter, r *http.Request) {
|
||||
// ErrSiteInUse is returned by DeleteEmptySite for a shop that has anything
|
||||
// under it.
|
||||
var ErrSiteInUse = errors.New("site has cameras or visits")
|
||||
|
||||
// PATCH /api/sites/{site} - rename a shop or change its timezone. Owner or
|
||||
// manager. The slug is not in the body and would be refused by the database
|
||||
// if it were: it is what the shop PC calls itself and a segment of the broker
|
||||
// topic, and renaming it would orphan both.
|
||||
func (s *Server) handleUpdateSite(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if !p.CanManageSites() || p.ClientID == "" {
|
||||
writeErr(w, http.StatusForbidden, "forbidden", "Only a manager or the owner can change a shop.")
|
||||
return
|
||||
}
|
||||
site, ok := s.resolveSite(w, r, r.PathValue("site"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in SiteUpdate
|
||||
if err := decode(w, r, &in); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if in.Name != nil {
|
||||
n := clip(trim(*in.Name), 120)
|
||||
if n == "" {
|
||||
badRequest(w, "The shop needs a name.")
|
||||
return
|
||||
}
|
||||
in.Name = &n
|
||||
}
|
||||
if in.Timezone != nil {
|
||||
if _, err := time.LoadLocation(strings.TrimSpace(*in.Timezone)); err != nil {
|
||||
badRequest(w, "Unknown timezone. Use an IANA name such as Asia/Kolkata.")
|
||||
return
|
||||
}
|
||||
}
|
||||
if in.Name == nil && in.Timezone == nil {
|
||||
badRequest(w, "Nothing to change: give a name or a timezone.")
|
||||
return
|
||||
}
|
||||
out, err := s.Store.UpdateSite(r.Context(), p.ClientID, site, in)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such shop.")
|
||||
return
|
||||
}
|
||||
s.serverError(w, "update site", err)
|
||||
return
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "site.updated", Entity: "site", EntityID: site,
|
||||
Detail: map[string]any{"name": out.Name, "timezone": out.Timezone},
|
||||
})
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
181
server/internal/api/handlers_admin_monitor.go
Normal file
181
server/internal/api/handlers_admin_monitor.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// The platform-admin drill-down: merchant -> shop -> camera.
|
||||
//
|
||||
// These exist because the tenant routes cannot serve this screen, and the
|
||||
// reason is structural rather than incidental. Every tenant handler derives
|
||||
// the client from the SESSION - that is what makes cross-tenant access
|
||||
// impossible rather than merely disallowed - and a platform admin has no
|
||||
// client at all. The three workarounds all make it worse: passing a company id
|
||||
// to a tenant route puts a caller-chosen tenant back into the one place this
|
||||
// system refuses to take one, filtering the whole estate in the browser ships
|
||||
// every merchant's data to render one, and signing in as the owner leaves an
|
||||
// audit trail naming the wrong person.
|
||||
//
|
||||
// So the tenant STORE functions are reused with an explicit client id and the
|
||||
// scoping the tenant handlers get from the session is done here instead.
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
|
||||
// clientForAdmin resolves {id} to a merchant that exists.
|
||||
//
|
||||
// A suspended merchant still resolves: "this company is suspended" is
|
||||
// precisely what an admin opens the console to look at, and hiding it would
|
||||
// make the one screen that can fix it the one screen that cannot see it.
|
||||
func (s *Server) clientForAdmin(w http.ResponseWriter, r *http.Request) (ClientDetail, bool) {
|
||||
c, err := s.Store.ClientDetail(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.serverError(w, "admin client", err)
|
||||
return ClientDetail{}, false
|
||||
}
|
||||
if c.ID == "" {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such merchant.")
|
||||
return ClientDetail{}, false
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
// siteForAdmin resolves {site} within that merchant. A site belonging to
|
||||
// somebody else is 404 and not an empty list: the caller asked for a named
|
||||
// thing, and "here are its zero cameras" is a different and wrong answer.
|
||||
func (s *Server) siteForAdmin(w http.ResponseWriter, r *http.Request, clientID string) (string, bool) {
|
||||
id, err := s.Store.AdminSiteID(r.Context(), clientID, r.PathValue("site"))
|
||||
if err != nil {
|
||||
s.serverError(w, "admin site", err)
|
||||
return "", false
|
||||
}
|
||||
if id == "" {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such shop for this merchant.")
|
||||
return "", false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminClient(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := s.clientForAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, c)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminClientSites(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := s.clientForAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := s.Store.SiteHealth(r.Context(), c.ID)
|
||||
if err != nil {
|
||||
s.serverError(w, "admin sites", err)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []SiteHealth{}
|
||||
}
|
||||
s.auditAdminRead(r, c.ID, "admin.sites.read", "client", c.ID, len(rows))
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminClientSite(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := s.clientForAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
siteID, ok := s.siteForAdmin(w, r, c.ID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// SiteHealth is the one place that knows what "online" means (three missed
|
||||
// heartbeats, not one) and what cameras_up counts. A second query here
|
||||
// would be a second definition of a working shop, and the two would drift.
|
||||
rows, err := s.Store.SiteHealth(r.Context(), c.ID)
|
||||
if err != nil {
|
||||
s.serverError(w, "admin site", err)
|
||||
return
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.SiteID == siteID {
|
||||
s.auditAdminRead(r, c.ID, "admin.site.read", "site", siteID, 1)
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such shop for this merchant.")
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSiteCameras(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := s.clientForAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
siteID, ok := s.siteForAdmin(w, r, c.ID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := s.Store.Cameras(r.Context(), c.ID, siteID)
|
||||
if err != nil {
|
||||
s.serverError(w, "admin cameras", err)
|
||||
return
|
||||
}
|
||||
s.auditAdminRead(r, c.ID, "admin.cameras.read", "site", siteID, len(rows))
|
||||
writeJSON(w, http.StatusOK, AdminCameras(rows))
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSiteCamera(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := s.clientForAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
siteID, ok := s.siteForAdmin(w, r, c.ID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
camID, err := s.Store.AdminCameraID(r.Context(), c.ID, siteID, r.PathValue("camera"))
|
||||
if err != nil {
|
||||
s.serverError(w, "admin camera", err)
|
||||
return
|
||||
}
|
||||
if camID == "" {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such camera for this shop.")
|
||||
return
|
||||
}
|
||||
rows, err := s.Store.Cameras(r.Context(), c.ID, siteID)
|
||||
if err != nil {
|
||||
s.serverError(w, "admin camera", err)
|
||||
return
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.ID == camID {
|
||||
s.auditAdminRead(r, c.ID, "admin.camera.read", "camera", camID, 1)
|
||||
writeJSON(w, http.StatusOK, adminCamera(row))
|
||||
return
|
||||
}
|
||||
}
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such camera for this shop.")
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminMonitoringSummary(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := s.Store.PlatformSummary(r.Context())
|
||||
if err != nil {
|
||||
s.serverError(w, "platform summary", err)
|
||||
return
|
||||
}
|
||||
// No audit row: this is counts across the platform, naming no merchant and
|
||||
// no person. Logging a header strip that a console refreshes on a timer
|
||||
// would bury the reads that are actually worth finding.
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// auditAdminRead records a platform admin reading inside one merchant.
|
||||
//
|
||||
// Below the merchant list, every read is somebody outside a company looking at
|
||||
// that company's estate. "Who looked at my shops" has to be answerable for the
|
||||
// same reason it does for face images, and an admin is exactly the account for
|
||||
// which nothing else in the system would leave a trace.
|
||||
func (s *Server) auditAdminRead(r *http.Request, clientID, action, entity, entityID string, n int) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: clientID, ActorID: p.UserID, ActorKind: "admin",
|
||||
Action: action, Entity: entity, EntityID: entityID,
|
||||
Detail: map[string]any{"path": r.URL.Path, "rows": n},
|
||||
})
|
||||
}
|
||||
@@ -21,7 +21,13 @@ 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"))
|
||||
// siteParam, not Query().Get("site_id"): every other filtered endpoint
|
||||
// takes both spellings, and this one took only the longer. `?site=chennai`
|
||||
// was therefore not a filter but an unknown parameter, silently ignored -
|
||||
// so a caller asking for one shop's cameras was handed the whole tenant's.
|
||||
// Measured: it made a script skip creating cameras for three shops because
|
||||
// another shop's already existed, and deleted a camera from the wrong shop.
|
||||
siteID := siteParam(r)
|
||||
if siteID != "" {
|
||||
var ok bool
|
||||
if siteID, ok = s.resolveSiteFilter(w, r, siteID); !ok {
|
||||
|
||||
134
server/internal/api/handlers_customers.go
Normal file
134
server/internal/api/handlers_customers.go
Normal file
@@ -0,0 +1,134 @@
|
||||
// Registering a customer nobody has photographed, and joining two records
|
||||
// that are one person.
|
||||
//
|
||||
// They ship together because the first creates the need for the second. A
|
||||
// customer typed in at a counter has no face template, so when a camera later
|
||||
// sees that person the matcher has nothing to compare against and enrols them
|
||||
// as somebody new. That is the design working, not failing - and it means
|
||||
// every hand-created customer is a duplicate waiting to happen. The merge is
|
||||
// the way back, and without it this pair of endpoints would manufacture
|
||||
// unrecoverable duplicates.
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// handleCreateCustomer is staff and above - the same bar as filling in a
|
||||
// profile, because that is what this is: a profile that arrives before the
|
||||
// face rather than after it.
|
||||
func (s *Server) handleCreateCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if !p.CanWriteProfiles() {
|
||||
writeErr(w, http.StatusForbidden, "forbidden",
|
||||
"Staff and above can add a customer.")
|
||||
return
|
||||
}
|
||||
|
||||
var in Profile
|
||||
if err := decode(w, r, &in); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
in.FullName = clip(trim(in.FullName), 200)
|
||||
in.Phone = clip(trim(in.Phone), 40)
|
||||
in.Email = clip(trim(in.Email), 200)
|
||||
in.Gender = clip(trim(in.Gender), 40)
|
||||
in.Notes = clip(trim(in.Notes), 2000)
|
||||
|
||||
// Something has to identify them to a human. A record with no name and no
|
||||
// phone is a number nobody can search for, and the customer standing at
|
||||
// the counter is the only source of either.
|
||||
if in.FullName == "" && in.Phone == "" {
|
||||
badRequest(w, "give at least a name or a phone number")
|
||||
return
|
||||
}
|
||||
|
||||
out, err := s.Store.CreateCustomer(r.Context(), p.ClientID, in, p.UserID)
|
||||
if err != nil {
|
||||
s.serverError(w, "create customer", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "customer.create", Entity: "visitor", EntityID: out.ID,
|
||||
Detail: map[string]any{"ref": out.Ref},
|
||||
})
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// handleMergeCustomers folds one customer into another.
|
||||
//
|
||||
// Manager and above, not staff. This is the only irreversible operation on a
|
||||
// customer record apart from erasure: two people welded together cannot be
|
||||
// separated afterwards, because nothing records which visit came from whom.
|
||||
// The edge gallery draws the same line for the same reason.
|
||||
func (s *Server) handleMergeCustomers(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if !p.CanManageSites() {
|
||||
writeErr(w, http.StatusForbidden, "forbidden",
|
||||
"Merging two customers cannot be undone; a manager or owner must do it.")
|
||||
return
|
||||
}
|
||||
|
||||
source, ok := s.resolveVisitor(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in MergeRequest
|
||||
if err := decode(w, r, &in); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if trim(in.Into) == "" {
|
||||
badRequest(w, `"into" must name the customer to keep`)
|
||||
return
|
||||
}
|
||||
// Resolved through the same path, so "into" accepts V-42 as well as a
|
||||
// uuid - the reference staff actually read off a screen.
|
||||
target, err := s.visitorIDFor(r.Context(), p.ClientID, trim(in.Into))
|
||||
if err != nil {
|
||||
s.serverError(w, "resolve customer", err)
|
||||
return
|
||||
}
|
||||
if target == "" {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such customer to merge into.")
|
||||
return
|
||||
}
|
||||
|
||||
out, err := s.Store.MergeVisitors(r.Context(), p.ClientID, source, target)
|
||||
switch {
|
||||
case errors.Is(err, ErrSameVisitor):
|
||||
badRequest(w, "that is the same customer")
|
||||
return
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
// One of the two is gone, erased, or another tenant's. All three read
|
||||
// as absent; which one it is only helps somebody probing ids.
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such customer.")
|
||||
return
|
||||
case err != nil:
|
||||
s.serverError(w, "merge customers", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Irreversible, so it leaves a trace at WARNING as well as in the audit
|
||||
// log - the same rule the edge gallery's merge follows.
|
||||
s.logf("WARNING merge: customer %s (%s) folded into %s (%s) by %s: "+
|
||||
"%d visits, %d purchases, %d templates moved",
|
||||
source, out.RetiredRef, out.VisitorID, out.Ref, p.Email,
|
||||
out.Visits, out.Purchases, out.Embeddings)
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "customer.merge", Entity: "visitor", EntityID: out.VisitorID,
|
||||
Detail: map[string]any{
|
||||
"retired_ref": out.RetiredRef, "kept_ref": out.Ref,
|
||||
"visits": out.Visits, "purchases": out.Purchases,
|
||||
"embeddings": out.Embeddings,
|
||||
},
|
||||
})
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
92
server/internal/api/handlers_password.go
Normal file
92
server/internal/api/handlers_password.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Changing your own password.
|
||||
//
|
||||
// This did not exist, and the cost of that was measured rather than guessed:
|
||||
// rotating three production accounts took a shell on the server, three round
|
||||
// trips, and briefly left a PLATFORM ADMIN - the account that reads every
|
||||
// company on the estate - with a password anyone watching could guess, because
|
||||
// a placeholder in a pasted command was taken literally.
|
||||
//
|
||||
// A manager could always reset somebody ELSE's password, and a platform admin
|
||||
// could be reset by nobody at all: they have no client, so the team routes are
|
||||
// not theirs, and `provision user` on the host was the only way. For a product
|
||||
// that puts accounts on shop-floor PCs and staff phones, "change my password"
|
||||
// is not a feature, it is the thing that makes every other credential decision
|
||||
// recoverable.
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
// handleChangePassword is on `authed`, NOT `tenantOnly`.
|
||||
//
|
||||
// A session is not a company's data. A platform admin has no client and must
|
||||
// still be able to change their own password - they are precisely the account
|
||||
// for which there was no other route.
|
||||
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
|
||||
var in ChangePassword
|
||||
if err := decode(w, r, &in); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// The CURRENT password is required, and that is the whole security
|
||||
// argument. An access token lives twelve hours and travels on shop-floor
|
||||
// devices; without this, anyone holding a stolen one could set a new
|
||||
// password and own the account permanently rather than for the rest of
|
||||
// the day.
|
||||
rec, err := s.Store.UserByEmail(r.Context(), p.Email)
|
||||
if err != nil {
|
||||
s.serverError(w, "change password", err)
|
||||
return
|
||||
}
|
||||
if !rec.Found || !auth.VerifyPassword(rec.PasswordHash, in.CurrentPassword) {
|
||||
// Deliberately not throttled separately: this needs a live session, so
|
||||
// it is not reachable by anyone guessing from outside, and the login
|
||||
// throttle already governs getting one.
|
||||
writeErr(w, http.StatusForbidden, "wrong_password",
|
||||
"That is not your current password.")
|
||||
return
|
||||
}
|
||||
if in.CurrentPassword == in.NewPassword {
|
||||
badRequest(w, "the new password is the same as the old one")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(in.NewPassword)
|
||||
if err != nil {
|
||||
// HashPassword enforces the length floor, and its message names it.
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.Store.SetUserPassword(r.Context(), p.UserID, hash); err != nil {
|
||||
s.serverError(w, "change password", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Every OTHER session goes, and the caller's stays. Somebody changing
|
||||
// their password because they think it is known must not have to guess
|
||||
// whether the change took effect on the device that already had it - and
|
||||
// must not be signed out of the one in their hand while they deal with it.
|
||||
revoked, err := s.Store.RevokeOtherSessions(r.Context(), p.UserID, p.SessionID)
|
||||
if err != nil {
|
||||
// The password IS changed. Reporting a failure here would tell the
|
||||
// user to try again, and the retry would fail on the current password
|
||||
// they just replaced.
|
||||
s.logf("change password: revoke other sessions: %v", err)
|
||||
}
|
||||
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "auth.change_password", Entity: "user", EntityID: p.UserID,
|
||||
Detail: map[string]any{"sessions_revoked": revoked},
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"changed": true,
|
||||
"sessions_revoked": revoked,
|
||||
})
|
||||
}
|
||||
131
server/internal/api/handlers_sales.go
Normal file
131
server/internal/api/handlers_sales.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Sales a person can read, and the merchant home screen.
|
||||
//
|
||||
// Both are reads over data the server already holds. Nothing here computes a
|
||||
// number a report does not already compute: where a figure exists behind
|
||||
// /api/reports it is asked for rather than re-derived, because two definitions
|
||||
// of "unique visitor" or of "online" drift, and the screen that disagrees with
|
||||
// the report it links to is the one nobody trusts afterwards.
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Server) handleSales(w http.ResponseWriter, r *http.Request) {
|
||||
// Same window, same site parameter, same parsing as every report. `site`
|
||||
// and `site_id` are both accepted, and an unknown one is a 400 rather than
|
||||
// being silently ignored - an ignored filter returns the whole estate,
|
||||
// which is a wrong number nobody would question.
|
||||
rq, err := s.reportQuery(r)
|
||||
if err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
q := SaleQuery{
|
||||
ClientID: rq.ClientID, SiteID: rq.SiteID,
|
||||
From: rq.From, To: rq.To,
|
||||
Limit: queryInt(r, "limit", 50, 200),
|
||||
}
|
||||
if raw := trim(r.URL.Query().Get("customer")); raw != "" {
|
||||
// A customer may be named by uuid or by "V-42", the reference the
|
||||
// product actually shows people.
|
||||
id, err := s.visitorIDFor(r.Context(), rq.ClientID, raw)
|
||||
if err != nil {
|
||||
s.serverError(w, "resolve customer", err)
|
||||
return
|
||||
}
|
||||
if id == "" {
|
||||
badRequest(w, "no customer called "+raw)
|
||||
return
|
||||
}
|
||||
q.VisitorID = id
|
||||
}
|
||||
|
||||
rows, err := s.Store.Sales(r.Context(), q)
|
||||
if err != nil {
|
||||
s.serverError(w, "sales", err)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []Sale{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
func (s *Server) handleSale(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
sale, err := s.Store.Sale(r.Context(), p.ClientID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.serverError(w, "sale", err)
|
||||
return
|
||||
}
|
||||
if sale.ID == "" {
|
||||
// Another tenant's sale reads as absent, never as forbidden.
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such sale.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sale)
|
||||
}
|
||||
|
||||
// handleDashboard is the merchant home screen in one request.
|
||||
//
|
||||
// It existed as four calls a client had to make and then combine, which is how
|
||||
// the desktop Footfall screen once computed its headline by adding the daily
|
||||
// bars up - silently too high, because a customer who came twice is one person
|
||||
// and two bucket-visitors. The combining happens here, against the same
|
||||
// functions the reports use.
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
rq, err := s.reportQuery(r)
|
||||
if err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
// Today, in the shop's own timezone. A dashboard that says "today" and
|
||||
// means UTC is wrong by five and a half hours in the one market this
|
||||
// currently ships to.
|
||||
loc, lerr := time.LoadLocation(rq.Timezone)
|
||||
if lerr != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
now := s.now().In(loc)
|
||||
rq.From = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
rq.To = rq.From.AddDate(0, 0, 1)
|
||||
rq.Bucket = "day"
|
||||
|
||||
_, totals, err := s.Store.Footfall(r.Context(), rq)
|
||||
if err != nil {
|
||||
s.serverError(w, "dashboard footfall", err)
|
||||
return
|
||||
}
|
||||
sites, err := s.Store.SiteHealth(r.Context(), rq.ClientID)
|
||||
if err != nil {
|
||||
s.serverError(w, "dashboard sites", err)
|
||||
return
|
||||
}
|
||||
|
||||
out := DashboardSummary{
|
||||
Date: rq.From.Format("2006-01-02"),
|
||||
Visitors: totals.UniqueVisitors,
|
||||
Visits: totals.Visits,
|
||||
// Carried from the report rather than recomputed: the share of faces
|
||||
// too poor to enrol is what says whether the count above is a number
|
||||
// or a floor, and it has to travel with it.
|
||||
FractionBelowGate: totals.FractionBelowGate,
|
||||
WorstSite: totals.WorstSite,
|
||||
Timezone: rq.Timezone,
|
||||
}
|
||||
for _, site := range sites {
|
||||
// One shop asked for narrows the tally to it; otherwise the estate.
|
||||
if rq.SiteID != "" && site.SiteID != rq.SiteID {
|
||||
continue
|
||||
}
|
||||
out.SitesTotal++
|
||||
if site.Online {
|
||||
out.SitesOnline++
|
||||
}
|
||||
out.CamerasTotal += site.CamerasTotal
|
||||
out.CamerasUp += site.CamerasUp
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
115
server/internal/api/password_test.go
Normal file
115
server/internal/api/password_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const pwPath = "/api/auth/password"
|
||||
|
||||
// loginCode signs in and returns only the status. The suite's login() fatals
|
||||
// on anything but 200, which is right everywhere else and useless here: half
|
||||
// of what these tests assert is that a password has STOPPED working.
|
||||
func loginCode(t *testing.T, s *Server, email, password string) int {
|
||||
t.Helper()
|
||||
return do(t, s, "POST", "/api/auth/login", "",
|
||||
map[string]string{"email": email, "password": password}).Code
|
||||
}
|
||||
|
||||
// The account this endpoint exists for. A platform admin has no company, so
|
||||
// the team routes are not theirs and tenantOnly refuses them - before this,
|
||||
// changing their password needed a shell on the production host.
|
||||
func TestAPlatformAdminCanChangeTheirOwnPassword(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedPlatformAdmin(fs)
|
||||
sess := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
rec := do(t, s, "POST", pwPath, sess.Token, map[string]string{
|
||||
"current_password": "admin123", "new_password": "a-much-longer-one",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// The new one works and the old one does not - asserted by signing in,
|
||||
// because that is the only thing a user actually cares about here.
|
||||
if c := loginCode(t, s, "root@loyaly.ai", "a-much-longer-one"); c != http.StatusOK {
|
||||
t.Errorf("new password signs in: got %d, want 200", c)
|
||||
}
|
||||
if c := loginCode(t, s, "root@loyaly.ai", "admin123"); c == http.StatusOK {
|
||||
t.Error("the old password still signs in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOrdinaryUserCanChangeTheirOwnPassword(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", pwPath, sess.Token, map[string]string{
|
||||
"current_password": "correct horse battery", "new_password": "staple-battery-horse",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The whole security argument. An access token lives twelve hours and travels
|
||||
// on shop-floor PCs and staff phones; without this, a stolen one owns the
|
||||
// account permanently instead of until it expires.
|
||||
func TestChangingAPasswordRequiresTheCurrentOne(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", pwPath, sess.Token, map[string]string{
|
||||
"current_password": "not the password", "new_password": "a-much-longer-one",
|
||||
})
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("got %d, want 403 - a token alone must not be enough: %s",
|
||||
rec.Code, rec.Body.String())
|
||||
}
|
||||
// And it must not have changed anything.
|
||||
if c := loginCode(t, s, "manager@acme.com", "correct horse battery"); c != http.StatusOK {
|
||||
t.Errorf("a refused change must leave the old password working: got %d", c)
|
||||
}
|
||||
}
|
||||
|
||||
// The floor lives in HashPassword, so this asserts the endpoint routes through
|
||||
// it rather than re-implementing a check that could drift from the constant.
|
||||
func TestAShortNewPasswordIsRefused(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", pwPath, sess.Token, map[string]string{
|
||||
"current_password": "correct horse battery", "new_password": "short",
|
||||
})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400 for a password under the floor", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReusingTheSamePasswordIsRefused(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "POST", pwPath, sess.Token, map[string]string{
|
||||
"current_password": "correct horse battery",
|
||||
"new_password": "correct horse battery",
|
||||
})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400 - a no-op change reads as success and is not", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangingAPasswordNeedsASession(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
rec := do(t, s, "POST", pwPath, "", map[string]string{
|
||||
"current_password": "correct horse battery", "new_password": "a-much-longer-one",
|
||||
})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("got %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,20 @@ func VisitorRef(number int64) string {
|
||||
return VisitorRefPrefix + strconv.FormatInt(number, 10)
|
||||
}
|
||||
|
||||
// VisitRefPrefix marks a visit reference. "#" rather than a letter because a
|
||||
// visit is a numbered event, not a named thing, and it reads correctly in a
|
||||
// sentence: "visit #1042 at chennai".
|
||||
const VisitRefPrefix = "#"
|
||||
|
||||
// VisitRef is what a person quotes for one visit. Empty for a visit recorded
|
||||
// before 014, which had no number - absent rather than wrong.
|
||||
func VisitRef(number int64) string {
|
||||
if number <= 0 {
|
||||
return ""
|
||||
}
|
||||
return VisitRefPrefix + strconv.FormatInt(number, 10)
|
||||
}
|
||||
|
||||
// ParseVisitorRef accepts "V-42", "v-42" and bare "42".
|
||||
//
|
||||
// Bare digits are accepted because a shop assistant reading a number off a
|
||||
|
||||
@@ -140,3 +140,18 @@ func TestAnArrivalCarriesTheShopReferenceItCanBeFilteredBy(t *testing.T) {
|
||||
t.Fatalf("the platform-wide visit counter leaked into the feed: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A visit reference is what a person quotes; the uuid is what a machine
|
||||
// de-duplicates on. Both travel, neither replaces the other.
|
||||
func TestVisitRefIsReadableAndAbsentWhenUnnumbered(t *testing.T) {
|
||||
if got := VisitRef(1042); got != "#1042" {
|
||||
t.Errorf("VisitRef(1042) = %q, want #1042", got)
|
||||
}
|
||||
// Visits recorded before 014 have no number. Absent, never "#0" - a
|
||||
// reference that looks real and is not is worse than none.
|
||||
for _, n := range []int64{0, -1} {
|
||||
if got := VisitRef(n); got != "" {
|
||||
t.Errorf("VisitRef(%d) = %q, want empty", n, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
195
server/internal/api/sales_test.go
Normal file
195
server/internal/api/sales_test.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func seedSales(fs *fakeStore) {
|
||||
seedUser(fs)
|
||||
fs.salesRows = []Sale{
|
||||
{ID: "s1", OccurredAt: "2026-09-20T10:00:00Z", SiteID: siteA, Site: "Chennai",
|
||||
Amount: 1499.50, Currency: "INR", VisitorID: "v1", VisitorRef: "V-42",
|
||||
VisitorLabel: "Visitor 42", Items: []string{"shirt"}, Source: "manual"},
|
||||
{ID: "s2", OccurredAt: "2026-09-19T10:00:00Z", SiteID: "other-site",
|
||||
Amount: 200, Currency: "INR", Items: []string{}, Source: "pos"},
|
||||
}
|
||||
fs.saleOwner = map[string]string{"s1": "client-acme", "s2": "client-acme"}
|
||||
}
|
||||
|
||||
func TestSalesListsRowsTheConversionReportOnlySummed(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedSales(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/sales", sess.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out []Sale
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("got %d sales, want 2", len(out))
|
||||
}
|
||||
// The reference the product shows people, not just the uuid.
|
||||
if out[0].VisitorRef != "V-42" {
|
||||
t.Errorf("visitor_ref %q, want the speakable reference", out[0].VisitorRef)
|
||||
}
|
||||
}
|
||||
|
||||
// A sale with no customer is an ordinary walk-in nobody identified, and it is
|
||||
// still revenue. Joining it away would make this list disagree with the
|
||||
// conversion report computed over the same table.
|
||||
func TestASaleWithNoCustomerIsStillListed(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedSales(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/sales", sess.Token, nil)
|
||||
var out []Sale
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
found := false
|
||||
for _, sale := range out {
|
||||
if sale.ID == "s2" && sale.VisitorID == "" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("a sale with no visitor must still appear: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// An empty basket must serialise as [] and not null, or a client mapping over
|
||||
// it breaks on the first sale recorded without one.
|
||||
func TestEmptyItemsSerialiseAsAnArray(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedSales(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/sales", sess.Token, nil)
|
||||
if strings.Contains(rec.Body.String(), `"items":null`) {
|
||||
t.Errorf("items must be [] and never null: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The hazard this API has already been bitten by: an unknown query parameter
|
||||
// is silently ignored, so a mistyped filter returns the whole estate.
|
||||
func TestAnUnknownShopFilterIsRefusedRatherThanIgnored(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedSales(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/sales?site=nowhere", sess.Token, nil)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400 - silently returning every shop's sales is "+
|
||||
"a wrong number nobody would question: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSalesCanBeNarrowedToOneCustomerByReference(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedSales(fs)
|
||||
fs.visitors = []Customer{{ID: "v1", Ref: "V-42", Label: "Visitor 42"}}
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/sales?customer=V-42", sess.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if fs.lastSaleQuery.VisitorID != "v1" {
|
||||
t.Errorf("resolved customer %q, want the uuid behind V-42",
|
||||
fs.lastSaleQuery.VisitorID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnotherTenantsSaleIs404(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedSales(fs)
|
||||
fs.saleOwner["s1"] = "client-rival"
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/sales/s1", sess.Token, nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("got %d, want 404 for another tenant's sale", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- dashboard
|
||||
|
||||
// The headline must be the server's own unique-visitor figure, never the sum
|
||||
// of the buckets: a customer who came twice is one person and two
|
||||
// bucket-visitors, and adding the bars up is silently too high.
|
||||
func TestDashboardReportsUniquePeopleAndVisitsSeparately(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.totals = Totals{UniqueVisitors: 7, Visits: 19,
|
||||
FractionBelowGate: 0.59, WorstSite: "TeNext Coimbatore"}
|
||||
fs.sites = []SiteHealth{
|
||||
{SiteID: siteA, Name: "Chennai", Online: true, CamerasUp: 1, CamerasTotal: 2},
|
||||
{SiteID: "s2", Name: "Mumbai", Online: false, CamerasUp: 0, CamerasTotal: 1},
|
||||
}
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/dashboard/summary", sess.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out DashboardSummary
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Visitors != 7 || out.Visits != 19 {
|
||||
t.Errorf("got %d people / %d visits, want 7 and 19 reported separately",
|
||||
out.Visitors, out.Visits)
|
||||
}
|
||||
if out.SitesTotal != 2 || out.SitesOnline != 1 {
|
||||
t.Errorf("sites %d/%d, want 1 of 2 online", out.SitesOnline, out.SitesTotal)
|
||||
}
|
||||
if out.CamerasTotal != 3 || out.CamerasUp != 1 {
|
||||
t.Errorf("cameras %d/%d, want 1 of 3", out.CamerasUp, out.CamerasTotal)
|
||||
}
|
||||
}
|
||||
|
||||
// The share of faces too poor to enrol is what says whether the headcount above
|
||||
// is a number or a floor. It has to travel with it, on this screen too.
|
||||
func TestDashboardCarriesTheConfidenceWithTheCount(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.totals = Totals{UniqueVisitors: 7, Visits: 19,
|
||||
FractionBelowGate: 0.59, WorstSite: "TeNext Coimbatore"}
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/dashboard/summary", sess.Token, nil)
|
||||
var out DashboardSummary
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out.FractionBelowGate != 0.59 || out.WorstSite == "" {
|
||||
t.Errorf("a headcount without its confidence is the thing this product "+
|
||||
"exists not to ship: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// "Today" means the shop's day. In the one market this ships to, UTC is five
|
||||
// and a half hours wrong.
|
||||
func TestDashboardCutsTodayInTheRequestedTimezone(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
rec := do(t, s, "GET", "/api/dashboard/summary?tz=Asia/Kolkata", sess.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out DashboardSummary
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out.Timezone != "Asia/Kolkata" {
|
||||
t.Errorf("timezone %q, want the one asked for so the client can label it",
|
||||
out.Timezone)
|
||||
}
|
||||
if fs.lastReport.From.Hour() != 0 {
|
||||
t.Errorf("the window must start at local midnight, got %v", fs.lastReport.From)
|
||||
}
|
||||
}
|
||||
61
server/internal/api/sitefilter_test.go
Normal file
61
server/internal/api/sitefilter_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every endpoint that narrows by shop must accept BOTH spellings, because an
|
||||
// unknown query parameter is silently ignored - so the wrong one is not an
|
||||
// error, it is the whole estate returned as though it were one shop. That is a
|
||||
// wrong answer nobody would question, and it has already caused a camera to be
|
||||
// deleted from the wrong shop.
|
||||
func TestEveryShopFilterAcceptsBothSpellings(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
fs.sites = []SiteHealth{
|
||||
{SiteID: siteA, Slug: "chennai", Name: "TeNext Coimbatore"},
|
||||
{SiteID: "site-other", Slug: "other-shop", Name: "Other Shop"},
|
||||
}
|
||||
fs.cameras = []Camera{
|
||||
{ID: "c1", SiteID: siteA, CameraID: "entrance"},
|
||||
{ID: "c2", SiteID: "site-other", CameraID: "backdoor"},
|
||||
}
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/cameras?site=chennai",
|
||||
"/api/cameras?site_id=" + siteA,
|
||||
} {
|
||||
rec := do(t, s, "GET", path, sess.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: got %d: %s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
var got []Camera
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
if len(got) != 1 || got[0].CameraID != "entrance" {
|
||||
t.Errorf("%s returned %d cameras %v - a shop filter that does not filter hands back the whole tenant",
|
||||
path, len(got), names(got))
|
||||
}
|
||||
}
|
||||
|
||||
// And with no filter at all, the tenant's cameras - which is the only case
|
||||
// that should ever return more than one shop's.
|
||||
rec := do(t, s, "GET", "/api/cameras", sess.Token, nil)
|
||||
var all []Camera
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &all)
|
||||
if len(all) != 2 {
|
||||
t.Errorf("unfiltered list returned %d, want 2", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func names(cams []Camera) []string {
|
||||
out := make([]string, 0, len(cams))
|
||||
for _, c := range cams {
|
||||
out = append(out, c.CameraID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -108,3 +108,22 @@ func TestNoBrokerConfiguredSaysSo(t *testing.T) {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAManagerRenamesAShopButTheSlugStays(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
seedSite(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
rec := do(t, s, "PATCH", "/api/sites/chennai", sess.Token, map[string]any{"name": "TeNext Coimbatore"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out SiteHealth
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out.Name != "TeNext Coimbatore" || out.Slug != "chennai" {
|
||||
t.Fatalf("renamed wrong: %+v", out)
|
||||
}
|
||||
if rec := do(t, s, "PATCH", "/api/sites/chennai", sess.Token, map[string]any{"timezone": "Mars/Olympus"}); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad timezone accepted: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
68
server/internal/api/tenant_guard_test.go
Normal file
68
server/internal/api/tenant_guard_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A platform admin has no client, and every tenant query scopes on one. Before
|
||||
// this guard the empty string reached Postgres as `client_id = ”::uuid`,
|
||||
// which is a cast ERROR and not an empty result - so five live endpoints
|
||||
// answered 500 to a signed-in platform admin. Found by calling them, not by a
|
||||
// test: the in-memory fake compares strings and is perfectly happy with "".
|
||||
func TestATenantRouteRefusesAnAccountWithNoCompany(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedPlatformAdmin(fs)
|
||||
sess := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/visits", "/api/cameras", "/api/sites", "/api/visitors",
|
||||
"/api/reports/footfall", "/api/sales", "/api/dashboard/summary",
|
||||
} {
|
||||
rec := do(t, s, "GET", path, sess.Token, nil)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("%s: got %d, want 403 - a platform admin reads a company's "+
|
||||
"data through /api/admin, and a 500 here reads as a broken "+
|
||||
"server rather than a wrong door", path, rec.Code)
|
||||
}
|
||||
// The message has to say where to go instead; "forbidden" alone sends
|
||||
// somebody hunting a permissions problem that does not exist.
|
||||
if !strings.Contains(rec.Body.String(), "/api/admin") {
|
||||
t.Errorf("%s: refusal should point at the admin routes: %s",
|
||||
path, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The guard must not lock a platform admin out of their own session, which is
|
||||
// not a company's data and is how they sign out or revoke a lost device.
|
||||
func TestAPlatformAdminKeepsTheirOwnSessionRoutes(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedPlatformAdmin(fs)
|
||||
sess := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
for _, path := range []string{"/api/auth/me", "/api/auth/sessions"} {
|
||||
if rec := do(t, s, "GET", path, sess.Token, nil); rec.Code != http.StatusOK {
|
||||
t.Errorf("%s: got %d, want 200", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And the ordinary case must be untouched: a tenant user still reaches
|
||||
// everything they always did.
|
||||
func TestATenantUserIsUnaffectedByTheGuard(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/visits", "/api/cameras", "/api/sites", "/api/visitors",
|
||||
"/api/sales", "/api/dashboard/summary",
|
||||
} {
|
||||
if rec := do(t, s, "GET", path, sess.Token, nil); rec.Code != http.StatusOK {
|
||||
t.Errorf("%s: got %d, want 200 for an ordinary tenant account: %s",
|
||||
path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,104 @@ type SalesReport struct {
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
// Sale is one recorded purchase, as a list shows it.
|
||||
//
|
||||
// The conversion report has always AGGREGATED this table; nothing could read a
|
||||
// row of it. "Revenue was 41,000 this week" and "which sales were those" are
|
||||
// different questions, and only the second can be checked against a till.
|
||||
//
|
||||
// Amount is a float here to match SalesReport.Revenue, and the column behind
|
||||
// it is numeric(14,2) precisely so the arithmetic never happens in a float.
|
||||
// Fourteen digits fits inside float64's exact-integer range, so the value
|
||||
// survives the trip; any SUM still happens in Postgres.
|
||||
type Sale struct {
|
||||
ID string `json:"id"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
SiteID string `json:"site_id"`
|
||||
Site string `json:"site,omitempty"`
|
||||
SiteSlug string `json:"site_slug,omitempty"`
|
||||
Amount float64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
|
||||
// Who bought, when the till knew. A sale with no visitor is ordinary - a
|
||||
// walk-in nobody identified - and it is still revenue, so it is listed
|
||||
// rather than joined away.
|
||||
VisitorID string `json:"visitor_id,omitempty"`
|
||||
VisitorRef string `json:"visitor_ref,omitempty"`
|
||||
VisitorLabel string `json:"visitor_label,omitempty"`
|
||||
VisitID string `json:"visit_id,omitempty"`
|
||||
|
||||
Items []string `json:"items"`
|
||||
Source string `json:"source"`
|
||||
ExternalRef string `json:"external_ref,omitempty"`
|
||||
}
|
||||
|
||||
// SaleQuery narrows a sales list. It reuses the report window, so `from`,
|
||||
// `to`, `site` and `site_id` mean here exactly what they mean on a report -
|
||||
// getting that wrong silently returns the whole estate, which this API has
|
||||
// already been bitten by once.
|
||||
type SaleQuery struct {
|
||||
ClientID string
|
||||
SiteID string
|
||||
VisitorID string
|
||||
From time.Time
|
||||
To time.Time
|
||||
Limit int
|
||||
}
|
||||
|
||||
// DashboardSummary is the merchant home screen in one call.
|
||||
//
|
||||
// Composed from the two functions that already answer these questions rather
|
||||
// than from new SQL: a second definition of "online" or of a unique visitor
|
||||
// would drift from the reports, and a home screen that disagrees with the
|
||||
// report it links to is worse than no home screen.
|
||||
type DashboardSummary struct {
|
||||
Date string `json:"date"`
|
||||
Visitors int `json:"visitors"`
|
||||
Visits int `json:"visits"`
|
||||
SitesTotal int `json:"sites_total"`
|
||||
SitesOnline int `json:"sites_online"`
|
||||
CamerasTotal int `json:"cameras_total"`
|
||||
CamerasUp int `json:"cameras_up"`
|
||||
FractionBelowGate float64 `json:"fraction_below_gate"`
|
||||
WorstSite string `json:"worst_site,omitempty"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
// ChangePassword is the body of POST /api/auth/password. The current password
|
||||
// is required: an access token alone must not be enough to take an account
|
||||
// over permanently.
|
||||
type ChangePassword struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// MergeResult says what moved, so an operator sees the size of a thing that
|
||||
// cannot be undone rather than a bare "ok".
|
||||
type MergeResult struct {
|
||||
VisitorID string `json:"visitor_id"`
|
||||
Ref string `json:"ref"`
|
||||
Label string `json:"label"`
|
||||
Visits int `json:"visits"`
|
||||
Purchases int `json:"purchases"`
|
||||
Embeddings int `json:"embeddings"`
|
||||
Consents int `json:"consents"`
|
||||
// RetiredRef is the reference that has STOPPED resolving. Staff write
|
||||
// these on cards and read them aloud, so a merge has to say which one
|
||||
// died rather than leaving somebody to discover it at a counter.
|
||||
RetiredRef string `json:"retired_ref"`
|
||||
// Discarded lists profile values the survivor already had a different
|
||||
// answer for - a second phone number, a different spelling of a name.
|
||||
// They are appended to the survivor's notes as well: this response is
|
||||
// read once and the record is read forever.
|
||||
Discarded []string `json:"discarded,omitempty"`
|
||||
}
|
||||
|
||||
// MergeRequest names the record to keep.
|
||||
type MergeRequest struct {
|
||||
Into string `json:"into"`
|
||||
}
|
||||
|
||||
type Customer struct {
|
||||
ID string `json:"id"`
|
||||
// Ref is the customer number - "V-42" - and is accepted anywhere this
|
||||
@@ -134,6 +232,21 @@ type VisitRow struct {
|
||||
Similarity float64 `json:"similarity,omitempty"`
|
||||
Quality float64 `json:"quality,omitempty"`
|
||||
Attributes map[string]any `json:"attributes,omitempty"`
|
||||
|
||||
// What they bought on that visit, if anything. Attached here because
|
||||
// "when was this customer last in" and "did they buy" are one question
|
||||
// staff ask in one breath, and answering it used to mean two calls and a
|
||||
// join in the client.
|
||||
//
|
||||
// Spend and Currency are omitted when a single visit somehow holds more
|
||||
// than one currency: adding rupees to dollars produces something that
|
||||
// looks like money and is not, and Purchases still says a sale happened.
|
||||
// A purchase with no visit_id is not here at all - it belongs to the
|
||||
// customer rather than to a moment - and is listed by
|
||||
// GET /api/sales?customer=V-42.
|
||||
Purchases int `json:"purchases,omitempty"`
|
||||
Spend float64 `json:"spend,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
@@ -184,6 +297,13 @@ type NewSite struct {
|
||||
Password string `json:"-"`
|
||||
}
|
||||
|
||||
// SiteUpdate is the editable part of a shop. Both optional; an absent field
|
||||
// is left alone.
|
||||
type SiteUpdate struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Timezone *string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
type SiteHealth struct {
|
||||
SiteID string `json:"site_id"`
|
||||
Slug string `json:"slug"`
|
||||
@@ -261,6 +381,11 @@ type AgentPrincipal struct {
|
||||
// audit log to do it.
|
||||
type Arrival struct {
|
||||
VisitID string `json:"visit_id"`
|
||||
// VisitRef is what a person quotes - "#1042" - per client, beside the uuid
|
||||
// rather than instead of it. Every other thing in the product a person
|
||||
// refers to has one: a shop is `chennai`, a camera `cam1`, a customer
|
||||
// `V-42`. A visit had only 36 hex characters.
|
||||
VisitRef string `json:"visit_ref,omitempty"`
|
||||
// Seq is this visit's position in the feed - assigned by the server when it
|
||||
// learned of the visit, not by the camera. It drives the cursor and the
|
||||
// ordering, and it is `json:"-"` on purpose.
|
||||
@@ -422,6 +547,71 @@ type ClientRow struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// ClientDetail is one merchant for the platform-admin console: the list row
|
||||
// plus the owner, which is who a support conversation actually starts with.
|
||||
type ClientDetail struct {
|
||||
ClientRow
|
||||
OwnerEmail string `json:"owner_email,omitempty"`
|
||||
OwnerName string `json:"owner_name,omitempty"`
|
||||
}
|
||||
|
||||
// PlatformSummary is the estate-wide header strip. Counts only.
|
||||
type PlatformSummary struct {
|
||||
CamerasTotal int `json:"cameras_total"`
|
||||
CamerasOnline int `json:"cameras_online"`
|
||||
MerchantsActive int `json:"merchants_active"`
|
||||
SitesTotal int `json:"sites_total"`
|
||||
EventsToday int `json:"events_today"`
|
||||
AsOf string `json:"as_of"`
|
||||
}
|
||||
|
||||
// AdminCamera is a camera as a PLATFORM ADMIN may see it, and it is a separate
|
||||
// type from Camera for the same reason AgentCamera is.
|
||||
//
|
||||
// It carries no host, port, path, username or has_password. A tenant seeing
|
||||
// those for their own camera is correct - it is their camera and their form
|
||||
// edits it. A platform admin browsing another company's estate is a different
|
||||
// question, and an RTSP host with a username beside it is most of a live path
|
||||
// into a customer's camera. Blanking fields on a shared struct would leave
|
||||
// "remember to redact, on every path, forever" as the only thing preventing a
|
||||
// leak; a type that cannot express them cannot forget.
|
||||
type AdminCamera struct {
|
||||
ID string `json:"id"`
|
||||
SiteID string `json:"site_id"`
|
||||
Site string `json:"site,omitempty"`
|
||||
CameraID string `json:"camera_id"`
|
||||
Label string `json:"label"`
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// Connected stays a POINTER: null is "no shop PC has reported on this
|
||||
// yet", false is "not connecting", and those send an installer to two
|
||||
// different places.
|
||||
Connected *bool `json:"connected"`
|
||||
LastSeenAt string `json:"last_seen_at,omitempty"`
|
||||
SnapshotAt string `json:"snapshot_at,omitempty"`
|
||||
Check CameraCheck `json:"check"`
|
||||
}
|
||||
|
||||
// adminCamera redacts one camera for the admin console.
|
||||
func adminCamera(c Camera) AdminCamera {
|
||||
return AdminCamera{
|
||||
ID: c.ID, SiteID: c.SiteID, Site: c.Site,
|
||||
CameraID: c.CameraID, Label: c.Label, Enabled: c.Enabled,
|
||||
Connected: c.Connected, LastSeenAt: c.LastSeenAt,
|
||||
SnapshotAt: c.SnapshotAt, Check: c.Check,
|
||||
}
|
||||
}
|
||||
|
||||
// AdminCameras redacts a list, and returns an empty slice rather than nil so
|
||||
// the response is [] and not null.
|
||||
func AdminCameras(in []Camera) []AdminCamera {
|
||||
out := make([]AdminCamera, 0, len(in))
|
||||
for _, c := range in {
|
||||
out = append(out, adminCamera(c))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- cameras
|
||||
|
||||
// Camera is one camera as head office sees it: how it is configured, and
|
||||
|
||||
@@ -146,3 +146,15 @@ func (s *Store) DeleteEmptySite(ctx context.Context, clientID, siteID string) (s
|
||||
}
|
||||
return username, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSite(ctx context.Context, clientID, siteID string, in api.SiteUpdate) (api.SiteHealth, error) {
|
||||
var out api.SiteHealth
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
UPDATE sites
|
||||
SET name = COALESCE($3, name),
|
||||
timezone = COALESCE($4, timezone)
|
||||
WHERE id = $1::uuid AND client_id = $2::uuid
|
||||
RETURNING id::text, slug, name, timezone`, siteID, clientID, in.Name, in.Timezone).
|
||||
Scan(&out.SiteID, &out.Slug, &out.Name, &out.Timezone)
|
||||
return out, err
|
||||
}
|
||||
|
||||
134
server/internal/store/api_admin_monitor.go
Normal file
134
server/internal/store/api_admin_monitor.go
Normal file
@@ -0,0 +1,134 @@
|
||||
// Platform-admin reads BELOW the merchant level: one company, its shops, its
|
||||
// cameras, and the estate-wide totals.
|
||||
//
|
||||
// Every function here takes the merchant's client id as an ARGUMENT, because
|
||||
// the caller is a platform admin who has no client of their own. That is the
|
||||
// whole reason these exist rather than reusing the tenant handlers: those
|
||||
// derive the tenant from the session, and an admin session carries none. The
|
||||
// tenant STORE functions already take a client id explicitly, so this file
|
||||
// adds the scoping the tenant handlers get for free and nothing else.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/api"
|
||||
)
|
||||
|
||||
// ClientDetail is one merchant, with the owner a support conversation starts
|
||||
// from. ListClients cannot carry it: an owner lookup per row would be a query
|
||||
// per merchant on a screen that only needs the name.
|
||||
//
|
||||
// `c.id::text = $1`, for the same reason the two resolvers below use it, and
|
||||
// this one learned it the hard way: as `c.id = $1::uuid` it answered 500 to
|
||||
// /api/admin/clients/not-a-uuid/sites on the first real database, because
|
||||
// casting a malformed string - or the empty one a shape check hands back - to
|
||||
// uuid is an ERROR in Postgres rather than a miss. Comparing the column as
|
||||
// text cannot fail: an id that is not a uuid simply matches nothing, which is
|
||||
// the 404 a wrong URL should get. The sibling queries were already written
|
||||
// this way and correctly 404'd; only this one was not.
|
||||
func (s *Store) ClientDetail(ctx context.Context, clientID string) (api.ClientDetail, error) {
|
||||
var c api.ClientDetail
|
||||
var at time.Time
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT c.id::text, c.slug, c.name, c.active, c.created_at,
|
||||
(SELECT count(*) FROM sites si WHERE si.client_id = c.id),
|
||||
(SELECT count(*) FROM app_users au WHERE au.client_id = c.id),
|
||||
COALESCE((SELECT au.email FROM app_users au
|
||||
WHERE au.client_id = c.id AND au.role = 'owner'
|
||||
AND au.active ORDER BY au.created_at LIMIT 1), ''),
|
||||
COALESCE((SELECT au.full_name FROM app_users au
|
||||
WHERE au.client_id = c.id AND au.role = 'owner'
|
||||
AND au.active ORDER BY au.created_at LIMIT 1), '')
|
||||
FROM clients c
|
||||
WHERE c.id::text = $1`, clientID).
|
||||
Scan(&c.ID, &c.Slug, &c.Name, &c.Active, &at, &c.Sites, &c.Users,
|
||||
&c.OwnerEmail, &c.OwnerName)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return api.ClientDetail{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return api.ClientDetail{}, err
|
||||
}
|
||||
c.CreatedAt = at.UTC().Format(time.RFC3339)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// AdminSiteID resolves a shop reference WITHIN one merchant, by slug or uuid.
|
||||
//
|
||||
// The uuid branch is the point. The tenant resolver returns a uuid untouched
|
||||
// and lets every downstream query's `client_id = $1` do the scoping, which is
|
||||
// sound there because the client id comes from the session and cannot be
|
||||
// chosen. Here the caller names BOTH, so an unowned uuid would otherwise reach
|
||||
// a query that quietly returns nothing - an empty shop rather than "no such
|
||||
// shop". Resolving through the database with both halves is what makes a
|
||||
// broken chain a 404.
|
||||
func (s *Store) AdminSiteID(ctx context.Context, clientID, ref string) (string, error) {
|
||||
var id string
|
||||
// `id::text = $2`, never `id = $2::uuid`. Using one parameter as both text
|
||||
// and uuid in the same statement is how Postgres ends up deducing two
|
||||
// types for it and refusing the whole query - the identical shape that
|
||||
// broke `'Visitor ' || $2::text` beside `number = $2`, which compiled,
|
||||
// passed every in-memory test and failed on the first real database.
|
||||
// Casting the COLUMN keeps one type per parameter, and it cannot raise an
|
||||
// invalid-uuid error on a malformed path segment either: it simply misses,
|
||||
// which is the 404 the caller should get anyway.
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id::text FROM sites
|
||||
WHERE client_id = $1::uuid
|
||||
AND (slug = $2 OR id::text = $2)`,
|
||||
clientID, ref).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// AdminCameraID resolves a camera within one shop of one merchant.
|
||||
//
|
||||
// All three links are checked in the one statement, so there is no ordering in
|
||||
// which a caller learns that a camera exists somewhere else.
|
||||
func (s *Store) AdminCameraID(ctx context.Context, clientID, siteID, ref string) (string, error) {
|
||||
var id string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT c.id::text
|
||||
FROM site_cameras c
|
||||
JOIN sites si ON si.id = c.site_id
|
||||
WHERE si.client_id = $1::uuid
|
||||
AND c.site_id = $2::uuid
|
||||
AND c.deleted_at IS NULL
|
||||
AND (c.camera_id = $3 OR c.id::text = $3)`,
|
||||
clientID, siteID, ref).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// PlatformSummary is counts and nothing else.
|
||||
//
|
||||
// Deliberately not a list: it backs a header strip, and an endpoint that
|
||||
// returns every camera on the platform to render four numbers is one that gets
|
||||
// slower with every customer signed.
|
||||
func (s *Store) PlatformSummary(ctx context.Context) (api.PlatformSummary, error) {
|
||||
var out api.PlatformSummary
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
(SELECT count(*) FROM site_cameras WHERE deleted_at IS NULL),
|
||||
(SELECT count(*) FROM site_cameras
|
||||
WHERE deleted_at IS NULL AND connected IS TRUE),
|
||||
(SELECT count(*) FROM clients WHERE active),
|
||||
(SELECT count(*) FROM sites),
|
||||
(SELECT count(*) FROM visits WHERE occurred_at >= date_trunc('day', now()))
|
||||
`).Scan(&out.CamerasTotal, &out.CamerasOnline, &out.MerchantsActive,
|
||||
&out.SitesTotal, &out.EventsToday)
|
||||
if err != nil {
|
||||
return api.PlatformSummary{}, err
|
||||
}
|
||||
out.AsOf = time.Now().UTC().Format(time.RFC3339)
|
||||
return out, nil
|
||||
}
|
||||
168
server/internal/store/api_admin_monitor_live_test.go
Normal file
168
server/internal/store/api_admin_monitor_live_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A malformed identifier must MISS, never error.
|
||||
//
|
||||
// This exists because the in-memory fake cannot catch it and did not. The API
|
||||
// fake resolves a merchant with a map lookup, so every handler test passed
|
||||
// while the real query answered 500 to /api/admin/clients/not-a-uuid/sites:
|
||||
// `c.id = $1::uuid` makes Postgres cast the path segment, and casting a
|
||||
// malformed string - or the empty one a shape check hands back in its place -
|
||||
// is an ERROR rather than no match. Comparing the column as text cannot fail.
|
||||
//
|
||||
// Kept as a live test on purpose. There is no way to assert this against a
|
||||
// fake: the property belongs to Postgres, and a fake that reproduced it would
|
||||
// be a second implementation of the thing under test.
|
||||
func TestLiveMalformedIdentifiersMissRatherThanError(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, id := range []string{"", "not-a-uuid", "'; DROP TABLE clients;--", "42"} {
|
||||
c, err := st.ClientDetail(ctx, id)
|
||||
if err != nil {
|
||||
t.Errorf("ClientDetail(%q): %v - a wrong URL must be a miss, not a 500", id, err)
|
||||
}
|
||||
if c.ID != "" {
|
||||
t.Errorf("ClientDetail(%q) matched %q", id, c.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// The sibling resolvers take a real client id and a free-text reference, so
|
||||
// the reference is the part a caller controls and the part that must not
|
||||
// blow up. A malformed CLIENT id here is covered above.
|
||||
const noClient = "00000000-0000-4000-8000-000000000000"
|
||||
for _, ref := range []string{"", "not-a-uuid", "'; --", "42"} {
|
||||
if id, err := st.AdminSiteID(ctx, noClient, ref); err != nil {
|
||||
t.Errorf("AdminSiteID(%q): %v", ref, err)
|
||||
} else if id != "" {
|
||||
t.Errorf("AdminSiteID(%q) matched %q", ref, id)
|
||||
}
|
||||
if id, err := st.AdminCameraID(ctx, noClient, noClient, ref); err != nil {
|
||||
t.Errorf("AdminCameraID(%q): %v", ref, err)
|
||||
} else if id != "" {
|
||||
t.Errorf("AdminCameraID(%q) matched %q", ref, id)
|
||||
}
|
||||
}
|
||||
|
||||
// And a sale id is the same shape of input on the tenant side.
|
||||
if s, err := st.Sale(ctx, noClient, "not-a-uuid"); err != nil {
|
||||
t.Errorf("Sale(not-a-uuid): %v", err)
|
||||
} else if s.ID != "" {
|
||||
t.Errorf("Sale(not-a-uuid) matched %q", s.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// A customer's history carries what they bought on each visit.
|
||||
//
|
||||
// Live, because the whole risk is in the SQL: a plain join onto purchases
|
||||
// would return a visit TWICE when it holds two sales, making a customer look
|
||||
// like they came more often than they did. The LATERAL aggregate is what
|
||||
// prevents that, and an in-memory fake asserting on it would only be checking
|
||||
// the fake.
|
||||
func TestLiveTwoSalesOnOneVisitDoNotDuplicateTheVisit(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
clientID, siteID, visitorID, visitID := seedVisitWithSales(t, st, 2)
|
||||
|
||||
rows, err := st.VisitorHistory(ctx, clientID, visitorID, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := 0
|
||||
for _, r := range rows {
|
||||
if r.ID != visitID {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
if r.Purchases != 2 {
|
||||
t.Errorf("purchases = %d, want 2", r.Purchases)
|
||||
}
|
||||
if r.Spend != 300 || r.Currency != "INR" {
|
||||
t.Errorf("spend = %v %s, want 300 INR", r.Spend, r.Currency)
|
||||
}
|
||||
}
|
||||
if seen != 1 {
|
||||
t.Fatalf("the visit appears %d times, want exactly 1 - two sales on one "+
|
||||
"visit must not make a customer look like two visits", seen)
|
||||
}
|
||||
_ = siteID
|
||||
}
|
||||
|
||||
// Mixed currencies on one visit report the count and NO figure. Adding rupees
|
||||
// to dollars produces something that looks like money and is not.
|
||||
func TestLiveMixedCurrenciesOnOneVisitReportNoTotal(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
clientID, _, visitorID, visitID := seedVisitWithSales(t, st, 0)
|
||||
seedSale(t, st, clientID, visitID, visitorID, 100, "INR")
|
||||
seedSale(t, st, clientID, visitID, visitorID, 50, "USD")
|
||||
|
||||
rows, err := st.VisitorHistory(ctx, clientID, visitorID, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.ID != visitID {
|
||||
continue
|
||||
}
|
||||
if r.Purchases != 2 {
|
||||
t.Errorf("purchases = %d, want 2 - the sales still happened", r.Purchases)
|
||||
}
|
||||
if r.Spend != 0 || r.Currency != "" {
|
||||
t.Errorf("spend = %v %q, want no figure for mixed currencies",
|
||||
r.Spend, r.Currency)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// seedVisitWithSales makes one tenant, one customer, one visit, and `sales`
|
||||
// purchases of 150 INR each against that visit.
|
||||
func seedVisitWithSales(t *testing.T, st *Store, sales int) (clientID, siteID, visitorID, visitID string) {
|
||||
t.Helper()
|
||||
clientID, siteID = seedTenant(t, st, "hist"+stamp(), 0, false)
|
||||
ctx := context.Background()
|
||||
at := time.Date(2026, 9, 3, 11, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO visitors (client_id, number, label, first_seen_at)
|
||||
VALUES ($1::uuid, 1, 'Visitor 1', $2) RETURNING id::text`,
|
||||
clientID, at).Scan(&visitorID); err != nil {
|
||||
t.Fatalf("seed visitor: %v", err)
|
||||
}
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO visits (client_id, site_id, visitor_id, source_event_id,
|
||||
occurred_at, camera_id, is_new_visitor)
|
||||
VALUES ($1::uuid, $2::uuid, $3::uuid, 'hist-e1', $4, 'door', true)
|
||||
RETURNING id::text`,
|
||||
clientID, siteID, visitorID, at).Scan(&visitID); err != nil {
|
||||
t.Fatalf("seed visit: %v", err)
|
||||
}
|
||||
for i := 0; i < sales; i++ {
|
||||
seedSale(t, st, clientID, visitID, visitorID, 150, "INR")
|
||||
}
|
||||
return clientID, siteID, visitorID, visitID
|
||||
}
|
||||
|
||||
func seedSale(t *testing.T, st *Store, clientID, visitID, visitorID string,
|
||||
amount float64, currency string) {
|
||||
t.Helper()
|
||||
var siteID string
|
||||
if err := st.pool.QueryRow(context.Background(),
|
||||
`SELECT site_id::text FROM visits WHERE id = $1::uuid`, visitID).Scan(&siteID); err != nil {
|
||||
t.Fatalf("site of visit: %v", err)
|
||||
}
|
||||
if _, err := st.pool.Exec(context.Background(), `
|
||||
INSERT INTO purchases (client_id, site_id, visitor_id, visit_id,
|
||||
amount, currency, occurred_at)
|
||||
VALUES ($1::uuid, $2::uuid, $3::uuid, $4::uuid, $5, $6, now())`,
|
||||
clientID, siteID, visitorID, visitID, amount, currency); err != nil {
|
||||
t.Fatalf("seed sale: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// mean the first poll of a feed and every poll after it returned different
|
||||
// shapes, which is the kind of bug that only shows up under load.
|
||||
const arrivalColumns = `
|
||||
vi.id::text, vi.seq, vi.occurred_at, vi.site_id::text, si.name, si.slug, vi.camera_id,
|
||||
vi.id::text, vi.seq, COALESCE(vi.number, 0), vi.occurred_at, vi.site_id::text, si.name, si.slug, vi.camera_id,
|
||||
vi.is_new_visitor, vi.similarity, vi.quality, vi.attributes, vi.image_key,
|
||||
COALESCE(vi.visitor_id::text, ''),
|
||||
COALESCE(vs.number, 0),
|
||||
@@ -112,13 +112,14 @@ func (s *Store) Arrivals(ctx context.Context, q api.ArrivalQuery) ([]api.Arrival
|
||||
var at time.Time
|
||||
var sim, qual *float64
|
||||
var imageKey string
|
||||
var number int64
|
||||
if err := rows.Scan(&a.VisitID, &a.Seq, &at, &a.SiteID, &a.Site, &a.SiteSlug, &a.CameraID,
|
||||
var number, visitNumber int64
|
||||
if err := rows.Scan(&a.VisitID, &a.Seq, &visitNumber, &at, &a.SiteID, &a.Site, &a.SiteSlug, &a.CameraID,
|
||||
&a.IsNew, &sim, &qual, &a.Attributes, &imageKey,
|
||||
&a.VisitorID, &number, &a.Label, &a.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.VisitorRef = api.VisitorRef(number)
|
||||
a.VisitRef = api.VisitRef(visitNumber)
|
||||
a.OccurredAt = at.UTC().Format(time.RFC3339Nano)
|
||||
if sim != nil {
|
||||
a.Similarity = *sim
|
||||
|
||||
313
server/internal/store/api_customers.go
Normal file
313
server/internal/store/api_customers.go
Normal file
@@ -0,0 +1,313 @@
|
||||
// Creating a customer nobody has photographed, and joining two records that
|
||||
// turn out to be one person.
|
||||
//
|
||||
// These ship together on purpose. A customer created by hand has no face, so
|
||||
// when a camera later sees that person the matcher has nothing to compare
|
||||
// against and records them as somebody new - by construction, not by failure.
|
||||
// Shipping the create without the merge would mean manufacturing duplicates
|
||||
// with no way back, which is the state CLAUDE.md already flags for the server:
|
||||
// "there is no merge endpoint server-side, so its duplicates would be
|
||||
// unrecoverable."
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/api"
|
||||
)
|
||||
|
||||
// CreateCustomer registers a person before any camera has seen them.
|
||||
//
|
||||
// The number comes from the same counter, taken the same way, as a customer
|
||||
// the engine enrols: `UPDATE ... RETURNING` inside the transaction. Two
|
||||
// sources of visitor numbers that could disagree would be worse than none,
|
||||
// and V-42 has to mean one person whichever way they arrived.
|
||||
func (s *Store) CreateCustomer(ctx context.Context, clientID string,
|
||||
in api.Profile, createdBy string) (api.Customer, error) {
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return api.Customer{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var number int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE clients SET visitor_seq = visitor_seq + 1
|
||||
WHERE id = $1::uuid RETURNING visitor_seq`, clientID).Scan(&number); err != nil {
|
||||
return api.Customer{}, fmt.Errorf("next visitor number: %w", err)
|
||||
}
|
||||
|
||||
// The label is the person's name when they gave one, and "Visitor N"
|
||||
// otherwise - the same string the engine would have written, so a record
|
||||
// created by hand is indistinguishable from an enrolled one afterwards.
|
||||
// Formatted in Go, never as `'Visitor ' || $2::text` beside `number = $2`:
|
||||
// one parameter used as a bigint and as a string operand makes Postgres
|
||||
// deduce two types for it and refuse the whole insert.
|
||||
label := in.FullName
|
||||
if label == "" {
|
||||
label = fmt.Sprintf("Visitor %d", number)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
var id string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO visitors (client_id, number, label, first_seen_at, visit_count)
|
||||
VALUES ($1::uuid, $2, $3, $4, 0) RETURNING id::text`,
|
||||
clientID, number, label, now).Scan(&id); err != nil {
|
||||
return api.Customer{}, err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO visitor_profiles (visitor_id, client_id, full_name, phone,
|
||||
email, gender, notes, collected_by)
|
||||
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7, NULLIF($8,'')::uuid)`,
|
||||
id, clientID, in.FullName, in.Phone, in.Email, in.Gender, in.Notes,
|
||||
createdBy); err != nil {
|
||||
return api.Customer{}, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return api.Customer{}, err
|
||||
}
|
||||
return api.Customer{
|
||||
ID: id, Ref: api.VisitorRef(number), Label: label,
|
||||
FullName: in.FullName, Phone: in.Phone, Email: in.Email,
|
||||
VisitCount: 0, HasProfile: true,
|
||||
FirstSeenAt: now.Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ErrSameVisitor is the API package's sentinel, aliased rather than
|
||||
// redeclared - two values would compare unequal and errors.Is would miss.
|
||||
var ErrSameVisitor = api.ErrSameVisitor
|
||||
|
||||
// MergeVisitors folds `sourceID` into `targetID` and deletes the source.
|
||||
//
|
||||
// One transaction, because a half-merge - visits moved, profile not - leaves
|
||||
// two records each holding part of one person, which is strictly worse than
|
||||
// the duplicate it was called to fix.
|
||||
//
|
||||
// Five tables reference visitors and every one is re-pointed here. A merge
|
||||
// that misses a table is the same half-merge arrived at by omission, and
|
||||
// ON DELETE CASCADE means the miss is not an error: the rows are silently
|
||||
// destroyed with the source row.
|
||||
func (s *Store) MergeVisitors(ctx context.Context, clientID, sourceID, targetID string) (
|
||||
api.MergeResult, error) {
|
||||
|
||||
var out api.MergeResult
|
||||
if sourceID == targetID {
|
||||
return out, ErrSameVisitor
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Both must exist, belong to this tenant, and not already be erased.
|
||||
// Locked in a stable order so two operators merging the same pair in
|
||||
// opposite directions deadlock on nothing and one simply loses.
|
||||
var srcNum, dstNum int64
|
||||
var srcLabel, dstLabel string
|
||||
var srcFirst, dstFirst time.Time
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT id::text, number, label, first_seen_at FROM visitors
|
||||
WHERE client_id = $1::uuid AND id::text IN ($2, $3)
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY id FOR UPDATE`, clientID, sourceID, targetID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
found := 0
|
||||
for rows.Next() {
|
||||
var id, label string
|
||||
var num int64
|
||||
var first time.Time
|
||||
if err := rows.Scan(&id, &num, &label, &first); err != nil {
|
||||
rows.Close()
|
||||
return out, err
|
||||
}
|
||||
found++
|
||||
if id == sourceID {
|
||||
srcNum, srcLabel, srcFirst = num, label, first
|
||||
} else {
|
||||
dstNum, dstLabel, dstFirst = num, label, first
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if found != 2 {
|
||||
return out, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
// Profile: visitor_profiles is UNIQUE on visitor_id, so the two cannot both
|
||||
// move and something has to win. Merged field by field in Go rather than in
|
||||
// one clever upsert, because the interesting case is not which value wins -
|
||||
// it is what happens to the one that loses.
|
||||
//
|
||||
// Blanks on the survivor are filled from the source. Where BOTH hold a
|
||||
// value the survivor keeps its own and the loser is recorded in `discarded`
|
||||
// and appended to notes. Dropping it silently was the first version's
|
||||
// behaviour and it lost a phone number on the very first live run: one
|
||||
// person can have two numbers, and a merge that quietly deletes one is
|
||||
// precisely the data loss an operator cannot see happen.
|
||||
var src, dst profileFields
|
||||
if err := readProfile(ctx, tx, sourceID, &src); err != nil {
|
||||
return out, fmt.Errorf("read source profile: %w", err)
|
||||
}
|
||||
if err := readProfile(ctx, tx, targetID, &dst); err != nil {
|
||||
return out, fmt.Errorf("read target profile: %w", err)
|
||||
}
|
||||
if src.exists {
|
||||
merged, discarded := mergeProfiles(src, dst)
|
||||
out.Discarded = discarded
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO visitor_profiles (visitor_id, client_id, full_name, phone,
|
||||
email, gender, notes)
|
||||
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (visitor_id) DO UPDATE SET
|
||||
full_name = EXCLUDED.full_name, phone = EXCLUDED.phone,
|
||||
email = EXCLUDED.email, gender = EXCLUDED.gender,
|
||||
notes = EXCLUDED.notes, updated_at = now()`,
|
||||
targetID, clientID, merged.fullName, merged.phone, merged.email,
|
||||
merged.gender, merged.notes); err != nil {
|
||||
return out, fmt.Errorf("merge profile: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, q := range []struct {
|
||||
name, sql string
|
||||
count *int
|
||||
}{
|
||||
{"visits", `UPDATE visits SET visitor_id = $2::uuid
|
||||
WHERE visitor_id = $1::uuid AND client_id = $3::uuid`, &out.Visits},
|
||||
{"purchases", `UPDATE purchases SET visitor_id = $2::uuid
|
||||
WHERE visitor_id = $1::uuid AND client_id = $3::uuid`, &out.Purchases},
|
||||
{"embeddings", `UPDATE visitor_embeddings SET visitor_id = $2::uuid
|
||||
WHERE visitor_id = $1::uuid AND client_id = $3::uuid`, &out.Embeddings},
|
||||
{"consents", `UPDATE consents SET visitor_id = $2::uuid
|
||||
WHERE visitor_id = $1::uuid AND client_id = $3::uuid`, &out.Consents},
|
||||
} {
|
||||
tag, err := tx.Exec(ctx, q.sql, sourceID, targetID, clientID)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("merge %s: %w", q.name, err)
|
||||
}
|
||||
*q.count = int(tag.RowsAffected())
|
||||
}
|
||||
|
||||
// A human-assigned name outranks an auto "Visitor N", whichever direction
|
||||
// the operator merged in. Silently turning "Alice" back into "Visitor 3"
|
||||
// is data loss they cannot see happen.
|
||||
label := dstLabel
|
||||
if isAutoLabel(dstLabel, dstNum) && !isAutoLabel(srcLabel, srcNum) {
|
||||
label = srcLabel
|
||||
}
|
||||
// first_seen_at takes the earlier of the two: it is one person and always
|
||||
// was. visit_count is recomputed with COUNT(*), never summed - the stored
|
||||
// counters may themselves be stale, and the row count cannot be.
|
||||
first := dstFirst
|
||||
if srcFirst.Before(first) {
|
||||
first = srcFirst
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE visitors SET
|
||||
label = $2, first_seen_at = $3,
|
||||
last_seen_at = GREATEST(last_seen_at,
|
||||
(SELECT max(occurred_at) FROM visits WHERE visitor_id = $1::uuid)),
|
||||
visit_count = (SELECT count(*) FROM visits WHERE visitor_id = $1::uuid)
|
||||
WHERE id = $1::uuid`, targetID, label, first); err != nil {
|
||||
return out, fmt.Errorf("merge totals: %w", err)
|
||||
}
|
||||
|
||||
// The source goes for real. A soft delete would leave its number resolving
|
||||
// to a record with nothing in it, which reads as "this customer exists and
|
||||
// has never been here" - a worse answer than "no such customer".
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM visitors WHERE id = $1::uuid`, sourceID); err != nil {
|
||||
return out, fmt.Errorf("delete merged customer: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
out.VisitorID = targetID
|
||||
out.Ref = api.VisitorRef(dstNum)
|
||||
out.RetiredRef = api.VisitorRef(srcNum)
|
||||
out.Label = label
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isAutoLabel reports whether a label is the one the system writes itself.
|
||||
// Compared against the record's OWN number: "Visitor 7" on customer 42 was
|
||||
// typed by a person and is a name, however unhelpful.
|
||||
func isAutoLabel(label string, number int64) bool {
|
||||
return label == fmt.Sprintf("Visitor %d", number)
|
||||
}
|
||||
|
||||
// profileFields is the part of a profile a merge has to reconcile.
|
||||
type profileFields struct {
|
||||
exists bool
|
||||
fullName, phone, email, gender, notes string
|
||||
}
|
||||
|
||||
func readProfile(ctx context.Context, tx pgx.Tx, visitorID string, out *profileFields) error {
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT full_name, phone, email, gender, notes
|
||||
FROM visitor_profiles WHERE visitor_id = $1::uuid`, visitorID).
|
||||
Scan(&out.fullName, &out.phone, &out.email, &out.gender, &out.notes)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
out.exists = err == nil
|
||||
return err
|
||||
}
|
||||
|
||||
// mergeProfiles keeps the survivor's own values, fills its blanks from the
|
||||
// source, and returns everything that lost so nothing disappears silently.
|
||||
func mergeProfiles(src, dst profileFields) (profileFields, []string) {
|
||||
out := dst
|
||||
var discarded []string
|
||||
keep := func(field string, mine *string, theirs string) {
|
||||
switch {
|
||||
case theirs == "":
|
||||
case *mine == "":
|
||||
*mine = theirs
|
||||
case *mine != theirs:
|
||||
discarded = append(discarded, field+": "+theirs)
|
||||
}
|
||||
}
|
||||
keep("name", &out.fullName, src.fullName)
|
||||
keep("phone", &out.phone, src.phone)
|
||||
keep("email", &out.email, src.email)
|
||||
keep("gender", &out.gender, src.gender)
|
||||
|
||||
// Notes are additive rather than a winner: two people writing about one
|
||||
// customer wrote two different true things.
|
||||
if src.notes != "" && src.notes != out.notes {
|
||||
if out.notes == "" {
|
||||
out.notes = src.notes
|
||||
} else {
|
||||
out.notes += "\n" + src.notes
|
||||
}
|
||||
}
|
||||
// And the losers land in notes too, because the response is read once and
|
||||
// the record is read forever.
|
||||
if len(discarded) > 0 {
|
||||
line := "merged, also known as - " + strings.Join(discarded, ", ")
|
||||
if out.notes == "" {
|
||||
out.notes = line
|
||||
} else {
|
||||
out.notes += "\n" + line
|
||||
}
|
||||
}
|
||||
return out, discarded
|
||||
}
|
||||
227
server/internal/store/api_customers_live_test.go
Normal file
227
server/internal/store/api_customers_live_test.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/api"
|
||||
)
|
||||
|
||||
// The case the whole feature exists for: a customer typed in at a counter,
|
||||
// then recognised by a camera a week later as somebody new, then joined.
|
||||
//
|
||||
// Live, because every property below is in the SQL and because the failure is
|
||||
// SILENT: five tables reference visitors with ON DELETE CASCADE, so a table
|
||||
// this merge forgets to re-point is not an error - those rows are destroyed
|
||||
// with the source row and nobody finds out until a customer's history is
|
||||
// short.
|
||||
func TestLiveMergeMovesEverythingAndLosesNothing(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
clientID, siteID := seedTenant(t, st, "merge"+stamp(), 0, false)
|
||||
|
||||
// The hand-typed record: a name and a phone, no face, no visits.
|
||||
typed, err := st.CreateCustomer(ctx, clientID,
|
||||
api.Profile{FullName: "Asha Menon", Phone: "9876543210"}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create customer: %v", err)
|
||||
}
|
||||
if typed.Ref == "" {
|
||||
t.Fatal("a hand-created customer must get a speakable reference")
|
||||
}
|
||||
|
||||
// The record a camera made later. Two visits, a purchase, a template and a
|
||||
// consent - one row in every table that references visitors.
|
||||
seen, visitIDs := seedRecognisedVisitor(t, st, clientID, siteID, 2)
|
||||
seedSale(t, st, clientID, visitIDs[0], seen, 250, "INR")
|
||||
seedEmbedding(t, st, clientID, seen)
|
||||
seedConsent(t, st, clientID, seen)
|
||||
|
||||
out, err := st.MergeVisitors(ctx, clientID, typed.ID, seen)
|
||||
if err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
if out.VisitorID != seen {
|
||||
t.Fatalf("survivor %s, want %s", out.VisitorID, seen)
|
||||
}
|
||||
if out.RetiredRef != typed.Ref {
|
||||
t.Errorf("retired ref %q, want %q - staff write these down",
|
||||
out.RetiredRef, typed.Ref)
|
||||
}
|
||||
|
||||
// Nothing orphaned, nothing cascaded away.
|
||||
for _, c := range []struct {
|
||||
what, sql string
|
||||
want int
|
||||
}{
|
||||
{"visits", `SELECT count(*) FROM visits WHERE visitor_id = $1::uuid`, 2},
|
||||
{"purchases", `SELECT count(*) FROM purchases WHERE visitor_id = $1::uuid`, 1},
|
||||
{"embeddings", `SELECT count(*) FROM visitor_embeddings WHERE visitor_id = $1::uuid`, 1},
|
||||
{"consents", `SELECT count(*) FROM consents WHERE visitor_id = $1::uuid`, 1},
|
||||
{"profiles", `SELECT count(*) FROM visitor_profiles WHERE visitor_id = $1::uuid`, 1},
|
||||
} {
|
||||
var n int
|
||||
if err := st.pool.QueryRow(ctx, c.sql, seen).Scan(&n); err != nil {
|
||||
t.Fatalf("%s: %v", c.what, err)
|
||||
}
|
||||
if n != c.want {
|
||||
t.Errorf("%s on the survivor = %d, want %d", c.what, n, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// The typed-in name reached the record that has the face. That IS the
|
||||
// feature: the survivor had no profile, so the source's fills it.
|
||||
var name, phone string
|
||||
if err := st.pool.QueryRow(ctx,
|
||||
`SELECT full_name, phone FROM visitor_profiles WHERE visitor_id = $1::uuid`,
|
||||
seen).Scan(&name, &phone); err != nil {
|
||||
t.Fatalf("profile: %v", err)
|
||||
}
|
||||
if name != "Asha Menon" || phone != "9876543210" {
|
||||
t.Errorf("profile = %q / %q, want the typed-in details", name, phone)
|
||||
}
|
||||
|
||||
// A human-assigned name outranks an auto "Visitor N", whichever way round
|
||||
// the operator merged.
|
||||
var label string
|
||||
var visitCount int
|
||||
if err := st.pool.QueryRow(ctx,
|
||||
`SELECT label, visit_count FROM visitors WHERE id = $1::uuid`,
|
||||
seen).Scan(&label, &visitCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if label != "Asha Menon" {
|
||||
t.Errorf("label %q, want the human name to survive the auto one", label)
|
||||
}
|
||||
// Recomputed with COUNT(*), never summed: the stored counters may be stale
|
||||
// and the row count cannot be.
|
||||
if visitCount != 2 {
|
||||
t.Errorf("visit_count = %d, want 2 counted from the rows", visitCount)
|
||||
}
|
||||
|
||||
// The source is gone for real. A soft delete would leave its number
|
||||
// resolving to a record holding nothing.
|
||||
var left int
|
||||
if err := st.pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM visitors WHERE id = $1::uuid`, typed.ID).Scan(&left); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if left != 0 {
|
||||
t.Error("the merged-away customer is still there")
|
||||
}
|
||||
}
|
||||
|
||||
// The survivor's own details are never overwritten. Merging must not silently
|
||||
// replace a name somebody checked with one they did not.
|
||||
func TestLiveMergeFillsBlanksAndOverwritesNothing(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
clientID, _ := seedTenant(t, st, "keep"+stamp(), 0, false)
|
||||
|
||||
src, _ := st.CreateCustomer(ctx, clientID,
|
||||
api.Profile{FullName: "Wrong Name", Phone: "1111111111", Email: "a@b.c"}, "")
|
||||
dst, _ := st.CreateCustomer(ctx, clientID,
|
||||
api.Profile{FullName: "Right Name"}, "")
|
||||
|
||||
if _, err := st.MergeVisitors(ctx, clientID, src.ID, dst.ID); err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
var name, phone, email string
|
||||
if err := st.pool.QueryRow(ctx,
|
||||
`SELECT full_name, phone, email FROM visitor_profiles WHERE visitor_id = $1::uuid`,
|
||||
dst.ID).Scan(&name, &phone, &email); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "Right Name" {
|
||||
t.Errorf("name = %q, want the survivor's own kept", name)
|
||||
}
|
||||
if phone != "1111111111" || email != "a@b.c" {
|
||||
t.Errorf("blanks not filled: phone=%q email=%q", phone, email)
|
||||
}
|
||||
}
|
||||
|
||||
// Another tenant's customer is not mergeable, and reads as absent.
|
||||
func TestLiveMergeRefusesAcrossTenants(t *testing.T) {
|
||||
st := liveStore(t)
|
||||
ctx := context.Background()
|
||||
aID, _ := seedTenant(t, st, "ta"+stamp(), 0, false)
|
||||
bID, _ := seedTenant(t, st, "tb"+stamp(), 0, false)
|
||||
|
||||
a, _ := st.CreateCustomer(ctx, aID, api.Profile{FullName: "A"}, "")
|
||||
b, _ := st.CreateCustomer(ctx, bID, api.Profile{FullName: "B"}, "")
|
||||
|
||||
if _, err := st.MergeVisitors(ctx, aID, a.ID, b.ID); err == nil {
|
||||
t.Fatal("merged a customer into another tenant's record")
|
||||
}
|
||||
var n int
|
||||
st.pool.QueryRow(ctx, `SELECT count(*) FROM visitors WHERE id = $1::uuid`, a.ID).Scan(&n)
|
||||
if n != 1 {
|
||||
t.Error("a refused merge must change nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- fixtures
|
||||
|
||||
func seedRecognisedVisitor(t *testing.T, st *Store, clientID, siteID string, visits int) (
|
||||
visitorID string, visitIDs []string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
at := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
|
||||
var number int64
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
UPDATE clients SET visitor_seq = visitor_seq + 1
|
||||
WHERE id = $1::uuid RETURNING visitor_seq`, clientID).Scan(&number); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO visitors (client_id, number, label, first_seen_at, visit_count)
|
||||
VALUES ($1::uuid, $2, 'Visitor '||$2, $3, 0) RETURNING id::text`,
|
||||
clientID, number, at).Scan(&visitorID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < visits; i++ {
|
||||
var id string
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO visits (client_id, site_id, visitor_id, source_event_id,
|
||||
occurred_at, camera_id, is_new_visitor)
|
||||
VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5, 'door', $6)
|
||||
RETURNING id::text`,
|
||||
clientID, siteID, visitorID, stamp()+string(rune('a'+i)),
|
||||
at.Add(time.Duration(i)*time.Hour), i == 0).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
visitIDs = append(visitIDs, id)
|
||||
}
|
||||
return visitorID, visitIDs
|
||||
}
|
||||
|
||||
func seedEmbedding(t *testing.T, st *Store, clientID, visitorID string) {
|
||||
t.Helper()
|
||||
v := make([]float32, 512)
|
||||
for i := range v {
|
||||
v[i] = 0.04
|
||||
}
|
||||
var siteID string
|
||||
if err := st.pool.QueryRow(context.Background(),
|
||||
`SELECT id::text FROM sites WHERE client_id = $1::uuid LIMIT 1`,
|
||||
clientID).Scan(&siteID); err != nil {
|
||||
t.Fatalf("site for embedding: %v", err)
|
||||
}
|
||||
if _, err := st.pool.Exec(context.Background(), `
|
||||
INSERT INTO visitor_embeddings
|
||||
(visitor_id, client_id, model, embedding, quality, source_site_id)
|
||||
VALUES ($1::uuid, $2::uuid, 'w600k_r50', $3::vector, 0.8, $4::uuid)`,
|
||||
visitorID, clientID, pgVector(v), siteID); err != nil {
|
||||
t.Fatalf("seed embedding: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedConsent(t *testing.T, st *Store, clientID, visitorID string) {
|
||||
t.Helper()
|
||||
if _, err := st.pool.Exec(context.Background(), `
|
||||
INSERT INTO consents (client_id, visitor_id, scope, granted)
|
||||
VALUES ($1::uuid, $2::uuid, 'marketing', true)`, clientID, visitorID); err != nil {
|
||||
t.Fatalf("seed consent: %v", err)
|
||||
}
|
||||
}
|
||||
71
server/internal/store/api_customers_test.go
Normal file
71
server/internal/store/api_customers_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mergeProfiles is pure, so the rule it encodes can be asserted without a
|
||||
// database - and it is the rule that matters: what happens to the value that
|
||||
// LOSES. The first version dropped it, and lost a phone number on the first
|
||||
// live run.
|
||||
func TestMergingProfilesKeepsWhatLoses(t *testing.T) {
|
||||
src := profileFields{exists: true, fullName: "Asha M", phone: "111", email: "a@b.c"}
|
||||
dst := profileFields{exists: true, fullName: "Asha Menon", phone: "222"}
|
||||
|
||||
out, discarded := mergeProfiles(src, dst)
|
||||
|
||||
if out.fullName != "Asha Menon" || out.phone != "222" {
|
||||
t.Errorf("survivor's own values must win: got %q / %q", out.fullName, out.phone)
|
||||
}
|
||||
if out.email != "a@b.c" {
|
||||
t.Errorf("a blank must be filled from the source, got %q", out.email)
|
||||
}
|
||||
if len(discarded) != 2 {
|
||||
t.Fatalf("discarded %v, want the losing name and phone", discarded)
|
||||
}
|
||||
// In the record, not only in the response: the response is read once.
|
||||
for _, want := range []string{"Asha M", "111"} {
|
||||
if !strings.Contains(out.notes, want) {
|
||||
t.Errorf("notes must retain %q: %q", want, out.notes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing to reconcile is the ordinary case - a hand-typed record joining a
|
||||
// camera record that has no profile at all - and it must add no noise.
|
||||
func TestMergingIntoAnEmptyProfileDiscardsNothing(t *testing.T) {
|
||||
src := profileFields{exists: true, fullName: "Asha Menon", phone: "111"}
|
||||
out, discarded := mergeProfiles(src, profileFields{})
|
||||
|
||||
if len(discarded) != 0 {
|
||||
t.Errorf("discarded %v, want none", discarded)
|
||||
}
|
||||
if out.fullName != "Asha Menon" || out.phone != "111" {
|
||||
t.Errorf("the typed details must reach the surviving record: %+v", out)
|
||||
}
|
||||
if out.notes != "" {
|
||||
t.Errorf("no conflict should leave no note, got %q", out.notes)
|
||||
}
|
||||
}
|
||||
|
||||
// Identical values are not a conflict.
|
||||
func TestIdenticalProfileValuesAreNotDiscarded(t *testing.T) {
|
||||
p := profileFields{exists: true, fullName: "Asha Menon", phone: "111"}
|
||||
out, discarded := mergeProfiles(p, p)
|
||||
if len(discarded) != 0 || out.notes != "" {
|
||||
t.Errorf("identical profiles produced %v / notes %q", discarded, out.notes)
|
||||
}
|
||||
}
|
||||
|
||||
// Two people writing about one customer wrote two different true things.
|
||||
func TestNotesAreAdditiveRatherThanAWinner(t *testing.T) {
|
||||
out, _ := mergeProfiles(
|
||||
profileFields{exists: true, notes: "prefers window seat"},
|
||||
profileFields{exists: true, notes: "allergic to nuts"})
|
||||
for _, want := range []string{"window seat", "allergic to nuts"} {
|
||||
if !strings.Contains(out.notes, want) {
|
||||
t.Errorf("notes lost %q: %q", want, out.notes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,13 @@ func (s *Store) SearchVisitors(ctx context.Context, clientID, query string, limi
|
||||
OR v.label ILIKE $3 ESCAPE '\'
|
||||
OR p.full_name ILIKE $3 ESCAPE '\'
|
||||
OR p.phone ILIKE $3 ESCAPE '\'
|
||||
OR p.email ILIKE $3 ESCAPE '\')
|
||||
OR p.email ILIKE $3 ESCAPE '\'
|
||||
-- Notes are searched for ONE reason: a merge records the
|
||||
-- phone and name it had to discard there, and a customer
|
||||
-- reached by their old number is exactly who somebody is
|
||||
-- looking for when they type it. Retained-but-unfindable
|
||||
-- answers the letter of "nothing is lost" and not the point.
|
||||
OR p.notes ILIKE $3 ESCAPE '\')
|
||||
ORDER BY v.last_seen_at DESC NULLS LAST, v.first_seen_at DESC
|
||||
LIMIT $4`,
|
||||
clientID, strings.TrimSpace(query), likePattern(strings.TrimSpace(query)),
|
||||
@@ -94,9 +100,19 @@ func (s *Store) VisitorHistory(ctx context.Context, clientID, visitorID string,
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT vi.id::text, vi.occurred_at, si.name, vi.camera_id,
|
||||
vi.is_new_visitor, vi.similarity, vi.quality, vi.attributes
|
||||
vi.is_new_visitor, vi.similarity, vi.quality, vi.attributes,
|
||||
pu.n, pu.total, pu.cur, pu.currencies
|
||||
FROM visits vi
|
||||
JOIN sites si ON si.id = vi.site_id
|
||||
-- LATERAL rather than a join onto purchases directly: two sales on one
|
||||
-- visit would otherwise return that visit twice and the customer would
|
||||
-- appear to have been in more often than they were.
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS n, sum(p.amount) AS total,
|
||||
max(p.currency) AS cur, count(DISTINCT p.currency) AS currencies
|
||||
FROM purchases p
|
||||
WHERE p.visit_id = vi.id AND p.client_id = vi.client_id
|
||||
) pu ON true
|
||||
WHERE vi.client_id = $1 AND vi.visitor_id = $2::uuid
|
||||
ORDER BY vi.occurred_at DESC
|
||||
LIMIT $3`, clientID, visitorID, limit)
|
||||
@@ -109,11 +125,20 @@ func (s *Store) VisitorHistory(ctx context.Context, clientID, visitorID string,
|
||||
for rows.Next() {
|
||||
var v api.VisitRow
|
||||
var at time.Time
|
||||
var sim, qual *float64
|
||||
var sim, qual, total *float64
|
||||
var nPurchases, nCurrencies int
|
||||
var cur *string
|
||||
if err := rows.Scan(&v.ID, &at, &v.Site, &v.CameraID, &v.IsNew,
|
||||
&sim, &qual, &v.Attributes); err != nil {
|
||||
&sim, &qual, &v.Attributes,
|
||||
&nPurchases, &total, &cur, &nCurrencies); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.Purchases = nPurchases
|
||||
// One currency or none. Mixed is left as a count with no figure
|
||||
// rather than a sum that means nothing.
|
||||
if nCurrencies == 1 && total != nil && cur != nil {
|
||||
v.Spend, v.Currency = *total, *cur
|
||||
}
|
||||
v.OccurredAt = at.UTC().Format(time.RFC3339)
|
||||
if sim != nil {
|
||||
v.Similarity = *sim
|
||||
|
||||
116
server/internal/store/api_sales.go
Normal file
116
server/internal/store/api_sales.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Reading individual sales.
|
||||
//
|
||||
// The conversion report has aggregated `purchases` since it existed and
|
||||
// nothing could read a row of it, so "revenue was 41,000 last week" could not
|
||||
// be checked against a till. These are the reads that make that number
|
||||
// falsifiable from outside.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/api"
|
||||
)
|
||||
|
||||
const saleCols = `
|
||||
p.id::text, p.occurred_at, p.site_id::text, si.name, si.slug,
|
||||
p.amount, p.currency, p.visitor_id::text, v.number, v.label,
|
||||
p.visit_id::text, p.items, p.source, p.external_ref`
|
||||
|
||||
func scanSale(row pgx.Row) (api.Sale, error) {
|
||||
var s api.Sale
|
||||
var at time.Time
|
||||
// LEFT JOINed: a sale with no visitor is an ordinary walk-in nobody
|
||||
// identified, and it is still revenue.
|
||||
var visitorID, visitorLabel, visitID *string
|
||||
var number *int64
|
||||
var items []byte
|
||||
if err := row.Scan(&s.ID, &at, &s.SiteID, &s.Site, &s.SiteSlug,
|
||||
&s.Amount, &s.Currency, &visitorID, &number, &visitorLabel,
|
||||
&visitID, &items, &s.Source, &s.ExternalRef); err != nil {
|
||||
return api.Sale{}, err
|
||||
}
|
||||
s.OccurredAt = at.UTC().Format(time.RFC3339)
|
||||
if visitorID != nil {
|
||||
s.VisitorID = *visitorID
|
||||
}
|
||||
if visitorLabel != nil {
|
||||
s.VisitorLabel = *visitorLabel
|
||||
}
|
||||
if number != nil {
|
||||
s.VisitorRef = api.VisitorRef(*number)
|
||||
}
|
||||
if visitID != nil {
|
||||
s.VisitID = *visitID
|
||||
}
|
||||
// items is jsonb defaulting to '[]', but a null column would otherwise
|
||||
// unmarshal into a nil slice and serialise as null - and a client mapping
|
||||
// over it breaks on the first sale recorded without a basket.
|
||||
s.Items = []string{}
|
||||
if len(items) > 0 {
|
||||
_ = json.Unmarshal(items, &s.Items)
|
||||
if s.Items == nil {
|
||||
s.Items = []string{}
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Sales lists purchases newest first, within one tenant.
|
||||
//
|
||||
// Bounded by `limit` and the date window rather than by a cursor. A keyset
|
||||
// cursor needs a monotonic server-assigned column, and purchases has none -
|
||||
// ordering by (occurred_at, id) with a random uuid tie-break is exactly the
|
||||
// shape that silently dropped four simultaneous visits from the arrivals feed
|
||||
// before `visits.seq` existed. Offering a cursor here would imply a delivery
|
||||
// guarantee this table cannot make; narrowing the window is honest and is what
|
||||
// a sales list is browsed by anyway.
|
||||
func (s *Store) Sales(ctx context.Context, q api.SaleQuery) ([]api.Sale, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+saleCols+`
|
||||
FROM purchases p
|
||||
JOIN sites si ON si.id = p.site_id
|
||||
LEFT JOIN visitors v ON v.id = p.visitor_id
|
||||
WHERE p.client_id = $1::uuid
|
||||
AND p.occurred_at >= $2 AND p.occurred_at < $3
|
||||
AND ($4 = '' OR p.site_id = $4::uuid)
|
||||
AND ($5 = '' OR p.visitor_id = $5::uuid)
|
||||
ORDER BY p.occurred_at DESC, p.id
|
||||
LIMIT $6`,
|
||||
q.ClientID, q.From, q.To, q.SiteID, q.VisitorID, q.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []api.Sale
|
||||
for rows.Next() {
|
||||
sale, err := scanSale(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sale)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Sale is one purchase, scoped to the tenant. A row belonging to somebody else
|
||||
// reads as absent, never as forbidden.
|
||||
func (s *Store) Sale(ctx context.Context, clientID, id string) (api.Sale, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT `+saleCols+`
|
||||
FROM purchases p
|
||||
JOIN sites si ON si.id = p.site_id
|
||||
LEFT JOIN visitors v ON v.id = p.visitor_id
|
||||
WHERE p.client_id = $1::uuid AND p.id::text = $2`, clientID, id)
|
||||
sale, err := scanSale(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return api.Sale{}, nil
|
||||
}
|
||||
return sale, err
|
||||
}
|
||||
@@ -414,3 +414,30 @@ func (s *Store) ResetMemberPassword(ctx context.Context, clientID, userID,
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// SetUserPassword changes one account's password, by user id.
|
||||
//
|
||||
// Deliberately NOT scoped by client, unlike ResetMemberPassword beside it.
|
||||
// That one is a manager acting on somebody else in their company, so the
|
||||
// tenant is the boundary. This is an account acting on ITSELF, and the caller
|
||||
// is the session - a platform admin has no client at all and was, before this,
|
||||
// the one account nobody could change the password of without a shell on the
|
||||
// host. Scoping by client here would have reproduced exactly that hole.
|
||||
//
|
||||
// The id comes from the verified session and never from the request, so there
|
||||
// is nothing here for a caller to point at somebody else.
|
||||
func (s *Store) SetUserPassword(ctx context.Context, userID, hash string) error {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE app_users SET password_hash = $2
|
||||
WHERE id = $1::uuid AND active`, userID, hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
// Deactivated mid-session: their sessions are already revoked, so this
|
||||
// is unreachable in practice, and silently succeeding would report a
|
||||
// password change that did not happen.
|
||||
return fmt.Errorf("no active account %s", userID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -113,10 +113,15 @@ func (s *Store) RecordVisit(ctx context.Context, site ingest.Site,
|
||||
// visitor for a visit we already recorded.
|
||||
var visitID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
WITH n AS (
|
||||
UPDATE clients SET visit_seq = visit_seq + 1
|
||||
WHERE id = $1 RETURNING visit_seq
|
||||
)
|
||||
INSERT INTO visits (client_id, site_id, source_event_id, occurred_at,
|
||||
camera_id, is_new_visitor, similarity, quality,
|
||||
attributes, image_key)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9, '{}'::jsonb), $10)
|
||||
attributes, image_key, number)
|
||||
SELECT $1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9, '{}'::jsonb), $10, n.visit_seq
|
||||
FROM n
|
||||
ON CONFLICT (client_id, source_event_id) DO NOTHING
|
||||
RETURNING id::text`,
|
||||
site.ClientID, site.SiteID, v.EventID, v.OccurredAt, v.CameraID,
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
server/internal/web/dist/index.html
vendored
4
server/internal/web/dist/index.html
vendored
@@ -6,8 +6,8 @@
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<title>Behavision</title>
|
||||
<script type="module" crossorigin src="/assets/index-CAACw-qR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-KC4SOUb9.css">
|
||||
<script type="module" crossorigin src="/assets/index-Ckr5hGZd.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D4KGRSVS.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
52
server/migrations/014_visit_numbers.sql
Normal file
52
server/migrations/014_visit_numbers.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
-- A visit reference a person can use, beside the key a machine uses.
|
||||
--
|
||||
-- Every other thing in this product a person refers to already has one: a shop
|
||||
-- is `chennai`, a camera is `cam1`, a customer is `V-42`, a person is their
|
||||
-- email. A visit had only its uuid:
|
||||
--
|
||||
-- "visit_id": "4cc216ca-dad3-4958-bb96-5f5a82022cf8"
|
||||
--
|
||||
-- 012 argued that this was acceptable because no route takes a visit id and
|
||||
-- nobody says one out loud. That is true of routing and false of everything
|
||||
-- else: it is what the arrivals feed shows, what a support conversation has to
|
||||
-- quote, and what somebody reading an API response judges the product by. The
|
||||
-- owner asked for it twice.
|
||||
--
|
||||
-- `#1042`, per client, mirroring `V-42` exactly and for the same three reasons
|
||||
-- (speakable, per-tenant so it discloses no platform-wide volume, and a
|
||||
-- reference beside the key rather than a replacement for it - eleven tables
|
||||
-- reference visits.id).
|
||||
--
|
||||
-- Why a stored counter is affordable on the hottest table in the schema:
|
||||
-- allocating it row-locks the client for the length of one insert, and visits
|
||||
-- from one tenant are ALREADY serialised - the MQTT consumer sets
|
||||
-- SetOrderMatters(true) precisely so that `seq` is a commit order. So this
|
||||
-- adds no contention a tenant did not already have, and tenants never block
|
||||
-- each other. A derived reference was the alternative and does not work:
|
||||
-- several people through one door share occurred_at to the microsecond, which
|
||||
-- is the very collision 004 exists to handle.
|
||||
|
||||
ALTER TABLE clients ADD COLUMN IF NOT EXISTS visit_seq bigint NOT NULL DEFAULT 0;
|
||||
ALTER TABLE visits ADD COLUMN IF NOT EXISTS number bigint;
|
||||
|
||||
-- Existing rows get their numbers in the order the server learned of them,
|
||||
-- which is what `seq` means - not occurred_at, which is the camera's clock and
|
||||
-- arrives out of order after a site has been offline.
|
||||
WITH numbered AS (
|
||||
SELECT id, row_number() OVER (PARTITION BY client_id ORDER BY seq) AS n
|
||||
FROM visits
|
||||
)
|
||||
UPDATE visits v SET number = numbered.n
|
||||
FROM numbered
|
||||
WHERE v.id = numbered.id AND v.number IS NULL;
|
||||
|
||||
UPDATE clients c
|
||||
SET visit_seq = GREATEST(c.visit_seq, COALESCE(
|
||||
(SELECT max(number) FROM visits WHERE client_id = c.id), 0));
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS visits_client_number_idx
|
||||
ON visits (client_id, number);
|
||||
|
||||
COMMENT ON COLUMN visits.number IS
|
||||
'Per-client visit number, shown as #1042. A public reference beside the '
|
||||
'uuid key, never a replacement for it.';
|
||||
44
tests/test_discover.py
Normal file
44
tests/test_discover.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Discovery parsing, with no network: the two shapes cameras actually send."""
|
||||
from behavision.discover import parse_probe_match, guess_make, local_networks
|
||||
|
||||
HIK = ('<?xml version="1.0"?><env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">'
|
||||
'<env:Body><d:ProbeMatches xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery"><d:ProbeMatch>'
|
||||
'<d:Scopes>onvif://www.onvif.org/type/video_encoder onvif://www.onvif.org/name/HIKVISION%20DS-2CD2043G2 '
|
||||
'onvif://www.onvif.org/hardware/DS-2CD2043G2-I onvif://www.onvif.org/location/city/hangzhou</d:Scopes>'
|
||||
'<d:XAddrs>http://192.168.1.122/onvif/device_service</d:XAddrs>'
|
||||
'</d:ProbeMatch></d:ProbeMatches></env:Body></env:Envelope>')
|
||||
|
||||
BARE = ('<SOAP-ENV:Envelope><SOAP-ENV:Body><wsdd:ProbeMatches><wsdd:ProbeMatch>'
|
||||
'<wsdd:XAddrs>http://10.0.0.9:8080/onvif/device_service http://[fe80::1]/onvif</wsdd:XAddrs>'
|
||||
'</wsdd:ProbeMatch></wsdd:ProbeMatches></SOAP-ENV:Body></SOAP-ENV:Envelope>')
|
||||
|
||||
|
||||
def test_hikvision_probe_match_is_named_and_recognised():
|
||||
f = parse_probe_match(HIK, "192.168.1.122")
|
||||
assert f.host == "192.168.1.122"
|
||||
assert f.onvif and not f.rtsp
|
||||
assert "HIKVISION DS-2CD2043G2" in f.name and "DS-2CD2043G2-I" in f.name
|
||||
assert f.make == "hikvision"
|
||||
|
||||
|
||||
def test_a_nameless_match_still_yields_its_address():
|
||||
f = parse_probe_match(BARE, "10.0.0.9")
|
||||
assert f.host == "10.0.0.9" and f.name == "" and f.make == ""
|
||||
assert f.onvif_url.startswith("http://10.0.0.9:8080")
|
||||
|
||||
|
||||
def test_garbage_is_not_a_camera():
|
||||
assert parse_probe_match("<html>not soap</html>", "1.2.3.4") is None
|
||||
|
||||
|
||||
def test_make_guesses_the_common_indian_retail_brands():
|
||||
assert guess_make("CP PLUS CP-UNC-TA21L3") == "cpplus"
|
||||
assert guess_make("Dahua IPC-HDW1230") == "dahua"
|
||||
assert guess_make("TP-LINK Tapo C200") == "tplink"
|
||||
assert guess_make("Something Else") == ""
|
||||
|
||||
|
||||
def test_local_networks_are_slash_24_and_never_loopback():
|
||||
for n in local_networks():
|
||||
assert n.prefixlen == 24
|
||||
assert not n.network_address.is_loopback
|
||||
97
tests/test_motion_gate.py
Normal file
97
tests/test_motion_gate.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""The motion gate must save CPU without ever losing a face.
|
||||
|
||||
Cheapness is easy; the property that makes it acceptable is that every way it
|
||||
could miss somebody is closed. These tests are that argument, written down.
|
||||
"""
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from behavision.config import Config
|
||||
from behavision.engine import CameraWorker
|
||||
|
||||
|
||||
class _Worker:
|
||||
"""CameraWorker's gate, without a camera, a model or a thread.
|
||||
|
||||
Built with object.__new__ and the two attributes the gate touches - the
|
||||
pattern the other engine tests already use, so no test needs a 166 MB
|
||||
model file or a live RTSP stream.
|
||||
"""
|
||||
|
||||
def __new__(cls, **app):
|
||||
w = object.__new__(CameraWorker)
|
||||
w.cfg = Config()
|
||||
for k, v in app.items():
|
||||
setattr(w.cfg.app, k, v)
|
||||
w._motion_prev = None
|
||||
w._motion_skipped = 0
|
||||
return w
|
||||
|
||||
|
||||
def frame(value: int, size=(90, 160)) -> np.ndarray:
|
||||
return np.full((*size, 3), value, dtype=np.uint8)
|
||||
|
||||
|
||||
def test_the_first_frame_is_always_searched():
|
||||
"""Nothing to compare against is not evidence that nothing moved."""
|
||||
w = _Worker()
|
||||
assert w._nothing_moved(frame(40)) is False
|
||||
|
||||
|
||||
def test_a_still_room_is_skipped():
|
||||
w = _Worker()
|
||||
w._nothing_moved(frame(40)) # prime
|
||||
assert w._nothing_moved(frame(40)) is True
|
||||
|
||||
|
||||
def test_movement_is_never_skipped():
|
||||
w = _Worker()
|
||||
w._nothing_moved(frame(40))
|
||||
# a person is an enormous change next to a 1.0 threshold
|
||||
assert w._nothing_moved(frame(120)) is False
|
||||
|
||||
|
||||
def test_it_gives_up_and_looks_anyway():
|
||||
"""A change too small or too gradual for a thumbnail must still be found.
|
||||
|
||||
The invariant is about the longest RUN of skips, not the total: what
|
||||
matters is the worst case a person could fall into, which is how long the
|
||||
camera can go without actually looking. Counting the total instead would
|
||||
pass a gate that skipped forty frames and then looked forty times.
|
||||
"""
|
||||
w = _Worker(motion_max_skip=5)
|
||||
w._nothing_moved(frame(40))
|
||||
run = longest = 0
|
||||
for _ in range(40):
|
||||
if w._nothing_moved(frame(40)):
|
||||
run += 1
|
||||
longest = max(longest, run)
|
||||
else:
|
||||
run = 0
|
||||
assert longest <= 5, f"went {longest} frames without looking, cap is 5"
|
||||
assert longest == 5, f"longest run was {longest} - the gate is not saving what it could"
|
||||
|
||||
|
||||
def test_a_slow_drift_cannot_creep_past_the_threshold():
|
||||
"""Each frame below the threshold, but the total far above it.
|
||||
|
||||
Compared against the last frame we SEARCHED rather than the last frame we
|
||||
saw, so a gradual change accumulates and eventually trips the gate instead
|
||||
of sliding under it one frame at a time. Without that, someone easing into
|
||||
view slowly enough is invisible forever.
|
||||
"""
|
||||
w = _Worker(motion_max_skip=10_000) # the safety net must not rescue this
|
||||
w._nothing_moved(frame(40))
|
||||
tripped = None
|
||||
for i in range(1, 30):
|
||||
if w._nothing_moved(frame(40 + i)) is False:
|
||||
tripped = i
|
||||
break
|
||||
assert tripped is not None, "a slow drift was never noticed"
|
||||
assert tripped <= 5, f"took {tripped} frames of drift to notice"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gate", [True, False])
|
||||
def test_the_gate_is_switchable(gate):
|
||||
w = _Worker(motion_gate=gate)
|
||||
assert w.cfg.app.motion_gate is gate
|
||||
170
tests/test_reliability.py
Normal file
170
tests/test_reliability.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Failure modes that look like health from outside.
|
||||
|
||||
Each of these was a state the engine could be in while every existing test
|
||||
passed and the dashboard showed green. They are grouped because they share
|
||||
one property: the process is fine and the product is not working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from behavision import capture
|
||||
from behavision.capture import VideoSource
|
||||
from behavision.config import RecognitionSection
|
||||
from behavision.gallery import Gallery, IdentityStore, VectorIndex
|
||||
from behavision.recognition import EMBEDDING_DIM
|
||||
|
||||
|
||||
def _vec(seed: int) -> np.ndarray:
|
||||
"""A distinct unit vector per seed.
|
||||
|
||||
Orthogonal per index, NOT a constant fill: a vector of all 0.3 and one of
|
||||
all 0.6 normalise to the same direction, so a fixture built that way would
|
||||
call two 'different' people one identity and prove nothing.
|
||||
"""
|
||||
v = np.zeros(EMBEDDING_DIM, dtype=np.float32)
|
||||
v[seed % EMBEDDING_DIM] = 1.0
|
||||
return v
|
||||
|
||||
|
||||
def _gallery(store: IdentityStore, model: str) -> Gallery:
|
||||
return Gallery(store, VectorIndex(EMBEDDING_DIM), RecognitionSection(),
|
||||
model_name=model)
|
||||
|
||||
|
||||
# -- the gallery the running encoder cannot read ------------------------
|
||||
|
||||
def test_matching_model_is_fully_usable(tmp_path):
|
||||
store = IdentityStore(tmp_path / "g.db")
|
||||
ident = store.create_identity("Alice")
|
||||
store.add_embedding(ident, _vec(1), 0.8, "w600k_r50")
|
||||
|
||||
health = _gallery(store, "w600k_r50").health
|
||||
assert health["usable"] == 1
|
||||
assert health["stranded"] == 0
|
||||
assert health["identities_stranded"] == 0
|
||||
|
||||
|
||||
def test_fallback_encoder_strands_the_gallery_and_says_so(tmp_path, caplog):
|
||||
"""The whole point: 2 known people, 0 recognisable, and it must be LOUD.
|
||||
|
||||
This is what a memory-starved box does when the 166 MB model loses the
|
||||
fallback chain to the 13 MB one. Footfall keeps counting, so nothing
|
||||
downstream looks wrong; every regular is simply greeted as a stranger and
|
||||
enrolled a second time.
|
||||
"""
|
||||
store = IdentityStore(tmp_path / "g.db")
|
||||
for i in (1, 2):
|
||||
ident = store.create_identity(f"Person {i}")
|
||||
store.add_embedding(ident, _vec(i), 0.8, "w600k_r50")
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
health = _gallery(store, "w600k_mbf").health
|
||||
|
||||
assert health["usable"] == 0
|
||||
assert health["stranded"] == 2
|
||||
assert health["identities_stranded"] == 2
|
||||
assert health["other_models"] == ["w600k_r50"]
|
||||
|
||||
warning = " ".join(r.getMessage() for r in caplog.records
|
||||
if r.levelname == "WARNING")
|
||||
assert "w600k_r50" in warning and "w600k_mbf" in warning, warning
|
||||
|
||||
|
||||
def test_partially_stranded_counts_only_the_unreachable(tmp_path):
|
||||
"""A mixed gallery is the normal state after a model change, and the
|
||||
number that matters is how many people are lost, not how many vectors."""
|
||||
store = IdentityStore(tmp_path / "g.db")
|
||||
old = store.create_identity("Old")
|
||||
store.add_embedding(old, _vec(1), 0.8, "w600k_mbf")
|
||||
store.add_embedding(old, _vec(2), 0.8, "w600k_mbf")
|
||||
both = store.create_identity("Both")
|
||||
store.add_embedding(both, _vec(3), 0.8, "w600k_mbf")
|
||||
store.add_embedding(both, _vec(4), 0.8, "w600k_r50")
|
||||
|
||||
health = _gallery(store, "w600k_r50").health
|
||||
assert health["stored"] == 4
|
||||
assert health["usable"] == 1
|
||||
assert health["stranded"] == 3
|
||||
# "Both" survives the change; only "Old" is unrecognisable.
|
||||
assert health["identities_stranded"] == 1
|
||||
|
||||
|
||||
def test_empty_gallery_is_not_reported_as_stranded(tmp_path):
|
||||
"""A new install must not raise an alarm about a gallery nobody has
|
||||
filled yet - crying wolf here trains people to ignore the real one."""
|
||||
health = _gallery(IdentityStore(tmp_path / "g.db"), "w600k_r50").health
|
||||
assert health["stranded"] == 0
|
||||
assert health["identities_stranded"] == 0
|
||||
|
||||
|
||||
# -- open, but not delivering -------------------------------------------
|
||||
|
||||
def _source() -> VideoSource:
|
||||
return VideoSource("cam", "rtsp://198.51.100.9:554/x")
|
||||
|
||||
|
||||
def test_a_camera_that_never_connected_is_not_stalled():
|
||||
"""`stalled` must mean 'was working, stopped'. A camera that has never
|
||||
delivered a frame is a different fault with a different fix."""
|
||||
src = _source()
|
||||
assert src.stalled() is False
|
||||
src.connected = True
|
||||
assert src.stalled() is False, "no frame ever seen is not a stall"
|
||||
|
||||
|
||||
def test_a_fresh_frame_is_not_a_stall():
|
||||
src = _source()
|
||||
src.connected = True
|
||||
src._frame_ts = time.time()
|
||||
assert src.stalled() is False
|
||||
assert src.stats()["streaming"] is True
|
||||
|
||||
|
||||
def test_an_old_frame_on_an_open_socket_is_a_stall():
|
||||
src = _source()
|
||||
src.connected = True
|
||||
src._frame_ts = time.time() - (capture.STALL_AFTER_S + 1)
|
||||
assert src.stalled() is True
|
||||
stats = src.stats()
|
||||
# The distinction that matters: still connected, no longer streaming.
|
||||
assert stats["connected"] is True
|
||||
assert stats["streaming"] is False
|
||||
assert stats["stalled"] is True
|
||||
|
||||
|
||||
def test_a_disconnected_camera_is_reported_as_down_not_stalled():
|
||||
"""Two states, opposite actions: check the network vs. the camera is
|
||||
answering and sending nothing. They must never share a verdict."""
|
||||
src = _source()
|
||||
src.connected = False
|
||||
src._frame_ts = time.time() - 3600
|
||||
assert src.stalled() is False
|
||||
assert src.stats()["streaming"] is False
|
||||
|
||||
|
||||
# -- a wrong address must not cost 30 seconds ---------------------------
|
||||
|
||||
def test_unreachable_source_fails_fast_with_a_reason():
|
||||
"""_open() used to hand an unroutable address straight to OpenCV, which
|
||||
blocks ~30s inside the constructor and cannot be interrupted by stop().
|
||||
198.51.100.0/24 is TEST-NET-2 and routes nowhere.
|
||||
"""
|
||||
src = VideoSource("cam", "rtsp://198.51.100.9:554/x")
|
||||
started = time.time()
|
||||
assert src._open() is None
|
||||
elapsed = time.time() - started
|
||||
assert elapsed < 8.0, f"pre-flight took {elapsed:.1f}s"
|
||||
assert src.last_error, "a failed open must say why"
|
||||
assert "198.51.100.9" in src.last_error
|
||||
assert src.stats()["last_error"] == src.last_error
|
||||
|
||||
|
||||
def test_webcam_sources_skip_the_preflight():
|
||||
"""An int source is a local device with no host to reach; the check must
|
||||
pass it through rather than refuse it."""
|
||||
ok, why = capture._tcp_reachable(0, 1.0)
|
||||
assert ok is True and why == ""
|
||||
@@ -206,6 +206,8 @@ export const api = {
|
||||
|
||||
sites: () => send('GET', '/api/sites'),
|
||||
createSite: (input) => send('POST', '/api/sites', input),
|
||||
updateSite: (site, input) => send('PATCH', `/api/sites/${encodeURIComponent(site)}`, input),
|
||||
deleteSite: (site) => send('DELETE', `/api/sites/${encodeURIComponent(site)}`),
|
||||
|
||||
// The live arrivals feed. `cursor` is opaque and must be echoed back.
|
||||
arrivals: (params) => send('GET', '/api/visits' + qs(params)),
|
||||
|
||||
@@ -568,3 +568,7 @@ button.ghost.danger:hover { border-color: var(--bad); }
|
||||
letter-spacing: .04em; text-transform: uppercase; }
|
||||
|
||||
.ask-btn .mark { width: 18px; height: 18px; }
|
||||
|
||||
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
button.ghost.danger { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }
|
||||
input[readonly] { opacity: .6; }
|
||||
|
||||
@@ -8,7 +8,7 @@ import { api } from '../api.js'
|
||||
// recognising almost nobody. Every step names what to do when it fails, and the
|
||||
// shop is only "working" when all of them pass: a partial pass is not a working
|
||||
// shop, and calling it one is how that site got signed off.
|
||||
export default function SiteCheck({ site, onClose }) {
|
||||
export default function SiteCheck({ site, user, onClose, onChanged }) {
|
||||
const [result, setResult] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -89,6 +89,7 @@ export default function SiteCheck({ site, onClose }) {
|
||||
)}
|
||||
|
||||
<ClaimPC site={site} />
|
||||
<ShopSettings site={site} user={user} onChanged={onChanged} onClose={onClose} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -169,3 +170,74 @@ function expiry(iso) {
|
||||
if (days <= 0) return 'today'
|
||||
return days === 1 ? 'tomorrow' : `in ${days} days`
|
||||
}
|
||||
|
||||
// The shop's own details: rename, timezone, and - for a shop opened by mistake
|
||||
// - removal. The short name is shown but not editable: it is what the shop PC
|
||||
// calls itself and a segment of the broker topic, so renaming it would orphan
|
||||
// both. The display name is what people read, and a system that cannot fix a
|
||||
// typo in a shop's name has confused the two.
|
||||
function ShopSettings({ site, user, onChanged, onClose }) {
|
||||
const [name, setName] = useState(site.name)
|
||||
const [tz, setTz] = useState(site.timezone || 'Asia/Kolkata')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const canManage = user?.role === 'owner' || user?.role === 'manager'
|
||||
const isOwner = user?.role === 'owner'
|
||||
if (!canManage) return null
|
||||
const dirty = name.trim() !== site.name || tz.trim() !== (site.timezone || 'Asia/Kolkata')
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
await api.updateSite(site.slug || site.site_id, { name: name.trim(), timezone: tz.trim() })
|
||||
onChanged?.()
|
||||
} catch (err) { setError(err.message) } finally { setBusy(false) }
|
||||
}
|
||||
const remove = async () => {
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
await api.deleteSite(site.slug || site.site_id)
|
||||
onChanged?.(); onClose?.()
|
||||
} catch (err) { setError(err.message); setConfirming(false) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="claim">
|
||||
<h3>Shop details</h3>
|
||||
<form onSubmit={save}>
|
||||
<label className="field">
|
||||
<span>Name</span>
|
||||
<input value={name} onChange={e => setName(e.target.value)} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Short name</span>
|
||||
<input value={site.slug} readOnly />
|
||||
<span className="hint">Fixed: the shop PC and the broker are keyed on it.</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Timezone</span>
|
||||
<input value={tz} onChange={e => setTz(e.target.value)} />
|
||||
</label>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
<div className="row">
|
||||
<button className="primary" disabled={busy || !dirty}>{busy ? 'Saving…' : 'Save'}</button>
|
||||
{isOwner && !confirming && (
|
||||
<button type="button" className="ghost danger" onClick={() => setConfirming(true)}>Remove this shop…</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
{confirming && (
|
||||
<div className="banner warn" style={{ marginTop: 12 }}>
|
||||
<b>Remove {site.name}?</b>
|
||||
<p className="sub">Only possible while it has no cameras and no visits. A shop with history is kept.</p>
|
||||
<div className="row">
|
||||
<button className="primary" disabled={busy} onClick={remove}>{busy ? 'Removing…' : 'Yes, remove it'}</button>
|
||||
<button className="ghost" onClick={() => setConfirming(false)}>Keep it</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function Sites({ user }) {
|
||||
{/* Reachable per shop, at last. The smoke test used to hang off a single
|
||||
button on the camera screen that always checked sites[0], so with two
|
||||
shops the second could not be checked at all. */}
|
||||
{checking && <SiteCheck site={checking} onClose={() => setChecking(null)} />}
|
||||
{checking && <SiteCheck site={checking} user={user} onClose={() => setChecking(null)} onChanged={reload} />}
|
||||
{opening && <NewShop onClose={() => setOpening(false)}
|
||||
onCreated={() => { setOpening(false); reload() }} />}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user