Behavision: face recognition for retail, edge to head office
Five components that ship as one product:
- behavision/ the recognition engine. RTSP ingest, YuNet detection, IoU
tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
FastAPI dashboard. Identity is decided once per TRACK from an
average of at least three embeddings, never per frame.
- agent/ the Go edge agent: supervises the engine, holds a durable
spool, and drains it to MQTT. Nothing is acked before the
broker confirms.
- desktop/ the shop PC application (Wails + React + tray).
- server/ the cloud API, MQTT consumer, reports and assistant.
- web/ platform.loyaly.ai, the head-office app, embedded in the
server binary.
The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.
CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
48
desktop/README.md
Normal file
48
desktop/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Behavision desktop
|
||||
|
||||
The store-facing app: a tray icon, a window, and the supervisor for the Python
|
||||
recognition engine.
|
||||
|
||||
## Why one process, not three
|
||||
|
||||
The tray, the window and the supervisor all need the same state, and a user who
|
||||
quits the tray expects recognition to stop. Splitting them means two things can
|
||||
disagree about whether the engine is running.
|
||||
|
||||
It is deliberately **not** a Windows service. A service runs in session 0 and
|
||||
cannot draw a tray icon — that is Windows session isolation, not a library
|
||||
limitation. Spawning a child process also needs no elevation, while controlling
|
||||
a service does, so this design never triggers UAC at runtime.
|
||||
|
||||
## Layout
|
||||
|
||||
main.go wails.Run, window options
|
||||
app.go the methods bound to the frontend
|
||||
tray.go fyne.io/systray — wails v2 has no tray of its own
|
||||
icons.go tray icons generated at run time, not embedded
|
||||
internal/local client for the engine on 127.0.0.1:8010
|
||||
internal/cloud client for https://mcp.loyaly.ai
|
||||
frontend/ React + Vite
|
||||
|
||||
The supervisor, durable spool, broker client and path resolution come from
|
||||
`../agent/pkg/*` — the same tested code the headless agent runs, imported
|
||||
rather than copied.
|
||||
|
||||
## Build
|
||||
|
||||
cd frontend && npm install && npm run build # then, from this directory:
|
||||
wails build -platform windows/amd64
|
||||
|
||||
`wails build` needs the Wails CLI:
|
||||
|
||||
go install github.com/wailsapp/wails/v2/cmd/wails@v2.9.2
|
||||
|
||||
Without it, `go build` still type-checks everything **provided
|
||||
`frontend/dist` exists** — the embed directive requires it.
|
||||
|
||||
## What the frontend talks to
|
||||
|
||||
Nothing is imported from generated bindings. `src/bridge.js` calls
|
||||
`window.go.main.App.*` directly, so `npm run build` works without running
|
||||
`wails generate`, and there is one place that handles "the engine is not
|
||||
running yet" — the state every screen has to survive on a fresh install.
|
||||
687
desktop/app.go
Normal file
687
desktop/app.go
Normal file
@@ -0,0 +1,687 @@
|
||||
// The methods bound to the frontend.
|
||||
//
|
||||
// Every one is a thin adapter: it talks to the local engine, the cloud, or the
|
||||
// supervisor, and returns something JSON-shaped. No recognition logic lives
|
||||
// here - the engine owns that, and duplicating any of it would give the UI a
|
||||
// second opinion about who someone is.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
agentbridge "github.com/loyaly/behavision-agent/pkg/bridge"
|
||||
agentcameras "github.com/loyaly/behavision-agent/pkg/cameras"
|
||||
agentcfg "github.com/loyaly/behavision-agent/pkg/config"
|
||||
agentengine "github.com/loyaly/behavision-agent/pkg/engine"
|
||||
agentmqtt "github.com/loyaly/behavision-agent/pkg/mqtt"
|
||||
agentpaths "github.com/loyaly/behavision-agent/pkg/paths"
|
||||
agentspool "github.com/loyaly/behavision-agent/pkg/spool"
|
||||
|
||||
"github.com/loyaly/behavision-desktop/internal/cloud"
|
||||
"github.com/loyaly/behavision-desktop/internal/local"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
mu sync.RWMutex
|
||||
cfg agentcfg.Config
|
||||
cloud *cloud.Client
|
||||
local *local.Client
|
||||
sup *agentengine.Supervisor
|
||||
spool *agentspool.Spool
|
||||
bridge *agentbridge.Bridge
|
||||
broker *agentmqtt.Client
|
||||
stopBridge func()
|
||||
hookURL string
|
||||
// Set once the operator logs in. Until then the UI shows the login sheet
|
||||
// and nothing else is reachable.
|
||||
onSessionChange func(bool)
|
||||
}
|
||||
|
||||
func NewApp() *App {
|
||||
cfg, _ := agentcfg.Load(agentpaths.AgentConfig())
|
||||
// The engine invents its own Basic credential when none is configured,
|
||||
// which is the default. Without this every call this app makes to the
|
||||
// engine - health, cameras, the live feed - comes back 401, and the tray
|
||||
// shows a healthy process the UI cannot talk to.
|
||||
cfg = cfg.WithEngineCredentials(agentpaths.APICredentials())
|
||||
base := cfg.APIBase
|
||||
if base == "" {
|
||||
base = "http://127.0.0.1:8010"
|
||||
}
|
||||
return &App{
|
||||
cfg: cfg,
|
||||
cloud: cloud.New(envOr("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")),
|
||||
local: local.New(base, cfg.APIUser, cfg.APIPassword),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
_ = agentpaths.EnsureState()
|
||||
|
||||
// A saved session means a shop PC that rebooted overnight comes back
|
||||
// working instead of waiting for someone to log in.
|
||||
if a.cfg.SessionToken != "" {
|
||||
a.cloud.SetSession(cloud.Session{
|
||||
Token: a.cfg.SessionToken, RefreshToken: a.cfg.SessionRefresh,
|
||||
User: cloud.User{Email: a.cfg.SessionEmail},
|
||||
})
|
||||
}
|
||||
// The server rotates the refresh token every time it is used, so a PC that
|
||||
// refreshes and then reboots would come back holding one the server has
|
||||
// already invalidated - it would look exactly like a normal expiry, twelve
|
||||
// hours after anyone last touched the machine.
|
||||
a.cloud.OnRefresh(func(s cloud.Session) { a.persistSession(s) })
|
||||
|
||||
exe := a.cfg.EngineExe
|
||||
if exe != "" && !filepath.IsAbs(exe) {
|
||||
exe = filepath.Join(agentpaths.InstallRoot(), exe)
|
||||
}
|
||||
logFile, _ := agentengine.LogFile(agentpaths.EngineLog())
|
||||
a.sup = agentengine.New(agentengine.Options{
|
||||
Command: func(c context.Context) *exec.Cmd {
|
||||
cmd := exec.CommandContext(c, exe, a.cfg.EngineArgs...)
|
||||
// How the engine learns where to post its detections. Its config
|
||||
// already reads `events.webhook_url: ${BEHAVISION_WEBHOOK_URL}`,
|
||||
// and python-dotenv does not override a variable the process
|
||||
// already has, so this needs no new endpoint and no fixed port.
|
||||
//
|
||||
// Read here rather than captured, because the bridge picks its
|
||||
// port after this closure is built and a restarted engine has to
|
||||
// be told again. Without it the engine recognised people and the
|
||||
// bridge received nothing: a claimed shop PC published heartbeats
|
||||
// and zero visits.
|
||||
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+a.webhookURL())
|
||||
return cmd
|
||||
},
|
||||
LogWriter: logFile,
|
||||
HealthURL: strings.TrimRight(a.cfg.APIBase, "/") + "/api/health",
|
||||
StatsURL: strings.TrimRight(a.cfg.APIBase, "/") + "/api/stats",
|
||||
User: a.cfg.APIUser, Password: a.cfg.APIPassword,
|
||||
})
|
||||
|
||||
a.startPipeline(ctx)
|
||||
}
|
||||
|
||||
// webhookURL is the loopback address the bridge is listening on, or empty
|
||||
// before it has started.
|
||||
func (a *App) webhookURL() string {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.hookURL
|
||||
}
|
||||
|
||||
// startPipeline connects detections to the server: the engine posts events to
|
||||
// a loopback webhook, the bridge queues them durably, and the pump drains the
|
||||
// queue to the broker. Without it the engine recognises people and nothing
|
||||
// ever leaves the PC.
|
||||
func (a *App) startPipeline(ctx context.Context) {
|
||||
logger := log.New(os.Stdout, "", log.LstdFlags)
|
||||
|
||||
// A PC set up on its own has nothing to report to, and unlike an unclaimed
|
||||
// one it never will. Queuing anyway would write up to SpoolMax visits to
|
||||
// disk - each carrying a face template, which is biometric personal data -
|
||||
// into a queue nothing is ever going to drain. Recognition, the gallery
|
||||
// and the cameras are unaffected: they are the engine's, not the pump's.
|
||||
//
|
||||
// Deliberately distinct from the unclaimed case below, where the queue is
|
||||
// exactly right: that PC is waiting for credentials, and its footfall from
|
||||
// the day it was installed should survive until they arrive.
|
||||
if a.cfg.Standalone && !a.cfg.Configured() {
|
||||
logger.Print("standalone: recognition runs locally, nothing is reported")
|
||||
go a.startLocalCameras(ctx, logger)
|
||||
return
|
||||
}
|
||||
|
||||
q, err := agentspool.Open(agentpaths.SpoolDir(), a.cfg.SpoolMax)
|
||||
if err != nil {
|
||||
logger.Printf("spool unavailable, detections will not be recorded: %v", err)
|
||||
return
|
||||
}
|
||||
a.spool = q
|
||||
|
||||
// Created before the bridge and handed over unconditionally: an unclaimed
|
||||
// PC has no pump reading it, which is harmless - the single slot fills
|
||||
// once and later rings are dropped.
|
||||
waker := agentmqtt.NewWaker()
|
||||
a.bridge = &agentbridge.Bridge{
|
||||
Queue: q,
|
||||
Wake: waker.Wake,
|
||||
Embeddings: agentbridge.NewEngineEmbeddings(
|
||||
a.cfg.APIBase, a.cfg.APIUser, a.cfg.APIPassword),
|
||||
TopicPrefix: topicPrefix(a.cfg),
|
||||
Log: logger,
|
||||
// Uploads face images through a URL the server mints, so this PC never
|
||||
// holds bucket credentials. Harmless when the engine writes no images
|
||||
// or the PC is not claimed: Upload reports "images off" and the visit
|
||||
// queues without a photo.
|
||||
Uploader: &agentbridge.SpacesUploader{
|
||||
BaseURL: a.cfg.CloudBase, Token: a.cfg.AgentToken,
|
||||
},
|
||||
}
|
||||
// Cameras, kept in step with head office. Started before the broker check
|
||||
// because it does not need one: an unclaimed PC still reconciles (to
|
||||
// nothing) and still keeps running its local cameras.
|
||||
go a.startLocalCameras(ctx, logger)
|
||||
|
||||
url, stop, err := a.bridge.Listen(ctx)
|
||||
if err != nil {
|
||||
logger.Printf("event bridge failed to start: %v", err)
|
||||
return
|
||||
}
|
||||
// Under the lock: the engine supervisor reads this from whichever goroutine
|
||||
// launches the child, and Claim can run startPipeline again at any time.
|
||||
a.mu.Lock()
|
||||
a.stopBridge = stop
|
||||
a.hookURL = url
|
||||
a.mu.Unlock()
|
||||
logger.Printf("event bridge on %s", url)
|
||||
|
||||
if !a.cfg.Configured() {
|
||||
// Not claimed yet. The bridge still runs, so footfall from today is on
|
||||
// disk waiting for the credentials rather than lost.
|
||||
return
|
||||
}
|
||||
client, err := agentmqtt.NewClient(agentmqtt.ClientOptions{
|
||||
BrokerURL: a.cfg.BrokerURL,
|
||||
ClientID: "behavision-" + a.cfg.ClientID + "-" + a.cfg.SiteID,
|
||||
Username: a.cfg.BrokerUsername, Password: a.cfg.BrokerPassword,
|
||||
CAFile: a.cfg.BrokerCAFile, Log: logger,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Printf("broker unavailable, queuing locally: %v", err)
|
||||
return
|
||||
}
|
||||
a.broker = client
|
||||
go (&agentmqtt.Pump{
|
||||
Queue: q, Publisher: client, Log: logger,
|
||||
Wake: waker.C(),
|
||||
HeartbeatTopic: topicPrefix(a.cfg) + "/heartbeat",
|
||||
HeartbeatPayload: a.heartbeat,
|
||||
}).Run(ctx)
|
||||
logger.Print("broker pump running")
|
||||
}
|
||||
|
||||
// startLocalCameras runs the reconciler that keeps this PC's cameras in step
|
||||
// with head office. It is deliberately not conditional on being claimed: with
|
||||
// no server to ask it reconciles against nothing and the locally configured
|
||||
// cameras keep running, which is the whole of standalone operation.
|
||||
func (a *App) startLocalCameras(ctx context.Context, logger *log.Logger) {
|
||||
camUploader := &agentbridge.SpacesUploader{
|
||||
BaseURL: a.cfg.CloudBase, Token: a.cfg.AgentToken,
|
||||
}
|
||||
camCloud := agentcameras.NewCloudClient(a.cfg.CloudBase, a.cfg.AgentToken)
|
||||
camCloud.Upload = camUploader.UploadBytes
|
||||
camEngine := agentcameras.NewEngineClient(
|
||||
a.cfg.APIBase, a.cfg.APIUser, a.cfg.APIPassword)
|
||||
// New(), not a struct literal: assembling the Syncer by hand here is how
|
||||
// this app - and the headless agent - both ended up wiring configuration
|
||||
// and forgetting the check runner, so "Test connection" at head office
|
||||
// never completed on any shop PC.
|
||||
agentcameras.New(camEngine, camCloud, logger).Run(ctx)
|
||||
}
|
||||
|
||||
// topicPrefix must equal the MQTT username: the broker enforces
|
||||
// `pattern write bv/%u/...`, so any other prefix is refused.
|
||||
func topicPrefix(cfg agentcfg.Config) string {
|
||||
if cfg.ClientID == "" || cfg.SiteID == "" {
|
||||
return ""
|
||||
}
|
||||
return "bv/" + cfg.ClientID + "." + cfg.SiteID
|
||||
}
|
||||
|
||||
func (a *App) heartbeat() []byte {
|
||||
hb := map[string]any{"sent_at": time.Now().UTC().Format(time.RFC3339)}
|
||||
if a.spool != nil {
|
||||
hb["queued"] = a.spool.Len()
|
||||
// Non-zero means this site's queue overflowed and it genuinely lost
|
||||
// footfall. Reported rather than inferred from a dip in a graph.
|
||||
hb["dropped"] = a.spool.Dropped()
|
||||
}
|
||||
s := a.EngineStatus()
|
||||
hb["engine_state"] = s.State
|
||||
if s.Model != "" {
|
||||
hb["recognition_model"] = s.Model
|
||||
}
|
||||
if s.Cameras != nil {
|
||||
hb["cameras"] = s.Cameras
|
||||
}
|
||||
b, _ := json.Marshal(hb)
|
||||
return b
|
||||
}
|
||||
|
||||
// PipelineStatus is what the UI shows about the link to head office.
|
||||
type PipelineStatus struct {
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
Queued int `json:"queued"`
|
||||
Dropped uint64 `json:"dropped"`
|
||||
Claimed bool `json:"claimed"`
|
||||
// Standalone separates "nothing is being sent because this PC is set up on
|
||||
// its own" from "nothing is being sent and something is wrong". They look
|
||||
// identical from the counters alone, and only one of them is a fault.
|
||||
Standalone bool `json:"standalone"`
|
||||
BrokerUp bool `json:"broker_up"`
|
||||
Accepted uint64 `json:"accepted"`
|
||||
}
|
||||
|
||||
func (a *App) PipelineStatus() PipelineStatus {
|
||||
out := PipelineStatus{
|
||||
WebhookURL: a.hookURL,
|
||||
Claimed: a.cfg.Configured(),
|
||||
Standalone: a.cfg.Standalone && !a.cfg.Configured(),
|
||||
}
|
||||
if a.spool != nil {
|
||||
out.Queued, out.Dropped = a.spool.Len(), a.spool.Dropped()
|
||||
}
|
||||
if a.bridge != nil {
|
||||
out.Accepted = a.bridge.Accepted
|
||||
}
|
||||
if a.broker != nil {
|
||||
out.BrokerUp = a.broker.Connected()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- session --
|
||||
|
||||
type SessionInfo struct {
|
||||
LoggedIn bool `json:"logged_in"`
|
||||
User cloud.User `json:"user"`
|
||||
SiteName string `json:"site_name"`
|
||||
Claimed bool `json:"claimed"`
|
||||
// Standalone is a PC deliberately run on its own. The UI then shows only
|
||||
// the screens that work without head office - the cameras and what this
|
||||
// PC is seeing - rather than a sign-in form for an account that does not
|
||||
// exist.
|
||||
Standalone bool `json:"standalone"`
|
||||
}
|
||||
|
||||
func (a *App) Session() SessionInfo {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return SessionInfo{
|
||||
LoggedIn: a.cloud.LoggedIn(),
|
||||
User: a.cloud.User(),
|
||||
SiteName: a.cfg.SiteName,
|
||||
Claimed: a.cfg.Configured(),
|
||||
Standalone: a.cfg.Standalone && !a.cfg.Configured(),
|
||||
}
|
||||
}
|
||||
|
||||
// RunStandalone sets this PC up on its own, with no head office.
|
||||
//
|
||||
// Recognition, the cameras and the local gallery all work without a server -
|
||||
// they always did - so refusing to open the app until somebody issues an
|
||||
// enrolment code held the product hostage to a component it does not need. The
|
||||
// choice is persisted because it has to survive a reboot, and it is reversible:
|
||||
// Claim still works afterwards and clears the flag.
|
||||
func (a *App) RunStandalone() (SessionInfo, error) {
|
||||
a.mu.Lock()
|
||||
a.cfg.Standalone = true
|
||||
err := a.cfg.Save(agentpaths.AgentConfig())
|
||||
a.mu.Unlock()
|
||||
if err != nil {
|
||||
// A choice that is not on disk works until the next restart and then
|
||||
// silently is not made any more, which looks exactly like the app
|
||||
// forgetting the setup step was ever done.
|
||||
return SessionInfo{}, fmt.Errorf("could not save this choice: %w", err)
|
||||
}
|
||||
return a.Session(), nil
|
||||
}
|
||||
|
||||
func (a *App) Login(email, password string) (SessionInfo, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
sess, err := a.cloud.Login(ctx, email, password)
|
||||
if err != nil {
|
||||
return SessionInfo{}, err
|
||||
}
|
||||
a.persistSession(sess)
|
||||
if a.onSessionChange != nil {
|
||||
a.onSessionChange(true)
|
||||
}
|
||||
return a.Session(), nil
|
||||
}
|
||||
|
||||
// persistSession writes the tokens to the DPAPI-protected config. Called on
|
||||
// sign-in and on every silent refresh, so the two can never diverge.
|
||||
func (a *App) persistSession(s cloud.Session) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.cfg.SessionToken = s.Token
|
||||
a.cfg.SessionRefresh = s.RefreshToken
|
||||
if s.User.Email != "" {
|
||||
a.cfg.SessionEmail = s.User.Email
|
||||
}
|
||||
_ = a.cfg.Save(agentpaths.AgentConfig())
|
||||
}
|
||||
|
||||
// Claim links this PC to a shop, using the one-shot code an operator is given.
|
||||
//
|
||||
// This is the half of onboarding that had no way to happen. The server has had
|
||||
// POST /api/agent/enrol since enrolment was built and `cloud.Client.Bootstrap`
|
||||
// has existed to call it - and nothing called it, so a freshly installed PC
|
||||
// displayed "Not linked to head office" and offered no way to link it. The
|
||||
// only route was hand-editing a JSON file on a shop counter.
|
||||
//
|
||||
// Deliberately NOT session-authenticated, mirroring the endpoint: the person
|
||||
// standing at a new shop PC has no account on it yet, and requiring a login
|
||||
// first would mean shipping a password to every shop that installs the
|
||||
// software.
|
||||
func (a *App) Claim(code string) (SessionInfo, error) {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return SessionInfo{}, errors.New("type the installation code you were given")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
b, err := a.cloud.Bootstrap(ctx, code)
|
||||
if err != nil {
|
||||
return SessionInfo{}, err
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
// The slugs, not the uuids: the topic prefix is <client>.<site> and the
|
||||
// broker's ACL is written against exactly that username.
|
||||
a.cfg.ClientID = b.ClientSlug
|
||||
a.cfg.SiteID = b.SiteSlug
|
||||
a.cfg.SiteName = b.SiteName
|
||||
a.cfg.BrokerURL = b.MQTTURL
|
||||
a.cfg.BrokerUsername = b.MQTTUser
|
||||
a.cfg.BrokerPassword = b.MQTTPass
|
||||
a.cfg.AgentToken = b.AgentToken
|
||||
a.cfg.CloudBase = a.cloud.Base
|
||||
// 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.
|
||||
a.cfg.Standalone = false
|
||||
err = a.cfg.Save(agentpaths.AgentConfig())
|
||||
a.mu.Unlock()
|
||||
if err != nil {
|
||||
// Reported, not swallowed. A claim that is not on disk works until the
|
||||
// next restart and then silently is not claimed any more, which looks
|
||||
// like the code was wrong when it was not.
|
||||
return SessionInfo{}, fmt.Errorf("could not save the settings: %w", err)
|
||||
}
|
||||
|
||||
// The pipeline was started unclaimed: no broker, no pump. Restart it so
|
||||
// this PC begins publishing now rather than at the next launch - an
|
||||
// installer who has to reboot to finish setting up will assume it failed.
|
||||
a.restartPipeline()
|
||||
return a.Session(), nil
|
||||
}
|
||||
|
||||
// restartPipeline tears the bridge and broker down and builds them again from
|
||||
// the current config. Only Claim needs it today; it exists as its own method
|
||||
// because "stop everything that reads the config, then start it" is the part
|
||||
// that is easy to get half right.
|
||||
func (a *App) restartPipeline() {
|
||||
if a.stopBridge != nil {
|
||||
a.stopBridge()
|
||||
a.stopBridge = nil
|
||||
}
|
||||
if a.broker != nil {
|
||||
a.broker.Close()
|
||||
a.broker = nil
|
||||
}
|
||||
a.startPipeline(a.ctx)
|
||||
}
|
||||
|
||||
func (a *App) Logout() SessionInfo {
|
||||
// Revoke server-side too. Clearing only the local copy leaves a live token
|
||||
// on a machine somebody is about to hand back or resell.
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
_ = a.cloud.Logout(ctx)
|
||||
a.mu.Lock()
|
||||
a.cfg.SessionToken, a.cfg.SessionRefresh, a.cfg.SessionEmail = "", "", ""
|
||||
_ = a.cfg.Save(agentpaths.AgentConfig())
|
||||
a.mu.Unlock()
|
||||
if a.onSessionChange != nil {
|
||||
a.onSessionChange(false)
|
||||
}
|
||||
return a.Session()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- engine ---
|
||||
|
||||
type EngineStatus struct {
|
||||
State string `json:"state"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Restarts int `json:"restarts"`
|
||||
Reachable bool `json:"reachable"`
|
||||
Model string `json:"recognition_model,omitempty"`
|
||||
Cameras map[string]bool `json:"cameras,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) EngineStatus() EngineStatus {
|
||||
out := EngineStatus{State: "stopped"}
|
||||
if a.sup == nil {
|
||||
return out
|
||||
}
|
||||
st, err := a.sup.State()
|
||||
out.State = string(st)
|
||||
out.Restarts = a.sup.Restarts()
|
||||
if err != nil {
|
||||
out.Error = err.Error()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 4*time.Second)
|
||||
defer cancel()
|
||||
// A running process is not a working engine: on a memory-starved box the
|
||||
// large model loses the fallback chain and the process stays up regardless,
|
||||
// so the UI reports which encoder actually loaded.
|
||||
if h, herr := a.sup.Health(ctx); herr == nil {
|
||||
out.Reachable = true
|
||||
out.Model = h.RecognitionModel
|
||||
out.Cameras = h.Cameras
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) StartEngine() EngineStatus {
|
||||
if a.sup != nil {
|
||||
a.sup.Start()
|
||||
}
|
||||
return a.EngineStatus()
|
||||
}
|
||||
|
||||
func (a *App) StopEngine() EngineStatus {
|
||||
if a.sup != nil {
|
||||
a.sup.Stop()
|
||||
}
|
||||
return a.EngineStatus()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- cameras --
|
||||
|
||||
func (a *App) Cameras() ([]map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
return a.local.Cameras(ctx)
|
||||
}
|
||||
|
||||
func (a *App) TestCamera(cam map[string]any) (map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
return a.local.TestCamera(ctx, cam)
|
||||
}
|
||||
|
||||
func (a *App) SaveCamera(id string, cam map[string]any) (map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
if id == "" {
|
||||
return a.local.AddCamera(ctx, cam)
|
||||
}
|
||||
return a.local.UpdateCamera(ctx, id, cam)
|
||||
}
|
||||
|
||||
func (a *App) DeleteCamera(id string) error {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
return a.local.DeleteCamera(ctx, id)
|
||||
}
|
||||
|
||||
// StartPlacementCheck begins the guided commissioning walk. This is the step
|
||||
// that stops a site being signed off with a camera that recognises nobody.
|
||||
func (a *App) StartPlacementCheck(id string, seconds float64) (map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
return a.local.StartPlacementCheck(ctx, id, seconds)
|
||||
}
|
||||
|
||||
func (a *App) PlacementResult(id string) (map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
return a.local.PlacementResult(ctx, id)
|
||||
}
|
||||
|
||||
// StreamURL is the MJPEG endpoint for a camera, with credentials inline so an
|
||||
// <img> tag can load it. Loopback only - it never leaves this machine.
|
||||
func (a *App) StreamURL(cameraID string) string {
|
||||
base := strings.TrimPrefix(strings.TrimPrefix(a.local.Base, "http://"), "https://")
|
||||
if a.local.User == "" {
|
||||
return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID)
|
||||
}
|
||||
return fmt.Sprintf("http://%s:%s@%s/api/cameras/%s/stream.mjpeg",
|
||||
a.local.User, a.local.Password, base, cameraID)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- live --
|
||||
|
||||
type LiveSnapshot struct {
|
||||
Stats map[string]any `json:"stats"`
|
||||
Events []map[string]any `json:"events"`
|
||||
}
|
||||
|
||||
func (a *App) Live() (LiveSnapshot, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
stats, err := a.local.Stats(ctx)
|
||||
if err != nil {
|
||||
return LiveSnapshot{}, err
|
||||
}
|
||||
events, err := a.local.Events(ctx, 40)
|
||||
if err != nil {
|
||||
return LiveSnapshot{}, err
|
||||
}
|
||||
return LiveSnapshot{Stats: stats, Events: events}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- reports --
|
||||
|
||||
func (a *App) Footfall(from, to, bucket string) (cloud.FootfallReport, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
return a.cloud.Footfall(ctx, from, to, bucket)
|
||||
}
|
||||
|
||||
func (a *App) Sales(from, to string) (cloud.SalesReport, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
return a.cloud.Sales(ctx, from, to)
|
||||
}
|
||||
|
||||
func (a *App) Customers(query string, limit int) ([]cloud.Customer, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
return a.cloud.Customers(ctx, query, limit)
|
||||
}
|
||||
|
||||
// Sites is the health of every store this account can see. It is what makes
|
||||
// "no customers today" distinguishable from "that shop's PC has been unplugged
|
||||
// for a week" - two identical rows of zeroes with completely different answers.
|
||||
func (a *App) Sites() ([]cloud.SiteHealth, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
return a.cloud.Sites(ctx)
|
||||
}
|
||||
|
||||
// VisitorHistory is one customer's timeline, for the customer record screen.
|
||||
func (a *App) VisitorHistory(id string, limit int) ([]cloud.Visit, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
return a.cloud.VisitorHistory(ctx, id, limit)
|
||||
}
|
||||
|
||||
// VisitorPhoto returns a link to this customer's face image, valid for a few
|
||||
// minutes. "There is no photo" comes back as a Photo with Available false and
|
||||
// a sentence explaining why, not as an error - see cloud.Photo.
|
||||
func (a *App) VisitorPhoto(id string) (cloud.Photo, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
return a.cloud.VisitorImage(ctx, id)
|
||||
}
|
||||
|
||||
// ForgetCustomer erases a person at the request of that person.
|
||||
//
|
||||
// Bound as its own method rather than folded into SaveProfile because it is
|
||||
// not an edit: it destroys the face template, the photo and the profile, and
|
||||
// cannot be undone.
|
||||
func (a *App) ForgetCustomer(id string) error {
|
||||
// Longer than the usual 20s: the server deletes every stored image from
|
||||
// object storage before it touches the database, and refuses the whole
|
||||
// request if any one of them fails.
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
return a.cloud.ForgetVisitor(ctx, id)
|
||||
}
|
||||
|
||||
func (a *App) SaveProfile(p cloud.Profile) error {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
return a.cloud.SaveProfile(ctx, p)
|
||||
}
|
||||
|
||||
func (a *App) RecordPurchase(visitorID string, amount float64,
|
||||
items []string, notes string) error {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
return a.cloud.RecordPurchase(ctx, visitorID, amount, items, notes)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- identity --
|
||||
|
||||
// LocalIdentities reads the engine's own gallery. Shown alongside the cloud
|
||||
// customer list because they answer different questions: this is who this PC
|
||||
// can recognise right now, that is who the business knows.
|
||||
func (a *App) LocalIdentities(limit int) ([]map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
return a.local.Identities(ctx, limit)
|
||||
}
|
||||
|
||||
func (a *App) LocalSightings(limit int) ([]map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
return a.local.Sightings(ctx, limit)
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := osGetenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
5
desktop/env.go
Normal file
5
desktop/env.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package main
|
||||
|
||||
import "os"
|
||||
|
||||
func osGetenv(k string) string { return os.Getenv(k) }
|
||||
1
desktop/frontend/dist/assets/index-XjqO50wd.css
vendored
Normal file
1
desktop/frontend/dist/assets/index-XjqO50wd.css
vendored
Normal file
File diff suppressed because one or more lines are too long
40
desktop/frontend/dist/assets/index-whFsTNQf.js
vendored
Normal file
40
desktop/frontend/dist/assets/index-whFsTNQf.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
desktop/frontend/dist/index.html
vendored
Normal file
13
desktop/frontend/dist/index.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Behavision</title>
|
||||
<script type="module" crossorigin src="./assets/index-whFsTNQf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-XjqO50wd.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
12
desktop/frontend/index.html
Normal file
12
desktop/frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Behavision</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1738
desktop/frontend/package-lock.json
generated
Normal file
1738
desktop/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
desktop/frontend/package.json
Normal file
18
desktop/frontend/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "behavision-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
161
desktop/frontend/src/App.jsx
Normal file
161
desktop/frontend/src/App.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api, isDesktop, message } from './bridge.js'
|
||||
import { usePolled } from './hooks.js'
|
||||
import Login from './views/Login.jsx'
|
||||
import Setup from './views/Setup.jsx'
|
||||
import Live from './views/Live.jsx'
|
||||
import Customers from './views/Customers.jsx'
|
||||
import Cameras from './views/Cameras.jsx'
|
||||
|
||||
// Three screens, and the trim is by AUDIENCE rather than by taste.
|
||||
//
|
||||
// This window runs on a PC behind a counter, and the person in front of it can
|
||||
// act on exactly three things: is it working, who is this customer, and is the
|
||||
// camera set up. Footfall and Sales answer a different person's questions - an
|
||||
// owner comparing shops, who is not standing in one - and they now live on the
|
||||
// head-office platform where a comparison across sites is even possible. A
|
||||
// month-on-month chart on a shop PC was a report nobody there could act on,
|
||||
// competing for the attention of somebody with a customer waiting.
|
||||
//
|
||||
// `cloud` marks a screen that cannot work without head office. A PC set up on
|
||||
// its own hides those rather than showing a screen that can only ever fail:
|
||||
// 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 },
|
||||
]
|
||||
|
||||
export default function App() {
|
||||
const [session, setSession] = useState(null)
|
||||
const [view, setView] = useState('live')
|
||||
const [booting, setBooting] = useState(true)
|
||||
// A standalone PC can join head office later. That is the same Setup screen,
|
||||
// reached deliberately rather than because the app will not open otherwise.
|
||||
const [linking, setLinking] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try { setSession(await api.session()) } catch { setSession(null) }
|
||||
setBooting(false)
|
||||
})()
|
||||
}, [])
|
||||
|
||||
if (!isDesktop()) {
|
||||
// The frontend can be served by `npm run dev` for styling work, where the
|
||||
// Go bindings do not exist. Saying so beats a blank screen and a console
|
||||
// error nobody will read.
|
||||
return (
|
||||
<div className="login"><div className="box">
|
||||
<h1>Behavision</h1>
|
||||
<p className="lead">
|
||||
This is the Behavision window running outside the app, so it has no
|
||||
connection to the recognition engine. Launch the Behavision
|
||||
application instead.
|
||||
</p>
|
||||
</div></div>
|
||||
)
|
||||
}
|
||||
|
||||
if (booting) return <div className="login"><p className="note">Starting…</p></div>
|
||||
// Which shop this PC IS comes before who is standing at it. An installer on a
|
||||
// brand new counter has a code and often no account yet, and every screen
|
||||
// behind here is about a shop this PC does not have one of. Unless there is
|
||||
// no head office at all, which is the other supported answer.
|
||||
if (!session?.claimed && !session?.standalone) return <Setup onDone={setSession} />
|
||||
if (!session?.standalone && !session?.logged_in) return <Login onDone={setSession} />
|
||||
|
||||
const views = VIEWS.filter(v => !v.cloud || !session.standalone)
|
||||
const Current = views.find(v => v.id === view)?.View ?? Live
|
||||
|
||||
if (linking) {
|
||||
return <Setup onDone={s => { setLinking(false); setSession(s) }}
|
||||
onCancel={() => setLinking(false)} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<aside className="side">
|
||||
<div className="brand">
|
||||
<h1>Behavision</h1>
|
||||
<p>{session.site_name || session.user?.client_name || 'Store'}</p>
|
||||
</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}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<EngineBox />
|
||||
<div style={{ padding: '10px 12px 14px', borderTop: '1px solid var(--line-soft)' }}>
|
||||
{session.standalone
|
||||
? <>
|
||||
<div className="note" style={{ marginBottom: 8 }}>
|
||||
Running on its own
|
||||
</div>
|
||||
<button className="btn sm" style={{ width: '100%' }}
|
||||
onClick={() => setLinking(true)}>
|
||||
Link to head office
|
||||
</button>
|
||||
</>
|
||||
: <>
|
||||
<div className="note" style={{ marginBottom: 8 }}>
|
||||
{session.user?.email}
|
||||
</div>
|
||||
<button className="btn sm" style={{ width: '100%' }}
|
||||
onClick={async () => setSession(await api.logout())}>
|
||||
Sign out
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main"><Current session={session} /></main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Always visible, because "is recognition actually running" is the question
|
||||
// behind every other screen — an empty Live page means something completely
|
||||
// different depending on the answer.
|
||||
function EngineBox() {
|
||||
const { data, reload } = usePolled(() => api.engineStatus(), 5000)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const s = data ?? { state: 'stopped' }
|
||||
|
||||
const act = useCallback(async fn => {
|
||||
setBusy(true)
|
||||
try { await fn() } catch (e) { alert(message(e)) }
|
||||
finally { setBusy(false); reload() }
|
||||
}, [reload])
|
||||
|
||||
const running = s.state === 'running'
|
||||
const cams = Object.values(s.cameras ?? {})
|
||||
const up = cams.filter(Boolean).length
|
||||
|
||||
let tone = 'idle', text = 'Stopped'
|
||||
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' }
|
||||
else if (running && up < cams.length) { tone = 'warn'; text = `${up} of ${cams.length} cameras` }
|
||||
else if (running) { tone = 'ok'; text = `Watching ${up} camera${up === 1 ? '' : 's'}` }
|
||||
|
||||
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>
|
||||
)}
|
||||
{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}
|
||||
onClick={() => act(api.stopEngine)}>Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
64
desktop/frontend/src/bridge.js
Normal file
64
desktop/frontend/src/bridge.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// The single seam between React and Go.
|
||||
//
|
||||
// Wails injects bound methods at window.go.main.App.*. Calling them through
|
||||
// here rather than importing generated bindings means `npm run build` works
|
||||
// without running `wails generate`, and it gives one place to handle the
|
||||
// "engine not running yet" case that every screen has to survive.
|
||||
const app = () => window?.go?.main?.App
|
||||
|
||||
export const isDesktop = () => Boolean(app())
|
||||
|
||||
async function call(name, ...args) {
|
||||
const a = app()
|
||||
if (!a || typeof a[name] !== 'function') {
|
||||
throw new Error(`${name} is unavailable — run this inside the Behavision app`)
|
||||
}
|
||||
return a[name](...args)
|
||||
}
|
||||
|
||||
// Every binding the UI uses, named as the UI thinks of them.
|
||||
export const api = {
|
||||
session: () => call('Session'),
|
||||
login: (email, password) => call('Login', email, password),
|
||||
logout: () => call('Logout'),
|
||||
// The one-shot installation code that links this PC to a shop.
|
||||
claim: (code) => call('Claim', code),
|
||||
// Set this PC up on its own, with no head office at all.
|
||||
runStandalone: () => call('RunStandalone'),
|
||||
|
||||
engineStatus: () => call('EngineStatus'),
|
||||
startEngine: () => call('StartEngine'),
|
||||
stopEngine: () => call('StopEngine'),
|
||||
|
||||
cameras: () => call('Cameras'),
|
||||
testCamera: (cam) => call('TestCamera', cam),
|
||||
saveCamera: (id, cam) => call('SaveCamera', id, cam),
|
||||
deleteCamera: (id) => call('DeleteCamera', id),
|
||||
startPlacement: (id, seconds) => call('StartPlacementCheck', id, seconds),
|
||||
placementResult: (id) => call('PlacementResult', id),
|
||||
streamURL: (id) => call('StreamURL', id),
|
||||
|
||||
live: () => call('Live'),
|
||||
pipelineStatus: () => call('PipelineStatus'),
|
||||
localIdentities: (n) => call('LocalIdentities', n),
|
||||
localSightings: (n) => call('LocalSightings', n),
|
||||
|
||||
footfall: (from, to, bucket) => call('Footfall', from, to, bucket),
|
||||
sites: () => call('Sites'),
|
||||
visitorHistory: (id, limit) => call('VisitorHistory', id, limit),
|
||||
visitorPhoto: (id) => call('VisitorPhoto', id),
|
||||
forgetCustomer: (id) => call('ForgetCustomer', id),
|
||||
sales: (from, to) => call('Sales', from, to),
|
||||
customers: (q, limit) => call('Customers', q, limit),
|
||||
saveProfile: (p) => call('SaveProfile', p),
|
||||
recordPurchase: (id, amount, items, notes) =>
|
||||
call('RecordPurchase', id, amount, items, notes),
|
||||
}
|
||||
|
||||
// Errors from Go arrive as strings or Error objects depending on the path.
|
||||
// Normalising here keeps every catch block in the UI to one line.
|
||||
export function message(err) {
|
||||
if (!err) return 'Something went wrong.'
|
||||
if (typeof err === 'string') return err
|
||||
return err.message || String(err)
|
||||
}
|
||||
65
desktop/frontend/src/hooks.js
Normal file
65
desktop/frontend/src/hooks.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { message } from './bridge.js'
|
||||
|
||||
// One hook for every screen that loads and refreshes.
|
||||
//
|
||||
// It exists because the naive version has two bugs every screen would repeat:
|
||||
// a slow response arriving after the user navigated away sets state on an
|
||||
// unmounted component, and a poll that fires while the previous request is
|
||||
// still running stacks up requests against an engine that is already slow.
|
||||
export function usePolled(fn, intervalMs, deps = []) {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const alive = useRef(true)
|
||||
const busy = useRef(false)
|
||||
|
||||
const run = useCallback(async () => {
|
||||
if (busy.current) return
|
||||
busy.current = true
|
||||
try {
|
||||
const result = await fn()
|
||||
if (!alive.current) return
|
||||
setData(result)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
if (alive.current) setError(message(err))
|
||||
} finally {
|
||||
busy.current = false
|
||||
if (alive.current) setLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps)
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true
|
||||
run()
|
||||
if (!intervalMs) return () => { alive.current = false }
|
||||
const id = setInterval(run, intervalMs)
|
||||
return () => { alive.current = false; clearInterval(id) }
|
||||
}, [run, intervalMs])
|
||||
|
||||
return { data, error, loading, reload: run }
|
||||
}
|
||||
|
||||
export function fmtTime(ts) {
|
||||
if (!ts) return '—'
|
||||
const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts)
|
||||
if (isNaN(d)) return '—'
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export function fmtDate(ts) {
|
||||
if (!ts) return '—'
|
||||
const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts)
|
||||
if (isNaN(d)) return '—'
|
||||
return d.toLocaleDateString([], { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
export function daysAgo(n) {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - n)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function today() { return new Date().toISOString().slice(0, 10) }
|
||||
8
desktop/frontend/src/main.jsx
Normal file
8
desktop/frontend/src/main.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode><App /></React.StrictMode>
|
||||
)
|
||||
252
desktop/frontend/src/styles.css
Normal file
252
desktop/frontend/src/styles.css
Normal file
@@ -0,0 +1,252 @@
|
||||
/* 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. */
|
||||
|
||||
: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;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
html, body, #root { height: 100%; }
|
||||
body {
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font: 14px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
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%; }
|
||||
.side {
|
||||
background: var(--surface); border-right: 1px solid var(--line);
|
||||
display: flex; flex-direction: column; min-height: 0;
|
||||
}
|
||||
.side .brand {
|
||||
padding: 18px 18px 14px; border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
.side .brand h1 { font-size: 15px; font-weight: 650; letter-spacing: -.01em; }
|
||||
.side .brand p { font-size: 11.5px; color: var(--muted); margin-top: 3px; }
|
||||
.nav { padding: 10px 10px; display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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; }
|
||||
|
||||
.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); }
|
||||
|
||||
.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; }
|
||||
|
||||
.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;
|
||||
}
|
||||
.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; }
|
||||
|
||||
.field { display: block; margin-bottom: 12px; }
|
||||
.field span { display: block; font-size: 11.5px; color: var(--muted);
|
||||
margin-bottom: 4px; letter-spacing: .01em; }
|
||||
.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;
|
||||
}
|
||||
.field input:focus, .field select:focus, .field textarea:focus {
|
||||
border-color: var(--accent); outline: none;
|
||||
}
|
||||
.field textarea { resize: vertical; min-height: 66px; }
|
||||
.fieldrow { display: grid; gap: 0 12px; grid-template-columns: 1fr 1fr; }
|
||||
|
||||
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; }
|
||||
|
||||
.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;
|
||||
}
|
||||
.note { color: var(--muted); font-size: 12.5px; }
|
||||
.mono { font-family: var(--mono); font-size: 12px; }
|
||||
|
||||
/* --------------------------------------------------------------- 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; }
|
||||
.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); }
|
||||
|
||||
/* ---------------------------------------------------------------- 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; }
|
||||
|
||||
.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; }
|
||||
|
||||
/* -------------------------------------------------------------- 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; }
|
||||
|
||||
/* --------------------------------------------------------------- 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. */
|
||||
|
||||
/* -- 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; }
|
||||
|
||||
.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; }
|
||||
280
desktop/frontend/src/views/Cameras.jsx
Normal file
280
desktop/frontend/src/views/Cameras.jsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
import { usePolled } from '../hooks.js'
|
||||
import { MAKES, makeById } from '../../../../shared/cameraMakes.js'
|
||||
|
||||
const BLANK = { id: '', host: '', port: 554, path: '', username: '', password: '',
|
||||
max_width: 1280 }
|
||||
|
||||
export default function Cameras() {
|
||||
const { data, error, reload } = usePolled(() => api.cameras(), 8000)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [check, setCheck] = useState(null)
|
||||
const cams = data ?? []
|
||||
|
||||
async function remove(id) {
|
||||
if (!confirm(`Remove camera "${id}"? Recognition from it stops immediately.`)) return
|
||||
try { await api.deleteCamera(id); reload() } catch (e) { alert(message(e)) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<h2>Cameras</h2>
|
||||
<p>Add a camera, check it can see faces properly, then it starts working.</p>
|
||||
</div>
|
||||
<button className="btn primary" onClick={() => setEditing({ ...BLANK })}>
|
||||
Add camera
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
|
||||
<div className="card">
|
||||
{cams.length === 0
|
||||
? <div className="empty">No cameras yet.</div>
|
||||
: <div className="tablewrap">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Address</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{cams.map(c => (
|
||||
<tr key={c.id}>
|
||||
<td>{c.id}</td>
|
||||
<td className="mono">{c.url}</td>
|
||||
<td>
|
||||
{c.connected === undefined
|
||||
? <span className="pill"><i className="dot idle" />stopped</span>
|
||||
: c.connected
|
||||
? <span className="pill ok"><i className="dot ok" />live</span>
|
||||
: <span className="pill bad"><i className="dot bad" />offline</span>}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button className="btn sm" onClick={() => setCheck(c.id)}>
|
||||
Check placement
|
||||
</button>{' '}
|
||||
<button className="btn sm" onClick={() => setEditing(c)}>Edit</button>{' '}
|
||||
<button className="btn sm danger" onClick={() => remove(c.id)}>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{editing && <CameraSheet cam={editing} onClose={() => setEditing(null)}
|
||||
onSaved={() => { setEditing(null); reload() }} />}
|
||||
{check && <PlacementSheet id={check} onClose={() => setCheck(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CameraSheet({ cam, onClose, onSaved }) {
|
||||
const isNew = !cam.id
|
||||
const [f, setF] = useState({ ...BLANK, ...cam, password: '',
|
||||
path: cam.path || (isNew ? MAKES[0].path : '') })
|
||||
const [make, setMake] = useState(isNew ? MAKES[0].id : 'manual')
|
||||
const [test, setTest] = useState(null)
|
||||
const [busy, setBusy] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const set = k => e => setF({ ...f, [k]: e.target.value })
|
||||
|
||||
// Only overwrite the path when the preset has one, so choosing "I know the
|
||||
// path" does not wipe what the installer already typed.
|
||||
function chooseMake(e) {
|
||||
const m = makeById(e.target.value)
|
||||
setMake(m.id)
|
||||
setF(prev => ({ ...prev, path: m.path || prev.path }))
|
||||
}
|
||||
|
||||
// Blank means "leave alone", never "clear". The engine never returns a
|
||||
// stored password, so sending an empty one would wipe it on every edit.
|
||||
function payload() {
|
||||
const out = {}
|
||||
for (const [k, v] of Object.entries(f)) {
|
||||
if (v === '' || v === null || v === undefined) continue
|
||||
out[k] = (k === 'port' || k === 'max_width') ? Number(v) : v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
setBusy('test'); setError(null); setTest(null)
|
||||
try { setTest(await api.testCamera(payload())) }
|
||||
catch (e) { setError(message(e)) } finally { setBusy(null) }
|
||||
}
|
||||
|
||||
async function save(e) {
|
||||
e.preventDefault()
|
||||
setBusy('save'); setError(null)
|
||||
try { await api.saveCamera(isNew ? '' : cam.id, payload()); onSaved() }
|
||||
catch (e) { setError(message(e)) } finally { setBusy(null) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="panel">
|
||||
<button className="btn sm close" onClick={onClose}>Close</button>
|
||||
<h3>{isNew ? 'Add camera' : cam.id}</h3>
|
||||
<p className="note" style={{ marginBottom: 18 }}>
|
||||
Test the connection before saving — a wrong address is the most common mistake.
|
||||
</p>
|
||||
<form onSubmit={save}>
|
||||
{error && <div className="err">{error}</div>}
|
||||
<label className="field">
|
||||
<span>Name</span>
|
||||
<input value={f.id} onChange={set('id')} disabled={!isNew}
|
||||
placeholder="entrance" required autoComplete="off" />
|
||||
</label>
|
||||
{/* The highest-value field on this form. The address and the
|
||||
password are on a label or in the installer's notes; the RTSP
|
||||
path is not written anywhere a shop owner would look, and getting
|
||||
it wrong produces "could not open stream", which reads like a
|
||||
password problem and is not. */}
|
||||
<label className="field"><span>Make of camera</span>
|
||||
<select value={make} onChange={chooseMake}>
|
||||
{MAKES.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{makeById(make).note && (
|
||||
<p className="note" style={{ marginTop: -8, marginBottom: 12 }}>
|
||||
{makeById(make).note}
|
||||
</p>
|
||||
)}
|
||||
<div className="fieldrow">
|
||||
<label className="field"><span>Camera address</span>
|
||||
<input value={f.host} onChange={set('host')} placeholder="192.168.0.138"
|
||||
autoComplete="off" />
|
||||
</label>
|
||||
<label className="field"><span>Port</span>
|
||||
<input value={f.port} onChange={set('port')} inputMode="numeric"
|
||||
autoComplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field"><span>Stream path</span>
|
||||
<input value={f.path} onChange={set('path')} placeholder="/ch0_0.264"
|
||||
autoComplete="off" />
|
||||
</label>
|
||||
<div className="fieldrow">
|
||||
{/* A text input next to a password input is a sign-in form as far
|
||||
as the webview is concerned, so without this the browser offers
|
||||
the operator's own Behavision email as the camera's username -
|
||||
which fails with a message about credentials that points at the
|
||||
camera. "off" alone is frequently ignored; a non-login name and
|
||||
new-password on the secret are what actually work. */}
|
||||
<label className="field"><span>Username</span>
|
||||
<input value={f.username} onChange={set('username')}
|
||||
name="camera-account" autoComplete="off" />
|
||||
</label>
|
||||
<label className="field"><span>Password</span>
|
||||
<input type="password" value={f.password} onChange={set('password')}
|
||||
name="camera-secret" autoComplete="new-password"
|
||||
placeholder={cam.has_password ? '(unchanged)' : ''} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||
<button type="button" className="btn" onClick={runTest} disabled={!!busy}>
|
||||
{busy === 'test' ? 'Connecting…' : 'Test connection'}
|
||||
</button>
|
||||
<button className="btn primary" disabled={!!busy || !f.id}>
|
||||
{busy === 'save' ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{test && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{test.ok
|
||||
? <>
|
||||
<p style={{ color: 'var(--ok)', fontSize: 13 }}>
|
||||
Connected — {test.width}×{test.height}
|
||||
</p>
|
||||
{test.snapshot && (
|
||||
<img alt="Camera preview" style={{ width: '100%', marginTop: 8,
|
||||
borderRadius: 6, border: '1px solid var(--line)' }}
|
||||
src={`data:image/jpeg;base64,${test.snapshot}`} />
|
||||
)}
|
||||
</>
|
||||
: <div className="err">{test.error}</div>}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The commissioning wizard. This is what stops a site being signed off with a
|
||||
// camera that recognises nobody — the failure that otherwise shows up weeks
|
||||
// later as a footfall report that was always zero.
|
||||
function PlacementSheet({ id, onClose }) {
|
||||
const [state, setState] = useState({ verdict: 'starting', advice: [] })
|
||||
const [error, setError] = useState(null)
|
||||
const timer = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
try {
|
||||
setState(await api.startPlacement(id, 25))
|
||||
timer.current = setInterval(async () => {
|
||||
try {
|
||||
const r = await api.placementResult(id)
|
||||
if (!alive) return
|
||||
setState(r)
|
||||
if (!r.running) clearInterval(timer.current)
|
||||
} catch (e) { if (alive) setError(message(e)) }
|
||||
}, 1000)
|
||||
} catch (e) { if (alive) setError(message(e)) }
|
||||
})()
|
||||
return () => { alive = false; clearInterval(timer.current) }
|
||||
}, [id])
|
||||
|
||||
const tone = { good: 'ok', marginal: 'warn', poor: 'bad', artifact: 'bad',
|
||||
no_faces: 'warn', inconclusive: 'warn' }[state.verdict]
|
||||
const pct = state.seconds ? Math.min(100, (state.elapsed / state.seconds) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="panel">
|
||||
<button className="btn sm close" onClick={onClose}>Close</button>
|
||||
<h3>Placement check — {id}</h3>
|
||||
<p className="note" style={{ marginBottom: 18 }}>
|
||||
Walk past the camera the way a customer would, a few times.
|
||||
</p>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
|
||||
<div className="card">
|
||||
<div style={{ fontSize: 15, fontWeight: 600,
|
||||
color: tone ? `var(--${tone})` : 'var(--ink)' }}>
|
||||
{state.headline || 'Starting…'}
|
||||
</div>
|
||||
{state.running && (
|
||||
<div style={{ height: 5, background: 'var(--surface-2)', borderRadius: 3,
|
||||
overflow: 'hidden', margin: '12px 0' }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: 'var(--accent)',
|
||||
transition: 'width .4s linear' }} />
|
||||
</div>
|
||||
)}
|
||||
{state.advice?.length > 0 && (
|
||||
<ul style={{ margin: '12px 0 0 18px', fontSize: 13, color: 'var(--ink-2)' }}>
|
||||
{state.advice.map((a, i) => <li key={i} style={{ marginBottom: 5 }}>{a}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{state.quality?.n > 0 && (
|
||||
<p className="note" style={{ marginTop: 12 }}>
|
||||
{state.quality.n} face{state.quality.n === 1 ? '' : 's'} seen ·
|
||||
median quality {state.quality.p50} ·
|
||||
gate {state.gate} ·
|
||||
{' '}{Math.round((state.quality.fraction_below_gate ?? 0) * 100)}% below it
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
182
desktop/frontend/src/views/CustomerForm.jsx
Normal file
182
desktop/frontend/src/views/CustomerForm.jsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
import CustomerPhoto, { useCustomerPhoto } from './CustomerPhoto.jsx'
|
||||
import VisitHistory from './VisitHistory.jsx'
|
||||
import EraseCustomer from './EraseCustomer.jsx'
|
||||
|
||||
// The in-store form. Two jobs in one sheet: capture who this person is, and
|
||||
// record what they bought — because staff have the customer in front of them
|
||||
// once, and asking them to open a second screen means the sale never gets
|
||||
// recorded.
|
||||
export default function CustomerForm({ customer, session, onClose, onSaved }) {
|
||||
const [f, setF] = useState({
|
||||
full_name: customer.full_name ?? '',
|
||||
phone: customer.phone ?? '',
|
||||
email: customer.email ?? '',
|
||||
gender: '',
|
||||
date_of_birth: '',
|
||||
notes: '',
|
||||
consent: customer.has_consent ?? false,
|
||||
})
|
||||
const [purchase, setPurchase] = useState({ amount: '', items: '', notes: '' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [erasing, setErasing] = useState(false)
|
||||
const [photo, setPhoto] = useState(null)
|
||||
const fetched = useCustomerPhoto(customer.id)
|
||||
const shown = photo ?? fetched
|
||||
|
||||
// Erasure destroys a record permanently, so it is a manager's decision. The
|
||||
// server enforces this too — this only avoids offering a button that would
|
||||
// come back 403.
|
||||
const role = session?.user?.role
|
||||
const canErase = ['admin', 'owner', 'manager'].includes(role)
|
||||
|
||||
const set = k => e => setF({ ...f, [k]: e.target.value })
|
||||
|
||||
async function save(e) {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await api.saveProfile({ visitor_id: customer.id, ...f })
|
||||
const amount = parseFloat(purchase.amount)
|
||||
if (!isNaN(amount) && amount > 0) {
|
||||
const items = purchase.items.split(',').map(s => s.trim()).filter(Boolean)
|
||||
await api.recordPurchase(customer.id, amount, items, purchase.notes)
|
||||
}
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setError(message(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="panel">
|
||||
<div className="who">
|
||||
<CustomerPhoto photo={shown} name={customer.full_name || customer.label}
|
||||
onBroken={() => setPhoto({ available: false,
|
||||
reason: 'The photo could not be loaded.' })} />
|
||||
<div className="grow">
|
||||
<h3>{customer.full_name || customer.label}</h3>
|
||||
<p className="note">
|
||||
{customer.visit_count} visit{customer.visit_count === 1 ? '' : 's'}
|
||||
{customer.last_seen_at && ` · last seen ${new Date(customer.last_seen_at).toLocaleDateString()}`}
|
||||
</p>
|
||||
{shown && !shown.available && shown.reason &&
|
||||
<p className="note">{shown.reason}</p>}
|
||||
</div>
|
||||
<button type="button" className="btn sm" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={save}>
|
||||
{error && <div className="err">{error}</div>}
|
||||
|
||||
<div className="card" style={{ marginBottom: 14 }}>
|
||||
<h3>Customer details</h3>
|
||||
<label className="field">
|
||||
<span>Full name</span>
|
||||
<input value={f.full_name} onChange={set('full_name')} autoFocus />
|
||||
</label>
|
||||
<div className="fieldrow">
|
||||
<label className="field">
|
||||
<span>Phone</span>
|
||||
<input value={f.phone} onChange={set('phone')} inputMode="tel" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Email</span>
|
||||
<input value={f.email} onChange={set('email')} type="email" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="fieldrow">
|
||||
<label className="field">
|
||||
<span>Gender</span>
|
||||
<select value={f.gender} onChange={set('gender')}>
|
||||
<option value="">Not recorded</option>
|
||||
<option>Female</option><option>Male</option><option>Other</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Date of birth</span>
|
||||
<input type="date" value={f.date_of_birth} onChange={set('date_of_birth')} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Notes</span>
|
||||
<textarea value={f.notes} onChange={set('notes')}
|
||||
placeholder="Preferences, sizes, anything worth remembering" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 14 }}>
|
||||
<h3>Purchase (optional)</h3>
|
||||
<div className="fieldrow">
|
||||
<label className="field">
|
||||
<span>Amount</span>
|
||||
<input value={purchase.amount} inputMode="decimal" placeholder="0.00"
|
||||
onChange={e => setPurchase({ ...purchase, amount: e.target.value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Items</span>
|
||||
<input value={purchase.items} placeholder="shirt, belt"
|
||||
onChange={e => setPurchase({ ...purchase, items: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
<p className="note">Leave the amount blank if they did not buy anything —
|
||||
a visit without a sale is still worth recording.</p>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<h3>Consent</h3>
|
||||
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start',
|
||||
fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={f.consent} style={{ marginTop: 3 }}
|
||||
onChange={e => setF({ ...f, consent: e.target.checked })} />
|
||||
<span>This customer agreed to us keeping their details and
|
||||
recognising them on future visits.</span>
|
||||
</label>
|
||||
<p className="note" style={{ marginTop: 10 }}>
|
||||
Recorded with the date and who collected it. They can withdraw it
|
||||
at any time, which erases their face data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 14 }}>
|
||||
<h3>Visits</h3>
|
||||
<VisitHistory customer={customer} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn primary" disabled={busy}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
|
||||
{canErase && (
|
||||
<div className="card danger-zone">
|
||||
<h3>At the customer's request</h3>
|
||||
{erasing
|
||||
? <EraseCustomer customer={customer}
|
||||
onCancel={() => setErasing(false)}
|
||||
onDone={onSaved} />
|
||||
: <>
|
||||
<p className="note">
|
||||
Erase this person's face data, photo and details. Their
|
||||
past visits stay in your footfall figures, without their
|
||||
name.
|
||||
</p>
|
||||
<button type="button" className="btn danger"
|
||||
onClick={() => setErasing(true)}>
|
||||
Erase this customer…
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
desktop/frontend/src/views/CustomerPhoto.jsx
Normal file
45
desktop/frontend/src/views/CustomerPhoto.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../bridge.js'
|
||||
|
||||
// The customer's face, when there is one.
|
||||
//
|
||||
// Fetched when the sheet opens rather than stored with the customer row: the
|
||||
// server hands out a signed link that expires in minutes, deliberately, so
|
||||
// that "delete my data" can actually make a picture stop loading. A link kept
|
||||
// in a list rendered an hour ago is a broken image.
|
||||
//
|
||||
// The fetch lives in a hook and happens ONCE per sheet, because the server
|
||||
// writes an audit_log row for every read of a face image — "who looked at my
|
||||
// customers" has to be answerable — and a component that fetched its own copy
|
||||
// for the picture and again for the caption would put two rows in that log for
|
||||
// one glance at one person.
|
||||
export function useCustomerPhoto(id) {
|
||||
const [photo, setPhoto] = useState(null)
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setPhoto(null)
|
||||
api.visitorPhoto(id)
|
||||
.then(p => { if (alive) setPhoto(p) })
|
||||
// A failure to load a photo must never take the customer record with
|
||||
// it: the name and phone number are what staff opened this for.
|
||||
.catch(() => { if (alive) setPhoto({ available: false, reason: '' }) })
|
||||
return () => { alive = false }
|
||||
}, [id])
|
||||
return photo
|
||||
}
|
||||
|
||||
// No photo is the normal case — images are off by default — so this renders
|
||||
// initials, not an error.
|
||||
export default function CustomerPhoto({ photo, name, onBroken }) {
|
||||
if (photo?.available) {
|
||||
return <img className="avatar" src={photo.url} alt={`Photo of ${name}`}
|
||||
onError={onBroken} />
|
||||
}
|
||||
const initials = String(name || '').split(/\s+/).filter(Boolean).slice(0, 2)
|
||||
.map(w => w[0].toUpperCase()).join('') || '?'
|
||||
return (
|
||||
<div className="avatar none" role="img" aria-label={`No photo of ${name}`}>
|
||||
<span>{initials}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
92
desktop/frontend/src/views/Customers.jsx
Normal file
92
desktop/frontend/src/views/Customers.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../bridge.js'
|
||||
import { usePolled, fmtDate } from '../hooks.js'
|
||||
import CustomerForm from './CustomerForm.jsx'
|
||||
|
||||
// The customer database, and the form staff fill in when someone walks in.
|
||||
export default function Customers({ session }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [selected, setSelected] = useState(null)
|
||||
const { data, error, reload } = usePolled(
|
||||
() => api.customers(query, 200), 30000, [query])
|
||||
|
||||
const rows = data ?? []
|
||||
const named = rows.filter(r => r.has_profile).length
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header>
|
||||
<h2>Customers</h2>
|
||||
<p>Everyone this business has recognised. Fill in details once and they
|
||||
are known at every store.</p>
|
||||
</header>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
|
||||
<div className="grid cols-4" style={{ marginBottom: 16 }}>
|
||||
<Stat label="Known people" value={rows.length || '—'} />
|
||||
<Stat label="With details" value={named || '—'}
|
||||
sub={rows.length ? `${Math.round(100 * named / rows.length)}% captured` : null} />
|
||||
<Stat label="Returning" value={rows.filter(r => r.visit_count > 1).length || '—'} />
|
||||
<Stat label="With consent" value={rows.filter(r => r.has_consent).length || '—'} />
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 12 }}>
|
||||
<input className="field" style={{ flex: 1, margin: 0, background: 'var(--ground)',
|
||||
border: '1px solid var(--line)', borderRadius: 6, padding: '8px 10px' }}
|
||||
placeholder="Search by name or phone"
|
||||
value={query} onChange={e => setQuery(e.target.value)} />
|
||||
<button className="btn" onClick={reload}>Refresh</button>
|
||||
</div>
|
||||
|
||||
{rows.length === 0
|
||||
? <div className="empty">
|
||||
No customers yet. They appear here the first time a camera sees them.
|
||||
</div>
|
||||
: <div className="tablewrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer</th><th>Phone</th>
|
||||
<th className="num">Visits</th>
|
||||
<th>First seen</th><th>Last seen</th><th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(c => (
|
||||
<tr key={c.id} className="click" onClick={() => setSelected(c)}>
|
||||
<td>{c.full_name || <span className="note">{c.label}</span>}</td>
|
||||
<td className="mono">{c.phone || '—'}</td>
|
||||
<td className="num">{c.visit_count}</td>
|
||||
<td>{fmtDate(c.first_seen_at)}</td>
|
||||
<td>{fmtDate(c.last_seen_at)}</td>
|
||||
<td>
|
||||
{c.has_profile
|
||||
? <span className="pill ok"><i className="dot ok" />captured</span>
|
||||
: <span className="pill warn"><i className="dot warn" />needed</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<CustomerForm customer={selected} session={session}
|
||||
onClose={() => setSelected(null)}
|
||||
onSaved={() => { setSelected(null); reload() }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value, sub }) {
|
||||
return (
|
||||
<div className="card stat">
|
||||
<h3>{label}</h3><div className="value">{value}</div>
|
||||
{sub && <div className="sub">{sub}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
desktop/frontend/src/views/EraseCustomer.jsx
Normal file
89
desktop/frontend/src/views/EraseCustomer.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
|
||||
// Erasing a customer, at that customer's request.
|
||||
//
|
||||
// This is a legal obligation with a destructive implementation, so the screen
|
||||
// does two things a plain "are you sure" cannot. It says exactly what is
|
||||
// destroyed and exactly what is kept — staff are asked "will you delete my
|
||||
// data?" by a real person standing in front of them and have to be able to
|
||||
// answer truthfully — and it requires the customer's name to be typed, because
|
||||
// this sits next to Save in a sheet used all day and a misclick is
|
||||
// unrecoverable.
|
||||
//
|
||||
// What is kept is not an oversight. Visits stay (unlinked): they are the
|
||||
// shop's own footfall history, and silently rewriting last quarter's numbers
|
||||
// because one customer exercised a right is both wrong and detectable. Consent
|
||||
// stays, revoked: deleting it destroys the proof of what we were permitted to
|
||||
// do and when, which is the first thing an auditor asks for.
|
||||
export default function EraseCustomer({ customer, onCancel, onDone }) {
|
||||
const name = customer.full_name || customer.label
|
||||
const [typed, setTyped] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const confirmed = typed.trim().toLowerCase() === name.trim().toLowerCase()
|
||||
|
||||
async function erase() {
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await api.forgetCustomer(customer.id)
|
||||
onDone()
|
||||
} catch (err) {
|
||||
// The server deletes stored photos before it touches the database and
|
||||
// refuses the whole request if one fails, so a failure here means
|
||||
// nothing was erased. Say so — the alternative is a shop believing a
|
||||
// request was honoured when it was not.
|
||||
setError(message(err))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="confirm">
|
||||
<h4>Erase {name}?</h4>
|
||||
|
||||
<div className="cols">
|
||||
<div>
|
||||
<p className="lbl bad">Deleted for good</p>
|
||||
<ul>
|
||||
<li>Their face data — they will not be recognised again</li>
|
||||
<li>Their photo</li>
|
||||
<li>Their name, phone, email and notes</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="lbl">Kept</p>
|
||||
<ul>
|
||||
<li>Past visits, with their name removed — your footfall figures
|
||||
do not change</li>
|
||||
<li>The consent record, marked withdrawn, as proof of what was
|
||||
agreed</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="note">This cannot be undone. If they come back they will be
|
||||
recorded as a new customer.</p>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
|
||||
<label className="field">
|
||||
<span>Type <b>{name}</b> to confirm</span>
|
||||
<input value={typed} onChange={e => setTyped(e.target.value)}
|
||||
autoFocus autoComplete="off" spellCheck="false"
|
||||
aria-label={`Type ${name} to confirm erasure`} />
|
||||
</label>
|
||||
|
||||
<div className="row">
|
||||
<button type="button" className="btn danger"
|
||||
disabled={!confirmed || busy} onClick={erase}>
|
||||
{busy ? 'Erasing…' : 'Erase permanently'}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={onCancel} disabled={busy}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
162
desktop/frontend/src/views/Footfall.jsx
Normal file
162
desktop/frontend/src/views/Footfall.jsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../bridge.js'
|
||||
import { usePolled, daysAgo, today } from '../hooks.js'
|
||||
|
||||
const RANGES = [
|
||||
{ label: '7 days', days: 7, bucket: 'day' },
|
||||
{ label: '30 days', days: 30, bucket: 'day' },
|
||||
{ label: '90 days', days: 90, bucket: 'week' },
|
||||
]
|
||||
|
||||
export default function Footfall() {
|
||||
const [range, setRange] = useState(RANGES[0])
|
||||
const { data, error, loading } = usePolled(
|
||||
() => api.footfall(daysAgo(range.days), today(), range.bucket),
|
||||
60000, [range.days, range.bucket])
|
||||
const sites = usePolled(() => api.sites(), 60000, [])
|
||||
|
||||
const points = data?.points ?? []
|
||||
const peak = Math.max(1, ...points.map(p => p.visitors))
|
||||
|
||||
// Both numbers come from the server, and neither is the sum of the chart.
|
||||
//
|
||||
// A customer who came on Monday and Thursday is ONE person and TWO
|
||||
// bucket-visitors, so adding the bars up gives a headcount that is silently
|
||||
// too high. Summing new + returning is wrong a second way: a site sending
|
||||
// counts without face templates produces visits that are real footfall but
|
||||
// an unknown person, and those are counted in neither.
|
||||
const unique = data?.total ?? 0
|
||||
const visits = data?.visits ?? 0
|
||||
const totalNew = points.reduce((n, p) => n + (p.new ?? 0), 0)
|
||||
const totalRet = points.reduce((n, p) => n + (p.returning ?? 0), 0)
|
||||
const identified = totalNew + totalRet
|
||||
const gate = data?.fraction_below_gate ?? 0
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<h2>Footfall</h2>
|
||||
<p>Visitors over time, split by whether we had seen them before.</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{RANGES.map(r => (
|
||||
<button key={r.label} className="btn sm"
|
||||
aria-pressed={r.days === range.days}
|
||||
style={r.days === range.days
|
||||
? { background: 'var(--accent-dim)', color: 'var(--accent)',
|
||||
borderColor: 'var(--accent)' } : undefined}
|
||||
onClick={() => setRange(r)}>{r.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
|
||||
<div className="grid cols-4" style={{ marginBottom: 16 }}>
|
||||
<Stat label="People" value={unique || '—'}
|
||||
sub={visits ? `${visits} visit${visits === 1 ? '' : 's'} in total` : null} />
|
||||
<Stat label="New" value={totalNew || '—'} />
|
||||
<Stat label="Returning" value={totalRet || '—'}
|
||||
sub={identified ? `${Math.round(100 * totalRet / identified)}% of recognised visits` : null} />
|
||||
<Stat label="Below quality gate"
|
||||
value={data ? `${Math.round(gate * 100)}%` : '—'}
|
||||
tone={gate > 0.5 ? 'bad' : gate > 0.2 ? 'warn' : undefined}
|
||||
sub={data?.worst_site ? `worst: ${data.worst_site}` : 'Faces seen but too poor to count'} />
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Visitors per {range.bucket}</h3>
|
||||
{loading && !points.length ? <div className="empty">Loading…</div>
|
||||
: points.length === 0 ? <div className="empty">No visits recorded in this period.</div>
|
||||
: <>
|
||||
<div className="bars">
|
||||
{points.map((p, i) => (
|
||||
<div className="col" key={i}
|
||||
title={`${bucketLabel(p.bucket, range.bucket)}: ${p.visitors} visitor${p.visitors === 1 ? '' : 's'}`}>
|
||||
<div className="seg new" style={{ height: `${(p.new / peak) * 100}%` }} />
|
||||
<div className="seg ret" style={{ height: `${(p.returning / peak) * 100}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="axis">
|
||||
<span>{bucketLabel(points[0]?.bucket, range.bucket)}</span>
|
||||
<span>{bucketLabel(points[points.length - 1]?.bucket, range.bucket)}</span>
|
||||
</div>
|
||||
<div className="key">
|
||||
<span><i style={{ background: 'var(--accent)' }} />Returning</span>
|
||||
<span><i style={{ background: '#2f6f81' }} />New</span>
|
||||
{data?.timezone && <span style={{ marginLeft: 'auto', opacity: 0.6 }}>
|
||||
times in {data.timezone}</span>}
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
<Sites sites={sites.data} error={sites.error} />
|
||||
|
||||
{gate > 0.5 && (
|
||||
<div className="err" style={{ marginTop: 16 }}>
|
||||
More than half the faces {data.worst_site ? `at ${data.worst_site}` : 'this site'} saw
|
||||
were too poor to count, so this chart understates real footfall. Run a
|
||||
placement check on that camera before trusting these numbers.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// A site that has stopped reporting looks exactly like a site with no
|
||||
// customers - the same row of zeroes - and only one of them is something to
|
||||
// act on. This is the difference, shown next to the chart it explains.
|
||||
function Sites({ sites, error }) {
|
||||
if (error || !sites || sites.length === 0) return null
|
||||
return (
|
||||
<div className="card" style={{ marginTop: 16 }}>
|
||||
<h3>Sites</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
{sites.map(s => (
|
||||
<tr key={s.site_id}>
|
||||
<td><b>{s.name}</b></td>
|
||||
<td>
|
||||
{/* .dot is sized, so it needs a box: a bare span is inline
|
||||
and would collapse to nothing inside a table cell. */}
|
||||
<span className={`dot ${s.online ? 'ok' : 'bad'}`}
|
||||
style={{ display: 'inline-block', marginRight: 6 }} />
|
||||
{s.online ? 'Reporting' : 'Not reporting'}
|
||||
</td>
|
||||
<td>{s.cameras_total
|
||||
? `${s.cameras_up}/${s.cameras_total} camera${s.cameras_total === 1 ? '' : 's'} connected`
|
||||
: 'no cameras'}</td>
|
||||
{/* Dropped events are footfall this site permanently lost, so it
|
||||
has to be visible rather than inferred from a dip in a graph. */}
|
||||
<td>{s.dropped > 0
|
||||
? <span style={{ color: 'var(--bad)' }}>{s.dropped} events lost</span>
|
||||
: s.queued > 0 ? `${s.queued} queued` : ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Buckets come back as local wall time with no offset, labelled by the
|
||||
// timezone in the report. Parsing them as a Date would re-interpret them in
|
||||
// the viewer's zone and shift every label by hours.
|
||||
function bucketLabel(bucket, size) {
|
||||
if (!bucket) return ''
|
||||
const [date, time] = bucket.split('T')
|
||||
if (size === 'hour') return `${date.slice(5)} ${(time || '').slice(0, 5)}`
|
||||
return date
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
192
desktop/frontend/src/views/Live.jsx
Normal file
192
desktop/frontend/src/views/Live.jsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../bridge.js'
|
||||
import { usePolled, fmtTime } from '../hooks.js'
|
||||
|
||||
// What is happening right now. The first screen a shop manager opens, so it
|
||||
// answers "is it working" before it answers anything else.
|
||||
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".
|
||||
const worst = cameras.reduce((acc, c) => {
|
||||
const f = c?.pipeline?.best_quality?.fraction_below_gate
|
||||
return typeof f === 'number' && f > acc ? f : acc
|
||||
}, 0)
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header>
|
||||
<h2>Live</h2>
|
||||
<p>Cameras, recent detections, and whether this site is recognising people.</p>
|
||||
</header>
|
||||
|
||||
{error && <div className="err">{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'} />
|
||||
</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>
|
||||
</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 }) {
|
||||
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.
|
||||
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>
|
||||
)
|
||||
}
|
||||
const stuck = pipe.claimed && !pipe.broker_up
|
||||
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>
|
||||
</span>
|
||||
<span className="note">{pipe.accepted} recorded today</span>
|
||||
{pipe.queued > 0 && (
|
||||
<span className="note" style={stuck ? { color: 'var(--warn)' } : undefined}>
|
||||
{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>
|
||||
)}
|
||||
</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(', ')
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
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
|
||||
}
|
||||
52
desktop/frontend/src/views/Login.jsx
Normal file
52
desktop/frontend/src/views/Login.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
|
||||
// The gate. Nothing else in the app is reachable until this succeeds, because
|
||||
// the broker credentials and the customer database both live behind it.
|
||||
export default function Login({ onDone }) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
onDone(await api.login(email.trim(), password))
|
||||
} catch (err) {
|
||||
setError(message(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="box">
|
||||
<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>}
|
||||
<label className="field">
|
||||
<span>Email</span>
|
||||
<input type="email" value={email} autoComplete="username" required
|
||||
autoFocus onChange={e => setEmail(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Password</span>
|
||||
<input type="password" value={password} autoComplete="current-password"
|
||||
required onChange={e => setPassword(e.target.value)} />
|
||||
</label>
|
||||
<button className="btn primary" disabled={busy || !email || !password}>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="foot">
|
||||
Signing in downloads this store's recognition models and connects it
|
||||
to your account. Nothing is sent until a camera is set up.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
desktop/frontend/src/views/Sales.jsx
Normal file
88
desktop/frontend/src/views/Sales.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../bridge.js'
|
||||
import { usePolled, daysAgo, today } from '../hooks.js'
|
||||
|
||||
const RANGES = [{ label: '7 days', days: 7 }, { label: '30 days', days: 30 },
|
||||
{ label: '90 days', days: 90 }]
|
||||
|
||||
// Footfall against sales. The number the business actually judges the product
|
||||
// by: how many of the people who walked in bought something.
|
||||
export default function Sales() {
|
||||
const [range, setRange] = useState(RANGES[1])
|
||||
const { data, error } = usePolled(
|
||||
() => api.sales(daysAgo(range.days), today()), 60000, [range.days])
|
||||
|
||||
const cur = data?.currency || 'INR'
|
||||
const money = n => typeof n === 'number'
|
||||
? new Intl.NumberFormat(undefined, { style: 'currency', currency: cur,
|
||||
maximumFractionDigits: 0 }).format(n)
|
||||
: '—'
|
||||
const conv = data?.conversion ?? 0
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<h2>Sales summary</h2>
|
||||
<p>How many of the people who came in actually bought something.</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{RANGES.map(r => (
|
||||
<button key={r.label} className="btn sm"
|
||||
style={r.days === range.days
|
||||
? { background: 'var(--accent-dim)', color: 'var(--accent)',
|
||||
borderColor: 'var(--accent)' } : undefined}
|
||||
onClick={() => setRange(r)}>{r.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
|
||||
<div className="grid cols-4" style={{ marginBottom: 16 }}>
|
||||
<Stat label="Visitors" value={data?.visitors ?? '—'} />
|
||||
<Stat label="Bought something" value={data?.purchasers ?? '—'} />
|
||||
<Stat label="Conversion" value={data ? `${Math.round(conv * 100)}%` : '—'}
|
||||
tone={conv >= 0.3 ? 'ok' : conv > 0 ? 'warn' : undefined} />
|
||||
<Stat label="Revenue" value={money(data?.revenue)}
|
||||
sub={data ? `${money(data.average_basket)} average basket` : null} />
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Visitors who bought</h3>
|
||||
{!data ? <div className="empty">Loading…</div>
|
||||
: data.visitors === 0
|
||||
? <div className="empty">No visits recorded in this period.</div>
|
||||
: <>
|
||||
<div style={{ display: 'flex', height: 34, borderRadius: 6,
|
||||
overflow: 'hidden', border: '1px solid var(--line)' }}>
|
||||
<div style={{ width: `${conv * 100}%`, background: 'var(--accent)' }} />
|
||||
<div style={{ flex: 1, background: 'var(--surface-2)' }} />
|
||||
</div>
|
||||
<div className="key">
|
||||
<span><i style={{ background: 'var(--accent)' }} />
|
||||
Bought — {data.purchasers}</span>
|
||||
<span><i style={{ background: 'var(--surface-2)' }} />
|
||||
Left without buying — {data.visitors - data.purchasers}</span>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
<p className="note" style={{ marginTop: 14 }}>
|
||||
Sales come from what staff enter on the customer form. A visit with no
|
||||
amount counts as a visit that did not convert, which is what makes this
|
||||
figure meaningful.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
103
desktop/frontend/src/views/Setup.jsx
Normal file
103
desktop/frontend/src/views/Setup.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
|
||||
// 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.
|
||||
//
|
||||
// It comes BEFORE sign-in on purpose. The installer standing at a new counter
|
||||
// has an installation code and, quite often, no account of their own yet; the
|
||||
// PC's identity is not a person's identity. The endpoint behind this is
|
||||
// deliberately unauthenticated for the same reason — requiring a login first
|
||||
// would mean shipping a password to every shop that installs the software.
|
||||
export default function Setup({ onDone, onCancel }) {
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [alone, setAlone] = useState(false)
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setBusy('claim'); setError(null)
|
||||
try {
|
||||
onDone(await api.claim(code))
|
||||
} catch (err) {
|
||||
setError(message(err))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function standalone() {
|
||||
setBusy('alone'); setError(null)
|
||||
try {
|
||||
onDone(await api.runStandalone())
|
||||
} catch (err) {
|
||||
setError(message(err))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="box">
|
||||
<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>}
|
||||
<label className="field">
|
||||
<span>Installation code</span>
|
||||
{/* Uppercase and letter-spaced because the code arrives read aloud
|
||||
down a phone or photographed off a screen. Spaces, dashes and
|
||||
case are stripped on the server, so what is typed here can be
|
||||
as untidy as it needs to be. */}
|
||||
<input value={code} autoFocus required
|
||||
placeholder="ABCDEF-123456-GHIJKL-789012"
|
||||
autoComplete="off" spellCheck="false"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '.06em' }}
|
||||
onChange={e => setCode(e.target.value)} />
|
||||
</label>
|
||||
<button className="btn primary" disabled={!!busy || code.trim().length < 6}>
|
||||
{busy === 'claim' ? 'Linking…' : 'Link this PC'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="foot">
|
||||
The code works once. Ask whoever manages your shops for it — they can
|
||||
create one from the Behavision platform, under the shop.
|
||||
</p>
|
||||
|
||||
{/* The second way out of this screen, and the reason it exists.
|
||||
Recognition, the cameras and this shop's own gallery all run on
|
||||
this PC and need no server, so a shop with one till and no head
|
||||
office was being blocked from adding a camera until somebody
|
||||
issued it a code — the software refusing to do the thing it is
|
||||
for. Linking later is still one click away, and it keeps the
|
||||
visits already recorded here. */}
|
||||
<div className="alt">
|
||||
{onCancel
|
||||
? <button type="button" className="linkbtn" onClick={onCancel}>
|
||||
Not now — go back
|
||||
</button>
|
||||
: !alone
|
||||
? <button type="button" className="linkbtn" onClick={() => setAlone(true)}>
|
||||
No head office — set this PC up on its own
|
||||
</button>
|
||||
: <>
|
||||
<p className="note">
|
||||
This PC will watch its cameras and recognise returning
|
||||
customers on its own. Nothing is sent anywhere. You can link
|
||||
it to head office later without losing anything recorded
|
||||
here.
|
||||
</p>
|
||||
<button type="button" className="btn" disabled={!!busy}
|
||||
onClick={standalone}>
|
||||
{busy === 'alone' ? 'Setting up…' : 'Use this PC on its own'}
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
desktop/frontend/src/views/VisitHistory.jsx
Normal file
51
desktop/frontend/src/views/VisitHistory.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { api } from '../bridge.js'
|
||||
import { usePolled } from '../hooks.js'
|
||||
|
||||
// One customer's timeline.
|
||||
//
|
||||
// The endpoint and the binding for this both already existed and nothing
|
||||
// called them, which meant the product could recognise a returning customer
|
||||
// and then had no screen able to say when they had been before — the single
|
||||
// question staff ask about a regular.
|
||||
//
|
||||
// Not polled: a record sheet open on a counter should not re-query every few
|
||||
// seconds, and the visit that matters is happening at the counter, not in the
|
||||
// list.
|
||||
export default function VisitHistory({ customer }) {
|
||||
const { data, error, loading } = usePolled(
|
||||
() => api.visitorHistory(customer.id, 50), 0, [customer.id])
|
||||
|
||||
if (loading) return <p className="note">Loading visits…</p>
|
||||
if (error) return <p className="note">Could not load visits: {error}</p>
|
||||
|
||||
const visits = data ?? []
|
||||
if (visits.length === 0) {
|
||||
return <p className="note">No recorded visits yet.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="timeline">
|
||||
{visits.map(v => (
|
||||
<li key={v.id}>
|
||||
<span className="when">{whenLabel(v.occurred_at)}</span>
|
||||
<span className="where">
|
||||
{v.site || 'this store'}
|
||||
{v.camera_id ? <span className="note"> · {v.camera_id}</span> : null}
|
||||
</span>
|
||||
{v.is_new_visitor && <span className="tag new">first visit</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
// The server returns visit times as an instant; showing the date and the time
|
||||
// of day matters more than precision here — "Tuesday afternoon" is how staff
|
||||
// remember a customer.
|
||||
function whenLabel(iso) {
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d)) return '—'
|
||||
return d.toLocaleString([], {
|
||||
day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
14
desktop/frontend/vite.config.js
Normal file
14
desktop/frontend/vite.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
// Wails serves these from an embedded FS at the root, so relative asset
|
||||
// paths are required - absolute ones 404 inside the webview.
|
||||
assetsDir: 'assets',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
base: './',
|
||||
})
|
||||
29
desktop/go.mod
Normal file
29
desktop/go.mod
Normal file
@@ -0,0 +1,29 @@
|
||||
module github.com/loyaly/behavision-desktop
|
||||
|
||||
go 1.22
|
||||
|
||||
// The desktop app is the agent with a face on it: same supervisor, same
|
||||
// durable spool, same broker client, all already tested. A replace rather
|
||||
// than a copy so there is exactly one implementation of each.
|
||||
replace github.com/loyaly/behavision-agent => ../agent
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.12.2
|
||||
github.com/loyaly/behavision-agent v0.0.0
|
||||
github.com/wailsapp/wails/v2 v2.9.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.3 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.0 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.16 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
)
|
||||
94
desktop/go.sum
Normal file
94
desktop/go.sum
Normal file
@@ -0,0 +1,94 @@
|
||||
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
|
||||
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN2M=
|
||||
github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
|
||||
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
|
||||
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.0 h1:T8TuMhFB6TUMIUm0oRrSbgJudTFw9csT3ZK09w0t4Pg=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.0/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/gosod v1.0.3 h1:Fnt+/B6NjQOVuCWOKYRREZnjGyvg+mEhd1nkkA04aTQ=
|
||||
github.com/leaanthony/gosod v1.0.3/go.mod h1:BJ2J+oHsQIyIQpnLPjnqFGTMnOZXDbvWtRCSG7jGxs4=
|
||||
github.com/leaanthony/slicer v1.5.0/go.mod h1:FwrApmf8gOrpzEWM2J/9Lh79tyq8KTX5AzRtwV7m4AY=
|
||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.0 h1:2n0d2BwPVXSUq5yhe8lJPHdxevE2qK5G99PMStMZMaI=
|
||||
github.com/leaanthony/u v1.1.0/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
|
||||
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/tkrajina/go-reflector v0.5.6 h1:hKQ0gyocG7vgMD2M3dRlYN6WBBOmdoOzJ6njQSepKdE=
|
||||
github.com/tkrajina/go-reflector v0.5.6/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/wailsapp/go-webview2 v1.0.16 h1:wffnvnkkLvhRex/aOrA3R7FP7rkvOqL/bir1br7BekU=
|
||||
github.com/wailsapp/go-webview2 v1.0.16/go.mod h1:Uk2BePfCRzttBBjFrBmqKGJd41P6QIHeV9kTgIeOZNo=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.9.2 h1:Xb5YRTos1w5N7DTMyYegWaGukCP2fIaX9WF21kPPF2k=
|
||||
github.com/wailsapp/wails/v2 v2.9.2/go.mod h1:uehvlCwJSFcBq7rMCGfk4rxca67QQGsbg5Nm4m9UnBs=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=
|
||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
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.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
130
desktop/icons.go
Normal file
130
desktop/icons.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"runtime"
|
||||
|
||||
agentpaths "github.com/loyaly/behavision-agent/pkg/paths"
|
||||
)
|
||||
|
||||
// iconFor renders the tray icon at run time rather than embedding four PNGs.
|
||||
//
|
||||
// A 16x16 filled circle is all the taskbar shows at this size, and generating
|
||||
// it means the four states cannot drift apart visually or have one file go
|
||||
// missing from a build.
|
||||
//
|
||||
// The encoding is per-platform and is NOT cosmetic. systray writes these bytes
|
||||
// to a temp file and, on Windows, hands the path to LoadImageW with
|
||||
// IMAGE_ICON|LR_LOADFROMFILE — which decodes .ico and nothing else. A PNG
|
||||
// there returns 0, systray logs "unable to set icon", and the product ships
|
||||
// with no tray icon at all: the one control surface a shop manager has.
|
||||
func iconFor(state string) []byte {
|
||||
img := circle(colorFor(state))
|
||||
if runtime.GOOS == "windows" {
|
||||
return encodeICO(img)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
_ = png.Encode(&buf, img)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// colorFor maps engine state to the only thing the taskbar conveys at 16px.
|
||||
func colorFor(state string) color.RGBA {
|
||||
switch state {
|
||||
case "ok":
|
||||
return color.RGBA{R: 0x2E, G: 0x9E, B: 0x60, A: 0xFF} // green
|
||||
case "warn":
|
||||
return color.RGBA{R: 0xE0, G: 0xA3, B: 0x3A, A: 0xFF} // amber
|
||||
case "error":
|
||||
return color.RGBA{R: 0xD1, G: 0x4B, B: 0x3F, A: 0xFF} // red
|
||||
default:
|
||||
return color.RGBA{R: 0x86, G: 0x93, B: 0x9E, A: 0xFF} // grey
|
||||
}
|
||||
}
|
||||
|
||||
const iconSize = 16
|
||||
|
||||
func circle(c color.RGBA) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
||||
const r = 6.5
|
||||
cx, cy := float64(iconSize)/2-0.5, float64(iconSize)/2-0.5
|
||||
for y := 0; y < iconSize; y++ {
|
||||
for x := 0; x < iconSize; x++ {
|
||||
dx, dy := float64(x)-cx, float64(y)-cy
|
||||
d := dx*dx + dy*dy
|
||||
switch {
|
||||
case d <= (r-1)*(r-1):
|
||||
img.SetRGBA(x, y, c)
|
||||
case d <= r*r:
|
||||
// One-pixel feathered edge; a hard-aliased circle looks broken
|
||||
// next to every other icon in the tray.
|
||||
a := uint8(float64(c.A) * (r*r - d) / (r*r - (r-1)*(r-1)))
|
||||
img.SetRGBA(x, y, color.RGBA{R: c.R, G: c.G, B: c.B, A: a})
|
||||
}
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// encodeICO writes a single-image .ico holding an uncompressed 32-bit DIB.
|
||||
//
|
||||
// Vista and later also accept a PNG stored inside the .ico container, which
|
||||
// would be a dozen lines instead of forty. It is not worth the risk: which
|
||||
// Windows builds accept PNG-in-ICO through LoadImage rather than through the
|
||||
// newer imaging APIs is genuinely murky, the failure is silent, and it would
|
||||
// only ever be discovered on a customer's counter. A DIB is what every version
|
||||
// of Windows has always loaded.
|
||||
func encodeICO(img *image.RGBA) []byte {
|
||||
w, h := img.Bounds().Dx(), img.Bounds().Dy()
|
||||
// 1bpp AND mask, each row padded to a 4-byte boundary. Unused with a 32-bit
|
||||
// alpha channel, but the format requires it to be present and sized.
|
||||
maskRow := ((w + 31) / 32) * 4
|
||||
xor := w * h * 4
|
||||
dib := 40 + xor + maskRow*h
|
||||
|
||||
var b bytes.Buffer
|
||||
// ICONDIR
|
||||
binary.Write(&b, binary.LittleEndian, uint16(0)) // reserved
|
||||
binary.Write(&b, binary.LittleEndian, uint16(1)) // type: icon
|
||||
binary.Write(&b, binary.LittleEndian, uint16(1)) // image count
|
||||
// ICONDIRENTRY. 256 is encoded as 0 in these byte fields; at 16px it is moot.
|
||||
b.WriteByte(byte(w))
|
||||
b.WriteByte(byte(h))
|
||||
b.WriteByte(0) // palette size: none
|
||||
b.WriteByte(0) // reserved
|
||||
binary.Write(&b, binary.LittleEndian, uint16(1)) // colour planes
|
||||
binary.Write(&b, binary.LittleEndian, uint16(32)) // bits per pixel
|
||||
binary.Write(&b, binary.LittleEndian, uint32(dib)) // bytes in resource
|
||||
binary.Write(&b, binary.LittleEndian, uint32(6+16)) // offset to that data
|
||||
|
||||
// BITMAPINFOHEADER. Height is doubled because the DIB nominally stacks the
|
||||
// colour image and the AND mask; omitting the doubling renders half an icon
|
||||
// stretched over the whole square.
|
||||
binary.Write(&b, binary.LittleEndian, uint32(40))
|
||||
binary.Write(&b, binary.LittleEndian, int32(w))
|
||||
binary.Write(&b, binary.LittleEndian, int32(h*2))
|
||||
binary.Write(&b, binary.LittleEndian, uint16(1))
|
||||
binary.Write(&b, binary.LittleEndian, uint16(32))
|
||||
binary.Write(&b, binary.LittleEndian, uint32(0)) // BI_RGB, uncompressed
|
||||
binary.Write(&b, binary.LittleEndian, uint32(xor+maskRow*h))
|
||||
for i := 0; i < 4; i++ { // resolution and palette counts, all unused
|
||||
binary.Write(&b, binary.LittleEndian, uint32(0))
|
||||
}
|
||||
|
||||
// Pixels: BGRA, bottom-up. Go's image is top-down and RGBA, so both the row
|
||||
// order and the channel order invert here.
|
||||
for y := h - 1; y >= 0; y-- {
|
||||
for x := 0; x < w; x++ {
|
||||
c := img.RGBAAt(x, y)
|
||||
b.Write([]byte{c.B, c.G, c.R, c.A})
|
||||
}
|
||||
}
|
||||
b.Write(make([]byte, maskRow*h)) // all-zero: every pixel opaque per the mask
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func logsDir() string { return agentpaths.StateRoot() }
|
||||
93
desktop/icons_test.go
Normal file
93
desktop/icons_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image/png"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The tray icon shipped as a PNG for a while. systray hands the bytes to
|
||||
// LoadImageW, which decodes .ico only, so Windows logged one line and drew
|
||||
// nothing — and nothing on a Mac could notice. These tests are the substitute
|
||||
// for the Windows box we do not have.
|
||||
func TestEncodeICOIsAValidIconFile(t *testing.T) {
|
||||
b := encodeICO(circle(colorFor("ok")))
|
||||
if len(b) < 22 {
|
||||
t.Fatalf("far too short: %d bytes", len(b))
|
||||
}
|
||||
if got := b[:6]; !bytes.Equal(got, []byte{0, 0, 1, 0, 1, 0}) {
|
||||
t.Errorf("ICONDIR = % x, want 00 00 01 00 01 00", got)
|
||||
}
|
||||
if b[6] != iconSize || b[7] != iconSize {
|
||||
t.Errorf("entry is %dx%d, want %dx%d", b[6], b[7], iconSize, iconSize)
|
||||
}
|
||||
if bpp := binary.LittleEndian.Uint16(b[12:14]); bpp != 32 {
|
||||
t.Errorf("bits per pixel = %d, want 32", bpp)
|
||||
}
|
||||
// A wrong length here loads as a truncated or garbage icon rather than
|
||||
// failing outright, which is the harder version of this bug to spot.
|
||||
size := binary.LittleEndian.Uint32(b[14:18])
|
||||
off := binary.LittleEndian.Uint32(b[18:22])
|
||||
if off != 22 {
|
||||
t.Errorf("image offset = %d, want 22", off)
|
||||
}
|
||||
if int(off)+int(size) != len(b) {
|
||||
t.Errorf("entry claims %d bytes at %d, file is %d", size, off, len(b))
|
||||
}
|
||||
if h := binary.LittleEndian.Uint32(b[22:26]); h != 40 {
|
||||
t.Errorf("BITMAPINFOHEADER size = %d, want 40", h)
|
||||
}
|
||||
// Height must be doubled for the implied AND mask or Windows draws the
|
||||
// bottom half of the icon stretched over the whole square.
|
||||
if hh := int32(binary.LittleEndian.Uint32(b[30:34])); hh != int32(iconSize*2) {
|
||||
t.Errorf("biHeight = %d, want %d", hh, iconSize*2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeICOPixelsAreBGRABottomUp(t *testing.T) {
|
||||
want := colorFor("error") // red: distinguishable from B and G if swapped
|
||||
img := circle(want)
|
||||
b := encodeICO(img)
|
||||
// Centre of the circle, which is solid fill. Bottom-up means image row
|
||||
// iconSize/2 lands at DIB row iconSize/2-1 counting from the start.
|
||||
row := iconSize - 1 - iconSize/2
|
||||
i := 22 + 40 + (row*iconSize+iconSize/2)*4
|
||||
got := b[i : i+4]
|
||||
if !bytes.Equal(got, []byte{want.B, want.G, want.R, 0xFF}) {
|
||||
t.Errorf("centre pixel = % x, want % x (BGRA)",
|
||||
got, []byte{want.B, want.G, want.R, 0xFF})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIconForMatchesThePlatformDecoder(t *testing.T) {
|
||||
b := iconFor("ok")
|
||||
pngMagic := []byte{0x89, 'P', 'N', 'G'}
|
||||
if runtime.GOOS == "windows" {
|
||||
if bytes.HasPrefix(b, pngMagic) {
|
||||
t.Fatal("Windows tray icon is a PNG; LoadImageW will refuse it")
|
||||
}
|
||||
if !bytes.HasPrefix(b, []byte{0, 0, 1, 0}) {
|
||||
t.Fatalf("Windows tray icon is not an ICO: % x", b[:4])
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := png.Decode(bytes.NewReader(b)); err != nil {
|
||||
t.Fatalf("non-Windows tray icon is not a decodable PNG: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The four states exist to be told apart at a glance; identical bytes would
|
||||
// mean the icon never changes and the amber "running but blind" state — the
|
||||
// one that otherwise goes unnoticed for weeks — looks exactly like healthy.
|
||||
func TestEveryStateLooksDifferent(t *testing.T) {
|
||||
seen := map[string]string{}
|
||||
for _, s := range []string{"ok", "warn", "error", "stopped"} {
|
||||
k := string(iconFor(s))
|
||||
if prev, dup := seen[k]; dup {
|
||||
t.Errorf("%q and %q render identically", prev, s)
|
||||
}
|
||||
seen[k] = s
|
||||
}
|
||||
}
|
||||
504
desktop/internal/cloud/client.go
Normal file
504
desktop/internal/cloud/client.go
Normal file
@@ -0,0 +1,504 @@
|
||||
// Package cloud talks to the Behavision server at mcp.loyaly.ai.
|
||||
//
|
||||
// Everything a store PC sends to head office goes over MQTT; this is the
|
||||
// request/response half — logging in, reading reports, saving the customer
|
||||
// form. A store PC never holds database credentials, so every one of these is
|
||||
// a call the server authorises against the session token.
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrUnauthorized means the session is gone. The UI shows the login sheet
|
||||
// again rather than an error dialog - an expired token is an ordinary event,
|
||||
// not a fault.
|
||||
var ErrUnauthorized = errors.New("session expired")
|
||||
|
||||
type Client struct {
|
||||
Base string
|
||||
http *http.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
token string
|
||||
refresh string
|
||||
user User
|
||||
|
||||
// Held across a whole refresh so concurrent screens cannot each spend the
|
||||
// single-use refresh token.
|
||||
refreshMu sync.Mutex
|
||||
onRefresh func(Session)
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
Role string `json:"role"`
|
||||
ClientID string `json:"client_id"`
|
||||
Client string `json:"client_name"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Token string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
func New(base string) *Client {
|
||||
return &Client{
|
||||
Base: strings.TrimRight(base, "/"),
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SetSession(s Session) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.token, c.refresh, c.user = s.Token, s.RefreshToken, s.User
|
||||
}
|
||||
|
||||
func (c *Client) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.token, c.refresh, c.user = "", "", User{}
|
||||
}
|
||||
|
||||
func (c *Client) User() User {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.user
|
||||
}
|
||||
|
||||
func (c *Client) LoggedIn() bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.token != ""
|
||||
}
|
||||
|
||||
// do sends a request, refreshing the session once if the access token has
|
||||
// expired.
|
||||
//
|
||||
// The body is marshalled up front and kept, because a retry has to send it
|
||||
// again and an io.Reader is spent after the first attempt - a bug that only
|
||||
// shows up twelve hours after a shop PC was last touched, which is the worst
|
||||
// possible time to find it.
|
||||
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
|
||||
var raw []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
if raw, err = json.Marshal(body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err := c.send(ctx, method, path, raw, out)
|
||||
if !errors.Is(err, errTokenExpired) {
|
||||
return err
|
||||
}
|
||||
if rerr := c.Refresh(ctx); rerr != nil {
|
||||
// The refresh token is gone too, so this really is a sign-in, not a
|
||||
// transient failure. Report it as such so the UI shows the login sheet
|
||||
// rather than an error dialog.
|
||||
return ErrUnauthorized
|
||||
}
|
||||
return c.send(ctx, method, path, raw, out)
|
||||
}
|
||||
|
||||
// APIError carries the server's machine-readable code alongside the prose.
|
||||
//
|
||||
// Some codes are not failures at all: a customer with no photo is the default
|
||||
// configuration of this product, not a fault, and a caller cannot tell that
|
||||
// from the message text. Error() still returns the server's own words, so
|
||||
// anything that only prints the error is unaffected.
|
||||
type APIError struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string { return e.Message }
|
||||
|
||||
// codeOf reports the server's error code, or "" for anything else.
|
||||
func codeOf(err error) string {
|
||||
var ae *APIError
|
||||
if errors.As(err, &ae) {
|
||||
return ae.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// errTokenExpired is internal: callers see either success or ErrUnauthorized.
|
||||
// An expiring access token is an ordinary event that the client handles on its
|
||||
// own, not something every screen should have to know about.
|
||||
var errTokenExpired = errors.New("access token expired")
|
||||
|
||||
func (c *Client) send(ctx context.Context, method, path string, raw []byte, out any) error {
|
||||
var rdr io.Reader
|
||||
if raw != nil {
|
||||
rdr = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.Base+path, rdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if raw != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
c.mu.RLock()
|
||||
tok := c.token
|
||||
c.mu.RUnlock()
|
||||
if tok != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot reach %s: %w", c.Base, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// The server returns {error, message, detail}; showing `message` puts the
|
||||
// server's own words in front of the user instead of a status code.
|
||||
var e struct {
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
_ = json.Unmarshal(body, &e)
|
||||
}
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusUnauthorized && e.Error == "token_expired":
|
||||
return errTokenExpired
|
||||
case resp.StatusCode == http.StatusUnauthorized:
|
||||
return ErrUnauthorized
|
||||
case resp.StatusCode >= 400:
|
||||
msg := e.Message
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("%s %s: %s", method, path, resp.Status)
|
||||
}
|
||||
return &APIError{Status: resp.StatusCode, Code: e.Error, Message: msg}
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(out)
|
||||
}
|
||||
|
||||
// Refresh swaps the refresh token for a new pair.
|
||||
//
|
||||
// Serialised behind refreshMu so a screen that fires four polls at once does
|
||||
// not spend the refresh token four times - the server rotates it on use, so
|
||||
// three of those four would race and lose, logging the shop out at random.
|
||||
func (c *Client) Refresh(ctx context.Context) error {
|
||||
c.refreshMu.Lock()
|
||||
defer c.refreshMu.Unlock()
|
||||
|
||||
c.mu.RLock()
|
||||
before, refresh := c.token, c.refresh
|
||||
c.mu.RUnlock()
|
||||
if refresh == "" {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
|
||||
var s Session
|
||||
if err := c.send(ctx, http.MethodPost, "/api/auth/refresh",
|
||||
mustJSON(map[string]string{"refresh_token": refresh}), &s); err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
// Another goroutine may have refreshed while this one waited on the lock;
|
||||
// its tokens are the live ones and must not be overwritten by ours.
|
||||
if c.token == before {
|
||||
c.token, c.refresh = s.Token, s.RefreshToken
|
||||
if s.User.Email != "" {
|
||||
c.user = s.User
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if c.onRefresh != nil {
|
||||
// So the caller can persist the rotated tokens. Without this a PC that
|
||||
// refreshes and then reboots comes back holding a refresh token the
|
||||
// server already invalidated.
|
||||
c.onRefresh(s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnRefresh registers a callback fired whenever the session rotates.
|
||||
func (c *Client) OnRefresh(fn func(Session)) { c.onRefresh = fn }
|
||||
|
||||
func mustJSON(v any) []byte {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err) // a map of strings cannot fail to marshal
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (c *Client) Login(ctx context.Context, email, password string) (Session, error) {
|
||||
var s Session
|
||||
err := c.do(ctx, http.MethodPost, "/api/auth/login",
|
||||
map[string]string{"email": email, "password": password}, &s)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
c.SetSession(s)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (c *Client) Me(ctx context.Context) (User, error) {
|
||||
var u User
|
||||
err := c.do(ctx, http.MethodGet, "/api/auth/me", nil, &u)
|
||||
if err == nil {
|
||||
c.mu.Lock()
|
||||
c.user = u
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
// Logout revokes the session server-side as well as forgetting it here.
|
||||
// Clearing only the local copy leaves a live token on a machine somebody is
|
||||
// about to hand back.
|
||||
func (c *Client) Logout(ctx context.Context) error {
|
||||
err := c.do(ctx, http.MethodPost, "/api/auth/logout", nil, nil)
|
||||
c.Clear()
|
||||
return err
|
||||
}
|
||||
|
||||
// Session returns the current tokens so the caller can persist them.
|
||||
func (c *Client) Session() Session {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return Session{Token: c.token, RefreshToken: c.refresh, User: c.user}
|
||||
}
|
||||
|
||||
// Bootstrap is what a freshly installed PC asks for after the operator logs
|
||||
// in: which models to fetch, and the broker credentials for this site. The
|
||||
// installer ships none of this, so a leaked build hands out nothing.
|
||||
type Bootstrap struct {
|
||||
SiteID string `json:"site_id"`
|
||||
SiteName string `json:"site_name"`
|
||||
SiteSlug string `json:"site_slug"`
|
||||
// ClientSlug and SiteSlug are what the agent's topic prefix is built from,
|
||||
// and the server derives ClientSlug from the broker username so the two
|
||||
// cannot disagree with the broker's ACL.
|
||||
ClientSlug string `json:"client_slug"`
|
||||
MQTTURL string `json:"mqtt_url"`
|
||||
MQTTUser string `json:"mqtt_username"`
|
||||
MQTTPass string `json:"mqtt_password"`
|
||||
// AgentToken is this PC's own credential for the HTTPS API - asking for an
|
||||
// image upload URL, pulling its camera list. Not the broker password: they
|
||||
// authenticate different things, so rotating one must not break the other.
|
||||
AgentToken string `json:"agent_token"`
|
||||
CACert string `json:"ca_cert"`
|
||||
Models []Model `json:"models"`
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
func (c *Client) Bootstrap(ctx context.Context, siteToken string) (Bootstrap, error) {
|
||||
var b Bootstrap
|
||||
return b, c.do(ctx, http.MethodPost, "/api/agent/enrol",
|
||||
map[string]string{"site_token": siteToken}, &b)
|
||||
}
|
||||
|
||||
type FootfallPoint struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Visitors int `json:"visitors"`
|
||||
New int `json:"new"`
|
||||
Returning int `json:"returning"`
|
||||
}
|
||||
|
||||
type FootfallReport struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Bucket string `json:"bucket"`
|
||||
TZ string `json:"timezone"`
|
||||
Points []FootfallPoint `json:"points"`
|
||||
// Total is unique people over the whole window; Visits counts every
|
||||
// appearance. Summing Points gives neither - a customer who came on Monday
|
||||
// and Thursday is one Total and two bucket-visitors - so both ship rather
|
||||
// than letting a screen add up the chart and call it a headcount.
|
||||
Total int `json:"total"`
|
||||
Visits int `json:"visits"`
|
||||
// Share of faces the cameras saw that fell below the enrolment gate. A
|
||||
// footfall figure from a badly placed camera is wrong in a way nobody can
|
||||
// see, so the number ships with its own confidence.
|
||||
FractionBelowGate float64 `json:"fraction_below_gate"`
|
||||
WorstSite string `json:"worst_site,omitempty"`
|
||||
}
|
||||
|
||||
// SiteHealth distinguishes "no customers" from "this shop's PC has been
|
||||
// unplugged for a week" - two identical rows of zeroes with completely
|
||||
// different responses.
|
||||
type SiteHealth struct {
|
||||
SiteID string `json:"site_id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Timezone string `json:"timezone"`
|
||||
Online bool `json:"online"`
|
||||
LastHeartbeatAt string `json:"last_heartbeat_at"`
|
||||
LastEventAt string `json:"last_event_at"`
|
||||
RecognitionModel string `json:"recognition_model"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
CamerasUp int `json:"cameras_up"`
|
||||
CamerasTotal int `json:"cameras_total"`
|
||||
FractionBelowGate float64 `json:"fraction_below_gate"`
|
||||
Queued int `json:"queued"`
|
||||
Dropped int64 `json:"dropped"`
|
||||
}
|
||||
|
||||
func (c *Client) Sites(ctx context.Context) ([]SiteHealth, error) {
|
||||
var out []SiteHealth
|
||||
return out, c.do(ctx, http.MethodGet, "/api/sites", nil, &out)
|
||||
}
|
||||
|
||||
// Visit is one appearance in a customer's timeline.
|
||||
type Visit struct {
|
||||
ID string `json:"id"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
Site string `json:"site"`
|
||||
CameraID string `json:"camera_id"`
|
||||
IsNew bool `json:"is_new_visitor"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
Quality float64 `json:"quality"`
|
||||
Attributes map[string]any `json:"attributes"`
|
||||
}
|
||||
|
||||
// Photo is a customer's face image, or a plain statement that there isn't one.
|
||||
//
|
||||
// Absence is modelled as data rather than as an error because it is the
|
||||
// ordinary case: images are off by default, so most deployments answer
|
||||
// "no photo" for every customer forever. Returning an error there would put a
|
||||
// red failure box on screen for a system working exactly as configured, and a
|
||||
// UI that cries wolf is a UI whose real errors get ignored.
|
||||
type Photo struct {
|
||||
URL string `json:"url"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// VisitorImage fetches a short-lived signed link to this customer's photo.
|
||||
//
|
||||
// The link expires (the server decides how soon, and says so), so it is
|
||||
// fetched when a screen opens rather than cached alongside the customer.
|
||||
func (c *Client) VisitorImage(ctx context.Context, id string) (Photo, error) {
|
||||
var out Photo
|
||||
err := c.do(ctx, http.MethodGet,
|
||||
"/api/visitors/"+url.PathEscape(id)+"/image", nil, &out)
|
||||
if err != nil {
|
||||
switch codeOf(err) {
|
||||
case "no_image":
|
||||
return Photo{Reason: "No photo of this customer has been captured."}, nil
|
||||
case "images_disabled":
|
||||
return Photo{Reason: "This system is not storing customer photos."}, nil
|
||||
}
|
||||
return Photo{}, err
|
||||
}
|
||||
out.Available = out.URL != ""
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ForgetVisitor erases a customer: face template, photo and profile.
|
||||
//
|
||||
// Irreversible by design — a soft-deleted face template is a retained
|
||||
// photograph by another name, because template inversion reconstructs a
|
||||
// recognisable face from it. The server refuses the whole request rather than
|
||||
// report a partial erasure, so an error here means nothing was deleted.
|
||||
func (c *Client) ForgetVisitor(ctx context.Context, id string) error {
|
||||
return c.do(ctx, http.MethodDelete,
|
||||
"/api/visitors/"+url.PathEscape(id), nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) VisitorHistory(ctx context.Context, id string, limit int) ([]Visit, error) {
|
||||
var out []Visit
|
||||
return out, c.do(ctx, http.MethodGet,
|
||||
fmt.Sprintf("/api/visitors/%s/history?limit=%d", url.PathEscape(id), limit),
|
||||
nil, &out)
|
||||
}
|
||||
|
||||
func (c *Client) Footfall(ctx context.Context, from, to, bucket string) (FootfallReport, error) {
|
||||
var r FootfallReport
|
||||
return r, c.do(ctx, http.MethodGet,
|
||||
fmt.Sprintf("/api/reports/footfall?from=%s&to=%s&bucket=%s", from, to, bucket),
|
||||
nil, &r)
|
||||
}
|
||||
|
||||
type SalesReport struct {
|
||||
Visitors int `json:"visitors"`
|
||||
Purchasers int `json:"purchasers"`
|
||||
Conversion float64 `json:"conversion"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AvgBasket float64 `json:"average_basket"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
func (c *Client) Sales(ctx context.Context, from, to string) (SalesReport, error) {
|
||||
var r SalesReport
|
||||
return r, c.do(ctx, http.MethodGet,
|
||||
fmt.Sprintf("/api/reports/conversion?from=%s&to=%s", from, to), nil, &r)
|
||||
}
|
||||
|
||||
type Customer struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
FullName string `json:"full_name"`
|
||||
Phone string `json:"phone"`
|
||||
Email string `json:"email"`
|
||||
VisitCount int `json:"visit_count"`
|
||||
FirstSeenAt string `json:"first_seen_at"`
|
||||
LastSeenAt string `json:"last_seen_at"`
|
||||
HasProfile bool `json:"has_profile"`
|
||||
HasConsent bool `json:"has_consent"`
|
||||
}
|
||||
|
||||
func (c *Client) Customers(ctx context.Context, query string, limit int) ([]Customer, error) {
|
||||
var out []Customer
|
||||
return out, c.do(ctx, http.MethodGet,
|
||||
fmt.Sprintf("/api/visitors?q=%s&limit=%d",
|
||||
url.QueryEscape(query), limit), nil, &out)
|
||||
}
|
||||
|
||||
// Profile is the in-store form. PUT rather than POST: a staff member
|
||||
// resubmitting on a bad connection must not create a second record for the
|
||||
// same person.
|
||||
type Profile struct {
|
||||
VisitorID string `json:"visitor_id"`
|
||||
FullName string `json:"full_name"`
|
||||
Phone string `json:"phone"`
|
||||
Email string `json:"email"`
|
||||
Gender string `json:"gender"`
|
||||
DateOfBirth string `json:"date_of_birth"`
|
||||
Notes string `json:"notes"`
|
||||
Consent bool `json:"consent"`
|
||||
}
|
||||
|
||||
func (c *Client) SaveProfile(ctx context.Context, p Profile) error {
|
||||
return c.do(ctx, http.MethodPut,
|
||||
"/api/visitors/"+url.PathEscape(p.VisitorID)+"/profile", p, nil)
|
||||
}
|
||||
|
||||
func (c *Client) RecordPurchase(ctx context.Context, visitorID string,
|
||||
amount float64, items []string, notes string) error {
|
||||
return c.do(ctx, http.MethodPost, "/api/purchases", map[string]any{
|
||||
"visitor_id": visitorID, "amount": amount,
|
||||
"items": items, "source": "manual", "notes": notes,
|
||||
}, nil)
|
||||
}
|
||||
139
desktop/internal/cloud/client_test.go
Normal file
139
desktop/internal/cloud/client_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func serve(t *testing.T, h http.HandlerFunc) *Client {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
c := New(srv.URL)
|
||||
c.SetSession(Session{Token: "test-token"})
|
||||
return c
|
||||
}
|
||||
|
||||
func fail(w http.ResponseWriter, status int, code, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": code, "message": msg})
|
||||
}
|
||||
|
||||
// A customer with no photo is the DEFAULT configuration of this product, not a
|
||||
// fault. If it surfaced as an error the record sheet would show a red failure
|
||||
// box for every customer in every shop that has not turned images on.
|
||||
func TestNoPhotoIsNotAnError(t *testing.T) {
|
||||
for _, tc := range []struct{ code, want string }{
|
||||
{"no_image", "No photo"},
|
||||
{"images_disabled", "not storing"},
|
||||
} {
|
||||
t.Run(tc.code, func(t *testing.T) {
|
||||
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, http.StatusNotFound, tc.code, "server prose")
|
||||
})
|
||||
p, err := c.VisitorImage(context.Background(), "abc")
|
||||
if err != nil {
|
||||
t.Fatalf("returned an error for a normal state: %v", err)
|
||||
}
|
||||
if p.Available {
|
||||
t.Error("Available should be false when there is no photo")
|
||||
}
|
||||
if p.Reason == "" {
|
||||
t.Error("a missing photo must come with an explanation")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhotoReturnsTheSignedLink(t *testing.T) {
|
||||
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
|
||||
t.Errorf("Authorization = %q", got)
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"url": "https://example.test/signed", "expires_in": 900})
|
||||
})
|
||||
p, err := c.VisitorImage(context.Background(), "abc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !p.Available || p.URL != "https://example.test/signed" || p.ExpiresIn != 900 {
|
||||
t.Fatalf("got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// A real failure must still be a failure: silently rendering initials would
|
||||
// hide a broken server behind a design that looks intentional.
|
||||
func TestPhotoServerErrorIsAnError(t *testing.T) {
|
||||
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, http.StatusInternalServerError, "server_error", "boom")
|
||||
})
|
||||
if _, err := c.VisitorImage(context.Background(), "abc"); err == nil {
|
||||
t.Fatal("a 500 must not be reported as 'no photo'")
|
||||
}
|
||||
}
|
||||
|
||||
// The server deletes stored images before it touches the database and refuses
|
||||
// the whole request if one fails, so an error here means NOTHING was erased.
|
||||
// Swallowing it would tell a shop a legal request had been honoured when it
|
||||
// had not.
|
||||
func TestForgetVisitorSurfacesFailure(t *testing.T) {
|
||||
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
t.Errorf("method = %s, want DELETE", r.Method)
|
||||
}
|
||||
fail(w, http.StatusBadGateway, "storage_error",
|
||||
"The photo could not be deleted, so nothing was erased.")
|
||||
})
|
||||
err := c.ForgetVisitor(context.Background(), "abc")
|
||||
if err == nil {
|
||||
t.Fatal("a refused erasure must not look like success")
|
||||
}
|
||||
if err.Error() != "The photo could not be deleted, so nothing was erased." {
|
||||
t.Errorf("lost the server's own words: %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgetVisitorSucceedsOn204(t *testing.T) {
|
||||
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
if err := c.ForgetVisitor(context.Background(), "abc"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// APIError carries the code without changing what anything that prints the
|
||||
// error sees — every existing screen relies on that text.
|
||||
func TestAPIErrorKeepsServerMessage(t *testing.T) {
|
||||
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, http.StatusForbidden, "forbidden",
|
||||
"Your account cannot delete customer records.")
|
||||
})
|
||||
err := c.ForgetVisitor(context.Background(), "abc")
|
||||
if err.Error() != "Your account cannot delete customer records." {
|
||||
t.Errorf("message = %q", err)
|
||||
}
|
||||
var ae *APIError
|
||||
if !errors.As(err, &ae) || ae.Code != "forbidden" || ae.Status != 403 {
|
||||
t.Errorf("code not preserved: %+v", ae)
|
||||
}
|
||||
}
|
||||
|
||||
// An id with a slash or a space must not silently address a different route.
|
||||
func TestVisitorIDIsPathEscaped(t *testing.T) {
|
||||
var got string
|
||||
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
got = r.URL.EscapedPath()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
c.ForgetVisitor(context.Background(), "a b/c") //nolint:errcheck
|
||||
if got != "/api/visitors/a%20b%2Fc" {
|
||||
t.Errorf("path = %q", got)
|
||||
}
|
||||
}
|
||||
134
desktop/internal/local/client.go
Normal file
134
desktop/internal/local/client.go
Normal file
@@ -0,0 +1,134 @@
|
||||
// Package local talks to the Python recognition engine running on this PC.
|
||||
//
|
||||
// The desktop app is a CLIENT of the engine and never imports it. The engine
|
||||
// owns the cameras, the models and the SQLite gallery; two processes touching
|
||||
// one webcam or one WAL is the failure this separation exists to prevent.
|
||||
package local
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Base string
|
||||
User string
|
||||
Password string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New(base, user, password string) *Client {
|
||||
return &Client{
|
||||
Base: strings.TrimRight(base, "/"), User: user, Password: password,
|
||||
// Generous: a camera Test opens an RTSP stream and can legitimately
|
||||
// take ten seconds against a slow NVR.
|
||||
http: &http.Client{Timeout: 45 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.Base+path, rdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.User != "" {
|
||||
req.SetBasicAuth(c.User, c.Password)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
// The single most common state on a fresh install: the engine has not
|
||||
// been started yet. Say that, rather than surfacing a dial error the
|
||||
// user cannot act on.
|
||||
return fmt.Errorf("engine not reachable at %s (is it running?): %w", c.Base, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("engine %s %s: %s: %s", method, path, resp.Status,
|
||||
strings.TrimSpace(string(msg)))
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(out)
|
||||
}
|
||||
|
||||
func (c *Client) Health(ctx context.Context) (map[string]any, error) {
|
||||
var out map[string]any
|
||||
return out, c.do(ctx, http.MethodGet, "/api/health", nil, &out)
|
||||
}
|
||||
|
||||
func (c *Client) Stats(ctx context.Context) (map[string]any, error) {
|
||||
var out map[string]any
|
||||
return out, c.do(ctx, http.MethodGet, "/api/stats", nil, &out)
|
||||
}
|
||||
|
||||
func (c *Client) Events(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
return out, c.do(ctx, http.MethodGet,
|
||||
fmt.Sprintf("/api/events?limit=%d", limit), nil, &out)
|
||||
}
|
||||
|
||||
func (c *Client) Cameras(ctx context.Context) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
return out, c.do(ctx, http.MethodGet, "/api/cameras", nil, &out)
|
||||
}
|
||||
|
||||
func (c *Client) AddCamera(ctx context.Context, cam map[string]any) (map[string]any, error) {
|
||||
var out map[string]any
|
||||
return out, c.do(ctx, http.MethodPost, "/api/cameras", cam, &out)
|
||||
}
|
||||
|
||||
func (c *Client) UpdateCamera(ctx context.Context, id string, cam map[string]any) (map[string]any, error) {
|
||||
var out map[string]any
|
||||
return out, c.do(ctx, http.MethodPatch, "/api/cameras/"+id, cam, &out)
|
||||
}
|
||||
|
||||
func (c *Client) DeleteCamera(ctx context.Context, id string) error {
|
||||
return c.do(ctx, http.MethodDelete, "/api/cameras/"+id, nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) TestCamera(ctx context.Context, cam map[string]any) (map[string]any, error) {
|
||||
var out map[string]any
|
||||
return out, c.do(ctx, http.MethodPost, "/api/cameras/test", cam, &out)
|
||||
}
|
||||
|
||||
func (c *Client) StartPlacementCheck(ctx context.Context, id string, seconds float64) (map[string]any, error) {
|
||||
var out map[string]any
|
||||
return out, c.do(ctx, http.MethodPost, "/api/cameras/"+id+"/commission",
|
||||
map[string]any{"seconds": seconds}, &out)
|
||||
}
|
||||
|
||||
func (c *Client) PlacementResult(ctx context.Context, id string) (map[string]any, error) {
|
||||
var out map[string]any
|
||||
return out, c.do(ctx, http.MethodGet, "/api/cameras/"+id+"/commission", nil, &out)
|
||||
}
|
||||
|
||||
func (c *Client) Identities(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
return out, c.do(ctx, http.MethodGet,
|
||||
fmt.Sprintf("/api/identities?limit=%d", limit), nil, &out)
|
||||
}
|
||||
|
||||
func (c *Client) Sightings(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
return out, c.do(ctx, http.MethodGet,
|
||||
fmt.Sprintf("/api/sightings?limit=%d", limit), nil, &out)
|
||||
}
|
||||
66
desktop/main.go
Normal file
66
desktop/main.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// Command behavision-desktop is the store-facing application: a tray icon, a
|
||||
// window, and the supervisor for the recognition engine.
|
||||
//
|
||||
// It is one process rather than three because the tray, the window and the
|
||||
// supervisor all need the same state, and because a user who quits the tray
|
||||
// expects recognition to stop. It is deliberately NOT a Windows service: a
|
||||
// service runs in session 0 and cannot draw a tray icon, and spawning a child
|
||||
// process needs no elevation while controlling a service does.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"log"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
//go:embed all:frontend/dist
|
||||
var assets embed.FS
|
||||
|
||||
func main() {
|
||||
app := NewApp()
|
||||
tray := newTray(app)
|
||||
|
||||
err := wails.Run(&options.App{
|
||||
Title: "Behavision",
|
||||
Width: 1280,
|
||||
Height: 820,
|
||||
// Small enough to still be usable on a cramped shop-counter monitor.
|
||||
MinWidth: 1024,
|
||||
MinHeight: 640,
|
||||
AssetServer: &assetserver.Options{Assets: assets},
|
||||
// Closing the window hides it rather than quitting: the engine must
|
||||
// keep recognising after a shop assistant clicks the X, and the tray
|
||||
// is where they get the window back.
|
||||
HideWindowOnClose: true,
|
||||
OnStartup: func(ctx context.Context) {
|
||||
app.startup(ctx)
|
||||
tray.start(ctx)
|
||||
},
|
||||
OnBeforeClose: func(ctx context.Context) bool {
|
||||
runtime.Hide(ctx)
|
||||
return true // prevent the close
|
||||
},
|
||||
OnShutdown: func(ctx context.Context) {
|
||||
tray.stop()
|
||||
app.StopEngine()
|
||||
},
|
||||
Bind: []any{app},
|
||||
Windows: &windows.Options{
|
||||
WebviewIsTransparent: false,
|
||||
WindowIsTranslucent: false,
|
||||
// A shop PC is not a developer machine; a stray right-click that
|
||||
// opens devtools looks like the software is broken.
|
||||
DisableWindowIcon: false,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("behavision-desktop: %v", err)
|
||||
}
|
||||
}
|
||||
174
desktop/tray.go
Normal file
174
desktop/tray.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fyne.io/systray"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// tray is the always-present control surface. Wails v2 has no systray of its
|
||||
// own, so this drives fyne.io/systray alongside the window.
|
||||
//
|
||||
// It is a CLIENT of the app, not a second copy of it: everything it shows
|
||||
// comes from EngineStatus(), so the tray and the dashboard can never disagree
|
||||
// about whether recognition is running.
|
||||
type tray struct {
|
||||
app *App
|
||||
once sync.Once
|
||||
quit chan struct{}
|
||||
|
||||
mStatus *systray.MenuItem
|
||||
mOpen *systray.MenuItem
|
||||
mStart *systray.MenuItem
|
||||
mStop *systray.MenuItem
|
||||
mLogs *systray.MenuItem
|
||||
mQuit *systray.MenuItem
|
||||
}
|
||||
|
||||
func newTray(a *App) *tray { return &tray{app: a, quit: make(chan struct{})} }
|
||||
|
||||
func (t *tray) start(ctx context.Context) {
|
||||
t.once.Do(func() {
|
||||
go systray.Run(func() { t.onReady(ctx) }, func() {})
|
||||
})
|
||||
}
|
||||
|
||||
func (t *tray) stop() {
|
||||
select {
|
||||
case <-t.quit:
|
||||
default:
|
||||
close(t.quit)
|
||||
}
|
||||
systray.Quit()
|
||||
}
|
||||
|
||||
func (t *tray) onReady(ctx context.Context) {
|
||||
systray.SetTitle("Behavision")
|
||||
systray.SetTooltip("Behavision — starting")
|
||||
systray.SetIcon(iconFor("stopped"))
|
||||
|
||||
t.mStatus = systray.AddMenuItem("Starting…", "")
|
||||
t.mStatus.Disable()
|
||||
systray.AddSeparator()
|
||||
t.mOpen = systray.AddMenuItem("Open dashboard", "Show the Behavision window")
|
||||
systray.AddSeparator()
|
||||
t.mStart = systray.AddMenuItem("Start recognition", "Start the engine")
|
||||
t.mStop = systray.AddMenuItem("Stop recognition", "Stop the engine")
|
||||
t.mLogs = systray.AddMenuItem("Open logs folder", "")
|
||||
systray.AddSeparator()
|
||||
t.mQuit = systray.AddMenuItem("Quit Behavision", "Stops recognition")
|
||||
|
||||
go t.poll(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-t.quit:
|
||||
return
|
||||
case <-t.mOpen.ClickedCh:
|
||||
runtime.Show(ctx)
|
||||
case <-t.mStart.ClickedCh:
|
||||
t.app.StartEngine()
|
||||
case <-t.mStop.ClickedCh:
|
||||
t.app.StopEngine()
|
||||
case <-t.mLogs.ClickedCh:
|
||||
runtime.BrowserOpenURL(ctx, "file://"+logsDir())
|
||||
case <-t.mQuit.ClickedCh:
|
||||
// Quitting the tray stops recognition. Leaving the engine running
|
||||
// with no visible control is worse than stopping it: nobody would
|
||||
// know it was still watching.
|
||||
t.app.StopEngine()
|
||||
runtime.Quit(ctx)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// poll keeps the icon honest. The colour answers the only question a shop
|
||||
// manager glancing at the taskbar has: is it working right now.
|
||||
func (t *tray) poll(ctx context.Context) {
|
||||
tick := time.NewTicker(5 * time.Second)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-t.quit:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
s := t.app.EngineStatus()
|
||||
state, label := describe(s)
|
||||
systray.SetIcon(iconFor(state))
|
||||
systray.SetTooltip("Behavision — " + label)
|
||||
if t.mStatus != nil {
|
||||
t.mStatus.SetTitle(label)
|
||||
}
|
||||
running := s.State == "running"
|
||||
if t.mStart != nil && t.mStop != nil {
|
||||
if running {
|
||||
t.mStart.Disable()
|
||||
t.mStop.Enable()
|
||||
} else {
|
||||
t.mStart.Enable()
|
||||
t.mStop.Disable()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// describe collapses engine state into the three things worth showing.
|
||||
//
|
||||
// "Running but no camera connected" is deliberately amber, not green: the
|
||||
// process is fine and the product is not working, and that is exactly the
|
||||
// state that otherwise goes unnoticed for weeks.
|
||||
func describe(s EngineStatus) (state, label string) {
|
||||
switch {
|
||||
case s.State == "stopped":
|
||||
return "stopped", "Stopped"
|
||||
case s.State == "failed":
|
||||
return "error", "Failed — " + firstLine(s.Error)
|
||||
case s.State == "backoff":
|
||||
return "error", fmt.Sprintf("Restarting (%d attempts)", s.Restarts)
|
||||
case !s.Reachable:
|
||||
return "warn", "Starting…"
|
||||
case len(s.Cameras) == 0:
|
||||
return "warn", "Running — no cameras configured"
|
||||
default:
|
||||
up := 0
|
||||
for _, ok := range s.Cameras {
|
||||
if ok {
|
||||
up++
|
||||
}
|
||||
}
|
||||
if up == 0 {
|
||||
return "error", fmt.Sprintf("No camera connected (0 of %d)", len(s.Cameras))
|
||||
}
|
||||
if up < len(s.Cameras) {
|
||||
return "warn", fmt.Sprintf("%d of %d cameras live", up, len(s.Cameras))
|
||||
}
|
||||
return "ok", fmt.Sprintf("Watching %d camera%s", up, plural(up))
|
||||
}
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
for i, r := range s {
|
||||
if r == '\n' {
|
||||
return s[:i]
|
||||
}
|
||||
}
|
||||
if len(s) > 60 {
|
||||
return s[:60] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
17
desktop/wails.json
Normal file
17
desktop/wails.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||
"name": "Behavision",
|
||||
"outputfilename": "Behavision",
|
||||
"frontend:install": "npm install",
|
||||
"frontend:build": "npm run build",
|
||||
"frontend:dev:watcher": "npm run dev",
|
||||
"frontend:dev:serverUrl": "auto",
|
||||
"author": { "name": "Loyaly" },
|
||||
"info": {
|
||||
"companyName": "Loyaly",
|
||||
"productName": "Behavision",
|
||||
"productVersion": "0.1.0",
|
||||
"copyright": "© Loyaly",
|
||||
"comments": "Footfall and customer recognition for retail"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user