Skip to content

7. The fleet · Contents · Next → 9. The API

8. The dashboard

make dashboard                 # build and serve it
open http://localhost:4200     # sign in as operator / operator
make badge VALUE=BDG-0001      # watch the row appear while you look at it
make dashboard-dev             # run it from source, with live reload

An Angular single-page application. It calls the same REST API as any other client. It has no privileged path of its own. It also listens to a WebSocket for what is happening right now.

Signing in

Keycloak, authorization code flow with PKCE. wardn holds no password and no client secret: the dashboard is a public client, so the code exchange is bound to a verifier the browser tab generated rather than to a secret a browser cannot keep.

The refresh token lives in sessionStorage: cleared when the tab closes, not shared with other tabs, never written to disk. The access token is renewed 30 seconds before it expires, so a request never carries a dead one.

A link to a specific screen survives signing in: the route is remembered and restored afterwards.

The screens

Route What it shows
/dashboard The last 24 hours: counters, the live feed, the state of the fleet
/zones The installation as a tree, and the door controls
/fleet One row per cabinet, with its last report
/fleet/:id One cabinet in detail: telemetry, links, documents, commands
/fleet/:id/debug The live diagnostic console
/users People
/access-rights Rights, their credentials and their revisions
/logs The full activity log with its filters

Dashboard

Counters over the last 24 hours: passages, denials, replayed passages, the number of cabinets online, the doors not in normal mode, and a live feed of the last sixty passages.

The door list is not decoration: a lane left in unlocked after roadworks is the kind of thing that is only noticed when it appears on a screen somebody looks at every morning.

Zones

The installation exactly as it is wired:

Tenant → Zone → Cabinet → Door → Reader

Everything is fetched once and assembled in the browser rather than expanded lazily per node: an installation is a few hundred rows, and a tree that fetches on click is a tree that stalls while an operator is looking for something.

This is also where a door is opened and where its mode is changed.

Fleet

One row per cabinet: presence, log level, firmware and OS versions, queue depth, and the rights fingerprint.

The revision a cabinet reports applying is shown against the revision the cloud believes it should hold. A door refusing someone who was granted access last week is otherwise a silent failure, and this is where it stops being silent. Reload its rights sends the cabinet back for its whole set.

Activity log

Filterable by zone, door, cabinet, level, time range, and "denials only", which is the slice an operator looks at first.

Paginated by cursor, not by page number: rows arrive constantly, and an offset would quietly repeat or skip entries while somebody reads.

Time, everywhere

Instants are stored in UTC and rendered in the timezone of the zone they belong to. That is why every activity row carries that timezone with it.

flowchart LR
    E["Passage happens<br/>in Lisboa, local time"] --> S["Stored in UTC<br/>on the server"]
    S --> D1["Shown in Lisboa time<br/>to a Lisboa operator"]
    S --> D2["Shown in Brussels time<br/>to a Brussels operator"]

A passage at the Lisboa site read in Brussels time is the wrong hour, and support looking for "the badge at 8am" would find nothing. The seed has three zones in three timezones so this is visible rather than theoretical.

The live feed

sequenceDiagram
    participant E as Cabinet
    participant M as Broker
    participant B1 as Backend #1
    participant B2 as Backend #2
    participant UI as A browser on #2

    E->>M: events (QoS 1)
    M->>B1: shared subscription: exactly one instance
    B1->>B1: insert into activity_logs
    B1->>M: wardn/internal/broadcast (QoS 0)
    M->>B2: every instance receives this one
    B2->>UI: /ws/activity, if this operator may see it

The socket carries activity, fleet and debug messages, and a ready frame when it attaches.

It holds no history and makes no attempt to replay a missed window. On every attach, first connection or fifth reconnection, the screen re-reads the log over REST, because the database is already the thing that knows what happened while the browser was away. Reconnection backs off exponentially from 1 s to 30 s, so a backend rolling out is not met by every open dashboard reconnecting in lockstep.

A live row and a row read from history are the same object, joined with their door, reader and holder names by the backend. A dashboard showing a live feed cannot stop to resolve four ids per line.

The broadcast is QoS 0 deliberately: a dashboard that misses a frame catches up from the database on its next read, and holding live traffic for a browser that is no longer there would be worse than dropping it.

The same confinement as the API

A socket is not a way around tenant confinement. Each message is filtered as it is sent: a signed-in operator sees every tenant, an API key attached to the same socket sees exactly one.

flowchart LR
    B["Broadcast message<br/>tenant: Acme"] --> S1["Operator dashboard<br/>sees every tenant"]
    B --> S2["Acme API key socket<br/>sees Acme only"]
    B -.->|"filtered out"| S3["Northwind API key socket"]

A message carrying no tenant reaches nobody who is confined. An unattributable payload is not a reason to widen what an API client can see.

make live   # asserts a badge reaches an open feed, and that a second tenant hears silence

Configured at start, not at build

The image is built once and reads a config.js written by its entrypoint from the environment:

window.wardnConfig = {
  apiUrl: 'http://localhost:3000',
  keycloakUrl: 'http://localhost:8080',
  realm: 'wardn',
  clientId: 'wardn-spa',
  sentryDsn: '',
  environment: 'development',
};

What it holds are the addresses a browser dials. It never holds the service names the containers use, because the SPA runs on the operator's machine and not inside the compose network.

The same image therefore goes to every environment.

What is not here yet

⚠️ Creating hardware. Cabinets, doors and readers are created through the seed or by SQL: the API has no write endpoints for them. Tenants, zones, users and access rights can be created through the API and the dashboard.

Errors

The API answers failures in RFC 9457 form, and the dashboard shows the detail sentence as written. That sentence is meant for a human, and it carries a traceId that can be pasted into a support ticket.


The screen is one client. Let us look at the interface every client shares.

Next → 9. The API