Compare commits
12 Commits
v0.4.1-dem
...
v0.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
| effa4f3d62 | |||
| 6c210f792f | |||
| 8786a5b0b4 | |||
| c93fbff31f | |||
| 4c750cb2ac | |||
| 5f83a1077d | |||
| a74cb899b4 | |||
| 8c88aad06e | |||
| 50a843ce46 | |||
| 979aa77cda | |||
| 3d3775c8be | |||
| a1fe0942e2 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -65,3 +65,4 @@ node_modules/
|
||||
|
||||
# Left behind by `pip install .` of the engine (setuptools metadata), not source.
|
||||
/behavision.egg-info/
|
||||
/.prod/
|
||||
|
||||
89
API.md
89
API.md
@@ -1,6 +1,6 @@
|
||||
# Behavision API — for the web console, a mobile app, and platform administration
|
||||
|
||||
Base URL: `https://platform.loyaly.ai` (locally `http://127.0.0.1:8088`).
|
||||
Base URL: `https://mcp.loyaly.ai` — the API host. (`platform.loyaly.ai` serves the head-office web console, not the API.)
|
||||
Everything is JSON unless stated. All times are RFC 3339 UTC unless a field says
|
||||
otherwise. Every shape below is taken from the server's own types, not written
|
||||
from memory — if the two ever disagree, the server is right and this file has a
|
||||
@@ -49,12 +49,13 @@ user; the tenant is always taken from the session and never from the request.
|
||||
| `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 |
|
||||
| `POST /api/sites` — open a shop · `DELETE /api/sites/{site}` — remove an empty one | owner |
|
||||
| `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 |
|
||||
| `POST /api/assistant` | authed |
|
||||
| `GET` / `POST /api/admin/clients` | **platform admin** |
|
||||
| `GET` / `POST /api/admin/clients` · `PATCH /api/admin/clients/{id}` · `POST /api/admin/clients/{id}/owner-password` · `DELETE /api/admin/clients/{id}` | **platform admin** |
|
||||
| `/api/agent/*` | **shop PC token** — never a user |
|
||||
|
||||
A role that may not call something gets **403 `forbidden`** with a message
|
||||
@@ -101,6 +102,9 @@ travels over chat.
|
||||
|
||||
```
|
||||
GET /api/admin/clients → every merchant, with site and user counts
|
||||
PATCH /api/admin/clients/{id} {"active": false} → suspend (or true: reinstate)
|
||||
POST /api/admin/clients/{id}/owner-password → new owner password, shown once
|
||||
DELETE /api/admin/clients/{id} {"confirm": "<slug>"} → delete a SUSPENDED company
|
||||
```
|
||||
|
||||
### Tier 2 — the merchant owner registers sales staff
|
||||
@@ -164,9 +168,9 @@ PATCH /api/team/{id} { "role": "manager" } → promote
|
||||
PATCH /api/team/{id} { "active": false } → they have left; signs them out now
|
||||
```
|
||||
|
||||
The owner also sets the shop up from the same login — `POST
|
||||
/api/sites/{site}/enrolment-code` for the shop PC, `POST
|
||||
/api/sites/{site}/cameras` for cameras — see §8.
|
||||
The owner also opens shops and sets them up from the same login — `POST
|
||||
/api/sites` to open one, `POST /api/sites/{site}/enrolment-code` for its
|
||||
shop PC, `POST /api/sites/{site}/cameras` for cameras — see §8.
|
||||
|
||||
### Tier 3 — the salesperson gets their mobile login
|
||||
|
||||
@@ -217,9 +221,10 @@ somebody else's account.
|
||||
|
||||
- **There is no mobile app in this repository.** Tier 3 is a complete API with
|
||||
no client yet. Everything above is what that app will call.
|
||||
- **The admin cannot reset a merchant owner's password over HTTP**, nor suspend
|
||||
or delete a merchant. Today that is `behavision-server provision` on the
|
||||
server.
|
||||
- **A shop with visit history cannot be deleted**, only its cameras removed.
|
||||
`DELETE /api/sites/{site}` is for the shop opened by mistake (no visits, no
|
||||
cameras); taking away footfall and faces is an erasure decision, and there
|
||||
is no endpoint for it yet.
|
||||
|
||||
---
|
||||
|
||||
@@ -260,10 +265,13 @@ GET /api/reports/footfall?from=&to= → the numbers, with their confidenc
|
||||
POST /api/auth/login (an account with no company)
|
||||
GET /api/admin/clients → every company
|
||||
POST /api/admin/clients → create one, with its owner
|
||||
PATCH /api/admin/clients/{id} → suspend / reinstate
|
||||
POST /api/admin/clients/{id}/owner-password → reset the owner's password
|
||||
DELETE /api/admin/clients/{id} → delete, once suspended
|
||||
```
|
||||
|
||||
That is the whole admin surface today. Everything inside a company is the
|
||||
company's own business and is reached by signing in as one of its users.
|
||||
That is the whole admin surface. Everything inside a company is the company's
|
||||
own business and is reached by signing in as one of its users.
|
||||
|
||||
---
|
||||
|
||||
@@ -718,6 +726,28 @@ unplugged PC.
|
||||
footfall lost because that queue overflowed. Non-zero `dropped` is a report
|
||||
that is wrong in a way the report itself cannot show.
|
||||
|
||||
### `POST /api/sites` — open a shop (owner)
|
||||
|
||||
```
|
||||
{ "name": "TeNext Bengaluru", "slug": "bengaluru", "timezone": "Asia/Kolkata" }
|
||||
→ 201 { "site_id": "…", "slug": "bengaluru", "name": "TeNext Bengaluru",
|
||||
"timezone": "Asia/Kolkata", "broker_username": "tenext-retail.bengaluru" }
|
||||
```
|
||||
|
||||
`slug` and `timezone` are optional: the slug is made from the name (lower-case,
|
||||
digits and dashes, 3–32 characters) and the timezone defaults to Asia/Kolkata.
|
||||
The slug is the shop PC's identity and an MQTT topic segment; it **cannot be
|
||||
changed afterwards**. The server registers the shop's broker login with
|
||||
Mosquitto in the same request, so the next step is simply
|
||||
`POST /api/sites/{slug}/enrolment-code` for the PC.
|
||||
|
||||
| status | code | meaning |
|
||||
|---|---|---|
|
||||
| 403 | `forbidden` | not the owner |
|
||||
| 409 | `conflict` | a shop with that slug exists |
|
||||
| 502 | `broker_unavailable` | the broker did not accept the login; **nothing was created** — try again |
|
||||
| 503 | `broker_unavailable` / `no_encryption_key` | this server cannot create shops; contact support |
|
||||
|
||||
### `GET /api/sites/{site}/check`
|
||||
|
||||
Five ordered steps that answer *is this shop working*, assembled from what head
|
||||
@@ -1022,6 +1052,45 @@ in as the company's owner, or by `behavision-server provision` on the server.
|
||||
|
||||
---
|
||||
|
||||
### `PATCH /api/admin/clients/{id}` — suspend or reinstate
|
||||
|
||||
```
|
||||
{ "active": false }
|
||||
→ 200 { "client": { "id": "…", "slug": "acme", "active": false, "sites": 2, "users": 5, … },
|
||||
"sessions_revoked": 3 }
|
||||
```
|
||||
|
||||
Suspension is complete the moment it returns: the company's users cannot sign
|
||||
in, every session they hold is revoked in the same transaction (so a live
|
||||
access token stops working now, not at expiry), and visits from its shop PCs
|
||||
are dropped at ingest. `{"active": true}` reinstates; sessions are not
|
||||
restored — people sign in again.
|
||||
|
||||
### `POST /api/admin/clients/{id}/owner-password` — reset the owner's password
|
||||
|
||||
```
|
||||
{ "email": "owner@acme.com" } ← optional when the company has exactly one owner
|
||||
→ 200 { "email": "owner@acme.com", "password": "n7xw…" } ← shown ONCE
|
||||
```
|
||||
|
||||
For the owner who has locked themselves out with nobody above them. Generated,
|
||||
never chosen; every session that owner held is revoked. With several owners
|
||||
and no `email`, 400 listing them.
|
||||
|
||||
### `DELETE /api/admin/clients/{id}` — delete a company
|
||||
|
||||
```
|
||||
{ "confirm": "acme" }
|
||||
→ 200 { "deleted": "acme", "images_deleted": 12 }
|
||||
```
|
||||
|
||||
Irreversible, and the data is biometric, so it is a two-step decision: the
|
||||
company must already be **suspended** (`409 still_active` otherwise) and the
|
||||
body must repeat its slug. Stored face images are deleted from object storage
|
||||
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.
|
||||
|
||||
## 12. Errors
|
||||
|
||||
```json
|
||||
|
||||
45
CLAUDE.md
45
CLAUDE.md
@@ -1696,20 +1696,41 @@ The enrol response gained `client_slug` and `topic_prefix`, both **derived from
|
||||
the broker username** rather than looked up separately, so the agent's topic
|
||||
prefix and the broker's ACL are equal by construction.
|
||||
|
||||
### Still a command: creating the shop itself
|
||||
### Opening a shop is an API call, and the broker learns of it in the same request
|
||||
|
||||
`provision site` prints a broker password that a human then has to add to
|
||||
Mosquitto. So a tenant cannot open their second shop without us, and that is the
|
||||
one remaining hole in self-service onboarding. Closing it needs a decision, not
|
||||
code:
|
||||
`POST /api/sites` (owner), and `provision site` behind the same code. This was
|
||||
the last piece of onboarding that needed a shell: `provision site` printed a
|
||||
broker password and a person typed it into Mosquitto's passwd file on the host
|
||||
— which turned out to be mounted read-only in the container, so the first
|
||||
attempt failed silently and the password had to be re-rolled. No tenant could
|
||||
open a second branch without us.
|
||||
|
||||
- **the server manages Mosquitto's `passwd`/`acl` and reloads it** — possible
|
||||
because they are co-located, and it couples the API to the broker's
|
||||
filesystem; or
|
||||
- **one broker user per CLIENT rather than per site** — then adding a shop needs
|
||||
no broker change at all. Cross-tenant isolation is unchanged; what is given up
|
||||
is that one of a customer's own PCs could publish as another of their sites.
|
||||
Every deployed site would need re-provisioning.
|
||||
Neither option recorded here before was taken. The server does not write the
|
||||
broker's files, and there is still one broker user per site. Mosquitto 2.0's
|
||||
**dynamic-security plugin** takes the same operations as commands on
|
||||
`$CONTROL/dynamic-security/v1`, from a client holding the `admin` role;
|
||||
`server/internal/broker` drives it over the server's own broker login.
|
||||
|
||||
- **A role per site, with literal topics.** The 2.0 plugin does **not**
|
||||
substitute `%u` in ACL topics (measured: the publish was denied), so
|
||||
`site.<client>.<site>` is created with the client and deleted with it.
|
||||
- **Idempotent.** Re-running `EnsureSite` on an existing login sets the password
|
||||
to the one the database holds and confirms the role. `addClientRole` on a
|
||||
client that already has the role answers "Internal error", so the role is
|
||||
checked with `getClient` rather than inferred from prose.
|
||||
- **The row and the login are created together, or not at all.** If the broker
|
||||
refuses, the just-created row is removed and the caller gets 502. A shop that
|
||||
exists in the database and not on the broker is one whose PC enrols fine and
|
||||
never delivers a visit — the silent-failure class this whole endpoint ends.
|
||||
- **Its own connection**, not the ingest client's: that one has
|
||||
`SetOrderMatters` and blocking handlers, and a provisioning call must neither
|
||||
wait behind a slow visit nor delay one.
|
||||
- **Cutover keeps every password.** `behavision-server broker-init` converts the
|
||||
passwd file into the plugin's store: `$7$` lines are PBKDF2-SHA512 with a
|
||||
salt and iteration count, which is exactly what the plugin stores, so no shop
|
||||
PC re-claims and no credential changes hands. Rehearsed locally against a
|
||||
file `mosquitto_passwd` wrote; `run-local.sh` now brings the broker up the
|
||||
same way as production.
|
||||
|
||||
## Running it against the real office camera: four dead wires
|
||||
|
||||
|
||||
@@ -7,4 +7,5 @@ require (
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
golang.org/x/net v0.8.0 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
)
|
||||
|
||||
@@ -6,3 +6,5 @@ golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
|
||||
@@ -119,6 +119,11 @@ func cmdClaim(args []string) error {
|
||||
cfg.BrokerPassword = b.MQTTPass
|
||||
cfg.AgentToken = b.AgentToken
|
||||
cfg.CloudBase = base
|
||||
caPath, err := enrol.SaveCA(b.CACert, paths.BrokerCA())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.BrokerCAFile = caPath
|
||||
// A PC that was running on its own and has now been linked is no longer
|
||||
// standalone.
|
||||
cfg.Standalone = false
|
||||
|
||||
@@ -194,9 +194,29 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
cmd.Stderr = cmd.Stdout
|
||||
// Cancel ends the whole process tree, not just the process exec spawned.
|
||||
// `kill` is filled in after Start, once the tree is confined; until then
|
||||
// it is exec's own behaviour.
|
||||
var kill func() error
|
||||
cmd.Cancel = func() error {
|
||||
if kill == nil {
|
||||
return cmd.Process.Kill()
|
||||
}
|
||||
return kill()
|
||||
}
|
||||
prepare(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("engine failed to start: %w", err)
|
||||
}
|
||||
k, release, err := confine(cmd)
|
||||
if err != nil {
|
||||
// Not fatal: the engine runs, and stopping it falls back to killing
|
||||
// the one process. Logged because on Windows that fallback is the
|
||||
// bug this exists to fix.
|
||||
fmt.Fprintf(s.opts.LogWriter, "supervisor: could not confine engine process tree: %v\n", err)
|
||||
}
|
||||
kill = k
|
||||
defer release()
|
||||
|
||||
pumped := make(chan struct{})
|
||||
go func() {
|
||||
|
||||
13
agent/pkg/engine/tree_other.go
Normal file
13
agent/pkg/engine/tree_other.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package engine
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// On every other platform the engine is one process and exec's own kill is
|
||||
// enough. See tree_windows.go for why Windows is not.
|
||||
func prepare(*exec.Cmd) {}
|
||||
|
||||
func confine(cmd *exec.Cmd) (kill func() error, release func(), err error) {
|
||||
return cmd.Process.Kill, func() {}, nil
|
||||
}
|
||||
111
agent/pkg/engine/tree_windows.go
Normal file
111
agent/pkg/engine/tree_windows.go
Normal file
@@ -0,0 +1,111 @@
|
||||
//go:build windows
|
||||
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// The engine is not one process on Windows, and stopping it used to leave
|
||||
// recognition running.
|
||||
//
|
||||
// The installer starts it as `<venv>\Scripts\python.exe -m behavision run`.
|
||||
// Since Python 3.7.2 that python.exe is a REDIRECTOR: a small launcher that
|
||||
// spawns the base interpreter as a child and waits for it. Stop() cancelled the
|
||||
// context, exec terminated the launcher, and the interpreter that actually
|
||||
// holds the cameras and the SQLite WAL carried on with no parent, no tray icon
|
||||
// and nothing left that could stop it. Seen on a Windows install: "Quit
|
||||
// Behavision" from the tray, and the engine still running.
|
||||
//
|
||||
// The fix is the primitive Windows has for exactly this: a job object with
|
||||
// KILL_ON_JOB_CLOSE. Every process the engine spawns inherits membership, and
|
||||
// the whole tree dies when the job is terminated or when this process's last
|
||||
// handle to it goes away - so "quitting the app stops recognition" holds even
|
||||
// if the app crashes, which no amount of careful Stop() code can promise.
|
||||
//
|
||||
// The child is started SUSPENDED and resumed only after it is in the job.
|
||||
// Assigning after the fact leaves a window in which the launcher has already
|
||||
// spawned the interpreter outside it, and that window is precisely the case
|
||||
// this file exists to close.
|
||||
|
||||
// prepare is applied to the command before it starts.
|
||||
func prepare(cmd *exec.Cmd) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
// CREATE_NO_WINDOW: python.exe is a console program and Behavision.exe is
|
||||
// not, so without this Windows opens a black console window for the
|
||||
// engine on a shop counter - the app looks like it has crashed into a
|
||||
// terminal. Output still arrives on the pipes.
|
||||
cmd.SysProcAttr.CreationFlags |= windows.CREATE_SUSPENDED | windows.CREATE_NO_WINDOW
|
||||
}
|
||||
|
||||
// confine is applied after Start. It puts the process in a kill-on-close job,
|
||||
// then resumes it. It returns a function that ends the whole tree, and one
|
||||
// that releases the job handle once the tree has exited.
|
||||
//
|
||||
// If the job cannot be set up the process is still resumed and the plain
|
||||
// terminate remains: a suspended engine that never runs is strictly worse
|
||||
// than one that may outlive its parent.
|
||||
func confine(cmd *exec.Cmd) (kill func() error, release func(), err error) {
|
||||
pid := uint32(cmd.Process.Pid)
|
||||
defer resumeProcess(pid)
|
||||
|
||||
kill = cmd.Process.Kill
|
||||
release = func() {}
|
||||
|
||||
job, err := windows.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
return kill, release, fmt.Errorf("create job object: %w", err)
|
||||
}
|
||||
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
|
||||
info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||
if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation,
|
||||
uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return kill, release, fmt.Errorf("configure job object: %w", err)
|
||||
}
|
||||
proc, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, pid)
|
||||
if err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return kill, release, fmt.Errorf("open engine process: %w", err)
|
||||
}
|
||||
defer windows.CloseHandle(proc)
|
||||
if err := windows.AssignProcessToJobObject(job, proc); err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return kill, release, fmt.Errorf("assign engine to job: %w", err)
|
||||
}
|
||||
|
||||
kill = func() error { return windows.TerminateJobObject(job, 1) }
|
||||
release = func() { windows.CloseHandle(job) }
|
||||
return kill, release, nil
|
||||
}
|
||||
|
||||
// resumeProcess resumes every thread of a process started CREATE_SUSPENDED.
|
||||
// exec does not hand back the main thread handle, so it is found through the
|
||||
// toolhelp snapshot; a suspended new process has exactly one.
|
||||
func resumeProcess(pid uint32) {
|
||||
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer windows.CloseHandle(snap)
|
||||
var te windows.ThreadEntry32
|
||||
te.Size = uint32(unsafe.Sizeof(te))
|
||||
for err = windows.Thread32First(snap, &te); err == nil; err = windows.Thread32Next(snap, &te) {
|
||||
if te.OwnerProcessID != pid {
|
||||
continue
|
||||
}
|
||||
h, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, te.ThreadID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
windows.ResumeThread(h)
|
||||
windows.CloseHandle(h)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -31,7 +32,7 @@ type Bootstrap struct {
|
||||
MQTTURL string `json:"mqtt_url"`
|
||||
MQTTUser string `json:"mqtt_username"`
|
||||
MQTTPass string `json:"mqtt_password"`
|
||||
CAPem string `json:"ca_pem,omitempty"`
|
||||
CACert string `json:"ca_cert,omitempty"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
|
||||
@@ -90,3 +91,23 @@ func Claim(ctx context.Context, base, code string) (Bootstrap, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveCA writes the broker's CA beside the agent config and returns its path.
|
||||
//
|
||||
// The server hands the CA out at enrolment precisely so it never has to be
|
||||
// shipped in an installer - and for a while nothing on the receiving end
|
||||
// wrote it anywhere. Every claimed PC then dialled tls://mcp.loyaly.ai:8883
|
||||
// with the system trust store, the private CA failed verification, and the
|
||||
// agent reported "the broker did not accept this PC" (a TLS failure is
|
||||
// indistinguishable from a refusal at that layer). No real site could ever
|
||||
// publish a visit. An empty CA returns "" so a deployment on a public
|
||||
// certificate keeps working unchanged.
|
||||
func SaveCA(pem, path string) (string, error) {
|
||||
if strings.TrimSpace(pem) == "" {
|
||||
return "", nil
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(pem), 0o600); err != nil {
|
||||
return "", fmt.Errorf("write broker CA: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
@@ -57,8 +57,11 @@ func InstallRoot() string {
|
||||
}
|
||||
|
||||
func AgentConfig() string { return filepath.Join(StateRoot(), "agent.json") }
|
||||
func SpoolDir() string { return filepath.Join(StateRoot(), "spool") }
|
||||
func EngineLog() string { return filepath.Join(StateRoot(), "engine.log") }
|
||||
|
||||
// BrokerCA is the broker's CA certificate, written at enrolment.
|
||||
func BrokerCA() string { return filepath.Join(StateRoot(), "broker-ca.crt") }
|
||||
func SpoolDir() string { return filepath.Join(StateRoot(), "spool") }
|
||||
func EngineLog() string { return filepath.Join(StateRoot(), "engine.log") }
|
||||
|
||||
// APICredentials is the file the engine writes when it generates its own
|
||||
// Basic credentials. The agent reads it rather than storing a second copy,
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
agentcameras "github.com/loyaly/behavision-agent/pkg/cameras"
|
||||
agentcfg "github.com/loyaly/behavision-agent/pkg/config"
|
||||
agentengine "github.com/loyaly/behavision-agent/pkg/engine"
|
||||
"github.com/loyaly/behavision-agent/pkg/enrol"
|
||||
agentmqtt "github.com/loyaly/behavision-agent/pkg/mqtt"
|
||||
agentpaths "github.com/loyaly/behavision-agent/pkg/paths"
|
||||
agentspool "github.com/loyaly/behavision-agent/pkg/spool"
|
||||
@@ -434,6 +435,11 @@ func (a *App) Claim(code string) (SessionInfo, error) {
|
||||
a.cfg.BrokerPassword = b.MQTTPass
|
||||
a.cfg.AgentToken = b.AgentToken
|
||||
a.cfg.CloudBase = a.cloud.Base
|
||||
caPath, err := enrol.SaveCA(b.CACert, agentpaths.BrokerCA())
|
||||
if err != nil {
|
||||
return SessionInfo{}, err
|
||||
}
|
||||
a.cfg.BrokerCAFile = caPath
|
||||
// A PC that was running on its own and has now been linked is no longer
|
||||
// standalone. Leaving the flag set would keep the head-office screens
|
||||
// hidden on the one machine that just earned them.
|
||||
|
||||
40
desktop/frontend/dist/assets/index-B3NH0cQK.js
vendored
40
desktop/frontend/dist/assets/index-B3NH0cQK.js
vendored
File diff suppressed because one or more lines are too long
1
desktop/frontend/dist/assets/index-Bml5ogS2.css
vendored
Normal file
1
desktop/frontend/dist/assets/index-Bml5ogS2.css
vendored
Normal file
File diff suppressed because one or more lines are too long
40
desktop/frontend/dist/assets/index-Dxm9O68-.js
vendored
Normal file
40
desktop/frontend/dist/assets/index-Dxm9O68-.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
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-B3NH0cQK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-XjqO50wd.css">
|
||||
<script type="module" crossorigin src="./assets/index-Dxm9O68-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-Bml5ogS2.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api, isDesktop, message } from './bridge.js'
|
||||
import { usePolled } from './hooks.js'
|
||||
import * as Icon from './ui/icons.jsx'
|
||||
import Login from './views/Login.jsx'
|
||||
import Setup from './views/Setup.jsx'
|
||||
import Live from './views/Live.jsx'
|
||||
@@ -22,9 +23,9 @@ import Cameras from './views/Cameras.jsx'
|
||||
// the customer record lives on the server, the cameras and what this PC is
|
||||
// seeing do not.
|
||||
const VIEWS = [
|
||||
{ id: 'live', label: 'Live', glyph: '◉', View: Live },
|
||||
{ id: 'customers', label: 'Customers', glyph: '☺', View: Customers, cloud: true },
|
||||
{ id: 'cameras', label: 'Cameras', glyph: '▢', View: Cameras },
|
||||
{ id: 'live', label: 'Live', Glyph: Icon.Live, View: Live },
|
||||
{ id: 'customers', label: 'Customers', Glyph: Icon.People, View: Customers, cloud: true },
|
||||
{ id: 'cameras', label: 'Cameras', Glyph: Icon.Camera, View: Cameras },
|
||||
]
|
||||
|
||||
export default function App() {
|
||||
@@ -48,6 +49,7 @@ export default function App() {
|
||||
// error nobody will read.
|
||||
return (
|
||||
<div className="login"><div className="box">
|
||||
<span className="mark"><Icon.Shield size={20} /></span>
|
||||
<h1>Behavision</h1>
|
||||
<p className="lead">
|
||||
This is the Behavision window running outside the app, so it has no
|
||||
@@ -78,36 +80,41 @@ export default function App() {
|
||||
<div className="shell">
|
||||
<aside className="side">
|
||||
<div className="brand">
|
||||
<h1>Behavision</h1>
|
||||
<p>{session.site_name || session.user?.client_name || 'Store'}</p>
|
||||
<span className="mark"><Icon.Shield size={17} /></span>
|
||||
<div className="id">
|
||||
<h1>Behavision</h1>
|
||||
<p>{session.site_name || session.user?.client_name || 'This shop'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="nav">
|
||||
{views.map(v => (
|
||||
<button key={v.id} onClick={() => setView(v.id)}
|
||||
aria-current={v.id === view ? 'page' : undefined}>
|
||||
<span className="glyph">{v.glyph}</span>{v.label}
|
||||
{views.map(({ id, label, Glyph }) => (
|
||||
<button key={id} onClick={() => setView(id)}
|
||||
aria-current={id === view ? 'page' : undefined}>
|
||||
<Glyph size={17} />{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<EngineBox />
|
||||
<div style={{ padding: '10px 12px 14px', borderTop: '1px solid var(--line-soft)' }}>
|
||||
<div className="who">
|
||||
{session.standalone
|
||||
? <>
|
||||
<div className="note" style={{ marginBottom: 8 }}>
|
||||
Running on its own
|
||||
<div className="id">
|
||||
<b>On its own</b>
|
||||
<span>No head office</span>
|
||||
</div>
|
||||
<button className="btn sm" style={{ width: '100%' }}
|
||||
<button className="btn sm icon" title="Link to head office"
|
||||
onClick={() => setLinking(true)}>
|
||||
Link to head office
|
||||
<Icon.Link size={15} />
|
||||
</button>
|
||||
</>
|
||||
: <>
|
||||
<div className="note" style={{ marginBottom: 8 }}>
|
||||
{session.user?.email}
|
||||
<div className="id">
|
||||
<b>Signed in</b>
|
||||
<span>{session.user?.email}</span>
|
||||
</div>
|
||||
<button className="btn sm" style={{ width: '100%' }}
|
||||
<button className="btn sm icon" title="Sign out"
|
||||
onClick={async () => setSession(await api.logout())}>
|
||||
Sign out
|
||||
<Icon.Logout size={15} />
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
@@ -136,7 +143,12 @@ function EngineBox() {
|
||||
const up = cams.filter(Boolean).length
|
||||
|
||||
let tone = 'idle', text = 'Stopped'
|
||||
if (s.state === 'failed' || s.state === 'backoff') { tone = 'bad'; text = 'Not running' }
|
||||
// Reachable but not ours: somebody started the engine outside this app, or a
|
||||
// previous copy is still up. Saying "Stopped" beside live camera feeds is the
|
||||
// two-surfaces-disagreeing bug the tray exists to avoid - and it is exactly
|
||||
// what this panel showed while recognition was visibly running.
|
||||
if (!running && s.reachable) { tone = 'warn'; text = 'Running outside the app' }
|
||||
else if (s.state === 'failed' || s.state === 'backoff') { tone = 'bad'; text = 'Not running' }
|
||||
else if (running && !s.reachable) { tone = 'warn'; text = 'Starting…' }
|
||||
else if (running && cams.length === 0) { tone = 'warn'; text = 'No cameras' }
|
||||
else if (running && up === 0) { tone = 'bad'; text = 'No camera connected' }
|
||||
@@ -145,16 +157,21 @@ function EngineBox() {
|
||||
|
||||
return (
|
||||
<div className="enginebox">
|
||||
<div className="row"><i className={`dot ${tone}`} /><strong>{text}</strong></div>
|
||||
{s.recognition_model && (
|
||||
<span className="label">Model: {s.recognition_model}</span>
|
||||
)}
|
||||
<div className="row">
|
||||
<i className={`dot ${tone === 'ok' ? 'live' : tone}`} />
|
||||
<span className="state">{text}</span>
|
||||
</div>
|
||||
{s.recognition_model && <span className="label">{s.recognition_model}</span>}
|
||||
{s.error && <span className="label" style={{ color: 'var(--bad)' }}>{s.error}</span>}
|
||||
<div className="actions">
|
||||
<button className="btn sm" disabled={busy || running}
|
||||
onClick={() => act(api.startEngine)}>Start</button>
|
||||
<button className="btn sm" disabled={busy || running || s.reachable}
|
||||
onClick={() => act(api.startEngine)}>
|
||||
<Icon.Play size={13} />Start
|
||||
</button>
|
||||
<button className="btn sm" disabled={busy || !running}
|
||||
onClick={() => act(api.stopEngine)}>Stop</button>
|
||||
onClick={() => act(api.stopEngine)}>
|
||||
<Icon.Stop size={13} />Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,252 +1,476 @@
|
||||
/* Behavision desktop — an instrument panel, not a website.
|
||||
A shop PC runs this all day on a cheap monitor, so: high contrast, dense
|
||||
but not cramped, and state readable at a glance from across a counter. */
|
||||
/* Behavision desktop — a shop-floor instrument, not a website.
|
||||
===========================================================================
|
||||
Designed for one situation: a PC behind a counter, on a cheap monitor, in a
|
||||
room with daylight, glanced at by somebody who is mid-conversation with a
|
||||
customer. Everything below follows from that.
|
||||
|
||||
- Dark, because the screen sits in peripheral vision all day and a white
|
||||
field at 1000 lux is a lamp pointed at the operator.
|
||||
- State is carried by shape AND colour: a pill, a dot and an edge stripe,
|
||||
never colour alone. This gets read from two metres away, and some
|
||||
operators do not see red and green apart.
|
||||
- One spacing scale and one type scale. The previous version set margins
|
||||
inline, per screen, which is how a UI ends up looking assembled rather
|
||||
than designed.
|
||||
- Motion only where it carries meaning: a live camera, a fresh arrival.
|
||||
Nothing loops for decoration — this process shares a CPU with recognition.
|
||||
=========================================================================== */
|
||||
|
||||
:root {
|
||||
--ground: #0E1317;
|
||||
--surface: #161D23;
|
||||
--surface-2: #1D262D;
|
||||
--line: #27333B;
|
||||
--line-soft: #1F2A31;
|
||||
--ink: #E7EEF3;
|
||||
--ink-2: #B4C2CC;
|
||||
--muted: #7C8B97;
|
||||
--accent: #45B0C7;
|
||||
--accent-dim:#123039;
|
||||
--ok: #4FB37B;
|
||||
--warn: #E0A33A;
|
||||
--bad: #E0655A;
|
||||
--radius: 8px;
|
||||
--mono: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
|
||||
/* ground → raised, four steps, blue-green biased: the product lives in the
|
||||
world of lenses and CCTV, and a neutral grey reads as unfinished. */
|
||||
--bg: #0A0F13;
|
||||
--s1: #111A20;
|
||||
--s2: #17232B;
|
||||
--s3: #1E2D37;
|
||||
--line: #223038;
|
||||
--line-2: #1A252C;
|
||||
|
||||
--ink: #ECF3F7;
|
||||
--ink-2: #A3B6C2;
|
||||
--ink-3: #6C808D;
|
||||
|
||||
/* Accent is for state and focus only, never decoration, so that when it does
|
||||
appear the eye goes to it. */
|
||||
--accent: #40C4DC;
|
||||
--accent-2: #0F3B47;
|
||||
--accent-3: #0B2A33;
|
||||
|
||||
--ok: #48C78E; --ok-2: #102E22;
|
||||
--warn: #EAAA3D; --warn-2: #31260F;
|
||||
--bad: #EC6A5C; --bad-2: #331815;
|
||||
|
||||
--r-sm: 6px; --r: 10px; --r-lg: 14px;
|
||||
|
||||
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
|
||||
--sp-5: 20px; --sp-6: 24px; --sp-7: 32px; --sp-8: 40px;
|
||||
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / .4), 0 8px 24px -12px rgb(0 0 0 / .6);
|
||||
--shadow-lg: 0 2px 4px rgb(0 0 0 / .4), 0 24px 48px -16px rgb(0 0 0 / .7);
|
||||
|
||||
/* Segoe UI Variable first: it is on every Windows 11 shop PC, it has real
|
||||
optical sizes, and it is what makes this look like an application rather
|
||||
than a web page in a frame. No webfont — a shop PC has no internet at
|
||||
install time, and a font that fails to arrive is a layout that shifts
|
||||
under the operator. */
|
||||
--font: "Segoe UI Variable Text", "Segoe UI", Inter, -apple-system,
|
||||
BlinkMacSystemFont, system-ui, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-display: "Segoe UI Variable Display", var(--font);
|
||||
--mono: "Cascadia Mono", "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
|
||||
|
||||
/* Kept as aliases so any screen not yet rewritten keeps its colours. */
|
||||
--ground: var(--bg); --surface: var(--s1); --surface-2: var(--s2);
|
||||
--line-soft: var(--line-2); --muted: var(--ink-3); --radius: var(--r);
|
||||
--accent-dim: var(--accent-3);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
body {
|
||||
background: var(--ground);
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 14px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* ---------------------------------------------------------------- shell -- */
|
||||
.shell { display: grid; grid-template-columns: 216px 1fr; height: 100%; }
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
input, textarea { user-select: text; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
|
||||
::selection { background: var(--accent-2); color: var(--ink); }
|
||||
|
||||
/* Digits that line up wherever they are compared or refreshed in place. */
|
||||
.num, .value, .metric-v, .when, .mono, .code, td { font-variant-numeric: tabular-nums; }
|
||||
.mono, .code { font-family: var(--mono); }
|
||||
|
||||
/* The default light scrollbar on a dark panel is the most obvious "this is a
|
||||
web page" tell there is. */
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--s3) transparent; }
|
||||
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
*::-webkit-scrollbar-thumb { background: var(--s3); border-radius: 99px; border: 3px solid var(--bg); }
|
||||
*::-webkit-scrollbar-thumb:hover { background: #2A3D49; }
|
||||
|
||||
/* ================================================================ shell == */
|
||||
|
||||
.shell { display: grid; grid-template-columns: 232px 1fr; height: 100%; }
|
||||
|
||||
.side {
|
||||
background: var(--surface); border-right: 1px solid var(--line);
|
||||
background: var(--s1); border-right: 1px solid var(--line);
|
||||
display: flex; flex-direction: column; min-height: 0;
|
||||
}
|
||||
.side .brand {
|
||||
padding: 18px 18px 14px; border-bottom: 1px solid var(--line-soft);
|
||||
.side .brand { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-5) var(--sp-5) var(--sp-4); }
|
||||
.side .brand .mark {
|
||||
width: 30px; height: 30px; border-radius: 9px; flex: none; display: grid; place-items: center;
|
||||
background: linear-gradient(160deg, var(--accent-2), var(--s2));
|
||||
border: 1px solid #1B4C5A; color: var(--accent);
|
||||
}
|
||||
.side .brand h1 { font-size: 15px; font-weight: 650; letter-spacing: -.01em; }
|
||||
.side .brand p { font-size: 11.5px; color: var(--muted); margin-top: 3px; }
|
||||
.nav { padding: 10px 10px; display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||
.side .brand .id { min-width: 0; }
|
||||
.side .brand h1 { font-family: var(--font-display); font-size: 15px; font-weight: 600; letter-spacing: -.012em; line-height: 1.2; }
|
||||
.side .brand p { font-size: 11.5px; color: var(--ink-3); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.nav { padding: var(--sp-2) var(--sp-3); display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||
.nav button {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%;
|
||||
background: none; border: 0; border-radius: 6px; padding: 8px 10px;
|
||||
color: var(--ink-2); cursor: pointer; text-align: left; font-size: 13.5px;
|
||||
position: relative; display: flex; align-items: center; gap: var(--sp-3); width: 100%;
|
||||
background: none; border: 0; border-radius: var(--r-sm); padding: 9px var(--sp-3);
|
||||
color: var(--ink-2); cursor: pointer; text-align: left; font-size: 13.5px; font-weight: 450;
|
||||
transition: background .12s ease, color .12s ease;
|
||||
}
|
||||
.nav button:hover { background: var(--surface-2); color: var(--ink); }
|
||||
.nav button[aria-current="page"] { background: var(--accent-dim); color: var(--accent); font-weight: 550; }
|
||||
.nav .glyph { width: 16px; text-align: center; opacity: .85; font-size: 13px; }
|
||||
|
||||
.enginebox { padding: 12px; border-top: 1px solid var(--line-soft); }
|
||||
.enginebox .row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||||
.enginebox .label { color: var(--muted); font-size: 11px; margin-top: 2px;
|
||||
display: block; line-height: 1.4; }
|
||||
.enginebox .actions { display: flex; gap: 6px; margin-top: 10px; }
|
||||
|
||||
.main { min-width: 0; min-height: 0; overflow-y: auto; }
|
||||
.page { padding: 22px 26px 40px; max-width: 1180px; }
|
||||
.page > header { margin-bottom: 18px; }
|
||||
.page h2 { font-size: 19px; font-weight: 620; letter-spacing: -.01em; }
|
||||
.page header p { color: var(--muted); font-size: 13px; margin-top: 3px; }
|
||||
|
||||
/* --------------------------------------------------------------- pieces -- */
|
||||
.card {
|
||||
background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); padding: 16px;
|
||||
.nav button svg { flex: none; opacity: .9; }
|
||||
.nav button:hover { background: var(--s2); color: var(--ink); }
|
||||
.nav button[aria-current="page"] { background: var(--accent-3); color: var(--accent); font-weight: 550; }
|
||||
/* A rail, not a background wash: it survives being looked at sideways. */
|
||||
.nav button[aria-current="page"]::before {
|
||||
content: ""; position: absolute; left: -12px; top: 7px; bottom: 7px;
|
||||
width: 2.5px; border-radius: 0 2px 2px 0; background: var(--accent);
|
||||
}
|
||||
.card h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .07em;
|
||||
color: var(--muted); font-weight: 600; margin-bottom: 12px; }
|
||||
.grid { display: grid; gap: 14px; }
|
||||
.cols-4 { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
|
||||
.cols-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
|
||||
|
||||
.stat .value { font-size: 30px; font-weight: 620; letter-spacing: -.02em;
|
||||
font-variant-numeric: tabular-nums; line-height: 1.1; }
|
||||
.stat .unit { font-size: 15px; color: var(--muted); margin-left: 3px; }
|
||||
.stat .sub { color: var(--muted); font-size: 12px; margin-top: 5px; }
|
||||
/* The one control that starts and stops the product, so it gets its own block
|
||||
at the foot rather than a row in a list. */
|
||||
.enginebox {
|
||||
margin: var(--sp-3); padding: var(--sp-3) var(--sp-4) var(--sp-4);
|
||||
border: 1px solid var(--line); border-radius: var(--r); background: var(--s2);
|
||||
}
|
||||
.enginebox .row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.enginebox .state { font-size: 12.5px; font-weight: 600; letter-spacing: -.005em; }
|
||||
.enginebox .label { display: block; color: var(--ink-3); font-size: 11px; line-height: 1.45; margin-top: 3px; font-variant-numeric: tabular-nums; }
|
||||
.enginebox .actions, .enginebox .controls { display: flex; gap: var(--sp-2); margin-top: var(--sp-3); }
|
||||
.enginebox .actions .btn, .enginebox .controls .btn { flex: 1; justify-content: center; padding: 6px 8px; font-size: 12px; }
|
||||
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
||||
.dot.ok { background: var(--ok); }
|
||||
.dot.warn { background: var(--warn); }
|
||||
.dot.bad { background: var(--bad); }
|
||||
.dot.idle { background: var(--muted); }
|
||||
.side .who {
|
||||
padding: var(--sp-3) var(--sp-5) var(--sp-5); border-top: 1px solid var(--line-2);
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
}
|
||||
.side .who .id { min-width: 0; flex: 1; }
|
||||
.side .who .id b { display: block; font-size: 12.5px; font-weight: 550; }
|
||||
.side .who .id span { display: block; font-size: 11px; color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.pill { display: inline-flex; align-items: center; gap: 5px; font-size: 11px;
|
||||
padding: 3px 8px; border-radius: 99px; border: 1px solid var(--line);
|
||||
color: var(--muted); white-space: nowrap; }
|
||||
.pill.ok { color: var(--ok); border-color: #2b5c42; background: #12251b; }
|
||||
.pill.warn { color: var(--warn); border-color: #5c4a22; background: #241d0f; }
|
||||
.pill.bad { color: var(--bad); border-color: #5c2e2a; background: #241312; }
|
||||
.main { min-width: 0; min-height: 0; overflow: auto; }
|
||||
|
||||
/* ================================================================= page == */
|
||||
|
||||
.page { padding: var(--sp-6) var(--sp-7) var(--sp-8); max-width: 1500px; }
|
||||
.page > header { margin-bottom: var(--sp-5); }
|
||||
.page > header h2, .page h2 { font-family: var(--font-display); font-size: 22px; font-weight: 600; letter-spacing: -.02em; line-height: 1.2; }
|
||||
.page > header p, .page header p { color: var(--ink-3); font-size: 13px; margin-top: 3px; }
|
||||
|
||||
.pagehead { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); margin-bottom: var(--sp-5); flex-wrap: wrap; }
|
||||
|
||||
h3 { font-size: 11px; font-weight: 600; letter-spacing: .085em; text-transform: uppercase; color: var(--ink-3); }
|
||||
|
||||
/* ================================================================ cards == */
|
||||
|
||||
.card { background: var(--s1); border: 1px solid var(--line); border-radius: var(--r); padding: var(--sp-4); }
|
||||
.card > h3 { margin-bottom: var(--sp-3); }
|
||||
.card.flush { padding: 0; overflow: hidden; }
|
||||
|
||||
.panel { background: var(--s1); border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
|
||||
.panel > .panelhead {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--line-2);
|
||||
background: linear-gradient(var(--s2), var(--s1)); flex: none;
|
||||
}
|
||||
.panel > .panelhead h3 { margin: 0; }
|
||||
.panel > .panelbody { padding: var(--sp-4); min-height: 0; overflow: auto; }
|
||||
.panel > .panelbody.flush { padding: 0; }
|
||||
|
||||
.grid { display: grid; gap: var(--sp-4); }
|
||||
.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
@media (max-width: 1180px) { .cols-4 { grid-template-columns: repeat(2, minmax(0,1fr)); } }
|
||||
@media (max-width: 980px) { .cols-2, .cols-3 { grid-template-columns: minmax(0,1fr); } }
|
||||
|
||||
/* Four equal boxes used to dominate this screen. The numbers matter, but they
|
||||
are not what anybody opens the app to see. */
|
||||
.metrics {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(152px, 1fr));
|
||||
gap: 1px; background: var(--line); border: 1px solid var(--line);
|
||||
border-radius: var(--r); overflow: hidden;
|
||||
}
|
||||
.metric { background: var(--s1); padding: var(--sp-3) var(--sp-4) var(--sp-4); }
|
||||
.metric .metric-k { font-size: 10.5px; font-weight: 600; letter-spacing: .085em; text-transform: uppercase; color: var(--ink-3); }
|
||||
.metric .metric-v { font-family: var(--font-display); font-size: 26px; font-weight: 600; letter-spacing: -.025em; line-height: 1.1; margin-top: 5px; }
|
||||
.metric .metric-s { font-size: 11.5px; color: var(--ink-3); margin-top: 3px; line-height: 1.4; }
|
||||
.metric.ok .metric-v { color: var(--ok); }
|
||||
.metric.warn .metric-v { color: var(--warn); }
|
||||
.metric.bad .metric-v { color: var(--bad); }
|
||||
|
||||
/* legacy .stat, for screens not yet rewritten */
|
||||
.stat h3 { margin-bottom: var(--sp-2); }
|
||||
.stat .value { font-family: var(--font-display); font-size: 26px; font-weight: 600; letter-spacing: -.025em; line-height: 1.1; }
|
||||
.stat .unit { font-size: 15px; color: var(--ink-3); margin-left: 3px; }
|
||||
.stat .sub { font-size: 11.5px; color: var(--ink-3); margin-top: 4px; line-height: 1.4; }
|
||||
|
||||
/* =============================================================== status == */
|
||||
|
||||
.dot { width: 7px; height: 7px; border-radius: 99px; flex: none; background: var(--ink-3); }
|
||||
.dot.ok { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 18%, transparent); }
|
||||
.dot.warn { background: var(--warn); box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 18%, transparent); }
|
||||
.dot.bad { background: var(--bad); box-shadow: 0 0 0 3px color-mix(in srgb, var(--bad) 18%, transparent); }
|
||||
.dot.idle { background: var(--ink-3); }
|
||||
|
||||
/* A live camera is the one thing that should breathe: it is how an operator
|
||||
knows the picture is not frozen. Everything else holds still. */
|
||||
.dot.live { background: var(--ok); animation: pulse 2.4s ease-in-out infinite; }
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ok) 55%, transparent); }
|
||||
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--ok) 0%, transparent); }
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px 3px 7px;
|
||||
border-radius: 99px; font-size: 11px; font-weight: 600; letter-spacing: .02em;
|
||||
background: var(--s3); color: var(--ink-2); border: 1px solid var(--line); white-space: nowrap;
|
||||
}
|
||||
.pill.ok { background: var(--ok-2); color: var(--ok); border-color: color-mix(in srgb, var(--ok) 28%, transparent); }
|
||||
.pill.warn { background: var(--warn-2); color: var(--warn); border-color: color-mix(in srgb, var(--warn) 28%, transparent); }
|
||||
.pill.bad { background: var(--bad-2); color: var(--bad); border-color: color-mix(in srgb, var(--bad) 28%, transparent); }
|
||||
.pill.accent { background: var(--accent-3); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 30%, transparent); }
|
||||
|
||||
.tag {
|
||||
display: inline-flex; align-items: center; padding: 2px 7px; border-radius: var(--r-sm);
|
||||
font-size: 10.5px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
|
||||
background: var(--s3); color: var(--ink-2);
|
||||
}
|
||||
.tag.new { background: var(--accent-3); color: var(--accent); }
|
||||
.tag.seen { background: var(--ok-2); color: var(--ok); }
|
||||
.tag.miss { background: var(--warn-2); color: var(--warn); }
|
||||
|
||||
/* One line that answers "is this shop working" above everything else. */
|
||||
.statusbar {
|
||||
display: flex; align-items: center; gap: var(--sp-5); flex-wrap: wrap;
|
||||
padding: var(--sp-3) var(--sp-4); background: var(--s1);
|
||||
border: 1px solid var(--line); border-radius: var(--r); margin-bottom: var(--sp-4);
|
||||
}
|
||||
.statusbar .item { display: flex; align-items: center; gap: var(--sp-2); font-size: 12.5px; }
|
||||
.statusbar .item b { font-weight: 600; letter-spacing: -.005em; }
|
||||
.statusbar .item svg { color: var(--ink-3); }
|
||||
.statusbar .sep { width: 1px; align-self: stretch; background: var(--line); }
|
||||
.statusbar .grow { flex: 1; }
|
||||
|
||||
/* ============================================================= arrivals == */
|
||||
|
||||
/* The reason the product exists, so it gets the width and the weight. */
|
||||
.arrivals { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); }
|
||||
|
||||
.arrival {
|
||||
display: grid; grid-template-columns: 46px 1fr auto; gap: var(--sp-3); align-items: center;
|
||||
padding: var(--sp-3); border-radius: var(--r);
|
||||
background: var(--s2); border: 1px solid var(--line-2);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.arrival::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 2.5px; background: var(--ink-3); }
|
||||
.arrival.is-new::before { background: var(--accent); }
|
||||
.arrival.is-seen::before { background: var(--ok); }
|
||||
.arrival.is-miss::before { background: var(--warn); }
|
||||
|
||||
/* Only the newest row animates, and only once. */
|
||||
.arrival.fresh { animation: slidein .28s cubic-bezier(.2,.8,.3,1); }
|
||||
@keyframes slidein { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.arrival .avatar {
|
||||
width: 46px; height: 46px; border-radius: var(--r-sm); display: grid; place-items: center;
|
||||
overflow: hidden; background: var(--s3); border: 1px solid var(--line);
|
||||
font-family: var(--font-display); font-size: 15px; font-weight: 600;
|
||||
color: var(--ink-2); letter-spacing: -.01em; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.arrival .avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.arrival.is-new .avatar { background: var(--accent-3); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 25%, transparent); }
|
||||
|
||||
.arrival .who { min-width: 0; }
|
||||
.arrival .who .name { font-size: 14.5px; font-weight: 600; letter-spacing: -.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.arrival .who .meta { font-size: 11.5px; color: var(--ink-3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.arrival .right { text-align: right; display: flex; flex-direction: column; align-items: flex-end; gap: 5px; }
|
||||
.arrival .right .when { font-size: 11.5px; color: var(--ink-3); }
|
||||
|
||||
/* ================================================================ feeds == */
|
||||
|
||||
.feeds { display: grid; gap: var(--sp-3); grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); padding: var(--sp-3); }
|
||||
|
||||
.feed { position: relative; border-radius: var(--r); overflow: hidden; background: #05090C; border: 1px solid var(--line); aspect-ratio: 16 / 9; }
|
||||
.feed img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.feed .placeholder { width: 100%; height: 100%; display: grid; place-items: center; color: var(--ink-3); }
|
||||
/* Caption over the picture, not beneath it: the tile stays a picture. */
|
||||
.feed .cap {
|
||||
position: absolute; left: 0; right: 0; bottom: 0;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
|
||||
padding: var(--sp-5) var(--sp-3) var(--sp-3);
|
||||
background: linear-gradient(transparent, rgb(0 0 0 / .8));
|
||||
font-size: 12.5px; font-weight: 600; letter-spacing: -.005em;
|
||||
}
|
||||
|
||||
/* ================================================================ lists == */
|
||||
|
||||
.events, .timeline { list-style: none; padding: 0; display: flex; flex-direction: column; }
|
||||
.events li { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-4); border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
||||
.timeline li { display: flex; align-items: center; gap: var(--sp-3); padding: 9px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
||||
.events li:last-child, .timeline li:last-child { border-bottom: 0; }
|
||||
.events .when, .timeline .when { font-size: 11.5px; color: var(--ink-3); width: 46px; flex: none; }
|
||||
|
||||
.empty {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: var(--sp-3); padding: var(--sp-8) var(--sp-5); color: var(--ink-3); text-align: center; font-size: 12.5px;
|
||||
}
|
||||
.empty svg { opacity: .35; }
|
||||
.empty b { display: block; color: var(--ink-2); font-size: 13.5px; font-weight: 550; }
|
||||
.empty p { max-width: 34ch; line-height: 1.5; }
|
||||
|
||||
.tablewrap { overflow: auto; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
th {
|
||||
text-align: left; padding: 9px var(--sp-4); font-size: 10.5px; font-weight: 600;
|
||||
letter-spacing: .085em; text-transform: uppercase; color: var(--ink-3);
|
||||
background: var(--s2); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 1;
|
||||
}
|
||||
td { padding: 10px var(--sp-4); border-bottom: 1px solid var(--line-2); }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
tbody tr[role="button"], tbody tr.clickable { cursor: pointer; }
|
||||
tbody tr[role="button"]:hover, tbody tr.clickable:hover { background: var(--s2); }
|
||||
|
||||
/* ============================================================= controls == */
|
||||
|
||||
.btn {
|
||||
background: var(--surface-2); border: 1px solid var(--line);
|
||||
border-radius: 6px; padding: 7px 13px; cursor: pointer; font-size: 13px;
|
||||
color: var(--ink); white-space: nowrap;
|
||||
display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px;
|
||||
border-radius: var(--r-sm); background: var(--s3); color: var(--ink);
|
||||
border: 1px solid var(--line); cursor: pointer;
|
||||
font-size: 13px; font-weight: 550; letter-spacing: -.005em; white-space: nowrap;
|
||||
transition: background .12s ease, border-color .12s ease, transform .06s ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: #26323a; }
|
||||
.btn:disabled { opacity: .45; cursor: default; }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #06222a;
|
||||
font-weight: 600; }
|
||||
.btn.primary:hover:not(:disabled) { background: #5ac0d6; }
|
||||
.btn.danger { color: var(--bad); border-color: #4a2823; }
|
||||
.btn.sm { padding: 4px 9px; font-size: 12px; }
|
||||
.btn:hover:not(:disabled) { background: #253643; border-color: #2E414E; }
|
||||
.btn:active:not(:disabled) { transform: translateY(.5px); }
|
||||
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.btn svg { flex: none; }
|
||||
.btn.primary { background: var(--accent); color: #04171C; border-color: transparent; font-weight: 600; }
|
||||
.btn.primary:hover:not(:disabled) { background: #55D0E6; }
|
||||
.btn.danger { background: var(--bad-2); color: var(--bad); border-color: color-mix(in srgb, var(--bad) 32%, transparent); }
|
||||
.btn.danger:hover:not(:disabled) { background: #43201C; }
|
||||
.btn.ghost { background: transparent; }
|
||||
.btn.ghost:hover:not(:disabled) { background: var(--s2); }
|
||||
.btn.sm { padding: 5px 10px; font-size: 12px; }
|
||||
.btn.icon { padding: 7px; }
|
||||
|
||||
.field { display: block; margin-bottom: 12px; }
|
||||
.field span { display: block; font-size: 11.5px; color: var(--muted);
|
||||
margin-bottom: 4px; letter-spacing: .01em; }
|
||||
.linkbtn { background: none; border: 0; color: var(--accent); cursor: pointer; font-size: 12.5px; padding: 2px 0; text-align: left; }
|
||||
.linkbtn:hover { text-decoration: underline; }
|
||||
|
||||
.seg { display: inline-flex; background: var(--s2); border: 1px solid var(--line); border-radius: var(--r-sm); padding: 2px; gap: 2px; }
|
||||
.seg button { background: none; border: 0; border-radius: 4px; padding: 5px 11px; color: var(--ink-3); cursor: pointer; font-size: 12.5px; font-weight: 500; }
|
||||
.seg button[aria-pressed="true"] { background: var(--s3); color: var(--ink); }
|
||||
|
||||
/* ================================================================ forms == */
|
||||
|
||||
.field { display: block; margin-bottom: var(--sp-4); }
|
||||
.field > span { display: block; font-size: 11.5px; font-weight: 550; color: var(--ink-2); margin-bottom: 6px; }
|
||||
.field input, .field select, .field textarea {
|
||||
width: 100%; background: var(--ground); border: 1px solid var(--line);
|
||||
border-radius: 6px; padding: 8px 10px; font-size: 13.5px;
|
||||
user-select: text;
|
||||
width: 100%; padding: 9px 11px; background: var(--s2); color: var(--ink);
|
||||
border: 1px solid var(--line); border-radius: var(--r-sm);
|
||||
transition: border-color .12s ease, background .12s ease, box-shadow .12s ease;
|
||||
}
|
||||
.field input::placeholder { color: var(--ink-3); }
|
||||
.field input:hover, .field select:hover, .field textarea:hover { border-color: #2C3D49; }
|
||||
.field input:focus, .field select:focus, .field textarea:focus {
|
||||
border-color: var(--accent); outline: none;
|
||||
outline: none; border-color: var(--accent); background: var(--s1); box-shadow: 0 0 0 3px var(--accent-3);
|
||||
}
|
||||
.field textarea { resize: vertical; min-height: 66px; }
|
||||
.fieldrow { display: grid; gap: 0 12px; grid-template-columns: 1fr 1fr; }
|
||||
.field .hint { display: block; font-size: 11.5px; color: var(--ink-3); margin-top: 5px; line-height: 1.45; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { text-align: left; font-size: 10.5px; text-transform: uppercase;
|
||||
letter-spacing: .08em; color: var(--muted); font-weight: 600;
|
||||
padding: 8px 10px; border-bottom: 1px solid var(--line); }
|
||||
td { padding: 9px 10px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
tbody tr.click { cursor: pointer; }
|
||||
tbody tr.click:hover { background: var(--surface-2); }
|
||||
td.num { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.tablewrap { overflow-x: auto; }
|
||||
.fieldrow { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: var(--sp-3); }
|
||||
.fieldrow.three { grid-template-columns: repeat(3, minmax(0,1fr)); }
|
||||
|
||||
.empty { color: var(--muted); font-size: 13px; padding: 26px 4px; text-align: center; }
|
||||
.err {
|
||||
border: 1px solid #5c2e2a; background: #241312; color: #f0b3ad;
|
||||
border-radius: 6px; padding: 10px 12px; font-size: 13px; margin-bottom: 14px;
|
||||
display: flex; align-items: flex-start; gap: var(--sp-2);
|
||||
background: var(--bad-2); color: var(--bad);
|
||||
border: 1px solid color-mix(in srgb, var(--bad) 30%, transparent);
|
||||
border-radius: var(--r-sm); padding: 9px 11px; font-size: 12.5px; line-height: 1.45; margin-bottom: var(--sp-4);
|
||||
}
|
||||
.note { color: var(--muted); font-size: 12.5px; }
|
||||
.mono { font-family: var(--mono); font-size: 12px; }
|
||||
.err svg { flex: none; margin-top: 1px; }
|
||||
|
||||
/* --------------------------------------------------------------- login --- */
|
||||
.login { height: 100%; display: grid; place-items: center; padding: 24px; }
|
||||
.login .box { width: 100%; max-width: 380px; }
|
||||
.login h1 { font-size: 21px; font-weight: 650; letter-spacing: -.015em; }
|
||||
.login .lead { color: var(--muted); font-size: 13px; margin: 6px 0 22px; }
|
||||
.login form { background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: 10px; padding: 20px; }
|
||||
.login .btn { width: 100%; margin-top: 6px; }
|
||||
.login .foot { color: var(--muted); font-size: 11.5px; margin-top: 14px;
|
||||
text-align: center; line-height: 1.5; }
|
||||
/* The second way out of the setup screen: a shop with no head office. Styled
|
||||
quieter than the form above it because linking is still the common case,
|
||||
but present, because for a single-till shop it is the only one that works. */
|
||||
.login .alt { margin-top: 18px; padding-top: 16px; text-align: center;
|
||||
border-top: 1px solid var(--line-soft); }
|
||||
.login .alt .note { line-height: 1.55; margin-bottom: 12px; text-align: left; }
|
||||
.note { color: var(--ink-3); font-size: 12px; line-height: 1.5; }
|
||||
.note.warn { color: var(--warn); }
|
||||
.note.bad { color: var(--bad); }
|
||||
.lead { color: var(--ink-2); font-size: 13.5px; line-height: 1.55; }
|
||||
.sm { font-size: 12px; }
|
||||
.lbl, .key { color: var(--ink-3); font-size: 11.5px; }
|
||||
.grow { flex: 1; }
|
||||
.row { display: flex; align-items: center; gap: var(--sp-3); }
|
||||
|
||||
/* ================================================================= gate == */
|
||||
|
||||
/* Login and Setup: the first thing anybody sees, and previously a grey box on
|
||||
a grey field. One soft light behind the card gives the window a centre and
|
||||
costs nothing — it is a static gradient, not an animation. */
|
||||
.login {
|
||||
height: 100%; display: grid; place-items: center; padding: var(--sp-6); overflow: auto;
|
||||
background: radial-gradient(900px 480px at 50% -10%, #10303A 0%, transparent 62%), var(--bg);
|
||||
}
|
||||
.login .box {
|
||||
width: 100%; max-width: 396px; background: var(--s1); border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg); padding: var(--sp-7); box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.login .mark {
|
||||
width: 38px; height: 38px; border-radius: 11px; margin-bottom: var(--sp-4);
|
||||
display: grid; place-items: center; color: var(--accent);
|
||||
background: linear-gradient(160deg, var(--accent-2), var(--s2)); border: 1px solid #1B4C5A;
|
||||
}
|
||||
.login h1 { font-family: var(--font-display); font-size: 21px; font-weight: 600; letter-spacing: -.022em; }
|
||||
.login .lead { margin: 6px 0 var(--sp-5); }
|
||||
.login .btn { width: 100%; justify-content: center; margin-top: var(--sp-1); }
|
||||
.login .foot { font-size: 11.5px; color: var(--ink-3); line-height: 1.55; margin-top: var(--sp-5); padding-top: var(--sp-4); border-top: 1px solid var(--line-2); }
|
||||
.login .alt { margin-top: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
.login .alt .btn { margin-top: 0; }
|
||||
.linkbtn { background: none; border: 0; padding: 0; cursor: pointer;
|
||||
font: inherit; font-size: 12.5px; color: var(--accent);
|
||||
text-decoration: underline; text-underline-offset: 3px; }
|
||||
.linkbtn:hover { color: var(--ink); }
|
||||
.login .note { margin: 0; }
|
||||
|
||||
/* ---------------------------------------------------------------- live --- */
|
||||
.feeds { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
|
||||
.feed { background: #000; border: 1px solid var(--line); border-radius: var(--radius);
|
||||
overflow: hidden; }
|
||||
.feed img { width: 100%; display: block; aspect-ratio: 16/9; object-fit: cover; background: #000; }
|
||||
.feed .cap { display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 8px 11px; background: var(--surface); font-size: 12.5px; }
|
||||
/* =============================================================== drawer == */
|
||||
|
||||
.events { list-style: none; max-height: 420px; overflow-y: auto; }
|
||||
.events li { display: flex; gap: 9px; align-items: baseline;
|
||||
padding: 7px 2px; border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
|
||||
.events li:last-child { border-bottom: 0; }
|
||||
.events .when { color: var(--muted); font-family: var(--mono); font-size: 11px;
|
||||
flex: none; }
|
||||
.tag { font-size: 10px; padding: 2px 6px; border-radius: 4px; flex: none;
|
||||
background: var(--surface-2); color: var(--muted); }
|
||||
.tag.new { background: #17364f; color: #86c2ec; }
|
||||
.tag.seen { background: #14301f; color: #7fcb9c; }
|
||||
.tag.miss { background: #3a1c1a; color: #eb9a92; }
|
||||
.drawer { position: fixed; inset: 0; z-index: 40; background: rgb(4 8 11 / .6); display: flex; justify-content: flex-end; animation: fade .16s ease; }
|
||||
@keyframes fade { from { opacity: 0 } to { opacity: 1 } }
|
||||
.drawer .sheet {
|
||||
width: min(540px, 100%); height: 100%; overflow: auto;
|
||||
background: var(--s1); border-left: 1px solid var(--line); box-shadow: var(--shadow-lg);
|
||||
animation: slidein-r .2s cubic-bezier(.2,.8,.3,1);
|
||||
}
|
||||
@keyframes slidein-r { from { transform: translateX(16px); opacity: .6 } to { transform: none; opacity: 1 } }
|
||||
.drawer .sheethead {
|
||||
position: sticky; top: 0; z-index: 1; display: flex; align-items: center; justify-content: space-between;
|
||||
gap: var(--sp-3); padding: var(--sp-4) var(--sp-5); background: var(--s1); border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.drawer .sheethead h2 { font-family: var(--font-display); font-size: 17px; font-weight: 600; letter-spacing: -.015em; }
|
||||
.drawer .sheetbody { padding: var(--sp-5); }
|
||||
.drawer .close { background: none; border: 0; color: var(--ink-3); cursor: pointer; padding: 6px; border-radius: var(--r-sm); display: grid; place-items: center; }
|
||||
.drawer .close:hover { background: var(--s2); color: var(--ink); }
|
||||
|
||||
/* -------------------------------------------------------------- charts --- */
|
||||
.bars { display: flex; align-items: flex-end; gap: 3px; height: 150px; margin-top: 4px; }
|
||||
.bars .col { flex: 1; display: flex; flex-direction: column; justify-content: flex-end;
|
||||
gap: 2px; min-width: 0; }
|
||||
.bars .seg { border-radius: 2px 2px 0 0; }
|
||||
.bars .seg.ret { background: var(--accent); }
|
||||
.bars .seg.new { background: #2f6f81; }
|
||||
.axis { display: flex; justify-content: space-between; color: var(--muted);
|
||||
font-size: 10.5px; margin-top: 6px; font-family: var(--mono); }
|
||||
.key { display: flex; gap: 14px; font-size: 11.5px; color: var(--muted); margin-top: 10px; }
|
||||
.key i { display: inline-block; width: 9px; height: 9px; border-radius: 2px;
|
||||
margin-right: 5px; vertical-align: -1px; }
|
||||
.avatar {
|
||||
width: 44px; height: 44px; border-radius: var(--r-sm); flex: none; display: grid; place-items: center;
|
||||
overflow: hidden; background: var(--s3); border: 1px solid var(--line);
|
||||
font-weight: 600; color: var(--ink-2); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
/* --------------------------------------------------------------- drawer -- */
|
||||
.drawer { position: fixed; inset: 0; background: rgba(4,8,10,.6);
|
||||
display: flex; justify-content: flex-end; z-index: 30; }
|
||||
.drawer .panel { width: min(480px, 100%); height: 100%; background: var(--surface);
|
||||
border-left: 1px solid var(--line); overflow-y: auto; padding: 20px 22px 40px; }
|
||||
.drawer h3 { font-size: 16px; font-weight: 620; text-transform: none;
|
||||
letter-spacing: -.01em; color: var(--ink); margin-bottom: 2px; }
|
||||
/* Close lives in the sticky header (.who) now. Positioned against the fixed
|
||||
overlay it stayed put while the sheet scrolled underneath it, printing the
|
||||
button on top of whatever happened to be at the top of the viewport. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
|
||||
/* -- customer record ---------------------------------------------------- */
|
||||
/* Full-bleed sticky header: a customer record is long enough to scroll, and
|
||||
both the name and the way out have to stay reachable. The negative margins
|
||||
cancel the panel's padding so the background covers the full width. */
|
||||
.who { position: sticky; top: -20px; z-index: 1; display: flex; gap: 14px;
|
||||
align-items: flex-start; background: var(--surface);
|
||||
margin: -20px -22px 18px; padding: 20px 22px 14px;
|
||||
border-bottom: 1px solid var(--line-soft); }
|
||||
.who .grow { flex: 1; min-width: 0; }
|
||||
.who h3 { margin-bottom: 2px; }
|
||||
.avatar { width: 64px; height: 64px; border-radius: 10px; flex: none;
|
||||
object-fit: cover; background: var(--ground);
|
||||
border: 1px solid var(--line); }
|
||||
.avatar.none { display: grid; place-items: center; color: var(--muted);
|
||||
font-size: 20px; font-weight: 600; letter-spacing: .02em; }
|
||||
/* Cameras and arrivals side by side, the same height, each scrolling its own
|
||||
content. Left to itself the arrivals panel shrank to fit two cards and left
|
||||
a hole beside a tall camera tile - the layout looked broken precisely when
|
||||
the shop was quiet, which is most of the time. */
|
||||
.live-split {
|
||||
display: grid; gap: var(--sp-4);
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
min-height: 420px;
|
||||
}
|
||||
.live-split > .panel { max-height: 62vh; }
|
||||
@media (max-width: 1100px) {
|
||||
.live-split { grid-template-columns: minmax(0, 1fr); }
|
||||
.live-split > .panel { max-height: none; }
|
||||
}
|
||||
|
||||
.timeline { list-style: none; max-height: 220px; overflow-y: auto; }
|
||||
.timeline li { display: flex; gap: 10px; align-items: baseline; padding: 6px 0;
|
||||
border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
|
||||
.timeline li:last-child { border-bottom: 0; }
|
||||
.timeline .when { font-family: var(--mono); font-size: 11px; color: var(--muted);
|
||||
flex: none; min-width: 108px; }
|
||||
.timeline .where { flex: 1; min-width: 0; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* Visually separated from Save: this is the one control in the sheet that
|
||||
cannot be undone, and it must not read as just another button in a row. */
|
||||
.danger-zone { margin-top: 22px; border-color: #4a2823; }
|
||||
.danger-zone > h3 { color: var(--bad); }
|
||||
.danger-zone .note { margin-bottom: 10px; }
|
||||
|
||||
.confirm h4 { font-size: 13.5px; font-weight: 620; margin-bottom: 10px; }
|
||||
.confirm .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px;
|
||||
margin-bottom: 12px; }
|
||||
@media (max-width: 560px) { .confirm .cols { grid-template-columns: 1fr; } }
|
||||
.confirm .lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .07em;
|
||||
color: var(--muted); margin-bottom: 5px; }
|
||||
.confirm .lbl.bad { color: var(--bad); }
|
||||
.confirm ul { list-style: none; font-size: 12.5px; }
|
||||
.confirm li { padding: 3px 0 3px 12px; position: relative; color: var(--ink); }
|
||||
.confirm li::before { content: '·'; position: absolute; left: 2px;
|
||||
color: var(--muted); }
|
||||
.confirm .row { display: flex; gap: 8px; }
|
||||
/* Arrivals alone on the Live screen: one column, capped so a long day scrolls
|
||||
inside the panel rather than pushing the metrics off the bottom. */
|
||||
.arrivals-panel { max-height: 64vh; margin-bottom: var(--sp-4); }
|
||||
.arrivals-panel .arrivals { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: var(--sp-2); }
|
||||
|
||||
71
desktop/frontend/src/ui/icons.jsx
Normal file
71
desktop/frontend/src/ui/icons.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
// One icon set, drawn rather than typed.
|
||||
//
|
||||
// The navigation used to be text characters — ◉ ☺ ▢ — which render in whatever
|
||||
// the system decides, sit on the text baseline instead of optical centre, and
|
||||
// cannot take a stroke weight. On a shop PC that is the difference between
|
||||
// software somebody trusts with their customers and something that looks
|
||||
// improvised.
|
||||
//
|
||||
// All of these are 24-unit grid, 1.6 stroke, currentColor, no fill. That means
|
||||
// one icon works on every surface and in every state without a second copy.
|
||||
|
||||
const base = {
|
||||
width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none',
|
||||
stroke: 'currentColor', strokeWidth: 1.6,
|
||||
strokeLinecap: 'round', strokeLinejoin: 'round',
|
||||
'aria-hidden': 'true', focusable: 'false',
|
||||
}
|
||||
|
||||
function Svg({ size, children, ...rest }) {
|
||||
return <svg {...base} {...rest} width={size ?? base.width} height={size ?? base.height}>{children}</svg>
|
||||
}
|
||||
|
||||
export const Live = p => (
|
||||
<Svg {...p}><circle cx="12" cy="12" r="3.2" /><path d="M5.6 5.6a9 9 0 0 0 0 12.8M18.4 18.4a9 9 0 0 0 0-12.8" /></Svg>
|
||||
)
|
||||
export const People = p => (
|
||||
<Svg {...p}><circle cx="9" cy="8.5" r="3.2" /><path d="M2.8 19.5a6.4 6.4 0 0 1 12.4 0" /><path d="M16.5 6.2a3.2 3.2 0 0 1 0 6.1M18 19.5a6 6 0 0 0-1.6-4" /></Svg>
|
||||
)
|
||||
export const Camera = p => (
|
||||
<Svg {...p}><path d="M3 8.5h3.4L8 6h8l1.6 2.5H21v10.2H3z" /><circle cx="12" cy="13.2" r="3.1" /></Svg>
|
||||
)
|
||||
export const Search = p => (
|
||||
<Svg {...p}><circle cx="11" cy="11" r="6.4" /><path d="M15.8 15.8 20.5 20.5" /></Svg>
|
||||
)
|
||||
export const Plus = p => (<Svg {...p}><path d="M12 5.5v13M5.5 12h13" /></Svg>)
|
||||
export const Close = p => (<Svg {...p}><path d="M6.5 6.5l11 11M17.5 6.5l-11 11" /></Svg>)
|
||||
export const Check = p => (<Svg {...p}><path d="M5 12.8l4.4 4.2L19 7" /></Svg>)
|
||||
export const Play = p => (<Svg {...p}><path d="M8 5.6v12.8L18.5 12z" /></Svg>)
|
||||
export const Stop = p => (<Svg {...p}><rect x="7" y="7" width="10" height="10" rx="1.6" /></Svg>)
|
||||
export const Warning = p => (
|
||||
<Svg {...p}><path d="M12 4.6 21 19.4H3z" /><path d="M12 10v4.1" /><path d="M12 17.1v.01" /></Svg>
|
||||
)
|
||||
export const Signal = p => (
|
||||
<Svg {...p}><path d="M5 19.4v-4.2M10.3 19.4v-7.6M15.7 19.4v-11M21 19.4V4.6" /></Svg>
|
||||
)
|
||||
export const Cloud = p => (
|
||||
<Svg {...p}><path d="M7.2 18.4a4.2 4.2 0 0 1-.6-8.35A6.2 6.2 0 0 1 18.4 9a4.2 4.2 0 0 1 .3 9.4z" /></Svg>
|
||||
)
|
||||
export const CloudOff = p => (
|
||||
<Svg {...p}><path d="M7.2 18.4a4.2 4.2 0 0 1-.6-8.35 6.2 6.2 0 0 1 2-3.2M10.6 5.2A6.2 6.2 0 0 1 18.4 9a4.2 4.2 0 0 1 1.9 7.6" /><path d="M3.6 3.6l16.8 16.8" /></Svg>
|
||||
)
|
||||
export const Shield = p => (
|
||||
<Svg {...p}><path d="M12 3.8 19.4 6.6v5.2c0 4.2-3 7.4-7.4 8.4-4.4-1-7.4-4.2-7.4-8.4V6.6z" /></Svg>
|
||||
)
|
||||
export const Link = p => (
|
||||
<Svg {...p}><path d="M10.2 13.8a3.6 3.6 0 0 0 5.2 0l2.8-2.8a3.7 3.7 0 0 0-5.2-5.2l-1.3 1.3" /><path d="M13.8 10.2a3.6 3.6 0 0 0-5.2 0l-2.8 2.8a3.7 3.7 0 0 0 5.2 5.2l1.3-1.3" /></Svg>
|
||||
)
|
||||
export const Logout = p => (
|
||||
<Svg {...p}><path d="M14.4 7.6V5.4H4.6v13.2h9.8v-2.2" /><path d="M10 12h9.4M16.4 8.8 19.8 12l-3.4 3.2" /></Svg>
|
||||
)
|
||||
export const Back = p => (<Svg {...p}><path d="M14.6 5.6 8 12l6.6 6.4" /></Svg>)
|
||||
export const Chevron = p => (<Svg {...p}><path d="M9.4 5.6 16 12l-6.6 6.4" /></Svg>)
|
||||
export const Dot = p => (<Svg {...p}><circle cx="12" cy="12" r="4.5" fill="currentColor" stroke="none" /></Svg>)
|
||||
|
||||
// Drawn for the empty states rather than an apologetic sentence in grey.
|
||||
export const NoCamera = p => (
|
||||
<Svg {...p} strokeWidth="1.2"><path d="M3 8.5h3.4L8 6h8l1.6 2.5H21v10.2H3z" /><circle cx="12" cy="13.2" r="3.1" /><path d="M3.6 3.6l16.8 16.8" /></Svg>
|
||||
)
|
||||
export const NoFaces = p => (
|
||||
<Svg {...p} strokeWidth="1.2"><circle cx="12" cy="9" r="3.4" /><path d="M5.4 20a6.8 6.8 0 0 1 13.2 0" /></Svg>
|
||||
)
|
||||
@@ -1,192 +1,192 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../bridge.js'
|
||||
import { usePolled, fmtTime } from '../hooks.js'
|
||||
import * as Icon from '../ui/icons.jsx'
|
||||
|
||||
// What is happening right now. The first screen a shop manager opens, so it
|
||||
// answers "is it working" before it answers anything else.
|
||||
// The shop floor screen.
|
||||
//
|
||||
// Rebuilt around what somebody standing at the counter is actually here for:
|
||||
// WHO JUST WALKED IN. The previous version led with four large stat boxes and
|
||||
// left arrivals as a thin list of "person.seen" rows in the corner — the least
|
||||
// actionable content taking the most space, and the product's whole reason for
|
||||
// existing rendered as a log.
|
||||
//
|
||||
// Now: a status strip that answers "is this working" in one line, and arrivals
|
||||
// as cards big enough to recognise a customer from while looking up at them.
|
||||
// No camera picture here - the person at the counter is not watching CCTV,
|
||||
// and a live video tile costs CPU the recognition pipeline needs. The picture
|
||||
// lives on the Cameras screen, where it is a setup tool.
|
||||
export default function Live() {
|
||||
const { data, error } = usePolled(() => api.live(), 3000)
|
||||
const { data: pipe } = usePolled(() => api.pipelineStatus(), 5000)
|
||||
const cams = useCameraFeeds()
|
||||
|
||||
const cameras = data?.stats?.cameras ?? []
|
||||
const gallery = data?.stats?.gallery ?? {}
|
||||
const events = data?.events ?? []
|
||||
|
||||
// fraction_below_gate is the number that decides a site: what share of the
|
||||
// faces this camera saw were too poor to enrol. Surfaced here rather than
|
||||
// buried, because a high value looks exactly like "a quiet day".
|
||||
// faces this camera saw were too poor to enrol. Surfaced rather than buried,
|
||||
// because a high value looks exactly like "a quiet day".
|
||||
const worst = cameras.reduce((acc, c) => {
|
||||
const f = c?.pipeline?.best_quality?.fraction_below_gate
|
||||
return typeof f === 'number' && f > acc ? f : acc
|
||||
}, 0)
|
||||
const up = cameras.filter(c => c.connected).length
|
||||
|
||||
const arrivals = events.filter(e => e.type === 'person.new' || e.type === 'person.seen')
|
||||
const freshest = useFreshest(arrivals[0])
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header>
|
||||
<h2>Live</h2>
|
||||
<p>Cameras, recent detections, and whether this site is recognising people.</p>
|
||||
<p>Who is in the shop, and whether it is reaching head office.</p>
|
||||
</header>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
|
||||
<div className="grid cols-4" style={{ marginBottom: 16 }}>
|
||||
<Stat label="People known" value={gallery.identities ?? '—'} />
|
||||
<Stat label="Sightings" value={gallery.sightings ?? '—'} />
|
||||
<Stat label="Cameras live"
|
||||
value={`${cameras.filter(c => c.connected).length}/${cameras.length || 0}`} />
|
||||
<Stat label="Below quality gate"
|
||||
value={cameras.length ? `${Math.round(worst * 100)}%` : '—'}
|
||||
tone={worst > 0.5 ? 'bad' : worst > 0.2 ? 'warn' : 'ok'}
|
||||
sub={worst > 0.5 ? 'Most visitors are being missed — check camera placement'
|
||||
: 'Share of faces too poor to enrol'} />
|
||||
<PipelineStrip pipe={pipe} cameras={cameras} up={up} />
|
||||
|
||||
<div className="panel arrivals-panel">
|
||||
<div className="panelhead">
|
||||
<h3>Who just walked in</h3>
|
||||
{arrivals.length > 0 && <span className="note">{arrivals.length} today</span>}
|
||||
</div>
|
||||
<div className="panelbody flush">
|
||||
{arrivals.length === 0
|
||||
? <div className="empty">
|
||||
<Icon.NoFaces size={34} />
|
||||
<b>Nobody yet</b>
|
||||
<p>Customers appear here the moment a camera recognises a face.</p>
|
||||
</div>
|
||||
: <div className="arrivals">
|
||||
{arrivals.slice(0, 30).map((e, i) => (
|
||||
<Arrival key={`${e.ts}-${i}`} e={e} fresh={i === 0 && freshest} />
|
||||
))}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pipeline pipe={pipe} />
|
||||
|
||||
<div className="grid cols-2">
|
||||
<div>
|
||||
<div className="card">
|
||||
<h3>Cameras</h3>
|
||||
{cameras.length === 0
|
||||
? <div className="empty">No cameras yet. Add one in Cameras.</div>
|
||||
: <div className="feeds">
|
||||
{cameras.map(c => (
|
||||
<div className="feed" key={c.camera_id}>
|
||||
{cams[c.camera_id]
|
||||
? <img src={cams[c.camera_id]} alt={c.camera_id} />
|
||||
: <div style={{ aspectRatio: '16/9' }} />}
|
||||
<div className="cap">
|
||||
<span>{c.camera_id}</span>
|
||||
<span className={`pill ${c.connected ? 'ok' : 'bad'}`}>
|
||||
<i className={`dot ${c.connected ? 'ok' : 'bad'}`} />
|
||||
{c.connected ? 'live' : 'offline'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Recent detections</h3>
|
||||
{events.length === 0
|
||||
? <div className="empty">Nothing detected yet.</div>
|
||||
: <ul className="events">
|
||||
{events.map((e, i) => <EventRow key={i} e={e} />)}
|
||||
</ul>}
|
||||
</div>
|
||||
<div className="metrics" style={{ marginTop: 'var(--sp-4)' }}>
|
||||
<Metric k="People known" v={gallery.identities ?? '—'} />
|
||||
<Metric k="Sightings" v={gallery.sightings ?? '—'} />
|
||||
<Metric k="Cameras live" v={cameras.length ? `${up}/${cameras.length}` : '—'}
|
||||
tone={!cameras.length ? null : up === 0 ? 'bad' : up < cameras.length ? 'warn' : 'ok'} />
|
||||
<Metric k="Faces too poor to use"
|
||||
v={cameras.length ? `${Math.round(worst * 100)}%` : '—'}
|
||||
tone={worst > 0.5 ? 'bad' : worst > 0.2 ? 'warn' : 'ok'}
|
||||
s={worst > 0.5 ? 'Most visitors are being missed — move the camera'
|
||||
: 'Share of faces below the enrolment gate'} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Whether anything is actually reaching head office. Without this the app can
|
||||
// look perfectly healthy while every detection piles up on disk unsent — which
|
||||
// is exactly what it did before the bridge existed.
|
||||
function Pipeline({ pipe }) {
|
||||
// One customer, big enough to match against the person in front of you.
|
||||
function Arrival({ e, fresh }) {
|
||||
const isNew = e.type === 'person.new'
|
||||
const name = e.data?.label || 'Unrecognised'
|
||||
const bits = [e.data?.gender, e.data?.age ?? e.data?.age_range, e.data?.emotion].filter(Boolean)
|
||||
const sim = typeof e.data?.similarity === 'number' ? e.data.similarity : null
|
||||
|
||||
return (
|
||||
<article className={`arrival ${isNew ? 'is-new' : 'is-seen'} ${fresh ? 'fresh' : ''}`}>
|
||||
<div className="avatar">{avatarText(name)}</div>
|
||||
<div className="who">
|
||||
<div className="name">{name}</div>
|
||||
<div className="meta">
|
||||
{e.camera_id}
|
||||
{bits.length > 0 && <> · {bits.join(', ')}</>}
|
||||
{sim !== null && !isNew && <> · match {sim.toFixed(2)}</>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="right">
|
||||
<span className={`tag ${isNew ? 'new' : 'seen'}`}>{isNew ? 'new' : 'returning'}</span>
|
||||
<span className="when">{fmtTime(e.ts)}</span>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
// "Visitor 13" must show 13, not V1 — initials() would give the same two
|
||||
// characters to Visitor 10, 13 and 15, and read as the reference V-1 for a
|
||||
// fourth person. Found by looking at the screen, not by a test.
|
||||
function avatarText(name) {
|
||||
const auto = /^Visitor (\d+)$/.exec(String(name).trim())
|
||||
if (auto) return auto[1]
|
||||
const words = String(name).trim().split(/\s+/).filter(Boolean)
|
||||
if (!words.length) return '?'
|
||||
return (words[0][0] + (words[1]?.[0] ?? '')).toUpperCase()
|
||||
}
|
||||
|
||||
// One line, above everything, answering the question every other screen is a
|
||||
// detail of: is this shop working, and is anything leaving it.
|
||||
function PipelineStrip({ pipe, cameras, up }) {
|
||||
if (!pipe) return null
|
||||
// A PC set up on its own is not "not linked yet" — nothing is coming, and
|
||||
// saying so with an idle dot beside a count of zero reads as a fault.
|
||||
|
||||
// A PC set up on its own is not "not linked yet" — nothing is coming, and an
|
||||
// idle dot beside a count of zero reads as a fault.
|
||||
if (pipe.standalone) {
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 16, display: 'flex',
|
||||
gap: 10, alignItems: 'center' }}>
|
||||
<i className="dot ok" />
|
||||
<strong style={{ fontSize: 13 }}>Running on this PC only</strong>
|
||||
<span className="note">Recognition and customers stay here.</span>
|
||||
<div className="statusbar">
|
||||
<span className="item"><i className="dot ok" /><b>Running on this PC only</b></span>
|
||||
<span className="sep" />
|
||||
<span className="item note">Recognition and customers stay here.</span>
|
||||
<span className="grow" />
|
||||
<span className="item note"><Icon.Signal size={14} />{up} of {cameras.length} cameras</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const stuck = pipe.claimed && !pipe.broker_up
|
||||
const tone = !pipe.claimed ? 'idle' : pipe.broker_up ? 'ok' : 'bad'
|
||||
const text = !pipe.claimed ? 'Not linked to head office'
|
||||
: pipe.broker_up ? 'Sending to head office' : 'Offline — saving locally'
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 16, display: 'flex',
|
||||
gap: 22, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<i className={`dot ${!pipe.claimed ? 'idle' : pipe.broker_up ? 'ok' : 'bad'}`} />
|
||||
<strong style={{ fontSize: 13 }}>
|
||||
{!pipe.claimed ? 'Not linked to head office'
|
||||
: pipe.broker_up ? 'Sending to head office' : 'Offline — saving locally'}
|
||||
</strong>
|
||||
<div className="statusbar">
|
||||
<span className="item">
|
||||
{pipe.broker_up ? <Icon.Cloud size={15} /> : <Icon.CloudOff size={15} />}
|
||||
<i className={`dot ${tone}`} /><b>{text}</b>
|
||||
</span>
|
||||
<span className="note">{pipe.accepted} recorded today</span>
|
||||
<span className="sep" />
|
||||
<span className="item note">{pipe.accepted} recorded today</span>
|
||||
{pipe.queued > 0 && (
|
||||
<span className="note" style={stuck ? { color: 'var(--warn)' } : undefined}>
|
||||
{pipe.queued} waiting to send
|
||||
</span>
|
||||
<span className={`item note ${stuck ? 'warn' : ''}`}>{pipe.queued} waiting to send</span>
|
||||
)}
|
||||
{pipe.dropped > 0 && (
|
||||
<span className="note" style={{ color: 'var(--bad)' }}>
|
||||
{pipe.dropped} lost — this PC was offline too long
|
||||
</span>
|
||||
<span className="item note bad"><Icon.Warning size={14} />{pipe.dropped} lost — this PC was offline too long</span>
|
||||
)}
|
||||
<span className="grow" />
|
||||
<span className="item note"><Icon.Signal size={14} />{up} of {cameras.length} cameras</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventRow({ e }) {
|
||||
const cls = e.type === 'person.new' ? 'new'
|
||||
: e.type === 'person.seen' ? 'seen'
|
||||
: e.type === 'person.missed' ? 'miss' : ''
|
||||
const age = e.data?.age ?? e.data?.age_range
|
||||
const extra = [e.data?.gender, age, e.data?.emotion].filter(Boolean).join(', ')
|
||||
function Metric({ k, v, s, tone }) {
|
||||
return (
|
||||
<li>
|
||||
<span className="when">{fmtTime(e.ts)}</span>
|
||||
<span className={`tag ${cls}`}>{label(e.type)}</span>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
{e.data?.label || e.camera_id}
|
||||
{extra && <span className="note"> · {extra}</span>}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// The event names are internal; a shop manager should not have to learn them.
|
||||
function label(type) {
|
||||
return {
|
||||
'person.new': 'new',
|
||||
'person.seen': 'returning',
|
||||
'person.missed': 'missed',
|
||||
'camera.up': 'camera up',
|
||||
'camera.down': 'camera down',
|
||||
'identity.merged': 'merged',
|
||||
}[type] ?? type
|
||||
}
|
||||
|
||||
function Stat({ label, value, sub, tone }) {
|
||||
return (
|
||||
<div className="card stat">
|
||||
<h3>{label}</h3>
|
||||
<div className="value" style={tone ? { color: `var(--${tone})` } : undefined}>
|
||||
{value}
|
||||
</div>
|
||||
{sub && <div className="sub">{sub}</div>}
|
||||
<div className={`metric ${tone ?? ''}`}>
|
||||
<div className="metric-k">{k}</div>
|
||||
<div className="metric-v">{v}</div>
|
||||
{s && <div className="metric-s">{s}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Stream URLs are fetched once per camera and then left alone: reassigning an
|
||||
// MJPEG <img> src restarts the stream, so rebuilding them on every poll would
|
||||
// make every feed flicker permanently.
|
||||
function useCameraFeeds() {
|
||||
const [urls, setUrls] = useState({})
|
||||
const { data } = usePolled(() => api.cameras(), 10000)
|
||||
// True for a few seconds after a genuinely new arrival, so the top card can
|
||||
// announce itself once. Keyed on the timestamp rather than the array, which
|
||||
// changes identity on every poll.
|
||||
function useFreshest(top) {
|
||||
const [fresh, setFresh] = useState(false)
|
||||
const seen = useRef(null)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const next = {}
|
||||
for (const cam of data ?? []) {
|
||||
if (urls[cam.id]) { next[cam.id] = urls[cam.id]; continue }
|
||||
try { next[cam.id] = await api.streamURL(cam.id) } catch { /* engine down */ }
|
||||
}
|
||||
const changed = Object.keys(next).length !== Object.keys(urls).length ||
|
||||
Object.keys(next).some(k => next[k] !== urls[k])
|
||||
if (!cancelled && changed) setUrls(next)
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data])
|
||||
return urls
|
||||
if (!top || top.ts === seen.current) return
|
||||
const first = seen.current === null
|
||||
seen.current = top.ts
|
||||
if (first) return // do not flash the whole list on mount
|
||||
setFresh(true)
|
||||
const id = setTimeout(() => setFresh(false), 1200)
|
||||
return () => clearTimeout(id)
|
||||
}, [top?.ts])
|
||||
return fresh
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
import * as Icon from '../ui/icons.jsx'
|
||||
|
||||
// The gate. Nothing else in the app is reachable until this succeeds, because
|
||||
// the broker credentials and the customer database both live behind it.
|
||||
@@ -24,10 +25,11 @@ export default function Login({ onDone }) {
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="box">
|
||||
<span className="mark"><Icon.Shield size={20} /></span>
|
||||
<h1>Behavision</h1>
|
||||
<p className="lead">Sign in to connect this PC to your store.</p>
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className="err">{error}</div>}
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
<label className="field">
|
||||
<span>Email</span>
|
||||
<input type="email" value={email} autoComplete="username" required
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
import * as Icon from '../ui/icons.jsx'
|
||||
|
||||
// Linking this PC to a shop — the first thing that happens on a new install,
|
||||
// and until now the one thing the app could not do.
|
||||
@@ -41,12 +42,13 @@ export default function Setup({ onDone, onCancel }) {
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="box">
|
||||
<span className="mark"><Icon.Link size={20} /></span>
|
||||
<h1>{onCancel ? 'Link to head office' : 'Set up this PC'}</h1>
|
||||
<p className="lead">
|
||||
Type the installation code for this shop. You only do this once.
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className="err">{error}</div>}
|
||||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||||
<label className="field">
|
||||
<span>Installation code</span>
|
||||
{/* Uppercase and letter-spaced because the code arrives read aloud
|
||||
|
||||
@@ -27,10 +27,33 @@ func main() {
|
||||
app := NewApp()
|
||||
tray := newTray(app)
|
||||
|
||||
// One process per PC, enforced by the OS rather than by hoping.
|
||||
//
|
||||
// The window hides to the tray on close, so the ordinary next thing a shop
|
||||
// assistant does is double-click the desktop shortcut again to get it
|
||||
// back. Without this lock that started a SECOND complete copy: a second
|
||||
// tray icon, a second engine supervisor on the same SQLite WAL and the
|
||||
// same port - the "start twice" failure the agent package exists to
|
||||
// prevent, on the one binary that never had the guard. Seen on a Windows
|
||||
// install as a row of Behavision icons in the tray. A second launch now
|
||||
// only brings the existing window to the front, which is what the person
|
||||
// wanted in the first place.
|
||||
var ctxRef context.Context
|
||||
single := &options.SingleInstanceLock{
|
||||
UniqueId: "ai.loyaly.behavision.desktop",
|
||||
OnSecondInstanceLaunch: func(options.SecondInstanceData) {
|
||||
if ctxRef != nil {
|
||||
runtime.Show(ctxRef)
|
||||
runtime.WindowUnminimise(ctxRef)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
err := wails.Run(&options.App{
|
||||
Title: "Behavision",
|
||||
Width: 1280,
|
||||
Height: 820,
|
||||
SingleInstanceLock: single,
|
||||
Title: "Behavision",
|
||||
Width: 1280,
|
||||
Height: 820,
|
||||
// Small enough to still be usable on a cramped shop-counter monitor.
|
||||
MinWidth: 1024,
|
||||
MinHeight: 640,
|
||||
@@ -40,6 +63,7 @@ func main() {
|
||||
// is where they get the window back.
|
||||
HideWindowOnClose: true,
|
||||
OnStartup: func(ctx context.Context) {
|
||||
ctxRef = ctx
|
||||
app.startup(ctx)
|
||||
tray.start(ctx)
|
||||
},
|
||||
|
||||
577
docs/Behavision-Architecture.html
Normal file
577
docs/Behavision-Architecture.html
Normal file
@@ -0,0 +1,577 @@
|
||||
<title>Behavision Architecture</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700&family=Source+Serif+4:opsz,wght@8..60,400;8..60,600&family=IBM+Plex+Mono:wght@400;500&display=swap">
|
||||
|
||||
<style>
|
||||
:root{
|
||||
--paper:#f1f4f6;--surface:#fff;--surface-2:#e7ecef;--ink:#131b22;--ink-soft:#46545f;--ink-faint:#6d7d88;--rule:#d3dbe0;
|
||||
--acc:#12707e;--acc-ink:#0b4d57;--acc-bg:#dcedf0;--good:#2f7d55;--warn:#9a6413;--bad:#a8403c;--good-bg:#e2efe8;--warn-bg:#f5ecdc;
|
||||
--sans:"Archivo","Helvetica Neue",Arial,sans-serif;--serif:"Source Serif 4",Georgia,serif;--mono:"IBM Plex Mono",ui-monospace,Menlo,monospace;
|
||||
}
|
||||
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--paper:#0e141b;--surface:#161f28;--surface-2:#1d2833;--ink:#e6edf2;--ink-soft:#a7b6c1;--ink-faint:#7b8b97;--rule:#2a3742;--acc:#4fc3d6;--acc-ink:#9adfeb;--acc-bg:#13303a;--good:#6cc394;--warn:#d5a55c;--bad:#e0817c;--good-bg:#172c22;--warn-bg:#2e2617}}
|
||||
:root[data-theme="dark"]{--paper:#0e141b;--surface:#161f28;--surface-2:#1d2833;--ink:#e6edf2;--ink-soft:#a7b6c1;--ink-faint:#7b8b97;--rule:#2a3742;--acc:#4fc3d6;--acc-ink:#9adfeb;--acc-bg:#13303a;--good:#6cc394;--warn:#d5a55c;--bad:#e0817c;--good-bg:#172c22;--warn-bg:#2e2617}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--serif);font-size:1rem;line-height:1.55;-webkit-font-smoothing:antialiased}
|
||||
h1,h2,h3,.eyebrow,.nav,.legend,.facts,.tag,.metric{font-family:var(--sans)}
|
||||
h1{font-size:clamp(2.2rem,5vw,3.2rem);line-height:1.02;font-weight:700;letter-spacing:-.025em;margin:0;text-wrap:balance}
|
||||
h2{font-size:1.5rem;line-height:1.15;font-weight:600;letter-spacing:-.01em;margin:0;text-wrap:balance}
|
||||
p{margin:0} code{font-family:var(--mono);font-size:.88em;background:var(--surface-2);padding:.06em .35em;border-radius:2px}
|
||||
a{color:var(--acc)} a:focus-visible{outline:2px solid var(--acc);outline-offset:3px}
|
||||
.wrap{max-width:74rem;margin:0 auto;padding-inline:20px}
|
||||
.eyebrow{font-size:.8rem;text-transform:uppercase;letter-spacing:.14em;font-weight:600;color:var(--acc)}
|
||||
|
||||
header.mast{background:var(--surface);border-bottom:1px solid var(--rule)}
|
||||
header.mast .wrap{padding-block:clamp(2.5rem,6vw,4rem) clamp(1.5rem,4vw,2.5rem);display:flex;flex-direction:column;gap:1.1rem}
|
||||
.brand{display:flex;align-items:center;gap:.7rem;font-family:var(--sans);font-weight:600;letter-spacing:.16em;text-transform:uppercase;font-size:.8rem;color:var(--ink-faint)}
|
||||
.lens{width:1rem;height:1rem;border-radius:50%;border:2px solid var(--acc)}
|
||||
.sub{font-size:1.15rem;color:var(--ink-soft);max-width:38rem;line-height:1.45}
|
||||
.nav{display:flex;flex-wrap:wrap;gap:.35rem .9rem;font-size:.8rem;margin-top:.5rem}
|
||||
.nav a{text-decoration:none;color:var(--ink-faint)} .nav a:hover{color:var(--ink)} .nav .n{font-family:var(--mono);color:var(--acc);margin-right:.35rem}
|
||||
|
||||
/* legend */
|
||||
.legend{display:flex;flex-wrap:wrap;gap:.6rem 1.6rem;font-size:.8rem;color:var(--ink-soft);align-items:center}
|
||||
.legend span{display:inline-flex;align-items:center;gap:.45rem}
|
||||
.legend svg{width:34px;height:20px;display:block}
|
||||
|
||||
/* plates */
|
||||
.plate{padding-block:clamp(2.2rem,5vw,3.5rem);border-bottom:1px solid var(--rule)}
|
||||
.plate:last-of-type{border-bottom:0}
|
||||
.head{display:grid;grid-template-columns:3.2rem 1fr;gap:1rem;align-items:baseline;margin-bottom:1.2rem}
|
||||
.head .n{font-family:var(--mono);font-size:.95rem;color:var(--acc)}
|
||||
.head p{color:var(--ink-soft);margin-top:.35rem;max-width:42rem}
|
||||
.fig{background:var(--surface);border:1px solid var(--rule);padding:clamp(.8rem,2.2vw,1.4rem);overflow-x:auto}
|
||||
.fig svg{display:block;max-width:100%;height:auto;min-width:40rem}
|
||||
.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr));gap:.9rem 2rem;margin-top:1.1rem;font-size:.86rem;line-height:1.45}
|
||||
.facts div{display:grid;grid-template-columns:.7rem 1fr;gap:.6rem}
|
||||
.facts div::before{content:"";width:.5rem;height:.5rem;border-radius:1px;background:var(--acc);margin-top:.45rem}
|
||||
.facts b{font-weight:600}
|
||||
|
||||
/* svg semantics */
|
||||
.s{stroke:currentColor;stroke-width:1.5;fill:none}
|
||||
.sa{stroke:var(--acc);stroke-width:1.75;fill:none}
|
||||
.sd{stroke:currentColor;stroke-width:1.25;fill:none;stroke-dasharray:4 4;opacity:.75}
|
||||
.fa{fill:var(--acc)} .fab{fill:var(--acc-bg)} .fs{fill:var(--surface-2)} .fg{fill:var(--good)} .fw{fill:var(--warn)} .fb{fill:var(--bad)} .fgb{fill:var(--good-bg)} .fwb{fill:var(--warn-bg)}
|
||||
.t{font-family:var(--sans);font-size:12.5px;font-weight:600;fill:currentColor}
|
||||
.ta{font-family:var(--sans);font-size:12.5px;font-weight:600;fill:var(--acc)}
|
||||
.m{font-family:var(--mono);font-size:10.5px;fill:currentColor;opacity:.68}
|
||||
.ma{font-family:var(--mono);font-size:10.5px;fill:var(--acc)}
|
||||
.l{font-family:var(--sans);font-size:10.5px;fill:currentColor;opacity:.78}
|
||||
.la{font-family:var(--sans);font-size:10.5px;fill:var(--acc)}
|
||||
.z{font-family:var(--sans);font-size:11.5px;font-weight:600;letter-spacing:1.5px;fill:currentColor;opacity:.5}
|
||||
.cap{font-family:var(--serif);font-size:12.5px;fill:currentColor;opacity:.8}
|
||||
|
||||
.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule)}
|
||||
.metric{background:var(--surface);padding:1rem 1.05rem 1.1rem;display:flex;flex-direction:column;gap:.2rem}
|
||||
.metric .v{font-size:1.9rem;font-weight:700;line-height:1;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
|
||||
.metric .k{font-size:.8rem;color:var(--ink-faint);line-height:1.35}
|
||||
|
||||
footer{background:var(--surface);border-top:1px solid var(--rule);color:var(--ink-faint);font-size:.8rem}
|
||||
footer .wrap{padding-block:1.6rem 2.6rem}
|
||||
@media (max-width:40rem){.head{grid-template-columns:1fr;gap:.2rem}}
|
||||
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
||||
</style>
|
||||
|
||||
<!-- shared glyphs -->
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
||||
<defs>
|
||||
<marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="currentColor"/></marker>
|
||||
<marker id="aa" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="fa"/></marker>
|
||||
<symbol id="g-cam" viewBox="0 0 24 24"><path d="M3 8h4l2-3h6l2 3h4v11H3z" class="s"/><circle cx="12" cy="13" r="3.2" class="s"/></symbol>
|
||||
<symbol id="g-db" viewBox="0 0 24 24"><ellipse cx="12" cy="6" rx="8" ry="3" class="s"/><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6" class="s"/><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" class="s"/></symbol>
|
||||
<symbol id="g-pc" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="1" class="s"/><path d="M8 20h8M12 16v4" class="s"/></symbol>
|
||||
<symbol id="g-phone" viewBox="0 0 24 24"><rect x="7" y="2" width="10" height="20" rx="2" class="s"/><path d="M11 18h2" class="s"/></symbol>
|
||||
<symbol id="g-lock" viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="10" rx="1" class="s"/><path d="M8 10V7a4 4 0 0 1 8 0v3" class="s"/></symbol>
|
||||
<symbol id="g-file" viewBox="0 0 24 24"><path d="M6 2h8l5 5v15H6z" class="s"/><path d="M14 2v5h5" class="s"/></symbol>
|
||||
<symbol id="g-cloud" viewBox="0 0 24 24"><path d="M7 18a4 4 0 0 1-.6-7.95A6 6 0 0 1 18 9a4 4 0 0 1 0 9z" class="s"/></symbol>
|
||||
<symbol id="g-person" viewBox="0 0 24 24"><circle cx="12" cy="8" r="3.5" class="s"/><path d="M5 20a7 7 0 0 1 14 0" class="s"/></symbol>
|
||||
<symbol id="g-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3" class="s"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4L7 17M17 7l1.4-1.4" class="s"/></symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<header class="mast">
|
||||
<div class="wrap">
|
||||
<div class="brand"><span class="lens" aria-hidden="true"></span> Behavision · Technical Overview</div>
|
||||
<h1>Behavision Architecture</h1>
|
||||
<p class="sub">Face recognition for retail. An engine that sees, an agent that delivers, a platform that understands — in nine diagrams.</p>
|
||||
<nav class="nav" aria-label="Plates">
|
||||
<a href="#p1"><span class="n">01</span>System</a><a href="#p2"><span class="n">02</span>Shop PC</a><a href="#p3"><span class="n">03</span>Recognition</a><a href="#p4"><span class="n">04</span>Delivery</a><a href="#p5"><span class="n">05</span>Local & master data</a><a href="#p6"><span class="n">06</span>Clients & API</a><a href="#p7"><span class="n">07</span>Onboarding</a><a href="#p8"><span class="n">08</span>Secrets</a><a href="#p9"><span class="n">09</span>Stack & numbers</a>
|
||||
</nav>
|
||||
<div class="legend" aria-label="Diagram legend">
|
||||
<span><svg viewBox="0 0 34 20"><rect x="2" y="3" width="30" height="14" class="s"/></svg>process</span>
|
||||
<span><svg viewBox="0 0 34 20"><ellipse cx="17" cy="5" rx="11" ry="3" class="s"/><path d="M6 5v10c0 1.7 4.9 3 11 3s11-1.3 11-3V5" class="s"/></svg>store</span>
|
||||
<span><svg viewBox="0 0 34 20"><rect x="2" y="3" width="30" height="14" class="sa"/></svg>the path in focus</span>
|
||||
<span><svg viewBox="0 0 34 20"><line x1="2" y1="10" x2="30" y2="10" class="s" marker-end="url(#a)"/></svg>data</span>
|
||||
<span><svg viewBox="0 0 34 20"><line x1="2" y1="10" x2="30" y2="10" class="sd" marker-end="url(#a)"/></svg>control / pull</span>
|
||||
<span><svg viewBox="0 0 34 20"><line x1="17" y1="1" x2="17" y2="19" stroke="currentColor" stroke-width="1.5" stroke-dasharray="4 3" opacity=".5"/></svg>trust boundary</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="wrap">
|
||||
|
||||
<!-- ============================================================ 01 -->
|
||||
<section class="plate" id="p1">
|
||||
<div class="head"><span class="n">01</span><div><h2>The whole system</h2><p>Video stays inside the shop. Only visit records cross the boundary — and every connection across it is made from the inside, outward.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 520" role="img" aria-label="Cameras stream RTSP to a shop PC running the engine, the agent and the shop app. The agent publishes visits over TLS MQTT to Mosquitto in the cloud; an ingest consumer writes them to PostgreSQL; the API serves the head-office console, the mobile app and platform administration. The shop PC pulls camera settings and check jobs from the API. A dashed boundary marks the shop network, with no inbound route.">
|
||||
<text x="24" y="28" class="z">SHOP NETWORK</text><text x="520" y="28" class="z">LOYALY CLOUD</text><text x="880" y="28" class="z">PEOPLE</text>
|
||||
<line x1="486" y1="42" x2="486" y2="490" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 4" opacity=".45"/>
|
||||
<use href="#g-lock" x="474" y="492" width="24" height="24"/>
|
||||
<text x="486" y="510" text-anchor="middle" class="l" dy="8">outbound only</text>
|
||||
|
||||
<!-- cameras -->
|
||||
<use href="#g-cam" x="30" y="188" width="40" height="40"/>
|
||||
<use href="#g-cam" x="30" y="236" width="40" height="40"/>
|
||||
<text x="50" y="292" text-anchor="middle" class="m">RTSP</text>
|
||||
|
||||
<!-- shop pc -->
|
||||
<rect x="120" y="70" width="330" height="400" rx="3" class="s"/>
|
||||
<use href="#g-pc" x="134" y="82" width="22" height="22"/><text x="164" y="99" class="t">Shop PC</text>
|
||||
<rect x="150" y="122" width="270" height="78" rx="2" class="sa"/>
|
||||
<use href="#g-gear" x="162" y="134" width="20" height="20"/>
|
||||
<text x="190" y="148" class="ta">Recognition engine</text>
|
||||
<text x="190" y="166" class="m">Python · ONNX Runtime · FAISS</text>
|
||||
<text x="190" y="182" class="m">detect → track → identify</text>
|
||||
<use href="#g-db" x="384" y="160" width="26" height="26"/><text x="397" y="200" text-anchor="middle" class="m">SQLite</text>
|
||||
|
||||
<rect x="150" y="226" width="270" height="96" rx="2" class="s"/>
|
||||
<text x="164" y="248" class="t">Agent</text><text x="164" y="266" class="m">Go · supervisor · camera sync</text>
|
||||
<rect x="164" y="278" width="242" height="32" rx="2" class="fab"/>
|
||||
<use href="#g-file" x="172" y="284" width="20" height="20"/>
|
||||
<text x="200" y="299" class="ma">durable spool — one file per event</text>
|
||||
|
||||
<rect x="150" y="348" width="270" height="56" rx="2" class="s"/>
|
||||
<text x="164" y="370" class="t">Shop app</text><text x="164" y="388" class="m">Wails · window + system tray</text>
|
||||
<text x="285" y="440" text-anchor="middle" class="cap">runs with no internet;</text>
|
||||
<text x="285" y="456" text-anchor="middle" class="cap">the spool drains when it returns</text>
|
||||
|
||||
<line x1="76" y1="212" x2="148" y2="160" class="s" marker-end="url(#a)"/><text x="126" y="214" class="l">video</text>
|
||||
<line x1="285" y1="202" x2="285" y2="224" class="sa" marker-end="url(#aa)"/><text x="294" y="217" class="la">detections</text>
|
||||
<line x1="285" y1="324" x2="285" y2="346" class="s" marker-end="url(#a)"/>
|
||||
|
||||
<!-- broker -->
|
||||
<rect x="530" y="108" width="170" height="60" rx="2" class="s"/>
|
||||
<use href="#g-cloud" x="542" y="118" width="22" height="22"/><text x="572" y="133" class="t">Mosquitto</text><text x="572" y="151" class="m">MQTT · TLS · per-tenant ACL</text>
|
||||
|
||||
<!-- server -->
|
||||
<rect x="530" y="210" width="290" height="120" rx="2" class="s"/>
|
||||
<text x="544" y="232" class="t">Behavision server</text><text x="544" y="249" class="m">Go · one binary</text>
|
||||
<rect x="546" y="262" width="120" height="50" rx="2" class="s"/><text x="606" y="284" text-anchor="middle" class="t">ingest</text><text x="606" y="300" text-anchor="middle" class="m">dedupe · reinforce</text>
|
||||
<rect x="684" y="262" width="120" height="50" rx="2" class="s"/><text x="744" y="284" text-anchor="middle" class="t">API + web</text><text x="744" y="300" text-anchor="middle" class="m">48 routes · SSE</text>
|
||||
|
||||
<!-- postgres -->
|
||||
<use href="#g-db" x="656" y="388" width="40" height="40"/>
|
||||
<text x="676" y="450" text-anchor="middle" class="ta">PostgreSQL</text>
|
||||
<text x="676" y="466" text-anchor="middle" class="m">master database</text>
|
||||
|
||||
<!-- arrows cloud -->
|
||||
<path d="M422 294 L505 294 L505 138 L528 138" class="sa" marker-end="url(#aa)"/>
|
||||
<text x="462" y="284" text-anchor="middle" class="la">visits · QoS 1</text>
|
||||
<line x1="615" y1="170" x2="606" y2="260" class="s" marker-end="url(#a)"/>
|
||||
<line x1="606" y1="314" x2="668" y2="386" class="s" marker-end="url(#a)"/>
|
||||
<line x1="744" y1="314" x2="690" y2="386" class="s" marker-start="url(#a)" marker-end="url(#a)"/>
|
||||
<path d="M528 300 L470 300 L470 246 L424 246" class="sd" marker-end="url(#a)"/>
|
||||
<text x="470" y="322" text-anchor="middle" class="l">pull: cameras, checks</text>
|
||||
|
||||
<!-- people -->
|
||||
<rect x="880" y="96" width="196" height="56" rx="2" class="s"/><use href="#g-pc" x="892" y="106" width="22" height="22"/><text x="922" y="121" class="t">Head-office console</text><text x="922" y="138" class="m">owner · manager</text>
|
||||
<rect x="880" y="182" width="196" height="56" rx="2" class="s"/><use href="#g-phone" x="892" y="192" width="22" height="22"/><text x="922" y="207" class="t">Mobile app</text><text x="922" y="224" class="m">sales staff</text>
|
||||
<rect x="880" y="268" width="196" height="56" rx="2" class="s"/><use href="#g-person" x="892" y="278" width="22" height="22"/><text x="922" y="293" class="t">Platform admin</text><text x="922" y="310" class="m">creates merchants</text>
|
||||
<path d="M878 124 L846 124 L846 288 L822 288" class="s" marker-end="url(#a)"/>
|
||||
<path d="M878 210 L846 210" class="s"/>
|
||||
<path d="M878 296 L846 296" class="s"/>
|
||||
<text x="846" y="360" text-anchor="middle" class="l">https · session</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>Three tiers</b>, one direction of trust: the shop initiates every connection it has.</div>
|
||||
<div><b>One server binary</b> carries ingest, the API and the head-office web app.</div>
|
||||
<div><b>One API</b> for the console, the mobile app and the shop app alike.</div>
|
||||
<div><b>Offline is a delay, not a loss</b>: visits queue on disk until the broker confirms them.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 02 -->
|
||||
<section class="plate" id="p2">
|
||||
<div class="head"><span class="n">02</span><div><h2>Inside the shop PC</h2><p>Three processes on one machine, each in the language its job is best done in, sharing one state root.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 400" role="img" aria-label="On the shop PC: the engine (Python) captures RTSP, runs detection and recognition, and keeps a SQLite gallery with a FAISS index. The agent (Go) supervises the engine, receives detections on a loopback webhook, spools them, and syncs cameras with head office. The shop app (Wails) hosts the window and tray and embeds the agent as a library. All three read and write one state root under ProgramData.">
|
||||
<!-- engine -->
|
||||
<rect x="30" y="50" width="340" height="230" rx="3" class="sa"/>
|
||||
<text x="46" y="76" class="ta">Recognition engine</text><text x="46" y="93" class="m">Python 3.10+ · private venv</text>
|
||||
<rect x="46" y="112" width="140" height="42" rx="2" class="s"/><text x="116" y="130" text-anchor="middle" class="t">capture thread</text><text x="116" y="146" text-anchor="middle" class="m">per camera · latest frame</text>
|
||||
<rect x="214" y="112" width="140" height="42" rx="2" class="s"/><text x="284" y="130" text-anchor="middle" class="t">worker thread</text><text x="284" y="146" text-anchor="middle" class="m">per camera · one track = one person</text>
|
||||
<line x1="188" y1="133" x2="212" y2="133" class="s" marker-end="url(#a)"/>
|
||||
<rect x="46" y="176" width="308" height="42" rx="2" class="s"/><text x="200" y="194" text-anchor="middle" class="t">models · ONNX Runtime</text><text x="200" y="210" text-anchor="middle" class="m">YuNet · ArcFace r50 · genderage · CoreML / DirectML</text>
|
||||
<use href="#g-db" x="60" y="232" width="30" height="30"/><text x="104" y="246" class="t">SQLite gallery</text><text x="104" y="262" class="m">identities · embeddings · sightings</text>
|
||||
<rect x="250" y="232" width="104" height="34" rx="2" class="fab"/><text x="302" y="253" text-anchor="middle" class="ma">FAISS index</text>
|
||||
<line x1="196" y1="249" x2="248" y2="249" class="sd" marker-end="url(#a)"/><text x="222" y="243" text-anchor="middle" class="l">rebuilt at boot</text>
|
||||
<text x="200" y="300" text-anchor="middle" class="m">FastAPI on 127.0.0.1:8010 · Basic auth, credential generated on first start</text>
|
||||
|
||||
<!-- agent -->
|
||||
<rect x="430" y="50" width="300" height="230" rx="3" class="s"/>
|
||||
<text x="446" y="76" class="t">Agent — Go library</text><text x="446" y="93" class="m">agent/pkg · shared by app and headless agent</text>
|
||||
<rect x="446" y="112" width="130" height="40" rx="2" class="s"/><text x="511" y="130" text-anchor="middle" class="t">supervisor</text><text x="511" y="146" text-anchor="middle" class="m">start · restart · backoff</text>
|
||||
<rect x="586" y="112" width="130" height="40" rx="2" class="s"/><text x="651" y="130" text-anchor="middle" class="t">bridge</text><text x="651" y="146" text-anchor="middle" class="m">loopback webhook</text>
|
||||
<rect x="446" y="166" width="130" height="40" rx="2" class="fab"/><text x="511" y="184" text-anchor="middle" class="ma">spool</text><text x="511" y="200" text-anchor="middle" class="m">bounded · acked per event</text>
|
||||
<rect x="586" y="166" width="130" height="40" rx="2" class="s"/><text x="651" y="184" text-anchor="middle" class="t">pump</text><text x="651" y="200" text-anchor="middle" class="m">MQTT QoS 1 · TLS</text>
|
||||
<rect x="446" y="220" width="270" height="40" rx="2" class="s"/><text x="581" y="238" text-anchor="middle" class="t">camera reconciler</text><text x="581" y="254" text-anchor="middle" class="m">pulls desired state · runs placement checks</text>
|
||||
|
||||
<!-- app -->
|
||||
<rect x="790" y="50" width="280" height="230" rx="3" class="s"/>
|
||||
<text x="806" y="76" class="t">Shop app — Wails</text><text x="806" y="93" class="m">Go + React in the system webview · 12 MB</text>
|
||||
<rect x="806" y="112" width="248" height="40" rx="2" class="s"/><text x="930" y="130" text-anchor="middle" class="t">window</text><text x="930" y="146" text-anchor="middle" class="m">Live · Customers · Cameras</text>
|
||||
<rect x="806" y="166" width="248" height="40" rx="2" class="s"/><text x="930" y="184" text-anchor="middle" class="t">system tray</text><text x="930" y="200" text-anchor="middle" class="m">green / amber / red · start · stop · quit</text>
|
||||
<rect x="806" y="220" width="248" height="40" rx="2" class="s"/><text x="930" y="238" text-anchor="middle" class="t">camera relay</text><text x="930" y="254" text-anchor="middle" class="m">loopback · no credential in the page</text>
|
||||
|
||||
<!-- links -->
|
||||
<path d="M372 133 L428 133" class="s" marker-end="url(#a)"/><text x="400" y="126" text-anchor="middle" class="l">events</text>
|
||||
<path d="M428 186 L372 186" class="sd" marker-end="url(#a)"/><text x="400" y="204" text-anchor="middle" class="l">health · stats</text>
|
||||
<path d="M732 165 L788 165" class="s" marker-start="url(#a)" marker-end="url(#a)"/><text x="760" y="158" text-anchor="middle" class="l">embeds</text>
|
||||
|
||||
<!-- state root -->
|
||||
<rect x="30" y="316" width="1040" height="60" rx="3" class="fs"/>
|
||||
<text x="50" y="340" class="t">One state root — ProgramData\Behavision</text>
|
||||
<text x="50" y="360" class="m">data\behavision.db · data\cameras.json (DPAPI) · data\api_credentials.txt · models\ · runtime\ (the engine's Python) · agent.json · spool\</text>
|
||||
<path d="M200 282 L200 314" class="sd"/><path d="M580 282 L580 314" class="sd"/><path d="M930 282 L930 314" class="sd"/>
|
||||
<text x="1050" y="360" text-anchor="end" class="ma">BEHAVISION_DATA_DIR</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>Python</b> where the recognition ecosystem is — ONNX, OpenCV, FAISS are first-class.</div>
|
||||
<div><b>Go</b> for lifecycle and delivery — static binaries, cross-compiled to Windows from anywhere.</div>
|
||||
<div><b>Wails</b> for the UI — window, tray and supervisor in one process; a service cannot draw a tray icon.</div>
|
||||
<div><b>Exact search</b>: 100,000 identities in 21.9 ms. Identity is decided once per track, so this is queries per minute, not per frame.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 03 -->
|
||||
<section class="plate" id="p3">
|
||||
<div class="head"><span class="n">03</span><div><h2>Recognition: one decision per visit</h2><p>Frames become tracks; tracks accumulate evidence; a track is identified once. A "not sure" outcome is what stops one person becoming three, and a stranger becoming a regular.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 430" role="img" aria-label="Flow: frames from a camera are detected by YuNet, associated into tracks by IoU, scored for quality, aligned and embedded with ArcFace, averaged over at least three views, then compared to the gallery. Similarity at or above 0.42 is a known person; between 0.32 and 0.42 the system waits for a better view; below 0.32 the person is new and enrolled. Known matches at good quality reinforce the gallery.">
|
||||
<g class="t" text-anchor="middle">
|
||||
<rect x="20" y="60" width="120" height="66" rx="2" class="s"/><text x="80" y="88">Frames</text>
|
||||
<rect x="176" y="60" width="130" height="66" rx="2" class="s"/><text x="241" y="88">Detect</text>
|
||||
<rect x="342" y="60" width="130" height="66" rx="2" class="s"/><text x="407" y="88">Track</text>
|
||||
<rect x="508" y="60" width="130" height="66" rx="2" class="s"/><text x="573" y="88">Quality gate</text>
|
||||
<rect x="674" y="60" width="130" height="66" rx="2" class="s"/><text x="739" y="88">Align + embed</text>
|
||||
<rect x="840" y="60" width="130" height="66" rx="2" class="sa"/><text x="905" y="88" class="ta">Average ≥ 3</text>
|
||||
</g>
|
||||
<g class="m" text-anchor="middle">
|
||||
<text x="80" y="108">15 fps · latest frame</text>
|
||||
<text x="241" y="108">YuNet · 5 landmarks</text><text x="241" y="121">score ≥ 0.82</text>
|
||||
<text x="407" y="108">greedy IoU 0.3</text><text x="407" y="121">one track per person</text>
|
||||
<text x="573" y="108">sharp · size · light · frontal</text><text x="573" y="121">per-camera threshold</text>
|
||||
<text x="739" y="108">Umeyama → 112×112</text><text x="739" y="121">ArcFace r50 · 512-d</text>
|
||||
<text x="905" y="108">normalised mean</text><text x="905" y="121">≥ 4 hits</text>
|
||||
</g>
|
||||
<g class="s" marker-end="url(#a)"><line x1="142" y1="93" x2="174" y2="93"/><line x1="308" y1="93" x2="340" y2="93"/><line x1="474" y1="93" x2="506" y2="93"/><line x1="640" y1="93" x2="672" y2="93"/><line x1="806" y1="93" x2="838" y2="93"/></g>
|
||||
|
||||
<!-- decision -->
|
||||
<path d="M905 128 L905 176" class="sa" marker-end="url(#aa)"/>
|
||||
<path d="M905 180 L985 236 L905 292 L825 236 Z" class="sa"/>
|
||||
<text x="905" y="231" text-anchor="middle" class="ta">cosine vs</text><text x="905" y="246" text-anchor="middle" class="ta">gallery</text>
|
||||
|
||||
<!-- outcomes -->
|
||||
<path d="M825 236 L720 236" class="s" marker-end="url(#a)"/>
|
||||
<rect x="590" y="206" width="128" height="60" rx="2" class="fgb"/><text x="654" y="230" text-anchor="middle" class="t">known</text><text x="654" y="248" text-anchor="middle" class="m">≥ 0.42 · person.seen</text>
|
||||
<path d="M905 292 L905 330" class="s" marker-end="url(#a)"/>
|
||||
<rect x="841" y="334" width="128" height="60" rx="2" class="fwb"/><text x="905" y="358" text-anchor="middle" class="t">not sure</text><text x="905" y="376" text-anchor="middle" class="m">0.32 – 0.42 · retry ≤ 8×</text>
|
||||
<path d="M985 236 L1090 236" class="s" marker-end="url(#a)" style="display:none"/>
|
||||
<path d="M985 236 L1020 236 L1020 260" class="s" marker-end="url(#a)"/>
|
||||
<rect x="956" y="264" width="128" height="60" rx="2" class="fab"/><text x="1020" y="288" text-anchor="middle" class="t">new</text><text x="1020" y="306" text-anchor="middle" class="m">< 0.32 · enrol</text>
|
||||
|
||||
<!-- gallery + reinforcement -->
|
||||
<use href="#g-db" x="380" y="216" width="40" height="40"/>
|
||||
<text x="400" y="278" text-anchor="middle" class="t">Gallery</text><text x="400" y="294" text-anchor="middle" class="m">SQLite + FAISS · ≤ 5 views per person</text>
|
||||
<path d="M588 236 L426 236" class="sd" marker-end="url(#a)"/><text x="507" y="228" text-anchor="middle" class="l">reinforce: good quality, not a near-duplicate</text>
|
||||
<path d="M956 300 L940 300 L940 410 L400 410 L400 262" class="sd" marker-end="url(#a)"/><text x="670" y="403" text-anchor="middle" class="l">enrol as "Visitor N"</text>
|
||||
<path d="M400 214 L400 140 L905 140" class="sd" stroke-dasharray="2 3"/><text x="650" y="134" text-anchor="middle" class="l">index searched once per track</text>
|
||||
|
||||
<!-- retry loop -->
|
||||
<path d="M841 364 L780 364 L780 93" class="sd" marker-end="url(#a)"/><text x="720" y="380" text-anchor="middle" class="l">wait 0.5 s for a better frame</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>Never per frame.</b> Single-frame decisions turned one walk-past into three or four "people"; averaging fixed it.</div>
|
||||
<div><b>Model-tagged embeddings.</b> Only same-model vectors share an index; swapping encoders can never mix spaces.</div>
|
||||
<div><b>Quality is per camera, match is shared.</b> Every camera writes into one gallery.</div>
|
||||
<div><b>Measured:</b> 103 tracks → 7 people, 44 correct re-recognitions, in five minutes on the office camera.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 04 -->
|
||||
<section class="plate" id="p4">
|
||||
<div class="head"><span class="n">04</span><div><h2>Delivery: durable before published</h2><p>Nothing is removed from the shop's disk until the broker has confirmed it, and the server drops what it has already seen. That pair is what makes an outage a delay and not a hole.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 380" role="img" aria-label="Swimlanes for shop PC, broker and cloud. A visit event flows from engine to bridge, is appended to the spool and derived an event id, the pump is woken, publishes at QoS 1 over TLS to Mosquitto, the broker acknowledges, only then is the spool file deleted. The ingest consumer deduplicates on the event id, writes to PostgreSQL, and rings a doorbell that wakes live SSE streams to head office. When offline the pump retries with backoff and the spool grows on disk.">
|
||||
<text x="24" y="26" class="z">SHOP PC</text><text x="560" y="26" class="z">BROKER</text><text x="790" y="26" class="z">CLOUD</text>
|
||||
<line x1="536" y1="36" x2="536" y2="350" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 4" opacity=".45"/>
|
||||
<line x1="760" y1="36" x2="760" y2="350" stroke="currentColor" stroke-width="1" opacity=".25"/>
|
||||
|
||||
<g class="t" text-anchor="middle">
|
||||
<rect x="24" y="70" width="110" height="56" rx="2" class="s"/><text x="79" y="94">engine</text>
|
||||
<rect x="164" y="70" width="110" height="56" rx="2" class="s"/><text x="219" y="94">bridge</text>
|
||||
<rect x="304" y="70" width="110" height="56" rx="2" class="sa"/><text x="359" y="94" class="ta">spool</text>
|
||||
<rect x="24" y="200" width="110" height="56" rx="2" class="s"/><text x="79" y="224">waker</text>
|
||||
<rect x="304" y="200" width="110" height="56" rx="2" class="s"/><text x="359" y="224">pump</text>
|
||||
<rect x="580" y="130" width="130" height="66" rx="2" class="s"/><text x="645" y="158">Mosquitto</text>
|
||||
<rect x="790" y="130" width="120" height="66" rx="2" class="s"/><text x="850" y="158">ingest</text>
|
||||
<rect x="790" y="250" width="120" height="56" rx="2" class="s"/><text x="850" y="274">API · SSE</text>
|
||||
</g>
|
||||
<g class="m" text-anchor="middle">
|
||||
<text x="79" y="113">webhook POST</text>
|
||||
<text x="219" y="113">event_id = site·cam·id·sec</text>
|
||||
<text x="359" y="113">append → fsync</text>
|
||||
<text x="79" y="243">rung AFTER append</text>
|
||||
<text x="359" y="243">QoS 1 · in order</text>
|
||||
<text x="645" y="178">TLS 8883 · ACL by tenant</text>
|
||||
<text x="850" y="178">INSERT … ON CONFLICT</text>
|
||||
<text x="850" y="293">arrivals feed</text>
|
||||
</g>
|
||||
<use href="#g-db" x="1000" y="140" width="40" height="40"/><text x="1020" y="200" text-anchor="middle" class="t">PostgreSQL</text>
|
||||
<use href="#g-pc" x="1000" y="262" width="36" height="36"/><text x="1020" y="316" text-anchor="middle" class="m">head office</text>
|
||||
|
||||
<g class="s" marker-end="url(#a)">
|
||||
<line x1="136" y1="98" x2="162" y2="98"/><line x1="276" y1="98" x2="302" y2="98"/>
|
||||
<path d="M219 128 L219 228 L136 228" /><path d="M136 228 L302 228" style="display:none"/>
|
||||
<line x1="136" y1="228" x2="302" y2="228" />
|
||||
<path d="M416 228 L470 228 L470 163 L578 163" class="sa" marker-end="url(#aa)"/>
|
||||
<line x1="712" y1="163" x2="788" y2="163"/>
|
||||
<line x1="912" y1="163" x2="998" y2="163"/>
|
||||
<line x1="850" y1="198" x2="850" y2="248"/>
|
||||
<line x1="912" y1="278" x2="998" y2="278"/>
|
||||
</g>
|
||||
<text x="228" y="245" text-anchor="middle" class="l">wake</text>
|
||||
<text x="470" y="152" text-anchor="middle" class="la">publish</text>
|
||||
<text x="750" y="156" text-anchor="middle" class="l">deliver</text>
|
||||
<text x="955" y="156" text-anchor="middle" class="l">write</text>
|
||||
<text x="862" y="228" class="l">doorbell</text>
|
||||
<text x="955" y="271" text-anchor="middle" class="l">push</text>
|
||||
|
||||
<!-- ack path -->
|
||||
<path d="M645 198 L645 320 L359 320 L359 258" class="sa" stroke-dasharray="5 4" marker-end="url(#aa)"/>
|
||||
<text x="500" y="338" text-anchor="middle" class="la">PUBACK → delete the spool file. Never before.</text>
|
||||
|
||||
<!-- offline loop -->
|
||||
<path d="M304 240 L280 240 L280 300 L304 300" class="sd" style="display:none"/>
|
||||
<rect x="160" y="284" width="126" height="44" rx="2" class="fs"/>
|
||||
<text x="223" y="302" text-anchor="middle" class="l">offline?</text><text x="223" y="318" text-anchor="middle" class="m">backoff 1 → 30 s · spool grows</text>
|
||||
<path d="M302 244 L286 300" class="sd" marker-end="url(#a)"/>
|
||||
<path d="M286 306 L350 260" class="sd" style="display:none"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>QoS 1, clean session.</b> QoS 0 could delete an event the wire dropped; QoS 2 buys nothing the derived id doesn't already give.</div>
|
||||
<div><b>Ordered.</b> A failed publish stops the batch — a customer's visits are a timeline.</div>
|
||||
<div><b>Bounded and honest.</b> The spool has a cap and reports what it dropped; a corrupt entry is quarantined, never retried forever.</div>
|
||||
<div><b>Measured:</b> 120 simultaneous visits published, 120 delivered; end to end in ~3 s.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 05 -->
|
||||
<section class="plate" id="p5">
|
||||
<div class="head"><span class="n">05</span><div><h2>Local gallery, master database</h2><p>Two stores with two jobs. The shop PC's gallery recognises people in that shop, offline if need be. The platform's database knows the business: customers across shops, history, reports, tenancy.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 360" role="img" aria-label="Left: the shop PC's SQLite gallery with identities, model-tagged embeddings and sightings, plus a camera list. Right: PostgreSQL with clients, sites, users, visitors, visits, embeddings, cameras and sessions, every row carrying a client id. Between them: templates travel up with each visit; camera configuration and check jobs travel down; nothing else crosses.">
|
||||
<text x="24" y="26" class="z">SHOP PC</text><text x="640" y="26" class="z">PLATFORM</text>
|
||||
<line x1="540" y1="36" x2="540" y2="330" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 4" opacity=".45"/>
|
||||
|
||||
<use href="#g-db" x="60" y="60" width="56" height="56"/>
|
||||
<text x="140" y="80" class="ta">SQLite gallery</text>
|
||||
<text x="140" y="98" class="m">the only persistent state on the PC · WAL</text>
|
||||
<g class="m"><text x="140" y="124">identities Visitor N, label</text><text x="140" y="140">embeddings 512-d · tagged by model</text><text x="140" y="156">sightings identity × camera × time</text></g>
|
||||
<text x="140" y="184" class="t">FAISS index — rebuilt from SQLite at boot</text>
|
||||
<text x="140" y="200" class="m">exact inner product · numpy fallback identical</text>
|
||||
|
||||
<use href="#g-file" x="60" y="230" width="44" height="44"/>
|
||||
<text x="140" y="250" class="t">cameras.json</text><text x="140" y="266" class="m">passwords DPAPI-encrypted · machine-bound</text>
|
||||
<text x="140" y="296" class="t">agent.json</text><text x="140" y="312" class="m">broker login · agent token · sealed at rest</text>
|
||||
|
||||
<use href="#g-db" x="590" y="60" width="56" height="56"/>
|
||||
<text x="670" y="80" class="ta">PostgreSQL</text>
|
||||
<text x="670" y="98" class="m">every table carries client_id · self-migrating schema · 13 migrations</text>
|
||||
<g class="m">
|
||||
<text x="670" y="124">clients slug = MQTT topic prefix</text>
|
||||
<text x="670" y="140">sites · agents slug · tz · heartbeat · fraction_below_gate</text>
|
||||
<text x="670" y="156">app_users owner · manager · staff · bcrypt</text>
|
||||
<text x="670" y="172">visitors number → V-42 · per tenant</text>
|
||||
<text x="670" y="188">visits seq (feed cursor) · source_event_id (dedupe)</text>
|
||||
<text x="670" y="204">visitor_embeddings ≤ 5 · reinforced server-side</text>
|
||||
<text x="670" y="220">site_cameras password sealed AES-GCM, aad = site</text>
|
||||
<text x="670" y="236">sessions SHA-256 of tokens · revocable</text>
|
||||
<text x="670" y="252">visit_faces · camera_snapshots · audit_log</text>
|
||||
</g>
|
||||
<text x="670" y="290" class="t">Object storage (optional)</text><text x="670" y="306" class="m">presigned PUT from the shop PC · presigned GET for staff · private ACL in the signature</text>
|
||||
|
||||
<!-- flows across -->
|
||||
<path d="M420 120 L660 120" style="display:none"/>
|
||||
<path d="M380 210 L528 210 L528 190 L556 190" class="sa" marker-end="url(#aa)" style="display:none"/>
|
||||
<path d="M400 216 L520 216" class="sa" marker-end="url(#aa)"/><text x="460" y="208" text-anchor="middle" class="la">visit + template ↑</text>
|
||||
<path d="M520 244 L400 244" class="sd" marker-end="url(#a)"/><text x="460" y="262" text-anchor="middle" class="l">cameras · checks ↓</text>
|
||||
<path d="M400 290 L520 290" class="sd" marker-end="url(#a)" opacity=".5"/><text x="460" y="308" text-anchor="middle" class="l">heartbeat · health ↑</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>Video, frames and raw images never cross.</b> A template and a timestamp do.</div>
|
||||
<div><b>Tenancy is a column and a rule</b>, and the two agree: the tenant comes from the session, never from the request.</div>
|
||||
<div><b>References are immutable</b> — slugs, camera ids, customer numbers — because other systems store them. Display names are free to change.</div>
|
||||
<div><b>Feed by <code>seq</code></b>, never by the camera's clock: lossless under bursts and backlogs; cursors are opaque.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 06 -->
|
||||
<section class="plate" id="p6">
|
||||
<div class="head"><span class="n">06</span><div><h2>Clients and the API</h2><p>Three kinds of people and one kind of machine, all through one API. Sessions are opaque tokens in a table, so "log that device out, now" actually works.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 400" role="img" aria-label="Head-office console, mobile app and shop app sign in with email and password and receive an opaque session; the tenant and role come from that session. They call the API's visits, visitors, sites, cameras, reports, team and assistant routes. The shop PC's agent uses its own token, issued at enrolment, against the agent routes only. Live camera video reaches head office through the agent's outbound relay.">
|
||||
<g class="t">
|
||||
<rect x="24" y="50" width="200" height="60" rx="2" class="s"/><use href="#g-pc" x="36" y="62" width="22" height="22"/><text x="66" y="77">Head-office console</text><text x="66" y="95" class="m">React · embedded in server</text>
|
||||
<rect x="24" y="130" width="200" height="60" rx="2" class="s"/><use href="#g-phone" x="36" y="142" width="22" height="22"/><text x="66" y="157">Mobile app</text><text x="66" y="175" class="m">arrivals · customers · sales</text>
|
||||
<rect x="24" y="210" width="200" height="60" rx="2" class="s"/><use href="#g-pc" x="36" y="222" width="22" height="22"/><text x="66" y="237">Shop app</text><text x="66" y="255" class="m">engine on loopback · cloud for the rest</text>
|
||||
<rect x="24" y="300" width="200" height="60" rx="2" class="sa"/><use href="#g-gear" x="36" y="312" width="22" height="22"/><text x="66" y="327" class="ta">Shop PC agent</text><text x="66" y="345" class="m">token issued once at enrolment</text>
|
||||
</g>
|
||||
|
||||
<rect x="300" y="50" width="220" height="220" rx="2" class="sa"/>
|
||||
<text x="316" y="76" class="ta">Session</text>
|
||||
<g class="m"><text x="316" y="100">256-bit opaque token</text><text x="316" y="116">stored as SHA-256 only</text><text x="316" y="132">refresh rotates in place</text><text x="316" y="148">revocable per device, instantly</text></g>
|
||||
<rect x="316" y="166" width="188" height="88" rx="2" class="fs"/>
|
||||
<text x="330" y="186" class="t">tenant ← session.client_id</text>
|
||||
<text x="330" y="206" class="m">staff arrivals · customers · sales</text>
|
||||
<text x="330" y="222" class="m">manager + cameras · team · erasure</text>
|
||||
<text x="330" y="238" class="m">owner + mint owners</text>
|
||||
|
||||
<rect x="600" y="50" width="476" height="310" rx="2" class="s"/>
|
||||
<text x="616" y="76" class="t">/api</text>
|
||||
<g class="m">
|
||||
<text x="616" y="104">/auth/login · refresh · sessions · register</text>
|
||||
<text x="616" y="124">/visits · /visits/stream ·································· SSE, cursor</text>
|
||||
<text x="616" y="144">/visitors · /history · /profile · /image · /purchases</text>
|
||||
<text x="616" y="164">/sites · /sites/{s}/check · /enrolment-code</text>
|
||||
<text x="616" y="184">/cameras · /check · /snapshot.jpg · /live ······· SSE relay</text>
|
||||
<text x="616" y="204">/reports/footfall · /reports/conversion</text>
|
||||
<text x="616" y="224">/team · /team/members · /team/{id}/password · /invitations</text>
|
||||
<text x="616" y="244">/assistant ··································· tools, never SQL</text>
|
||||
<text x="616" y="264">/admin/clients ····························· platform admin only</text>
|
||||
</g>
|
||||
<rect x="616" y="284" width="444" height="56" rx="2" class="fab"/>
|
||||
<text x="630" y="306" class="ma">/agent/* — enrol · cameras · checks · faces · upload-url · live</text>
|
||||
<text x="630" y="326" class="m">agent token only · a user session is refused</text>
|
||||
|
||||
<g class="s" marker-end="url(#a)"><line x1="226" y1="80" x2="298" y2="80"/><line x1="226" y1="160" x2="298" y2="160"/><line x1="226" y1="240" x2="298" y2="240"/><line x1="522" y1="160" x2="598" y2="160"/></g>
|
||||
<path d="M226 330 L560 330 L560 312 L614 312" class="sa" marker-end="url(#aa)"/>
|
||||
<text x="262" y="72" class="l">email + password</text>
|
||||
<text x="1090" y="385" text-anchor="end" class="cap">Ids accept names: /api/visitors/V-42 · ?site=chennai · /api/cameras/cam1 — a uuid still works everywhere.</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>Login is boring on purpose.</b> Unknown address and wrong password are byte-identical and cost the same time.</div>
|
||||
<div><b>Another tenant's data is 404</b>, never 403 — nothing to enumerate.</div>
|
||||
<div><b>Live video</b> at head office: the agent pushes ~13 fps only while someone watches. 259 KB/s measured.</div>
|
||||
<div><b>The assistant</b> answers from the same report tools, as the signed-in user; it has no tenant parameter to misuse.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 07 -->
|
||||
<section class="plate" id="p7">
|
||||
<div class="head"><span class="n">07</span><div><h2>Onboarding: each tier creates the next</h2><p>No credential ships inside an installer, and nobody creates their own account from nothing.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 300" role="img" aria-label="Sequence: the platform admin creates a merchant and its owner, receiving a one-time password. The owner creates sales staff, receiving a one-time password, or issues an invitation code. Staff sign in on the mobile app. The owner mints a single-use installation code; the shop PC redeems it and receives its broker login and API token. Head office then pushes the shop's cameras to the PC.">
|
||||
<g class="t" text-anchor="middle">
|
||||
<use href="#g-person" x="60" y="40" width="40" height="40"/><text x="80" y="100">Platform admin</text>
|
||||
<use href="#g-person" x="300" y="40" width="40" height="40"/><text x="320" y="100">Merchant owner</text>
|
||||
<use href="#g-phone" x="540" y="40" width="40" height="40"/><text x="560" y="100">Sales staff</text>
|
||||
<use href="#g-pc" x="780" y="40" width="40" height="40"/><text x="800" y="100">Shop PC</text>
|
||||
<use href="#g-cam" x="1000" y="40" width="40" height="40"/><text x="1020" y="100">Cameras</text>
|
||||
</g>
|
||||
<g class="s" marker-end="url(#a)">
|
||||
<line x1="120" y1="140" x2="278" y2="140"/>
|
||||
<line x1="360" y1="140" x2="518" y2="140"/>
|
||||
<line x1="360" y1="200" x2="758" y2="200"/>
|
||||
<line x1="840" y1="200" x2="998" y2="200"/>
|
||||
</g>
|
||||
<path d="M840 240 L1000 240" class="sd" marker-end="url(#a)"/>
|
||||
<g class="m" text-anchor="middle">
|
||||
<text x="199" y="130">POST /api/admin/clients</text><text x="199" y="158">company + owner, one transaction</text><text x="199" y="172" class="ma" opacity="1">owner password, shown once</text>
|
||||
<text x="439" y="130">POST /api/team/members</text><text x="439" y="158">or /team/invitations → a code they redeem</text><text x="439" y="172" class="ma" opacity="1">staff password, shown once</text>
|
||||
<text x="559" y="190">POST /api/sites/{shop}/enrolment-code</text><text x="559" y="218">single use · 7 days · redeemed by the PC:</text><text x="559" y="232" class="ma" opacity="1">broker login + agent token + CA to pin</text>
|
||||
<text x="919" y="190">head office pushes cameras</text><text x="919" y="230">the PC pulls · adopts local ones up</text><text x="919" y="258">passwords travel only to that site's agent</text>
|
||||
</g>
|
||||
<text x="24" y="288" class="cap">Single use is enforced by the UPDATE itself, so two PCs racing on one code cannot both win. A wrong, spent or expired code all read the same.</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="facts">
|
||||
<div><b>Direct or by invitation.</b> A manager can hand over a generated password, or let the salesperson choose their own via a code.</div>
|
||||
<div><b>Reset signs the lost phone out</b> in the same transaction as the new password.</div>
|
||||
<div><b>Standalone</b> is a first-class answer on the setup screen: a single-till shop with no head office runs the full product locally.</div>
|
||||
<div><b>Demo build:</b> cameras ship sealed (AES-256-GCM); the unlock code travels separately from the zip.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 08 -->
|
||||
<section class="plate" id="p8">
|
||||
<div class="head"><span class="n">08</span><div><h2>Where every secret lives</h2><p>Biometric data is treated as biometric data. Each credential has one home and one protection, and none of them is ever returned by an API.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 330" role="img" aria-label="Map of secrets: on the shop PC, camera passwords under DPAPI, the engine API credential in a 0600 file, agent token and broker password sealed in agent.json; in the database, site broker passwords and camera passwords under AES-256-GCM with the site as additional data, user passwords under bcrypt cost 12, session tokens as SHA-256; in transit, TLS for MQTT and HTTPS for the API; face templates never leave the shop as images, photos are opt-in, erasure deletes templates and objects, every image read is audited.">
|
||||
<text x="24" y="26" class="z">SHOP PC</text><text x="400" y="26" class="z">IN TRANSIT</text><text x="640" y="26" class="z">DATABASE</text><text x="900" y="26" class="z">POLICY</text>
|
||||
<line x1="376" y1="36" x2="376" y2="300" stroke="currentColor" stroke-width="1" opacity=".25"/>
|
||||
<line x1="616" y1="36" x2="616" y2="300" stroke="currentColor" stroke-width="1" opacity=".25"/>
|
||||
<line x1="876" y1="36" x2="876" y2="300" stroke="currentColor" stroke-width="1" opacity=".25"/>
|
||||
<g class="t"><use href="#g-lock" x="24" y="52" width="20" height="20"/><text x="52" y="67">camera passwords</text><text x="52" y="84" class="m">DPAPI · machine-bound · has_password only</text>
|
||||
<use href="#g-lock" x="24" y="110" width="20" height="20"/><text x="52" y="125">engine API credential</text><text x="52" y="142" class="m">generated on first start · 0600 · read by the agent</text>
|
||||
<use href="#g-lock" x="24" y="168" width="20" height="20"/><text x="52" y="183">agent token · broker password</text><text x="52" y="200" class="m">sealed in agent.json · earned by enrolment, never shipped</text>
|
||||
<use href="#g-lock" x="24" y="226" width="20" height="20"/><text x="52" y="241">face templates</text><text x="52" y="258" class="m">SQLite · treated as personal data · erasure deletes outright</text>
|
||||
</g>
|
||||
<g class="t"><text x="400" y="67">MQTT</text><text x="400" y="84" class="m">TLS 8883 · pinned issuer · plaintext to any non-loopback host is refused</text>
|
||||
<text x="400" y="125">API</text><text x="400" y="142" class="m">HTTPS behind Traefik · bearer sessions</text>
|
||||
<text x="400" y="183">images</text><text x="400" y="200" class="m">presigned URLs, minutes-long · private ACL inside the signature</text>
|
||||
<text x="400" y="241">shop PC ↔ engine</text><text x="400" y="258" class="m">loopback only · relay token per run, no password in the page</text>
|
||||
</g>
|
||||
<g class="t"><text x="640" y="67">broker + camera passwords</text><text x="640" y="84" class="m">AES-256-GCM · aad = owning site · row copies don't decrypt</text>
|
||||
<text x="640" y="125">user passwords</text><text x="640" y="142" class="m">bcrypt cost 12 · 10 failures / 15 min per account</text>
|
||||
<text x="640" y="183">session tokens</text><text x="640" y="200" class="m">SHA-256 only · a dump holds no usable session</text>
|
||||
<text x="640" y="241">audit_log</text><text x="640" y="258" class="m">every face-image hand-out, every code minted, every merchant created</text>
|
||||
</g>
|
||||
<g class="t"><text x="900" y="67">video never leaves</text><text x="900" y="84" class="m">recognition runs in the shop</text>
|
||||
<text x="900" y="125">photos are opt-in</text><text x="900" y="142" class="m">store_faces defaults to off</text>
|
||||
<text x="900" y="183">tenancy is structural</text><text x="900" y="200" class="m">session decides · 404, never 403</text>
|
||||
<text x="900" y="241">erasure erases</text><text x="900" y="258" class="m">object first · 502 changes nothing</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ 09 -->
|
||||
<section class="plate" id="p9">
|
||||
<div class="head"><span class="n">09</span><div><h2>The stack, and the numbers behind it</h2><p>Each layer, the choice, and the one reason that decided it.</p></div></div>
|
||||
<div class="fig">
|
||||
<svg viewBox="0 0 1100 330" role="img" aria-label="Layer stack: clients (React console, mobile, Wails app); API and web (Go, one binary, opaque sessions); transport (Mosquitto MQTT, QoS 1, TLS); master data (PostgreSQL, self-migrating); shop agent (Go library: spool, pump, supervisor); recognition (Python: YuNet, ArcFace r50 on ONNX Runtime, FAISS, SQLite); cameras (any RTSP). Each with its deciding reason.">
|
||||
<g class="t">
|
||||
<rect x="24" y="30" width="1052" height="38" rx="2" class="s"/><text x="40" y="54">Clients</text><text x="200" y="54" class="m">React console (embedded) · mobile app · Wails shop app</text><text x="1060" y="54" text-anchor="end" class="l">one API; UI can never lag its server</text>
|
||||
<rect x="24" y="74" width="1052" height="38" rx="2" class="s"/><text x="40" y="98">API + web</text><text x="200" y="98" class="m">Go · one binary · opaque sessions in a table</text><text x="1060" y="98" text-anchor="end" class="l">instant per-device revocation; JWTs cannot</text>
|
||||
<rect x="24" y="118" width="1052" height="38" rx="2" class="s"/><text x="40" y="142">Transport</text><text x="200" y="142" class="m">Mosquitto · MQTT QoS 1 · TLS · per-tenant ACL</text><text x="1060" y="142" text-anchor="end" class="l">built for many outbound clients; ~10 MB</text>
|
||||
<rect x="24" y="162" width="1052" height="38" rx="2" class="s"/><text x="40" y="186">Master data</text><text x="200" y="186" class="m">PostgreSQL · self-applying migrations · advisory lock · checksums</text><text x="1060" y="186" text-anchor="end" class="l">transactions across tenant + owner; keyset feeds</text>
|
||||
<rect x="24" y="206" width="1052" height="38" rx="2" class="s"/><text x="40" y="230">Shop agent</text><text x="200" y="230" class="m">Go library · spool · pump · supervisor · reconciler</text><text x="1060" y="230" text-anchor="end" class="l">static binary, cross-compiled; one tested implementation</text>
|
||||
<rect x="24" y="250" width="1052" height="38" rx="2" class="sa"/><text x="40" y="274" class="ta">Recognition</text><text x="200" y="274" class="m">Python · YuNet · ArcFace r50 (ONNX Runtime) · FAISS IndexFlatIP · SQLite WAL</text><text x="1060" y="274" text-anchor="end" class="la">97.25 IJB-C · exact search · no second process</text>
|
||||
<rect x="24" y="294" width="1052" height="30" rx="2" class="fs"/><text x="40" y="314">Cameras</text><text x="200" y="314" class="m">any RTSP camera · make picker fills the stream path · placement proved by a 25-second walk-past</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="metrics" style="margin-top:1.1rem">
|
||||
<div class="metric"><span class="v">103 → 7</span><span class="k">tracks to people, 5 min, office camera — 44 re-recognitions</span></div>
|
||||
<div class="metric"><span class="v">120 / 120</span><span class="k">simultaneous visits delivered, real broker and database</span></div>
|
||||
<div class="metric"><span class="v">21.9 ms</span><span class="k">exact search over 100,000 identities</span></div>
|
||||
<div class="metric"><span class="v">~3 s</span><span class="k">camera to head-office feed</span></div>
|
||||
<div class="metric"><span class="v">14 fps</span><span class="k">live picture on the shop PC vs a 15 fps camera</span></div>
|
||||
<div class="metric"><span class="v">10 / 10</span><span class="k">install steps on a clean machine, both cameras connected</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="wrap">Behavision — Loyaly · Technical overview, 11 September 2026 · release 0.4.1 · engine 1.1.0 · schema at migration 13. All figures measured on the running system.</div>
|
||||
</footer>
|
||||
69
release.sh
Executable file
69
release.sh
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the Windows shop-PC package and publish it as a Gitea release.
|
||||
#
|
||||
# ./release.sh v0.4.2 build dist/Behavision-v0.4.2-windows-x64.zip and publish
|
||||
# PUBLISH=0 ./release.sh v0.4.2 build only
|
||||
#
|
||||
# The package is a SOURCE install: the Go binaries are cross-compiled here, the
|
||||
# engine ships as a pure-Python wheel and behavision-setup.exe builds a venv on
|
||||
# the shop PC. PyInstaller cannot cross-compile, so a frozen engine needs a
|
||||
# Windows build machine we do not have; this is what lets a release happen
|
||||
# from this Mac at all. Layout matches what behavision-setup expects and what
|
||||
# INSTALL.txt describes.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
export PATH="$PATH:$HOME/go/bin:/opt/homebrew/bin"
|
||||
|
||||
TAG=${1:?usage: release.sh vX.Y.Z}
|
||||
REPO_API=https://gitapp.workolik.com/api/v1/repos/Loyaly/Behavision
|
||||
STAGE=dist/Behavision
|
||||
ZIP="dist/Behavision-$TAG-windows-x64.zip"
|
||||
|
||||
step() { printf '\n\033[1m%s\033[0m\n' "$*"; }
|
||||
case "$(git describe --tags --always --dirty)" in *-dirty) echo "refusing to release uncommitted changes" >&2; exit 1;; esac
|
||||
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
||||
[ "$(git rev-parse "$TAG^{}")" = "$(git rev-parse HEAD)" ] || { echo "$TAG exists and is not HEAD" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
step "1. Desktop app (Wails, pure-Go Windows target)"
|
||||
(cd desktop/frontend && npm run build >/dev/null)
|
||||
rm -rf "$STAGE" && mkdir -p "$STAGE/engine-src"
|
||||
(cd desktop && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath \
|
||||
-ldflags "-H windowsgui -s -w -X main.version=$TAG" -o "../$STAGE/Behavision.exe" .)
|
||||
|
||||
step "2. Agent and setup tool"
|
||||
(cd agent && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=$TAG" -o "../$STAGE/behavision-agent.exe" . \
|
||||
&& CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=$TAG" -o "../$STAGE/behavision-setup.exe" ./cmd/behavision-setup)
|
||||
|
||||
step "3. Engine source and wheel"
|
||||
# The wheel is built with the checkout's own interpreter; requires-python is a
|
||||
# statement about the SHOP PC, which setup enforces when it finds Python there.
|
||||
.venv/bin/python -m pip wheel --no-deps --ignore-requires-python -q -w "$STAGE/engine-src" . 2>&1 | grep -v "DEPRECATION\|WARNING: Ignoring" || true
|
||||
ls "$STAGE"/engine-src/behavision-*.whl >/dev/null || { echo "wheel was not built" >&2; exit 1; }
|
||||
cp pyproject.toml requirements.txt "$STAGE/engine-src/"
|
||||
mkdir -p "$STAGE/engine-src/config" && cp config/default.yaml "$STAGE/engine-src/config/"
|
||||
rsync -a --exclude '__pycache__' behavision/ "$STAGE/engine-src/behavision/"
|
||||
cp installer/INSTALL.txt installer/run-with-lan-head-office.cmd "$STAGE/"
|
||||
|
||||
step "4. Package"
|
||||
rm -f "$ZIP" && (cd dist && zip -qr "$(basename "$ZIP")" Behavision) && ls -la "$ZIP" | awk '{print " " $5 " bytes " $9}'
|
||||
unzip -l "$ZIP" | grep -E "Behavision\.exe|agent\.exe|setup\.exe|\.whl|INSTALL" | awk '{print " " $4}'
|
||||
|
||||
[ "${PUBLISH:-1}" = "1" ] || { echo "built, not published"; exit 0; }
|
||||
|
||||
step "5. Tag and publish"
|
||||
git rev-parse -q --verify "refs/tags/$TAG" >/dev/null || git tag -a "$TAG" -m "$TAG"
|
||||
git push -q origin "$TAG"
|
||||
# The notes come from a file so they are reviewed, not typed into a shell.
|
||||
NOTES=${NOTES:-dist/RELEASE-NOTES-$TAG.md}
|
||||
[ -f "$NOTES" ] || { echo "write the release notes to $NOTES first" >&2; exit 1; }
|
||||
# Same credential git pushes with; Gitea accepts it as Basic auth for the API.
|
||||
CRED=$(printf 'protocol=https\nhost=gitapp.workolik.com\n' | git credential fill)
|
||||
USER=$(printf '%s' "$CRED" | sed -n 's/^username=//p'); PASS=$(printf '%s' "$CRED" | sed -n 's/^password=//p')
|
||||
BODY=$(python3 -c 'import json,sys;print(json.dumps({"tag_name":sys.argv[1],"name":sys.argv[2],"body":open(sys.argv[3]).read(),"prerelease":True}))' "$TAG" "$TAG — $(head -1 "$NOTES" | sed 's/^#* *//')" "$NOTES")
|
||||
REL=$(curl -sS -u "$USER:$PASS" -H 'content-type: application/json' -d "$BODY" "$REPO_API/releases")
|
||||
ID=$(printf '%s' "$REL" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
|
||||
curl -sS -u "$USER:$PASS" -F "attachment=@$ZIP" "$REPO_API/releases/$ID/assets?name=$(basename "$ZIP")" >/dev/null
|
||||
echo " published: https://gitapp.workolik.com/Loyaly/Behavision/releases/tag/$TAG"
|
||||
49
run-local.sh
49
run-local.sh
@@ -72,15 +72,34 @@ step "3b. Schema"
|
||||
"./$STATE/bv-server" migrate
|
||||
|
||||
step "4. Mosquitto"
|
||||
if [ ! -f "$STATE/mosquitto/mosquitto.conf" ]; then
|
||||
# Dynamic security, not a passwd file - the same shape as production. The
|
||||
# server registers each site's broker login itself over the control topic, so
|
||||
# there is no per-site password to type here and nothing to restart. The store
|
||||
# is seeded once with the server's own login as the plugin admin; after that
|
||||
# the plugin owns the file.
|
||||
mkdir -p "$STATE/mosquitto/data"
|
||||
# Rewritten when it is the pre-plugin shape, so a checkout that ran the old
|
||||
# script comes up in the new one rather than half of each.
|
||||
if ! grep -q mosquitto_dynamic_security "$STATE/mosquitto/mosquitto.conf" 2>/dev/null; then
|
||||
rm -f "$STATE/mosquitto/passwd" "$STATE/mosquitto/acl"
|
||||
docker rm -f bv-mqtt >/dev/null 2>&1 || true
|
||||
cat > "$STATE/mosquitto/mosquitto.conf" <<EOF
|
||||
per_listener_settings false
|
||||
listener 1883
|
||||
allow_anonymous false
|
||||
password_file /mosquitto/config/passwd
|
||||
acl_file /mosquitto/config/acl
|
||||
plugin /usr/lib/mosquitto_dynamic_security.so
|
||||
plugin_opt_config_file /mosquitto/data/dynamic-security.json
|
||||
EOF
|
||||
printf 'user behavision-server\ntopic read bv/#\n' > "$STATE/mosquitto/acl"
|
||||
: > "$STATE/mosquitto/passwd"
|
||||
fi
|
||||
if [ ! -f "$STATE/mosquitto/data/dynamic-security.json" ]; then
|
||||
: > "$STATE/mosquitto/passwd.seed"
|
||||
docker run --rm -v "$PWD/$STATE/mosquitto:/m" eclipse-mosquitto:2 \
|
||||
mosquitto_passwd -b /m/passwd.seed behavision-server "$MQTT_PASSWORD" 2>/dev/null
|
||||
"./$STATE/bv-server" broker-init -passwd "$STATE/mosquitto/passwd.seed" \
|
||||
-out "$STATE/mosquitto/data/dynamic-security.json" -backend-user behavision-server >/dev/null
|
||||
rm -f "$STATE/mosquitto/passwd.seed"
|
||||
# The plugin rewrites this file, so the broker's user (1883) must own it.
|
||||
chmod 666 "$STATE/mosquitto/data/dynamic-security.json"
|
||||
fi
|
||||
# A container is reused only if its config mount still points HERE. The bind
|
||||
# source is baked in when the container is created, so one made while the
|
||||
@@ -98,6 +117,7 @@ if docker inspect bv-mqtt >/dev/null 2>&1; then
|
||||
fi
|
||||
docker inspect bv-mqtt >/dev/null 2>&1 || docker run -d --name bv-mqtt \
|
||||
-p "${MQTT_PORT}:1883" -v "$MQTT_CONF:/mosquitto/config" \
|
||||
-v "$MQTT_CONF/data:/mosquitto/data" \
|
||||
eclipse-mosquitto:2 >/dev/null
|
||||
docker start bv-mqtt >/dev/null 2>&1 || true
|
||||
|
||||
@@ -114,13 +134,7 @@ if ! docker exec bv-mqtt sh -c 'exit 0' >/dev/null 2>&1; then
|
||||
docker logs --tail 5 bv-mqtt >&2
|
||||
exit 1
|
||||
fi
|
||||
# stderr is NOT discarded here. A failure means the server cannot authenticate
|
||||
# to its own broker, and the whole point of this script is that you find that
|
||||
# out now rather than from an empty arrivals feed.
|
||||
docker exec bv-mqtt mosquitto_passwd -b /mosquitto/config/passwd \
|
||||
behavision-server "$MQTT_PASSWORD" >/dev/null
|
||||
docker restart bv-mqtt >/dev/null
|
||||
echo " broker on ${MQTT_PORT}"
|
||||
echo " broker on ${MQTT_PORT} (dynamic security)"
|
||||
|
||||
step "5. First accounts"
|
||||
# Idempotent throughout: every provision subcommand upserts, so re-running this
|
||||
@@ -140,14 +154,9 @@ step "5. First accounts"
|
||||
# never readable again - so it is pushed into Mosquitto here in the same breath.
|
||||
# A shop PC enrolled on an earlier run therefore has to be claimed again, which
|
||||
# is the right trade locally and is why this is not how production works.
|
||||
SITE_OUT=$("./$STATE/bv-server" provision site -client tenext-retail -slug chennai \
|
||||
-name "TeNext Chennai" -tz Asia/Kolkata)
|
||||
BUSER=$(printf '%s' "$SITE_OUT" | sed -n "s/.*passwd \([^ ]*\) .*/\1/p")
|
||||
BPASS=$(printf '%s' "$SITE_OUT" | sed -n "s/.*passwd [^ ]* '\(.*\)'.*/\1/p")
|
||||
docker exec bv-mqtt mosquitto_passwd -b /mosquitto/config/passwd "$BUSER" "$BPASS" >/dev/null
|
||||
grep -q "^user $BUSER$" "$STATE/mosquitto/acl" || \
|
||||
printf '\nuser %s\ntopic write bv/%s/#\n' "$BUSER" "$BUSER" >> "$STATE/mosquitto/acl"
|
||||
docker restart bv-mqtt >/dev/null
|
||||
MQTT_URL="tcp://127.0.0.1:${MQTT_PORT}" MQTT_USERNAME=behavision-server MQTT_PASSWORD="$MQTT_PASSWORD" \
|
||||
"./$STATE/bv-server" provision site -client tenext-retail -slug chennai \
|
||||
-name "TeNext Chennai" -tz Asia/Kolkata | sed 's/^/ /'
|
||||
|
||||
printf ' platform admin admin@loyaly.ai / loyaly-platform-2026 (Companies only)\n'
|
||||
printf ' TeNext owner suriya@tenext.in / tenext-2026 (Shops, Live, Cameras, Customers, Reports)\n'
|
||||
|
||||
14
server/Dockerfile.runtime
Normal file
14
server/Dockerfile.runtime
Normal file
@@ -0,0 +1,14 @@
|
||||
# Runtime-only image, for a host that must not compile.
|
||||
#
|
||||
# The production box has 3.6 GB of RAM shared with other tenants' services;
|
||||
# `Dockerfile` pulls a Go toolchain and builds there, which is how deploys
|
||||
# became something nobody wanted to run. deploy.sh builds the static binary
|
||||
# on the developer's machine and ships only that. Same runtime layer as
|
||||
# Dockerfile, on purpose - the two must not drift.
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates tzdata && \
|
||||
adduser -D -u 10001 behavision
|
||||
COPY behavision-server /usr/local/bin/behavision-server
|
||||
USER behavision
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/local/bin/behavision-server"]
|
||||
59
server/broker-cutover.sh
Executable file
59
server/broker-cutover.sh
Executable file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Move a running broker from passwd/acl files to the dynamic-security plugin,
|
||||
# keeping every existing login and password. Run AFTER deploy.sh has put a
|
||||
# server on the host that has `broker-init`.
|
||||
#
|
||||
# server/broker-cutover.sh do it
|
||||
# ROLLBACK=1 server/broker-cutover.sh put the previous config back
|
||||
#
|
||||
# What it does: backs up mosquitto/config, converts passwd → the plugin's store
|
||||
# inside the broker's data volume (same hashes, so no shop PC re-claims),
|
||||
# rewrites mosquitto.conf, restarts the broker, and proves the server and the
|
||||
# health probe reconnect. Every step before the restart is reversible by not
|
||||
# doing the restart; the rollback restores the backed-up config and restarts.
|
||||
set -euo pipefail
|
||||
HOST=${HOST:-root@66.116.226.161}
|
||||
KEY=${KEY:-$HOME/.ssh/behavision_deploy}
|
||||
DIR=/root/behavision
|
||||
SSH=(ssh -i "$KEY" -o BatchMode=yes -o ConnectTimeout=10 "$HOST")
|
||||
step() { printf '\n\033[1m%s\033[0m\n' "$*"; }
|
||||
|
||||
if [ -n "${ROLLBACK:-}" ]; then
|
||||
step "Rolling back to the passwd/acl configuration"
|
||||
"${SSH[@]}" "cd $DIR && latest=\$(ls -d mosquitto/config.bak-* | tail -1) && cp \$latest/mosquitto.conf mosquitto/config/mosquitto.conf && docker compose restart mosquitto && sleep 3 && docker logs --tail 5 behavision-mqtt"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
step "1. Back up the broker configuration"
|
||||
"${SSH[@]}" "cd $DIR && cp -a mosquitto/config mosquitto/config.bak-\$(date +%Y%m%d-%H%M%S) && ls -d mosquitto/config.bak-* | tail -1"
|
||||
|
||||
step "2. Convert passwd into the plugin's store (hashes unchanged)"
|
||||
# The data volume belongs to the broker's user (1883); the init runs as root to
|
||||
# write there and then hands the file over. Refuses if a store already exists.
|
||||
"${SSH[@]}" "cd $DIR && docker run --rm --user root \
|
||||
-v $DIR/mosquitto/config:/m:ro -v behavision_mosquitto-data:/d \
|
||||
--entrypoint /usr/local/bin/behavision-server behavision-backend:latest \
|
||||
broker-init -passwd /m/passwd -out /d/dynamic-security.json \
|
||||
&& docker run --rm --user root -v behavision_mosquitto-data:/d alpine:3.20 sh -c 'chown 1883:1883 /d/dynamic-security.json && chmod 600 /d/dynamic-security.json && ls -la /d/dynamic-security.json'"
|
||||
|
||||
step "3. Rewrite mosquitto.conf for the plugin"
|
||||
"${SSH[@]}" "cd $DIR && python3 - <<'PY'
|
||||
import re
|
||||
p = 'mosquitto/config/mosquitto.conf'
|
||||
s = open(p).read()
|
||||
s = re.sub(r'^per_listener_settings\s+true\s*$', 'per_listener_settings false', s, flags=re.M)
|
||||
s = re.sub(r'^(password_file|acl_file)\s+.*\n', '', s, flags=re.M)
|
||||
if 'mosquitto_dynamic_security' not in s:
|
||||
s = s.rstrip('\n') + '\n\n# Logins and topic permissions live in the dynamic-security plugin now.\n# The server creates a shop\'s login over the control topic; nothing is\n# edited by hand and nothing is reloaded.\nplugin /usr/lib/mosquitto_dynamic_security.so\nplugin_opt_config_file /mosquitto/data/dynamic-security.json\n'
|
||||
open(p, 'w').write(s)
|
||||
print(open(p).read())
|
||||
PY"
|
||||
|
||||
step "4. Restart the broker"
|
||||
"${SSH[@]}" "cd $DIR && docker compose restart mosquitto && sleep 4 && docker logs --tail 8 behavision-mqtt 2>&1 | grep -i 'error\|plugin\|running\|connected' | tail -6"
|
||||
|
||||
step "5. Prove the server and the health probe are back"
|
||||
"${SSH[@]}" "cd $DIR && sleep 6 && docker logs --since 30s behavision-backend 2>&1 | grep -i 'broker\|subscribed' | tail -3; docker inspect behavision-mqtt --format 'health: {{.State.Health.Status}}' 2>/dev/null || true; docker logs --since 40s behavision-mqtt 2>&1 | grep -i 'not authori\|denied' | head -3 || true"
|
||||
echo
|
||||
echo "If step 5 shows 'subscribed to bv/#' and no 'not authorised', the cutover is done."
|
||||
echo "Anything wrong: ROLLBACK=1 server/broker-cutover.sh"
|
||||
69
server/cmd/behavision-server/brokerinit.go
Normal file
69
server/cmd/behavision-server/brokerinit.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/broker"
|
||||
)
|
||||
|
||||
// broker-init converts Mosquitto's passwd file into the dynamic-security
|
||||
// plugin's store, once, at cutover. After it the broker is driven over MQTT
|
||||
// and the passwd and acl files are no longer read.
|
||||
func runBrokerInit(args []string) error {
|
||||
fs := flag.NewFlagSet("broker-init", flag.ExitOnError)
|
||||
passwd := fs.String("passwd", "", "path to the mosquitto passwd file to convert")
|
||||
out := fs.String("out", "", "where to write dynamic-security.json (must be writable by the broker)")
|
||||
backend := fs.String("backend-user", "behavision-backend", "the server's own broker username; becomes the plugin admin")
|
||||
health := fs.String("health-user", "health", "the healthcheck username")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, `usage: behavision-server broker-init -passwd FILE -out FILE
|
||||
|
||||
Converts a mosquitto_passwd file into the dynamic-security plugin's store,
|
||||
keeping every password hash exactly as it is, so no shop PC has to be
|
||||
re-claimed. Then in mosquitto.conf replace password_file/acl_file with:
|
||||
|
||||
per_listener_settings false
|
||||
plugin /usr/lib/mosquitto_dynamic_security.so
|
||||
plugin_opt_config_file /mosquitto/data/dynamic-security.json
|
||||
|
||||
and restart the broker. From then on 'provision site' and POST /api/sites
|
||||
register a shop's login themselves.
|
||||
`)
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *passwd == "" || *out == "" {
|
||||
fs.Usage()
|
||||
return errors.New("-passwd and -out are required")
|
||||
}
|
||||
f, err := os.Open(*passwd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
st, err := broker.FromPasswd(f, *backend, *health)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(*out); err == nil {
|
||||
return fmt.Errorf("%s already exists - refusing to overwrite a live store", *out)
|
||||
}
|
||||
w, err := os.OpenFile(*out, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer w.Close()
|
||||
if err := st.Encode(w); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("wrote %s: %d clients, %d roles\n", *out, len(st.Clients), len(st.Roles))
|
||||
for _, c := range st.Clients {
|
||||
fmt.Printf(" %-28s %s\n", c.Username, c.Roles[0].Rolename)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -25,11 +25,12 @@ import (
|
||||
"github.com/loyaly/behavision-server/internal/api"
|
||||
"github.com/loyaly/behavision-server/internal/assistant"
|
||||
"github.com/loyaly/behavision-server/internal/blob"
|
||||
"github.com/loyaly/behavision-server/internal/broker"
|
||||
"github.com/loyaly/behavision-server/internal/ingest"
|
||||
"github.com/loyaly/behavision-server/internal/migrate"
|
||||
"github.com/loyaly/behavision-server/internal/web"
|
||||
"github.com/loyaly/behavision-server/internal/secret"
|
||||
"github.com/loyaly/behavision-server/internal/store"
|
||||
"github.com/loyaly/behavision-server/internal/web"
|
||||
"github.com/loyaly/behavision-server/migrations"
|
||||
)
|
||||
|
||||
@@ -46,6 +47,13 @@ func main() {
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(os.Args) > 1 && os.Args[1] == "broker-init" {
|
||||
if err := runBrokerInit(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(os.Args) > 1 && os.Args[1] == "migrate" {
|
||||
if err := runMigrate(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
@@ -194,8 +202,11 @@ func run() error {
|
||||
apiSrv := &api.Server{
|
||||
Store: st,
|
||||
Log: logger,
|
||||
Blob: objectStore(ctx, logger),
|
||||
Hub: hub,
|
||||
// Opening a shop registers its broker login at the same moment, over the
|
||||
// same broker credential the ingest side already holds.
|
||||
Broker: broker.New(brokerURL, brokerUser, brokerPass, logger),
|
||||
Blob: objectStore(ctx, logger),
|
||||
Hub: hub,
|
||||
Bootstrap: api.BootstrapConfig{
|
||||
// What an enrolling PC is told to connect to. From the server's own
|
||||
// environment, never from the request: an agent asking where to
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/broker"
|
||||
"github.com/loyaly/behavision-server/internal/provision"
|
||||
"github.com/loyaly/behavision-server/internal/secret"
|
||||
)
|
||||
@@ -43,6 +44,14 @@ func runProvision(args []string) error {
|
||||
|
||||
box, boxErr := secret.FromEnv("BEHAVISION_SECRET_KEY")
|
||||
p := &provision.Provisioner{Pool: pool, Secrets: box}
|
||||
// The broker, so a new site's login is registered here and now instead of
|
||||
// printed for somebody to type into a password file. Same variables the
|
||||
// server itself connects with.
|
||||
if u := os.Getenv("MQTT_URL"); u != "" && os.Getenv("MQTT_USERNAME") != "" {
|
||||
dyn := broker.New(u, os.Getenv("MQTT_USERNAME"), os.Getenv("MQTT_PASSWORD"), nil)
|
||||
defer dyn.Close()
|
||||
p.Broker = dyn
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "client":
|
||||
@@ -82,12 +91,16 @@ func runProvision(args []string) error {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("site created: %s\n", res.SiteID)
|
||||
if res.BrokerRegistered {
|
||||
fmt.Printf("broker login %s registered - this site can publish now.\n", res.Username)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("\nAdd this broker user to Mosquitto, then this site can publish:\n\n")
|
||||
fmt.Printf(" mosquitto_passwd -b /mosquitto/config/passwd %s '%s'\n\n",
|
||||
res.Username, res.Password)
|
||||
// The broker keeps a hash; we keep it sealed. Neither side can show it
|
||||
// again, which is why it is printed here in full.
|
||||
fmt.Printf("The password is stored encrypted and handed out only at "+
|
||||
fmt.Printf("The password is stored encrypted and handed out only at " +
|
||||
"enrolment.\nIt is not recoverable from the logs. Copy it now.\n")
|
||||
return nil
|
||||
|
||||
|
||||
71
server/deploy.sh
Executable file
71
server/deploy.sh
Executable file
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy the server to the production host, from this checkout.
|
||||
#
|
||||
# Until this existed the deployment was manual to a box nobody had written
|
||||
# down, and production ran code months behind main - three of the desktop
|
||||
# app's screens talked to routes that were not there. A deploy that is a
|
||||
# script gets run; one that is a memory does not.
|
||||
#
|
||||
# server/deploy.sh build, back up the database, migrate, switch
|
||||
# BASELINE=3 server/deploy.sh first run against a database that predates
|
||||
# migration tracking: adopt 1..3 unrun
|
||||
# DRY_RUN=1 server/deploy.sh build and ship, touch nothing running
|
||||
#
|
||||
# What it does, in order, and why the order matters:
|
||||
# 1. builds the head-office web app INTO the Go module, then a static
|
||||
# linux/amd64 binary here - the host has 3.6 GB of RAM and must not compile
|
||||
# 2. pg_dumps the database to backups/ on the host BEFORE anything changes
|
||||
# 3. builds the runtime-only image on the host from the shipped binary
|
||||
# 4. runs `migrate` with the NEW binary while the OLD server still serves;
|
||||
# a failing migration therefore stops here with production untouched
|
||||
# 5. switches the container, then proves the routes answer over the public URL
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
HOST=${HOST:-root@66.116.226.161}
|
||||
KEY=${KEY:-$HOME/.ssh/behavision_deploy}
|
||||
REMOTE_DIR=/root/behavision
|
||||
PUBLIC=https://mcp.loyaly.ai
|
||||
SSH=(ssh -i "$KEY" -o BatchMode=yes -o ConnectTimeout=10 "$HOST")
|
||||
|
||||
VERSION=$(git describe --tags --always --dirty)
|
||||
case "$VERSION" in *-dirty) echo "refusing to deploy uncommitted changes ($VERSION)" >&2; exit 1;; esac
|
||||
|
||||
step() { printf '\n\033[1m%s\033[0m\n' "$*"; }
|
||||
|
||||
step "1. Build $VERSION"
|
||||
(cd ../web && npm run build >/dev/null)
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=${VERSION}" -o /tmp/behavision-server ./cmd/behavision-server
|
||||
ls -la /tmp/behavision-server | awk '{print " " $5 " bytes"}'
|
||||
|
||||
step "2. Ship"
|
||||
"${SSH[@]}" "mkdir -p $REMOTE_DIR/release/$VERSION $REMOTE_DIR/backups"
|
||||
scp -q -i "$KEY" /tmp/behavision-server Dockerfile.runtime "$HOST:$REMOTE_DIR/release/$VERSION/"
|
||||
git rev-parse HEAD | "${SSH[@]}" "cat > $REMOTE_DIR/release/$VERSION/GIT_SHA"
|
||||
|
||||
if [ "${DRY_RUN:-}" != "" ]; then echo "DRY_RUN: shipped to $REMOTE_DIR/release/$VERSION, nothing changed"; exit 0; fi
|
||||
|
||||
step "3. Back up the database"
|
||||
"${SSH[@]}" "docker exec behavision-db sh -c 'PGPASSWORD=\$POSTGRES_PASSWORD pg_dump -U behavision -d behavision' | gzip > $REMOTE_DIR/backups/pre-$VERSION-\$(date +%Y%m%d-%H%M%S).sql.gz && ls -la $REMOTE_DIR/backups | tail -1"
|
||||
|
||||
step "4. Image"
|
||||
"${SSH[@]}" "cd $REMOTE_DIR/release/$VERSION && docker build -q -t behavision-backend:$VERSION -f Dockerfile.runtime . && docker tag behavision-backend:$VERSION behavision-backend:latest"
|
||||
|
||||
step "5. Migrate (old server still serving)"
|
||||
# `run` uses the compose service's environment and network, so the new binary
|
||||
# reaches postgres exactly as the server will. --no-deps: do not restart the
|
||||
# broker or the database to run a migration.
|
||||
if [ -n "${BASELINE:-}" ]; then
|
||||
"${SSH[@]}" "cd $REMOTE_DIR && docker compose run --rm --no-deps -T backend migrate -baseline $BASELINE"
|
||||
fi
|
||||
"${SSH[@]}" "cd $REMOTE_DIR && docker compose run --rm --no-deps -T backend migrate && docker compose run --rm --no-deps -T backend migrate -status"
|
||||
|
||||
step "6. Switch"
|
||||
"${SSH[@]}" "cd $REMOTE_DIR && docker compose up -d --no-build --no-deps backend && sleep 4 && docker logs --tail 15 behavision-backend"
|
||||
|
||||
step "7. Verify over $PUBLIC"
|
||||
for p in /healthz /api/admin/clients /api/team /api/visits /api/cameras; do
|
||||
printf ' %-20s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' -m 15 "$PUBLIC$p")"
|
||||
done
|
||||
curl -s -m 15 "$PUBLIC/healthz" | head -c 300; echo
|
||||
128
server/internal/api/admin_clients_test.go
Normal file
128
server/internal/api/admin_clients_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func seedTenantWithOwner(fs *fakeStore) {
|
||||
fs.clients = []ClientRow{{ID: "client-acme", Slug: "acme", Name: "Acme Retail", Active: true}}
|
||||
fs.addUser("owner@acme.com", "correct horse battery", UserRecord{
|
||||
ID: "u-owner", ClientID: "client-acme", Role: "owner", Active: true, Email: "owner@acme.com",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSuspendingACompanyEndsItsSessionsNow(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedPlatformAdmin(fs)
|
||||
seedTenantWithOwner(fs)
|
||||
owner := login(t, s, "owner@acme.com", "correct horse battery")
|
||||
admin := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
rec := do(t, s, "PATCH", "/api/admin/clients/client-acme", admin.Token, map[string]any{"active": false})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
SessionsRevoked int `json:"sessions_revoked"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out.SessionsRevoked != 1 {
|
||||
t.Fatalf("expected the owner's one session revoked, got %d", out.SessionsRevoked)
|
||||
}
|
||||
// The owner's token stops working immediately, not at expiry.
|
||||
if rec := do(t, s, "GET", "/api/team", owner.Token, nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("suspended tenant's session still works: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletingACompanyIsATwoStepDecision(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedPlatformAdmin(fs)
|
||||
seedTenantWithOwner(fs)
|
||||
b := &fakeBroker{}
|
||||
s.Broker = b
|
||||
admin := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
// Active: refused, whatever the confirmation says.
|
||||
rec := do(t, s, "DELETE", "/api/admin/clients/client-acme", admin.Token, map[string]any{"confirm": "acme"})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("deleted an active company: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
do(t, s, "PATCH", "/api/admin/clients/client-acme", admin.Token, map[string]any{"active": false})
|
||||
// Suspended but the slug is wrong: refused.
|
||||
rec = do(t, s, "DELETE", "/api/admin/clients/client-acme", admin.Token, map[string]any{"confirm": "acm"})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("deleted without the slug: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = do(t, s, "DELETE", "/api/admin/clients/client-acme", admin.Token, map[string]any{"confirm": "acme"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(fs.clients) != 0 {
|
||||
t.Fatal("company row survived")
|
||||
}
|
||||
if len(b.deleted) != 1 || b.deleted[0] != "acme.shop1" {
|
||||
t.Fatalf("broker logins not removed: %v", b.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminResetsTheOwnersPasswordAndItIsShownOnce(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedPlatformAdmin(fs)
|
||||
seedTenantWithOwner(fs)
|
||||
admin := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
rec := do(t, s, "POST", "/api/admin/clients/client-acme/owner-password", admin.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out struct{ Email, Password string }
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out.Email != "owner@acme.com" || out.Password == "" {
|
||||
t.Fatalf("unexpected result: %s", rec.Body.String())
|
||||
}
|
||||
if rec := do(t, s, "POST", "/api/auth/login", "", map[string]string{"email": "owner@acme.com", "password": "correct horse battery"}); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("old password still works: %d", rec.Code)
|
||||
}
|
||||
login(t, s, "owner@acme.com", out.Password)
|
||||
}
|
||||
|
||||
func TestATenantUserCannotReachTheAdminClientRoutes(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedTenantWithOwner(fs)
|
||||
owner := login(t, s, "owner@acme.com", "correct horse battery")
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{"PATCH", "/api/admin/clients/client-acme"},
|
||||
{"POST", "/api/admin/clients/client-acme/owner-password"},
|
||||
{"DELETE", "/api/admin/clients/client-acme"},
|
||||
} {
|
||||
if rec := do(t, s, c.method, c.path, owner.Token, map[string]any{"active": false, "confirm": "acme"}); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s %s: tenant user got %d, want 404", c.method, c.path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOwnerRemovesAnEmptyShopButNotOneWithCameras(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
b := &fakeBroker{}
|
||||
s.Broker = b
|
||||
seedTenantWithOwner(fs)
|
||||
fs.sites = []SiteHealth{
|
||||
{SiteID: "site-empty", Slug: "empty", Name: "Empty"},
|
||||
{SiteID: siteA, Slug: "chennai", Name: "TeNext Chennai"},
|
||||
}
|
||||
fs.cameras = []Camera{{ID: "c1", SiteID: siteA, CameraID: "entrance"}}
|
||||
owner := login(t, s, "owner@acme.com", "correct horse battery")
|
||||
|
||||
if rec := do(t, s, "DELETE", "/api/sites/chennai", owner.Token, nil); rec.Code != http.StatusConflict {
|
||||
t.Fatalf("removed a shop with a camera: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := do(t, s, "DELETE", "/api/sites/empty", owner.Token, nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(b.deleted) != 1 {
|
||||
t.Fatalf("broker login not removed: %v", b.deleted)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,15 @@ import (
|
||||
type Store interface {
|
||||
// --- identity ---
|
||||
UserByEmail(ctx context.Context, email string) (UserRecord, error)
|
||||
|
||||
// --- shops ---
|
||||
// CreateSite writes the shop and its sealed broker password in one
|
||||
// transaction and returns the plaintext once, for the broker registration
|
||||
// that must follow. DeleteNewSite is the compensation when that
|
||||
// registration fails: a shop whose PC can enrol but never publish is the
|
||||
// silent failure this whole endpoint exists to end.
|
||||
CreateSite(ctx context.Context, clientID, slug, name, tz string) (NewSite, error)
|
||||
DeleteNewSite(ctx context.Context, clientID, siteID string) error
|
||||
TouchUserLogin(ctx context.Context, userID string) error
|
||||
CreateSession(ctx context.Context, s NewSession) error
|
||||
SessionByAccess(ctx context.Context, hash []byte) (auth.Principal, time.Time, error)
|
||||
@@ -128,6 +137,28 @@ type Store interface {
|
||||
// --- platform administration ---
|
||||
CreateClientWithOwner(ctx context.Context, in NewClientInput) (NewClientResult, error)
|
||||
ListClients(ctx context.Context) ([]ClientRow, error)
|
||||
// SetClientActive suspends or reinstates a company. Suspending revokes every
|
||||
// session its users hold in the same transaction - login and ingest already
|
||||
// refuse an inactive client, but a live access token would otherwise keep
|
||||
// reading for up to twelve hours. Returns the slug and how many sessions
|
||||
// were ended.
|
||||
SetClientActive(ctx context.Context, clientID string, active bool) (ClientRow, int, error)
|
||||
// ClientOwners lists the active owners of a company, for a platform admin
|
||||
// resetting one of their passwords.
|
||||
ClientOwners(ctx context.Context, clientID string) ([]TeamMember, error)
|
||||
// ClientImageKeys is every face image a company holds - the first step of
|
||||
// deleting the company, for the same reason it is the first step of erasing
|
||||
// a person: once the rows are gone nothing knows which objects to remove.
|
||||
ClientImageKeys(ctx context.Context, clientID string) ([]string, error)
|
||||
// DeleteClient removes a SUSPENDED company and everything under it, and
|
||||
// returns the broker usernames of its sites so their logins can be removed.
|
||||
// Refuses an active company: suspension first is what makes this a
|
||||
// two-step decision instead of one click.
|
||||
DeleteClient(ctx context.Context, clientID string) (ClientRow, []string, error)
|
||||
// DeleteEmptySite removes a shop that has no visits and no cameras - the
|
||||
// one opened by mistake - and returns its broker username. A shop with
|
||||
// history is closed, not deleted.
|
||||
DeleteEmptySite(ctx context.Context, clientID, siteID string) (string, error)
|
||||
|
||||
// --- enrolment ---
|
||||
RedeemEnrolment(ctx context.Context, hash []byte) (Enrolment, error)
|
||||
@@ -168,6 +199,10 @@ type Server struct {
|
||||
// business questions the screens ask. Nil means this deployment has no
|
||||
// API key, which is supported: the UI hides the panel.
|
||||
Assistant Assistant
|
||||
// Broker registers a shop's login with Mosquitto at the moment the shop is
|
||||
// created. Nil means this deployment cannot create shops through the API
|
||||
// and says so, rather than creating one that can never publish.
|
||||
Broker SiteBroker
|
||||
// Live relays camera frames from a shop PC to whoever is watching, on
|
||||
// demand. Created on first use.
|
||||
Live *LiveHub
|
||||
@@ -264,6 +299,8 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
mux.HandleFunc("GET /api/reports/footfall", s.authed(s.handleFootfall))
|
||||
mux.HandleFunc("GET /api/reports/conversion", s.authed(s.handleConversion))
|
||||
mux.HandleFunc("GET /api/sites", s.authed(s.handleSites))
|
||||
mux.HandleFunc("POST /api/sites", s.authed(s.handleCreateSite))
|
||||
mux.HandleFunc("DELETE /api/sites/{site}", s.authed(s.handleDeleteSite))
|
||||
|
||||
// Cameras, onboarded from head office. The shop PC still does the
|
||||
// connecting - it is the only thing on the camera's network - so these
|
||||
@@ -308,6 +345,9 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
// because creating the first admin cannot require being signed in as one.
|
||||
mux.HandleFunc("GET /api/admin/clients", s.adminOnly(s.handleListClients))
|
||||
mux.HandleFunc("POST /api/admin/clients", s.adminOnly(s.handleCreateClient))
|
||||
mux.HandleFunc("PATCH /api/admin/clients/{id}", s.adminOnly(s.handleSetClientActive))
|
||||
mux.HandleFunc("POST /api/admin/clients/{id}/owner-password", s.adminOnly(s.handleResetOwnerPassword))
|
||||
mux.HandleFunc("DELETE /api/admin/clients/{id}", s.adminOnly(s.handleDeleteClient))
|
||||
|
||||
// Not session-authenticated: this is how a PC with no credentials gets
|
||||
// some. The enrolment token is the credential.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -261,6 +262,32 @@ func (f *fakeStore) Conversion(_ context.Context, q ReportQuery) (SalesReport, e
|
||||
return f.sales, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateSite(_ context.Context, clientID, slug, name, tz string) (NewSite, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, s := range f.sites {
|
||||
if s.Slug == slug {
|
||||
return NewSite{}, &pgconn.PgError{Code: "23505"}
|
||||
}
|
||||
}
|
||||
id := "site-" + slug
|
||||
f.sites = append(f.sites, SiteHealth{SiteID: id, Slug: slug, Name: name, Timezone: tz})
|
||||
return NewSite{SiteID: id, Slug: slug, Name: name, Timezone: tz, Username: "acme." + slug, Password: "pw-" + slug}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteNewSite(_ context.Context, _ string, siteID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
kept := f.sites[:0]
|
||||
for _, s := range f.sites {
|
||||
if s.SiteID != siteID {
|
||||
kept = append(kept, s)
|
||||
}
|
||||
}
|
||||
f.sites = kept
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SiteHealth(_ context.Context, _ string) ([]SiteHealth, error) {
|
||||
return f.sites, nil
|
||||
}
|
||||
@@ -475,6 +502,75 @@ func (f *fakeStore) ReleaseStaleChecks(_ context.Context, _ time.Duration) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetClientActive(_ context.Context, clientID string, active bool) (ClientRow, int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for i := range f.clients {
|
||||
if f.clients[i].ID != clientID {
|
||||
continue
|
||||
}
|
||||
f.clients[i].Active = active
|
||||
revoked := 0
|
||||
if !active {
|
||||
for _, sess := range f.sessions {
|
||||
if sess.p.ClientID == clientID && !sess.revoked {
|
||||
sess.revoked = true
|
||||
revoked++
|
||||
}
|
||||
}
|
||||
}
|
||||
return f.clients[i], revoked, nil
|
||||
}
|
||||
return ClientRow{}, 0, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (f *fakeStore) ClientOwners(_ context.Context, clientID string) ([]TeamMember, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []TeamMember
|
||||
for _, u := range f.users {
|
||||
if u.ClientID == clientID && u.Role == "owner" && u.Active {
|
||||
out = append(out, TeamMember{ID: u.ID, Email: u.Email, FullName: u.FullName, Role: u.Role, Active: u.Active})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ClientImageKeys(_ context.Context, _ string) ([]string, error) { return nil, nil }
|
||||
|
||||
func (f *fakeStore) DeleteClient(_ context.Context, clientID string) (ClientRow, []string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for i, c := range f.clients {
|
||||
if c.ID != clientID {
|
||||
continue
|
||||
}
|
||||
if c.Active {
|
||||
return c, nil, errors.New("client is active")
|
||||
}
|
||||
f.clients = append(f.clients[:i], f.clients[i+1:]...)
|
||||
return c, []string{c.Slug + ".shop1"}, nil
|
||||
}
|
||||
return ClientRow{}, nil, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteEmptySite(_ context.Context, _ string, siteID string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, c := range f.cameras {
|
||||
if c.SiteID == siteID {
|
||||
return "", ErrSiteInUse
|
||||
}
|
||||
}
|
||||
for i, s := range f.sites {
|
||||
if s.SiteID == siteID {
|
||||
f.sites = append(f.sites[:i], f.sites[i+1:]...)
|
||||
return "acme." + s.Slug, nil
|
||||
}
|
||||
}
|
||||
return "", pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListClients(_ context.Context) ([]ClientRow, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
259
server/internal/api/handlers_admin_clients.go
Normal file
259
server/internal/api/handlers_admin_clients.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
// The platform administrator's remaining shell-only jobs, as endpoints:
|
||||
// suspend or reinstate a company, reset its owner's password, delete it.
|
||||
//
|
||||
// All three are behind adminOnly (a principal with the role AND no client), and
|
||||
// all three read the company id from the path. None takes a client id from a
|
||||
// body - the same rule every tenant handler follows.
|
||||
|
||||
// PATCH /api/admin/clients/{id} {"active": false}
|
||||
//
|
||||
// Suspension is the reversible step and it is complete: login refuses the
|
||||
// company's users, the broker's visits are dropped at ingest, and every live
|
||||
// session is revoked in the same transaction. Without the last, "suspend" would
|
||||
// mean "suspend some time tomorrow", which is not what anybody pressing it
|
||||
// believes they did.
|
||||
func (s *Server) handleSetClientActive(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
var in struct {
|
||||
Active *bool `json:"active"`
|
||||
}
|
||||
if err := decode(w, r, &in); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if in.Active == nil {
|
||||
badRequest(w, `"active" is required: true to reinstate, false to suspend`)
|
||||
return
|
||||
}
|
||||
row, revoked, err := s.Store.SetClientActive(r.Context(), r.PathValue("id"), *in.Active)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such company.")
|
||||
return
|
||||
}
|
||||
s.serverError(w, "set client active", err)
|
||||
return
|
||||
}
|
||||
action := "client.suspended"
|
||||
if *in.Active {
|
||||
action = "client.reinstated"
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ActorID: p.UserID, ActorKind: "user", Action: action,
|
||||
Entity: "client", EntityID: row.ID,
|
||||
Detail: map[string]any{"slug": row.Slug, "sessions_revoked": revoked},
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"client": row, "sessions_revoked": revoked})
|
||||
}
|
||||
|
||||
// POST /api/admin/clients/{id}/owner-password {"email": "…"}
|
||||
//
|
||||
// The support case this exists for: the owner has locked themselves out and
|
||||
// there is nobody above them in the company to reset it. The new password is
|
||||
// generated, shown once, and every session that owner held is revoked. `email`
|
||||
// picks the owner when the company has more than one; with exactly one it may
|
||||
// be omitted.
|
||||
func (s *Server) handleResetOwnerPassword(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
clientID := r.PathValue("id")
|
||||
var in struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := decodeOptional(w, r, &in); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
owners, err := s.Store.ClientOwners(r.Context(), clientID)
|
||||
if err != nil {
|
||||
s.serverError(w, "list owners", err)
|
||||
return
|
||||
}
|
||||
var target *TeamMember
|
||||
switch {
|
||||
case len(owners) == 0:
|
||||
writeErr(w, http.StatusNotFound, "not_found", "That company has no active owner.")
|
||||
return
|
||||
case in.Email != "":
|
||||
want := auth.NormalizeEmail(in.Email)
|
||||
for i := range owners {
|
||||
if owners[i].Email == want {
|
||||
target = &owners[i]
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No active owner with that address.")
|
||||
return
|
||||
}
|
||||
case len(owners) == 1:
|
||||
target = &owners[0]
|
||||
default:
|
||||
emails := make([]string, 0, len(owners))
|
||||
for _, o := range owners {
|
||||
emails = append(emails, o.Email)
|
||||
}
|
||||
badRequest(w, "That company has several owners; say which with \"email\": "+strings.Join(emails, ", "))
|
||||
return
|
||||
}
|
||||
|
||||
password, err := auth.RandomPassword()
|
||||
if err != nil {
|
||||
s.serverError(w, "generate password", err)
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
s.serverError(w, "hash password", err)
|
||||
return
|
||||
}
|
||||
m, err := s.Store.ResetMemberPassword(r.Context(), clientID, target.ID, hash)
|
||||
if err != nil {
|
||||
s.serverError(w, "reset owner password", err)
|
||||
return
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ActorID: p.UserID, ActorKind: "user", Action: "admin.reset_owner_password",
|
||||
Entity: "user", EntityID: m.ID, Detail: map[string]any{"email": m.Email, "client_id": clientID},
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"email": m.Email, "password": password})
|
||||
}
|
||||
|
||||
// DELETE /api/admin/clients/{id} {"confirm": "<slug>"}
|
||||
//
|
||||
// Irreversible, and the data is biometric, so it is deliberately hard to do by
|
||||
// accident: the company must already be suspended, and the request must repeat
|
||||
// the slug. Objects go first - once the rows are gone nothing knows which files
|
||||
// to remove - then the broker logins, then the rows (everything cascades from
|
||||
// clients). A storage failure aborts before anything else is touched.
|
||||
func (s *Server) handleDeleteClient(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
clientID := r.PathValue("id")
|
||||
var in struct {
|
||||
Confirm string `json:"confirm"`
|
||||
}
|
||||
if err := decode(w, r, &in); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
rows, err := s.Store.ListClients(r.Context())
|
||||
if err != nil {
|
||||
s.serverError(w, "list clients", err)
|
||||
return
|
||||
}
|
||||
var row *ClientRow
|
||||
for i := range rows {
|
||||
if rows[i].ID == clientID {
|
||||
row = &rows[i]
|
||||
}
|
||||
}
|
||||
if row == nil {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such company.")
|
||||
return
|
||||
}
|
||||
if row.Active {
|
||||
writeErr(w, http.StatusConflict, "still_active",
|
||||
"Suspend the company first (PATCH active=false). Deleting is the second step, not the first.")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(in.Confirm) != row.Slug {
|
||||
badRequest(w, "Repeat the company's slug in \"confirm\" to delete it.")
|
||||
return
|
||||
}
|
||||
|
||||
keys, err := s.Store.ClientImageKeys(r.Context(), clientID)
|
||||
if err != nil {
|
||||
s.serverError(w, "list images for client delete", err)
|
||||
return
|
||||
}
|
||||
if s.Blob != nil {
|
||||
for _, key := range keys {
|
||||
if isDBKey(key) {
|
||||
continue // goes with the rows
|
||||
}
|
||||
if err := s.Blob.Delete(r.Context(), key); err != nil {
|
||||
s.logf("ERROR delete client %s: cannot delete %s: %v", row.Slug, key, err)
|
||||
writeErr(w, http.StatusBadGateway, "storage_error",
|
||||
"A stored photo could not be deleted, so the company was not deleted. Try again.")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
_, brokerUsers, err := s.Store.DeleteClient(r.Context(), clientID)
|
||||
if err != nil {
|
||||
s.serverError(w, "delete client", err)
|
||||
return
|
||||
}
|
||||
if s.Broker != nil {
|
||||
for _, u := range brokerUsers {
|
||||
if err := s.Broker.DeleteSite(r.Context(), u); err != nil {
|
||||
// The rows are gone and the login cannot publish anywhere the
|
||||
// server will accept (ingest resolves the site and finds none),
|
||||
// so this is a leftover to tidy, not a failure to report as one.
|
||||
s.logf("delete client %s: broker login %s not removed: %v", row.Slug, u, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ActorID: p.UserID, ActorKind: "user", Action: "client.deleted",
|
||||
Entity: "client", EntityID: clientID,
|
||||
Detail: map[string]any{"slug": row.Slug, "images_deleted": len(keys), "broker_logins": brokerUsers},
|
||||
})
|
||||
s.logf("WARNING company %s deleted by %s: %d images, %d broker logins", row.Slug, p.UserID, len(keys), len(brokerUsers))
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": row.Slug, "images_deleted": len(keys)})
|
||||
}
|
||||
|
||||
// DELETE /api/sites/{site} - an owner removes a shop opened by mistake.
|
||||
//
|
||||
// Only a shop with no visits and no cameras. A shop with history holds the
|
||||
// tenant's footfall and, through its visits, faces; taking that away is an
|
||||
// erasure decision, not a tidy-up, and there is no endpoint for it yet.
|
||||
func (s *Server) handleDeleteSite(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if p.Role != "owner" || p.ClientID == "" {
|
||||
writeErr(w, http.StatusForbidden, "forbidden", "Only the owner can remove a shop.")
|
||||
return
|
||||
}
|
||||
site, ok := s.resolveSite(w, r, r.PathValue("site"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
username, err := s.Store.DeleteEmptySite(r.Context(), p.ClientID, site)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSiteInUse) {
|
||||
writeErr(w, http.StatusConflict, "in_use",
|
||||
"This shop has cameras or visits, so it cannot simply be removed. Remove its cameras first; a shop with visit history is kept.")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such shop.")
|
||||
return
|
||||
}
|
||||
s.serverError(w, "delete site", err)
|
||||
return
|
||||
}
|
||||
if s.Broker != nil && username != "" {
|
||||
if err := s.Broker.DeleteSite(r.Context(), username); err != nil {
|
||||
s.logf("delete site %s: broker login %s not removed: %v", site, username, err)
|
||||
}
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "site.deleted", Entity: "site", EntityID: site,
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ErrSiteInUse is returned by DeleteEmptySite for a shop that has anything
|
||||
// under it.
|
||||
var ErrSiteInUse = errors.New("site has cameras or visits")
|
||||
107
server/internal/api/handlers_sites.go
Normal file
107
server/internal/api/handlers_sites.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// SiteBroker is the broker-side half of creating a shop. It is the
|
||||
// internal/broker package's interface, redeclared here so this package does
|
||||
// not import a paho dependency for the sake of one method.
|
||||
type SiteBroker interface {
|
||||
EnsureSite(ctx context.Context, username, password string) error
|
||||
DeleteSite(ctx context.Context, username string) error
|
||||
}
|
||||
|
||||
// Same rule the database enforces (sites_slug_format), checked here so the
|
||||
// caller gets a sentence instead of a constraint name.
|
||||
var slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$`)
|
||||
|
||||
// POST /api/sites - an owner opens a shop.
|
||||
//
|
||||
// Until this existed a shop was `provision site` on the server's command line
|
||||
// followed by a hand edit of the broker's password file. That made every new
|
||||
// branch a support ticket, and it was the last piece of onboarding that could
|
||||
// not be done from the product. The row and the broker login are created
|
||||
// together here; if the broker will not take the login, the row is removed
|
||||
// again and the caller is told, because a shop that exists in the database and
|
||||
// not on the broker is one whose PC enrols fine and never delivers a visit.
|
||||
func (s *Server) handleCreateSite(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
// Owner, not manager: a shop is a billing and tenancy object, not a
|
||||
// setting. Managers can set up the PC and cameras once it exists.
|
||||
if p.Role != "owner" || p.ClientID == "" {
|
||||
writeErr(w, http.StatusForbidden, "forbidden", "Only the owner can open a new shop.")
|
||||
return
|
||||
}
|
||||
if s.Broker == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "broker_unavailable",
|
||||
"This server is not connected to a broker that can register shops. Contact support.")
|
||||
return
|
||||
}
|
||||
var in NewSiteInput
|
||||
if err := decode(w, r, &in); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
in.Name = clip(trim(in.Name), 120)
|
||||
if in.Name == "" {
|
||||
badRequest(w, "Give the shop a name.")
|
||||
return
|
||||
}
|
||||
in.Slug = slugify(in.Slug)
|
||||
if in.Slug == "" {
|
||||
in.Slug = slugify(in.Name)
|
||||
}
|
||||
if !slugRe.MatchString(in.Slug) {
|
||||
badRequest(w, "The short name must be 3-32 characters: lower-case letters, digits and dashes.")
|
||||
return
|
||||
}
|
||||
tz := strings.TrimSpace(in.Timezone)
|
||||
if tz == "" {
|
||||
tz = "Asia/Kolkata"
|
||||
}
|
||||
if _, err := time.LoadLocation(tz); err != nil {
|
||||
badRequest(w, "Unknown timezone. Use an IANA name such as Asia/Kolkata.")
|
||||
return
|
||||
}
|
||||
|
||||
site, err := s.Store.CreateSite(r.Context(), p.ClientID, in.Slug, in.Name, tz)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
writeErr(w, http.StatusConflict, "conflict", "A shop with that short name already exists.")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrNoSecrets) {
|
||||
writeErr(w, http.StatusServiceUnavailable, "no_encryption_key",
|
||||
"This server has no encryption key, so a shop's broker password cannot be stored. Contact support.")
|
||||
return
|
||||
}
|
||||
s.serverError(w, "create site", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.Broker.EnsureSite(r.Context(), site.Username, site.Password); err != nil {
|
||||
s.logf("create site %s: broker registration failed, removing the row: %v", site.Slug, err)
|
||||
if derr := s.Store.DeleteNewSite(r.Context(), p.ClientID, site.SiteID); derr != nil {
|
||||
s.logf("create site %s: could not remove the row after broker failure: %v", site.Slug, derr)
|
||||
}
|
||||
writeErr(w, http.StatusBadGateway, "broker_unavailable",
|
||||
"The broker did not accept the new shop, so it was not created. Try again in a moment; if it keeps failing, contact support.")
|
||||
return
|
||||
}
|
||||
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "site.created", Entity: "site", EntityID: site.SiteID,
|
||||
Detail: map[string]any{"slug": site.Slug, "name": site.Name, "timezone": site.Timezone},
|
||||
})
|
||||
writeJSON(w, http.StatusCreated, site)
|
||||
}
|
||||
110
server/internal/api/sites_test.go
Normal file
110
server/internal/api/sites_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeBroker records what the server asked the broker to do.
|
||||
type fakeBroker struct {
|
||||
ensured map[string]string
|
||||
deleted []string
|
||||
fail error
|
||||
}
|
||||
|
||||
func (b *fakeBroker) EnsureSite(_ context.Context, user, pass string) error {
|
||||
if b.fail != nil {
|
||||
return b.fail
|
||||
}
|
||||
if b.ensured == nil {
|
||||
b.ensured = map[string]string{}
|
||||
}
|
||||
b.ensured[user] = pass
|
||||
return nil
|
||||
}
|
||||
func (b *fakeBroker) DeleteSite(_ context.Context, user string) error {
|
||||
b.deleted = append(b.deleted, user)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ownerSession(t *testing.T, s *Server, fs *fakeStore) Session {
|
||||
t.Helper()
|
||||
fs.addUser("owner@acme.com", "correct horse battery", UserRecord{
|
||||
ID: "u-owner", ClientID: "client-acme", Role: "owner", Active: true,
|
||||
})
|
||||
return login(t, s, "owner@acme.com", "correct horse battery")
|
||||
}
|
||||
|
||||
func TestAnOwnerOpensAShopAndTheBrokerLearnsOfIt(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
b := &fakeBroker{}
|
||||
s.Broker = b
|
||||
sess := ownerSession(t, s, fs)
|
||||
|
||||
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Acme Bengaluru!"})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out["slug"] != "acme-bengaluru" {
|
||||
t.Errorf("slug not derived from the name: %v", out["slug"])
|
||||
}
|
||||
if _, leaked := out["password"]; leaked {
|
||||
t.Fatal("the broker password was serialised")
|
||||
}
|
||||
if b.ensured["acme.acme-bengaluru"] == "" {
|
||||
t.Fatalf("broker was not told about the shop: %+v", b.ensured)
|
||||
}
|
||||
if len(fs.sites) != 1 {
|
||||
t.Fatalf("expected one site, have %d", len(fs.sites))
|
||||
}
|
||||
}
|
||||
|
||||
func TestABrokerFailureLeavesNoHalfMadeShop(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Broker = &fakeBroker{fail: errors.New("no answer on the control topic")}
|
||||
sess := ownerSession(t, s, fs)
|
||||
|
||||
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Ghost"})
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(fs.sites) != 0 {
|
||||
t.Fatalf("a shop the broker never accepted was kept: %+v", fs.sites)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAManagerCannotOpenAShop(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Broker = &fakeBroker{}
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Nope"})
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("manager opened a shop: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADuplicateShortNameIsAConflict(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Broker = &fakeBroker{}
|
||||
seedSite(fs)
|
||||
sess := ownerSession(t, s, fs)
|
||||
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Again", "slug": "chennai"})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoBrokerConfiguredSaysSo(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
sess := ownerSession(t, s, fs)
|
||||
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Shop"})
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -160,6 +160,30 @@ type PurchaseInput struct {
|
||||
// SiteHealth is what the dashboard needs to distinguish "no customers" from
|
||||
// "this shop's PC has been unplugged for a week" - two identical rows of zeroes
|
||||
// with completely different responses.
|
||||
// NewSiteInput is what an owner types to open a shop. The slug is derived
|
||||
// from the name when absent, because it becomes the shop PC's identity and
|
||||
// an MQTT topic segment, and a person asked to invent one invents a bad one.
|
||||
type NewSiteInput struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
// NewSite is the created shop plus the broker login the server registered for
|
||||
// it. The password is sealed in the database and handed to a shop PC at
|
||||
// enrolment; it is NOT in the API response, because nobody needs to see it -
|
||||
// the enrolment code is the credential a person handles.
|
||||
type NewSite struct {
|
||||
SiteID string `json:"site_id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Timezone string `json:"timezone"`
|
||||
// Username is the broker login; Password is returned to the caller in
|
||||
// Go only, for the broker registration, and never serialised.
|
||||
Username string `json:"broker_username"`
|
||||
Password string `json:"-"`
|
||||
}
|
||||
|
||||
type SiteHealth struct {
|
||||
SiteID string `json:"site_id"`
|
||||
Slug string `json:"slug"`
|
||||
@@ -392,6 +416,7 @@ type ClientRow struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
Sites int `json:"sites"`
|
||||
Users int `json:"users"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
|
||||
325
server/internal/broker/dynsec.go
Normal file
325
server/internal/broker/dynsec.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// Package broker creates and removes a site's broker login at runtime.
|
||||
//
|
||||
// Until this existed a shop was created in two places by two mechanisms:
|
||||
// `provision site` wrote the row and printed a password, and a person then
|
||||
// typed that password into Mosquitto's passwd file on the host and reloaded
|
||||
// the broker. Nothing else in the product needed a shell, so this one step
|
||||
// was what stopped a tenant opening a second branch on their own - and it was
|
||||
// fragile even for us: the file was mounted read-only in the container, the
|
||||
// first attempt failed, and the password had to be re-rolled.
|
||||
//
|
||||
// Mosquitto 2.0's dynamic-security plugin takes the same operations as
|
||||
// commands on a control topic, from a client that holds the `admin` role.
|
||||
// The server already holds a broker login; this gives it that role and uses
|
||||
// it. No file, no reload, no docker socket, and the per-site credential
|
||||
// model is unchanged - one username per shop, topics only under its own
|
||||
// prefix.
|
||||
//
|
||||
// Isolation is a ROLE PER SITE with literal topics, not one role with `%u`:
|
||||
// the 2.0 plugin does not substitute `%u` in ACL topics (measured - the
|
||||
// publish was denied). The role is created and deleted with the client, so
|
||||
// there is still exactly one thing to get right and it is done in one place.
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
paho "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
const (
|
||||
controlTopic = "$CONTROL/dynamic-security/v1"
|
||||
responseTopic = "$CONTROL/dynamic-security/v1/response"
|
||||
// A control round trip on a healthy broker is milliseconds; this is a
|
||||
// bound on a broker that is up but not answering control commands, which
|
||||
// is what a broker WITHOUT the plugin looks like.
|
||||
commandTimeout = 8 * time.Second
|
||||
connectTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// ErrUnavailable is returned when the broker cannot be reached or does not
|
||||
// answer control commands. Callers turn it into a 502/503, never a 500: the
|
||||
// operator's next step is to look at the broker, not at the server.
|
||||
var ErrUnavailable = errors.New("broker: dynamic security not available")
|
||||
|
||||
// SiteBroker is what the API and the provisioner depend on. A fake satisfies
|
||||
// it in tests; Dynsec satisfies it in production.
|
||||
type SiteBroker interface {
|
||||
// EnsureSite creates the broker login for one site, or resets its password
|
||||
// if it already exists. Idempotent: safe to run again after any failure.
|
||||
EnsureSite(ctx context.Context, username, password string) error
|
||||
// DeleteSite removes the login and its role. Missing is not an error.
|
||||
DeleteSite(ctx context.Context, username string) error
|
||||
}
|
||||
|
||||
// Dynsec drives the plugin over its own connection - not the ingest client's.
|
||||
// That one has SetOrderMatters and blocking handlers, and a provisioning call
|
||||
// must neither wait behind a slow visit nor delay one.
|
||||
type Dynsec struct {
|
||||
url, user, pass string
|
||||
log *log.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
client paho.Client
|
||||
pending map[string]chan []response // keyed by batch id
|
||||
}
|
||||
|
||||
func New(url, user, pass string, logger *log.Logger) *Dynsec {
|
||||
if logger == nil {
|
||||
logger = log.Default()
|
||||
}
|
||||
return &Dynsec{url: url, user: user, pass: pass, log: logger, pending: map[string]chan []response{}}
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Command string `json:"command"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CorrelationData string `json:"correlationData,omitempty"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// SiteTopics are exactly what a shop PC may do: publish its own visits,
|
||||
// heartbeats and status, and read its own commands. This mirrors the acl
|
||||
// file the broker used to be configured with, rule for rule.
|
||||
func siteACLs(username string) []map[string]any {
|
||||
prefix := "bv/" + username
|
||||
acl := func(kind, topic string) map[string]any {
|
||||
return map[string]any{"acltype": kind, "topic": topic, "allow": true, "priority": 0}
|
||||
}
|
||||
return []map[string]any{
|
||||
acl("publishClientSend", prefix+"/visit"),
|
||||
acl("publishClientSend", prefix+"/heartbeat"),
|
||||
acl("publishClientSend", prefix+"/status"),
|
||||
acl("publishClientReceive", prefix+"/cmd/#"),
|
||||
acl("subscribePattern", prefix+"/cmd/#"),
|
||||
}
|
||||
}
|
||||
|
||||
// RoleName is the per-site role. Exported so the migration can name it the
|
||||
// same way.
|
||||
func RoleName(username string) string { return "site." + username }
|
||||
|
||||
func (d *Dynsec) EnsureSite(ctx context.Context, username, password string) error {
|
||||
if strings.TrimSpace(username) == "" || password == "" {
|
||||
return errors.New("broker: username and password are required")
|
||||
}
|
||||
role := RoleName(username)
|
||||
|
||||
cmds := []map[string]any{{"command": "createRole", "rolename": role}}
|
||||
for _, a := range siteACLs(username) {
|
||||
c := map[string]any{"command": "addRoleACL", "rolename": role}
|
||||
for k, v := range a {
|
||||
c[k] = v
|
||||
}
|
||||
cmds = append(cmds, c)
|
||||
}
|
||||
cmds = append(cmds, map[string]any{
|
||||
"command": "createClient", "username": username, "password": password,
|
||||
"roles": []map[string]any{{"rolename": role}},
|
||||
})
|
||||
res, err := d.run(ctx, cmds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clientExisted := false
|
||||
for _, r := range res {
|
||||
switch {
|
||||
case r.Error == "":
|
||||
case strings.Contains(r.Error, "already exists") && r.Command != "createClient":
|
||||
// A re-run after a partial failure. Fine.
|
||||
case r.Command == "createClient" && strings.Contains(r.Error, "already exists"):
|
||||
clientExisted = true
|
||||
default:
|
||||
return fmt.Errorf("broker: %s: %s", r.Command, r.Error)
|
||||
}
|
||||
}
|
||||
if !clientExisted {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The login exists from an earlier run: make its password THIS one - the
|
||||
// database holds this one and the enrolment hands it out - and make sure
|
||||
// it carries the role. addClientRole on a client that already has it
|
||||
// answers "Internal error", so check first rather than guess from prose.
|
||||
res, err = d.run(ctx, []map[string]any{
|
||||
{"command": "setClientPassword", "username": username, "password": password},
|
||||
{"command": "getClient", "username": username},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasRole := false
|
||||
for _, r := range res {
|
||||
if r.Error != "" {
|
||||
return fmt.Errorf("broker: %s: %s", r.Command, r.Error)
|
||||
}
|
||||
if r.Command == "getClient" {
|
||||
var got struct {
|
||||
Client struct {
|
||||
Roles []struct{ Rolename string } `json:"roles"`
|
||||
} `json:"client"`
|
||||
}
|
||||
_ = json.Unmarshal(r.Data, &got)
|
||||
for _, rr := range got.Client.Roles {
|
||||
if rr.Rolename == role {
|
||||
hasRole = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasRole {
|
||||
return nil
|
||||
}
|
||||
res, err = d.run(ctx, []map[string]any{{"command": "addClientRole", "username": username, "rolename": role}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res[0].Error != "" {
|
||||
return fmt.Errorf("broker: addClientRole: %s", res[0].Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Dynsec) DeleteSite(ctx context.Context, username string) error {
|
||||
res, err := d.run(ctx, []map[string]any{
|
||||
{"command": "deleteClient", "username": username},
|
||||
{"command": "deleteRole", "rolename": RoleName(username)},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range res {
|
||||
if r.Error != "" && !strings.Contains(r.Error, "not found") {
|
||||
return fmt.Errorf("broker: %s: %s", r.Command, r.Error)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close drops the control connection. Safe when never connected.
|
||||
func (d *Dynsec) Close() {
|
||||
d.mu.Lock()
|
||||
c := d.client
|
||||
d.client = nil
|
||||
d.mu.Unlock()
|
||||
if c != nil && c.IsConnected() {
|
||||
c.Disconnect(250)
|
||||
}
|
||||
}
|
||||
|
||||
// run sends one batch and waits for its one response message. Every command
|
||||
// carries the batch id as correlationData, and the plugin echoes it, so a
|
||||
// response for somebody else's batch - two servers, or a retry - is never
|
||||
// mistaken for ours.
|
||||
func (d *Dynsec) run(ctx context.Context, cmds []map[string]any) ([]response, error) {
|
||||
if err := d.connect(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := newID()
|
||||
for _, c := range cmds {
|
||||
c["correlationData"] = id
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"commands": cmds})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan []response, 1)
|
||||
d.mu.Lock()
|
||||
d.pending[id] = ch
|
||||
client := d.client
|
||||
d.mu.Unlock()
|
||||
defer func() {
|
||||
d.mu.Lock()
|
||||
delete(d.pending, id)
|
||||
d.mu.Unlock()
|
||||
}()
|
||||
|
||||
tok := client.Publish(controlTopic, 1, false, body)
|
||||
if !tok.WaitTimeout(commandTimeout) {
|
||||
return nil, fmt.Errorf("%w: publish timed out", ErrUnavailable)
|
||||
}
|
||||
if tok.Error() != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrUnavailable, tok.Error())
|
||||
}
|
||||
select {
|
||||
case res := <-ch:
|
||||
return res, nil
|
||||
case <-time.After(commandTimeout):
|
||||
return nil, fmt.Errorf("%w: no answer on %s - is the dynamic-security plugin enabled and does %q hold the admin role?", ErrUnavailable, responseTopic, d.user)
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dynsec) onResponse(_ paho.Client, m paho.Message) {
|
||||
var env struct {
|
||||
Responses []response `json:"responses"`
|
||||
}
|
||||
if err := json.Unmarshal(m.Payload(), &env); err != nil || len(env.Responses) == 0 {
|
||||
return
|
||||
}
|
||||
id := env.Responses[0].CorrelationData
|
||||
d.mu.Lock()
|
||||
ch, ok := d.pending[id]
|
||||
d.mu.Unlock()
|
||||
if ok {
|
||||
select {
|
||||
case ch <- env.Responses:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dynsec) connect(ctx context.Context) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.client != nil && d.client.IsConnectionOpen() {
|
||||
return nil
|
||||
}
|
||||
opts := paho.NewClientOptions().
|
||||
AddBroker(d.url).
|
||||
SetClientID(fmt.Sprintf("behavision-dynsec-%s", newID()[:8])).
|
||||
SetUsername(d.user).
|
||||
SetPassword(d.pass).
|
||||
SetAutoReconnect(true).
|
||||
SetCleanSession(true).
|
||||
SetKeepAlive(30 * time.Second).
|
||||
SetConnectTimeout(connectTimeout)
|
||||
opts.OnConnect = func(c paho.Client) {
|
||||
// Re-subscribed on every (re)connect: clean session keeps nothing.
|
||||
c.Subscribe(responseTopic, 1, d.onResponse)
|
||||
}
|
||||
c := paho.NewClient(opts)
|
||||
tok := c.Connect()
|
||||
if !tok.WaitTimeout(connectTimeout) {
|
||||
return fmt.Errorf("%w: connect to %s timed out", ErrUnavailable, d.url)
|
||||
}
|
||||
if tok.Error() != nil {
|
||||
return fmt.Errorf("%w: %v", ErrUnavailable, tok.Error())
|
||||
}
|
||||
// The subscription must be in place before the first command is sent, or
|
||||
// its answer is published to nobody.
|
||||
st := c.Subscribe(responseTopic, 1, d.onResponse)
|
||||
if !st.WaitTimeout(connectTimeout) || st.Error() != nil {
|
||||
c.Disconnect(100)
|
||||
return fmt.Errorf("%w: cannot subscribe to %s (does %q hold the admin role?)", ErrUnavailable, responseTopic, d.user)
|
||||
}
|
||||
d.client = c
|
||||
return nil
|
||||
}
|
||||
|
||||
func newID() string {
|
||||
var b [12]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
96
server/internal/broker/dynsec_live_test.go
Normal file
96
server/internal/broker/dynsec_live_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
paho "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
// Runs against a real Mosquitto with the dynamic-security plugin, because a
|
||||
// fake broker would only prove the JSON matches what I believe the plugin
|
||||
// wants. Gated on the environment like the store's live tests:
|
||||
//
|
||||
// DYNSEC_TEST_URL=tcp://127.0.0.1:51884 DYNSEC_TEST_USER=behavision-backend \
|
||||
// DYNSEC_TEST_PASS=pw-backend go test ./internal/broker -run Live -v
|
||||
func TestLiveEnsureAndDeleteSite(t *testing.T) {
|
||||
url, user, pass := os.Getenv("DYNSEC_TEST_URL"), os.Getenv("DYNSEC_TEST_USER"), os.Getenv("DYNSEC_TEST_PASS")
|
||||
if url == "" {
|
||||
t.Skip("DYNSEC_TEST_URL not set")
|
||||
}
|
||||
d := New(url, user, pass, nil)
|
||||
defer d.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
site := "livetest.shop1"
|
||||
if err := d.EnsureSite(ctx, site, "first-pw"); err != nil {
|
||||
t.Fatalf("EnsureSite: %v", err)
|
||||
}
|
||||
// Idempotent, and the password becomes the one given THIS time.
|
||||
if err := d.EnsureSite(ctx, site, "second-pw"); err != nil {
|
||||
t.Fatalf("EnsureSite again: %v", err)
|
||||
}
|
||||
if err := canConnect(url, site, "first-pw"); err == nil {
|
||||
t.Fatalf("old password still accepted after re-ensure")
|
||||
}
|
||||
if err := canConnect(url, site, "second-pw"); err != nil {
|
||||
t.Fatalf("new password refused: %v", err)
|
||||
}
|
||||
// Own topic allowed, another site's refused. A denied publish at QoS 1
|
||||
// still gets a PUBACK, so this is observed through the backend's inbox.
|
||||
got := make(chan string, 4)
|
||||
backend := connect(t, url, user, pass)
|
||||
defer backend.Disconnect(100)
|
||||
backend.Subscribe("bv/#", 1, func(_ paho.Client, m paho.Message) { got <- m.Topic() }).Wait()
|
||||
shop := connect(t, url, site, "second-pw")
|
||||
defer shop.Disconnect(100)
|
||||
shop.Publish("bv/other.shop/visit", 1, false, "leak").Wait()
|
||||
shop.Publish("bv/"+site+"/visit", 1, false, "ok").Wait()
|
||||
select {
|
||||
case topic := <-got:
|
||||
if topic != "bv/"+site+"/visit" {
|
||||
t.Fatalf("first delivered topic was %s", topic)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("own-topic publish never arrived")
|
||||
}
|
||||
select {
|
||||
case topic := <-got:
|
||||
t.Fatalf("unexpected second delivery: %s", topic)
|
||||
case <-time.After(1500 * time.Millisecond):
|
||||
}
|
||||
|
||||
if err := d.DeleteSite(ctx, site); err != nil {
|
||||
t.Fatalf("DeleteSite: %v", err)
|
||||
}
|
||||
if err := d.DeleteSite(ctx, site); err != nil {
|
||||
t.Fatalf("DeleteSite twice: %v", err)
|
||||
}
|
||||
if err := canConnect(url, site, "second-pw"); err == nil {
|
||||
t.Fatal("deleted site can still connect")
|
||||
}
|
||||
}
|
||||
|
||||
func connect(t *testing.T, url, user, pass string) paho.Client {
|
||||
t.Helper()
|
||||
c := paho.NewClient(paho.NewClientOptions().AddBroker(url).SetUsername(user).SetPassword(pass).SetConnectTimeout(5 * time.Second))
|
||||
tok := c.Connect()
|
||||
tok.Wait()
|
||||
if tok.Error() != nil {
|
||||
t.Fatalf("connect as %s: %v", user, tok.Error())
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func canConnect(url, user, pass string) error {
|
||||
c := paho.NewClient(paho.NewClientOptions().AddBroker(url).SetUsername(user).SetPassword(pass).SetConnectTimeout(5 * time.Second))
|
||||
tok := c.Connect()
|
||||
tok.Wait()
|
||||
if tok.Error() == nil {
|
||||
c.Disconnect(50)
|
||||
}
|
||||
return tok.Error()
|
||||
}
|
||||
132
server/internal/broker/migrate.go
Normal file
132
server/internal/broker/migrate.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Store is the plugin's on-disk shape - the part of it this code writes.
|
||||
type Store struct {
|
||||
Clients []Client `json:"clients"`
|
||||
Roles []Role `json:"roles"`
|
||||
DefaultACLAccess map[string]bool `json:"defaultACLAccess"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
Username string `json:"username"`
|
||||
TextName string `json:"textName,omitempty"`
|
||||
Password string `json:"password"`
|
||||
Salt string `json:"salt"`
|
||||
Iterations int `json:"iterations"`
|
||||
Roles []RoleRef `json:"roles"`
|
||||
}
|
||||
|
||||
type RoleRef struct {
|
||||
Rolename string `json:"rolename"`
|
||||
}
|
||||
|
||||
type Role struct {
|
||||
Rolename string `json:"rolename"`
|
||||
ACLs []ACL `json:"acls"`
|
||||
}
|
||||
|
||||
type ACL struct {
|
||||
ACLType string `json:"acltype"`
|
||||
Topic string `json:"topic"`
|
||||
Allow bool `json:"allow"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
// FromPasswd converts a mosquitto_passwd file into the plugin's store,
|
||||
// keeping every password exactly as it is.
|
||||
//
|
||||
// This is the cutover for a broker that already has sites: the hashes in the
|
||||
// passwd file are PBKDF2-SHA512 ($7$<iterations>$<salt>$<digest>, base64),
|
||||
// which is the same thing the plugin stores as password/salt/iterations - so
|
||||
// no shop PC has to be re-claimed and no credential changes hands. The roles
|
||||
// reproduce the acl file rule for rule: the backend reads everything and
|
||||
// drives the plugin, the health probe reads uptime, and every other user is a
|
||||
// site that may write under its own prefix and read its own commands.
|
||||
func FromPasswd(r io.Reader, backendUser, healthUser string) (*Store, error) {
|
||||
st := &Store{
|
||||
DefaultACLAccess: map[string]bool{
|
||||
"publishClientSend": false, "publishClientReceive": false,
|
||||
"subscribe": false, "unsubscribe": true,
|
||||
},
|
||||
}
|
||||
st.Roles = append(st.Roles,
|
||||
Role{Rolename: "admin", ACLs: []ACL{
|
||||
{"publishClientSend", "$CONTROL/dynamic-security/#", true, 0},
|
||||
{"publishClientReceive", "$CONTROL/dynamic-security/#", true, 0},
|
||||
{"subscribePattern", "$CONTROL/dynamic-security/#", true, 0},
|
||||
}},
|
||||
Role{Rolename: "backend", ACLs: []ACL{
|
||||
{"publishClientSend", "bv/#", true, 0},
|
||||
{"publishClientReceive", "bv/#", true, 0},
|
||||
{"subscribePattern", "bv/#", true, 0},
|
||||
{"publishClientReceive", "$SYS/#", true, 0},
|
||||
{"subscribePattern", "$SYS/#", true, 0},
|
||||
}},
|
||||
Role{Rolename: "health", ACLs: []ACL{
|
||||
{"publishClientReceive", "$SYS/broker/uptime", true, 0},
|
||||
{"subscribePattern", "$SYS/broker/uptime", true, 0},
|
||||
}},
|
||||
)
|
||||
|
||||
sc := bufio.NewScanner(r)
|
||||
line := 0
|
||||
for sc.Scan() {
|
||||
line++
|
||||
text := strings.TrimSpace(sc.Text())
|
||||
if text == "" || strings.HasPrefix(text, "#") {
|
||||
continue
|
||||
}
|
||||
user, hash, ok := strings.Cut(text, ":")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("passwd line %d: no ':'", line)
|
||||
}
|
||||
parts := strings.Split(hash, "$")
|
||||
// "", "7", iterations, salt, digest
|
||||
if len(parts) != 5 || parts[1] != "7" {
|
||||
return nil, fmt.Errorf("passwd line %d (%s): not a $7$ PBKDF2 hash; re-set that password with mosquitto_passwd first", line, user)
|
||||
}
|
||||
iters, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("passwd line %d (%s): iterations %q", line, user, parts[2])
|
||||
}
|
||||
c := Client{Username: user, Password: parts[4], Salt: parts[3], Iterations: iters}
|
||||
switch user {
|
||||
case backendUser:
|
||||
c.TextName = "Behavision server"
|
||||
c.Roles = []RoleRef{{"admin"}, {"backend"}}
|
||||
case healthUser:
|
||||
c.TextName = "health probe"
|
||||
c.Roles = []RoleRef{{"health"}}
|
||||
default:
|
||||
role := RoleName(user)
|
||||
var acls []ACL
|
||||
for _, a := range siteACLs(user) {
|
||||
acls = append(acls, ACL{a["acltype"].(string), a["topic"].(string), true, 0})
|
||||
}
|
||||
st.Roles = append(st.Roles, Role{Rolename: role, ACLs: acls})
|
||||
c.TextName = "site " + user
|
||||
c.Roles = []RoleRef{{role}}
|
||||
}
|
||||
st.Clients = append(st.Clients, c)
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Encode writes the store as the plugin reads it.
|
||||
func (s *Store) Encode(w io.Writer) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", "\t")
|
||||
return enc.Encode(s)
|
||||
}
|
||||
73
server/internal/broker/migrate_test.go
Normal file
73
server/internal/broker/migrate_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const passwd = `behavision-backend:$7$101$c2FsdA==$ZGlnZXN0
|
||||
health:$7$101$aGVhbHRo$aGFzaA==
|
||||
acme.store1:$7$101$c2l0ZQ==$c2l0ZWhhc2g=
|
||||
`
|
||||
|
||||
func TestPasswdBecomesTheStoreWithHashesIntact(t *testing.T) {
|
||||
st, err := FromPasswd(strings.NewReader(passwd), "behavision-backend", "health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(st.Clients) != 3 {
|
||||
t.Fatalf("clients: %d", len(st.Clients))
|
||||
}
|
||||
site := st.Clients[2]
|
||||
if site.Password != "c2l0ZWhhc2g=" || site.Salt != "c2l0ZQ==" || site.Iterations != 101 {
|
||||
t.Fatalf("hash not carried over intact: %+v", site)
|
||||
}
|
||||
if site.Roles[0].Rolename != "site.acme.store1" {
|
||||
t.Fatalf("site role: %+v", site.Roles)
|
||||
}
|
||||
var siteRole *Role
|
||||
for i := range st.Roles {
|
||||
if st.Roles[i].Rolename == "site.acme.store1" {
|
||||
siteRole = &st.Roles[i]
|
||||
}
|
||||
}
|
||||
if siteRole == nil {
|
||||
t.Fatal("no role for the site")
|
||||
}
|
||||
topics := map[string]bool{}
|
||||
for _, a := range siteRole.ACLs {
|
||||
topics[a.ACLType+" "+a.Topic] = true
|
||||
}
|
||||
for _, want := range []string{
|
||||
"publishClientSend bv/acme.store1/visit",
|
||||
"publishClientSend bv/acme.store1/heartbeat",
|
||||
"publishClientSend bv/acme.store1/status",
|
||||
"subscribePattern bv/acme.store1/cmd/#",
|
||||
} {
|
||||
if !topics[want] {
|
||||
t.Errorf("missing %q in %v", want, topics)
|
||||
}
|
||||
}
|
||||
if st.Clients[0].Roles[0].Rolename != "admin" {
|
||||
t.Fatalf("backend is not an admin: %+v", st.Clients[0].Roles)
|
||||
}
|
||||
if st.DefaultACLAccess["publishClientSend"] || st.DefaultACLAccess["subscribe"] {
|
||||
t.Fatal("default access must be deny")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := st.Encode(&buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !json.Valid(buf.Bytes()) {
|
||||
t.Fatal("encoded store is not valid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestANonPBKDF2LineIsRefusedByName(t *testing.T) {
|
||||
_, err := FromPasswd(strings.NewReader("old:$6$abc$def\n"), "b", "h")
|
||||
if err == nil || !strings.Contains(err.Error(), "old") {
|
||||
t.Fatalf("expected a named refusal, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,15 @@ import (
|
||||
type Provisioner struct {
|
||||
Pool *pgxpool.Pool
|
||||
Secrets *secret.Box
|
||||
// Broker registers a site's login with Mosquitto when set. Without it the
|
||||
// command prints the password for a hand edit of the passwd file, which
|
||||
// is the pre-dynamic-security fallback and nothing more.
|
||||
Broker SiteBroker
|
||||
}
|
||||
|
||||
// SiteBroker mirrors internal/broker's interface without importing it.
|
||||
type SiteBroker interface {
|
||||
EnsureSite(ctx context.Context, username, password string) error
|
||||
}
|
||||
|
||||
func (p *Provisioner) CreateClient(ctx context.Context, slug, name string) (string, error) {
|
||||
@@ -48,6 +57,9 @@ type SiteResult struct {
|
||||
AgentID string
|
||||
Username string
|
||||
Password string
|
||||
// BrokerRegistered is true when the login was pushed to Mosquitto here,
|
||||
// so nothing remains for a person to type.
|
||||
BrokerRegistered bool
|
||||
}
|
||||
|
||||
// CreateSite makes a site, its agent row, and the broker password.
|
||||
@@ -120,7 +132,16 @@ func (p *Provisioner) CreateSite(ctx context.Context, clientSlug, siteSlug, name
|
||||
out.AgentID, sealed); err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, tx.Commit(ctx)
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if p.Broker != nil {
|
||||
if err := p.Broker.EnsureSite(ctx, out.Username, out.Password); err != nil {
|
||||
return out, fmt.Errorf("site %s created, but the broker did not accept its login: %w", out.Username, err)
|
||||
}
|
||||
out.BrokerRegistered = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provisioner) CreateUser(ctx context.Context, clientSlug, email, role,
|
||||
|
||||
@@ -76,7 +76,7 @@ func (s *Store) CreateClientWithOwner(ctx context.Context, in api.NewClientInput
|
||||
// in whichever direction the operator's eye went first.
|
||||
func (s *Store) ListClients(ctx context.Context) ([]api.ClientRow, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT c.id::text, c.slug, c.name, c.created_at,
|
||||
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)
|
||||
FROM clients c
|
||||
@@ -90,7 +90,7 @@ func (s *Store) ListClients(ctx context.Context) ([]api.ClientRow, error) {
|
||||
for rows.Next() {
|
||||
var c api.ClientRow
|
||||
var at time.Time
|
||||
if err := rows.Scan(&c.ID, &c.Slug, &c.Name, &at, &c.Sites, &c.Users); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.Slug, &c.Name, &c.Active, &at, &c.Sites, &c.Users); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.CreatedAt = at.UTC().Format(time.RFC3339)
|
||||
|
||||
148
server/internal/store/api_admin_clients.go
Normal file
148
server/internal/store/api_admin_clients.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/api"
|
||||
)
|
||||
|
||||
func (s *Store) SetClientActive(ctx context.Context, clientID string, active bool) (api.ClientRow, int, error) {
|
||||
var row api.ClientRow
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return row, 0, err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
var at time.Time
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE clients SET active = $2 WHERE id = $1::uuid
|
||||
RETURNING id::text, slug, name, active, created_at,
|
||||
(SELECT count(*) FROM sites WHERE client_id = clients.id),
|
||||
(SELECT count(*) FROM app_users WHERE client_id = clients.id)`, clientID, active).
|
||||
Scan(&row.ID, &row.Slug, &row.Name, &row.Active, &at, &row.Sites, &row.Users); err != nil {
|
||||
return row, 0, err
|
||||
}
|
||||
row.CreatedAt = at.UTC().Format(time.RFC3339)
|
||||
revoked := 0
|
||||
if !active {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE sessions SET revoked_at = now()
|
||||
WHERE revoked_at IS NULL
|
||||
AND user_id IN (SELECT id FROM app_users WHERE client_id = $1::uuid)`, clientID)
|
||||
if err != nil {
|
||||
return row, 0, fmt.Errorf("revoke sessions: %w", err)
|
||||
}
|
||||
revoked = int(tag.RowsAffected())
|
||||
}
|
||||
return row, revoked, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) ClientOwners(ctx context.Context, clientID string) ([]api.TeamMember, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id::text, email, full_name, role, active
|
||||
FROM app_users
|
||||
WHERE client_id = $1::uuid AND role = 'owner' AND active
|
||||
ORDER BY created_at`, clientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []api.TeamMember
|
||||
for rows.Next() {
|
||||
var m api.TeamMember
|
||||
if err := rows.Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ClientImageKeys(ctx context.Context, clientID string) ([]string, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT image_key FROM visits
|
||||
WHERE client_id = $1::uuid AND image_key <> '' AND image_deleted_at IS NULL`, clientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var keys []string
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteClient relies on ON DELETE CASCADE from clients, which every tenant
|
||||
// table declares (001-011). The broker usernames are read before the rows go,
|
||||
// because afterwards nothing remembers them.
|
||||
func (s *Store) DeleteClient(ctx context.Context, clientID string) (api.ClientRow, []string, error) {
|
||||
var row api.ClientRow
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return row, nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
if err := tx.QueryRow(ctx, `SELECT id::text, slug, name, active FROM clients WHERE id = $1::uuid FOR UPDATE`, clientID).
|
||||
Scan(&row.ID, &row.Slug, &row.Name, &row.Active); err != nil {
|
||||
return row, nil, err
|
||||
}
|
||||
if row.Active {
|
||||
return row, nil, fmt.Errorf("client %s is active; suspend it first", row.Slug)
|
||||
}
|
||||
rows, err := tx.Query(ctx, `SELECT mqtt_username FROM agents WHERE client_id = $1::uuid`, clientID)
|
||||
if err != nil {
|
||||
return row, nil, err
|
||||
}
|
||||
var users []string
|
||||
for rows.Next() {
|
||||
var u string
|
||||
if err := rows.Scan(&u); err != nil {
|
||||
rows.Close()
|
||||
return row, nil, err
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
rows.Close()
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM clients WHERE id = $1::uuid`, clientID); err != nil {
|
||||
return row, nil, fmt.Errorf("delete client: %w", err)
|
||||
}
|
||||
return row, users, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteEmptySite(ctx context.Context, clientID, siteID string) (string, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
var used bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT EXISTS (SELECT 1 FROM visits WHERE site_id = $1::uuid)
|
||||
OR EXISTS (SELECT 1 FROM site_cameras WHERE site_id = $1::uuid AND deleted_at IS NULL)`, siteID).Scan(&used); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if used {
|
||||
return "", api.ErrSiteInUse
|
||||
}
|
||||
var username string
|
||||
if err := tx.QueryRow(ctx, `SELECT COALESCE((SELECT mqtt_username FROM agents WHERE site_id = $1::uuid LIMIT 1), '')`, siteID).Scan(&username); err != nil {
|
||||
return "", err
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `DELETE FROM sites WHERE id = $1::uuid AND client_id = $2::uuid`, siteID, clientID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return "", pgx.ErrNoRows
|
||||
}
|
||||
return username, tx.Commit(ctx)
|
||||
}
|
||||
115
server/internal/store/api_sites.go
Normal file
115
server/internal/store/api_sites.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/api"
|
||||
)
|
||||
|
||||
// CreateSite is the row half of opening a shop. The broker half follows it in
|
||||
// the handler and the provisioner, and both use this so there is one
|
||||
// definition of what a site is.
|
||||
func (s *Store) CreateSite(ctx context.Context, clientID, slug, name, tz string) (api.NewSite, error) {
|
||||
var out api.NewSite
|
||||
if s.secrets == nil {
|
||||
return out, ErrNoSecrets
|
||||
}
|
||||
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||
if tz == "" {
|
||||
tz = "UTC"
|
||||
}
|
||||
if _, err := time.LoadLocation(tz); err != nil {
|
||||
return out, fmt.Errorf("unknown timezone %q", tz)
|
||||
}
|
||||
|
||||
var clientSlug string
|
||||
if err := s.pool.QueryRow(ctx, `SELECT slug FROM clients WHERE id = $1::uuid`, clientID).Scan(&clientSlug); err != nil {
|
||||
return out, fmt.Errorf("client %s: %w", clientID, err)
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
|
||||
// A plain INSERT, not an upsert: the API must not let an owner silently
|
||||
// rename an existing shop by re-posting its slug. A duplicate surfaces as
|
||||
// 23505 and the handler turns it into 409.
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO sites (client_id, slug, name, timezone)
|
||||
VALUES ($1::uuid, $2, $3, $4)
|
||||
RETURNING id::text`, clientID, slug, name, tz).Scan(&out.SiteID); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Slug, out.Name, out.Timezone = slug, name, tz
|
||||
// The broker username IS the topic namespace: <client>.<site>. The ACL is
|
||||
// written against it, so it is derived, never chosen.
|
||||
out.Username = clientSlug + "." + slug
|
||||
|
||||
var agentID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO agents (client_id, site_id, mqtt_username)
|
||||
VALUES ($1::uuid, $2::uuid, $3)
|
||||
RETURNING id::text`, clientID, out.SiteID, out.Username).Scan(&agentID); err != nil {
|
||||
return out, fmt.Errorf("create agent: %w", err)
|
||||
}
|
||||
|
||||
out.Password, err = randomSecret(24)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
// Sealed with the agent id as aad, so a row copied between agents does not
|
||||
// decrypt into a working credential.
|
||||
sealed, err := s.secrets.SealString(out.Password, agentID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE agents SET mqtt_password_enc = $2 WHERE id = $1::uuid`, agentID, sealed); err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// DeleteNewSite removes a shop that was created moments ago and could not be
|
||||
// registered with the broker. Scoped to the tenant and refused once the shop
|
||||
// has anything under it: this is compensation for a failed create, not a
|
||||
// delete-shop feature.
|
||||
func (s *Store) DeleteNewSite(ctx context.Context, clientID, siteID string) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
var used bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT EXISTS (SELECT 1 FROM visits WHERE site_id = $1::uuid)
|
||||
OR EXISTS (SELECT 1 FROM site_cameras WHERE site_id = $1::uuid)`, siteID).Scan(&used); err != nil {
|
||||
return err
|
||||
}
|
||||
if used {
|
||||
return fmt.Errorf("site %s is in use", siteID)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM agents WHERE site_id = $1::uuid AND client_id = $2::uuid`, siteID, clientID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM sites WHERE id = $1::uuid AND client_id = $2::uuid`, siteID, clientID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func randomSecret(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// base32 without padding: this gets typed, pasted into config files and
|
||||
// read down a phone line, and base64's + / = survive none of that.
|
||||
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)), nil
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
2
server/internal/web/dist/index.html
vendored
2
server/internal/web/dist/index.html
vendored
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Behavision</title>
|
||||
<script type="module" crossorigin src="/assets/index-BI5JLIeo.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-xQ5C-n-U.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bgt5SnW3.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -205,6 +205,7 @@ export const api = {
|
||||
me: () => send('GET', '/api/auth/me'),
|
||||
|
||||
sites: () => send('GET', '/api/sites'),
|
||||
createSite: (input) => send('POST', '/api/sites', input),
|
||||
|
||||
// The live arrivals feed. `cursor` is opaque and must be echoed back.
|
||||
arrivals: (params) => send('GET', '/api/visits' + qs(params)),
|
||||
|
||||
@@ -17,18 +17,22 @@ import SiteCheck from './SiteCheck.jsx'
|
||||
// shops. So the shop's own camera view is the card, the numbers sit under it,
|
||||
// and one line says what to do — with the technical detail one click away
|
||||
// rather than on the surface.
|
||||
export default function Sites() {
|
||||
const { data, error, loading } = usePolled(() => api.sites(), 20000, [])
|
||||
export default function Sites({ user }) {
|
||||
const { data, error, loading, reload } = usePolled(() => api.sites(), 20000, [])
|
||||
// Cameras come from a second call and are joined here rather than server-side:
|
||||
// the picture is decoration on this screen, so it must never be able to make
|
||||
// the health list fail. If this errors the cards simply have no photograph.
|
||||
const { data: cams } = usePolled(() => api.cameras(), 60000, [])
|
||||
const [checking, setChecking] = useState(null)
|
||||
const [opening, setOpening] = useState(false)
|
||||
const sites = data || []
|
||||
// Opening a shop is the owner's: it is a billing and tenancy object, not a
|
||||
// setting. Managers set up the PC and cameras once it exists.
|
||||
const canOpen = user?.role === 'owner'
|
||||
|
||||
if (loading && !data) return <Loading />
|
||||
if (error) return <Problem error={error} />
|
||||
if (!sites.length) return <Empty />
|
||||
if (!sites.length && !opening) return <Empty canOpen={canOpen} onOpen={() => setOpening(true)} />
|
||||
|
||||
// Counted from the same verdicts the cards show. Summarising with a second,
|
||||
// simpler rule up here is how a header ends up reading "all working" over a
|
||||
@@ -51,6 +55,9 @@ export default function Sites() {
|
||||
{fresh > 0 && <> · {fresh} not set up yet</>}
|
||||
{!broken && !watch && !fresh && <> · <b className="ok">all working</b></>}
|
||||
</p>
|
||||
{canOpen && (
|
||||
<button className="primary" onClick={() => setOpening(true)}>Open a new shop</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="grid sites">
|
||||
@@ -64,10 +71,66 @@ export default function Sites() {
|
||||
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)} />}
|
||||
{opening && <NewShop onClose={() => setOpening(false)}
|
||||
onCreated={() => { setOpening(false); reload() }} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Until this existed a shop was a command on the server plus a hand edit of
|
||||
// the broker's password file - every new branch a support ticket, and the one
|
||||
// piece of onboarding that could not be done from the product. The server now
|
||||
// registers the shop's broker login as it creates the row, so the next step
|
||||
// really is just "Set up a shop PC" on the new card.
|
||||
function NewShop({ onClose, onCreated }) {
|
||||
const [form, setForm] = useState({ name: '', slug: '', timezone: 'Asia/Kolkata' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const set = (k) => (e) => setForm({ ...form, [k]: e.target.value })
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
onCreated(await api.createSite({ name: form.name, slug: form.slug || undefined, timezone: form.timezone }))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<aside className="drawer narrow" onClick={e => e.stopPropagation()}>
|
||||
<header className="drawer-head">
|
||||
<h2>Open a new shop</h2>
|
||||
<button className="ghost" onClick={onClose}>Close</button>
|
||||
</header>
|
||||
<form className="drawer-body" onSubmit={submit}>
|
||||
<label>Shop name
|
||||
<input value={form.name} onChange={set('name')} required autoFocus placeholder="TeNext Bengaluru" />
|
||||
</label>
|
||||
<label>Short name
|
||||
<input value={form.slug} onChange={set('slug')} placeholder="made from the name if left empty" />
|
||||
<span className="hint">
|
||||
Lower-case letters, digits and dashes. It becomes the shop PC’s
|
||||
identity and cannot be changed afterwards.
|
||||
</span>
|
||||
</label>
|
||||
<label>Timezone
|
||||
<input value={form.timezone} onChange={set('timezone')} required />
|
||||
<span className="hint">Footfall is bucketed by the shop’s own clock, e.g. Asia/Kolkata.</span>
|
||||
</label>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
<button className="primary" disabled={busy || !form.name.trim()}>
|
||||
{busy ? 'Opening…' : 'Open shop'}
|
||||
</button>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The one line the card leads with, in severity order. Only the first is shown:
|
||||
// a shop that is offline AND has a bad camera needs its PC turned on first, and
|
||||
// listing both invites someone to start with the wrong one.
|
||||
@@ -272,13 +335,15 @@ export function Problem({ error }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
function Empty({ canOpen, onOpen }) {
|
||||
return (
|
||||
<div className="state">
|
||||
<h2>No shops yet</h2>
|
||||
<p className="sub">
|
||||
A shop appears here once its PC has been claimed with an enrolment code.
|
||||
{canOpen ? 'Open your first shop, then set up its PC and cameras from its card.'
|
||||
: 'The owner opens shops; they appear here once created.'}
|
||||
</p>
|
||||
{canOpen && <button className="primary" onClick={onOpen}>Open a new shop</button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user