← 7. The fleet · Contents · Next → 9. The API
8. The dashboard¶
A single-page web application. It calls the same programming interface any other client would, with no privileged path of its own, and it listens for what is happening right now over a live connection instead of only ever checking back and asking.
Signing in¶
Through your organisation's own identity provider, the same way you sign into other work applications. See 10. Security.
wardn itself never holds a password or any secret of its own for this: each sign-in is bound to a one-time value the browser tab generates for itself, rather than to a secret a browser has no safe way to keep.
The session lives only in that browser tab: closing it clears it, another tab does not share it, and nothing is written to disk. It is renewed shortly before it would expire, so an action in the dashboard is never carried out on a session that has already gone stale.
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 |
/topology | The installation's physical hierarchy, zone → controller → door → reader, as a browsable tree |
/zones | Every zone, with its controller and door counts, and links out to each filtered to it |
/controllers | One row per controller, with its last report |
/controllers/:id | One controller in detail: heartbeat, links, documents, commands |
/controllers/:id/debug | The live diagnostic console |
/doors | Every door, wherever it hangs off the fleet. Also where one is opened or its mode changed |
/doors/:id | One door in detail, in a drawer |
/readers | Every identification device, wherever it's wired |
/readers/:id | One reader in detail, in a drawer |
/diagnostics | Every controller, door and reader with its last contact, plus the catalog integrity check |
/users | People who hold access rights, searchable by name, description or external subject id |
/access-rights | Rights, their credentials and their revisions, searchable by holder or credential value |
/logs | The full activity log with its filters |
/alerts | Operational alerts: a silent controller, a stuck queue, a rights delta that failed to land |
/workspaces | Workspace management. Sits above workspace scoping, not itself workspace-scoped |
/identifier-types | The global identifier type catalog. Sits above workspace scoping, not itself workspace-scoped |
/reader-formats | The global reader format catalog, with a preview panel. Sits above workspace scoping, not itself workspace-scoped |
/transformers | The global transformer catalog, with a preview panel. Sits above workspace scoping, not itself workspace-scoped |
/firmware | Published releases and how much of the fleet each accounts for, searchable by version. Sits above workspace scoping, since a release and the fleet it reaches span every workspace |
/firmware/:version/assign | Picking the controllers a release should be installed on, from every workspace, each with its workspace named. None starts picked |
A few interaction details that don't show up in a route list:
- Users, Access rights and Firmware search the server rather than filtering what's already on screen, so a match holds even past whatever page happens to be loaded. Firmware searches the version only: the rest of what a row shows lives inside a manifest the catalogue would have to read in full to match on.
- A zone rename or a door's mode change applies to the row before the server confirms it, and rolls back with an error toast if it didn't take.
- The sidebar's Alerts entry carries a count of what's new since it was last opened.
Dashboard¶
Counters over the last 24 hours: passages, denials, replayed passages, the number of controllers 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.
Topology¶
The installation exactly as it is wired, browsed one level at a time: Zones, then that zone's Controllers, then that controller's Doors, then that door's Readers, in columns, the way a file manager browses folders. Clicking a card drills into its children. "Open →" leaves for that entity's real screen instead.
Workspace → Zone → Controller → Door → Reader
Each column is fetched once and filtered in the browser rather than queried per click: an installation is a few hundred rows, not enough to justify a round trip every time an operator drills one level deeper.
The workspace itself isn't a column. It's already fixed by the sidebar's workspace switcher.
Zones¶
A flat list: one row per zone, with its timezone, and how many controllers and doors it holds.
Every count is a link into the first-level Controllers or Doors screen, filtered to that zone — and the doors count links three ways: the total, and the IN and OUT halves it breaks down into, each landing on the same screen already narrowed. The deep nesting this screen used to require doesn't have to be walked to find one controller.
Controllers¶
One row per controller: presence, log level, firmware and OS versions, queue depth, and the rights fingerprint. The last report also says whether the controller vouches for its clock: a battery-less board that lost power comes back in the past until NTP catches up, and until then its decisions and the times on its passages are not to be trusted (7. The fleet).
The revision a controller reports applying is shown against the revision the server 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 controller back for its whole set.
History draws the stored telemetry samples — delivery lag, CPU load, free memory, disk used, uptime — over the last hour, day or five days. The heartbeat above answers "how is it right now"; these answer "how has it been", and they are the only place the difference between a controller that lost its uplink and one that was down is visible. A backfilled stretch is dashed: those samples were taken on time and delivered late. A stretch with no line at all is a controller that was not running to take any, and nothing will ever fill it in (7. The fleet).
Delivery lag is the chart that states that difference outright rather than leaving it to be read off the others. It plots how long each sample waited between being taken and arriving, so a hump is an outage the controller sat through and backfilled — its height is how long the link was gone — while a break in the line is a stretch that produced no sample at all.
Availability sits above the charts on the same window and says it in one line: a strip that is green while the controller was reporting and red for every outage, each listed underneath with when it began, when it ended and how long it lasted. An outage begins at the last heartbeat the server received, not thirty seconds later when the sweep noticed, and one still going on runs to the right edge. It is drawn from the technical journal's own offline and back-online notes (7. The fleet), so a stretch that was never marked offline — a link that dropped for under thirty seconds — is not on it, and shows only as backfilled samples on the charts below.
Commissioning does the same for the wiring. The controller reports how many doors and readers it is actually holding, and that is shown against how many were configured for it — a controller acknowledges a topology when it applies it rather than when it applies all of it, so a report that landed incompletely would otherwise leave this screen showing a controller in sync with a configuration it does not have.
Above those counts, the wiring panel draws the same installation as a diagram: the controller, one rail per way it reaches a peripheral, and a card on that rail for every door and reader hanging off it, carrying the address or the terminals it occupies. The colours are the point. Green is a peripheral answering on the bus, red one that was polled and did not answer, grey one that has never been polled — which is a different fault and would hide behind the other two if they shared a colour. Amber is a link nothing can confirm: a door with no output, or an address answering that nothing here claims. Anything reached through the controller's own header reads wired, because a pin reports nothing back. Clicking a card opens that door or reader.
Two other things appear there, and neither is a fault. Doors wired to nothing are listed by name, linking straight to the door that needs a terminal. Bus addresses answering that nothing owns are listed too: somebody plugged a device in and nobody declared it here, so the fix is to assign it to a door, not to go and unplug it.
Download the wiring report turns the whole of it into a PDF meant to be printed and carried to the cabinet. It lists every door with the terminal or bus address its contact is declared on, and every reader with its address and the format its frames are decoded through — each with a box to tick, because the point is to confirm the wiring matches, one line at a time, with a pen. Two things are flagged in red rather than left to be noticed: a door nobody has witnessed opening (a dry contact reports nothing back, so only a person can confirm it), and a reader that is configured at an address and has never answered on the bus. The report is generated on the spot and kept nowhere — it describes the cabinet as configured right now, and a stored copy would only go stale.
Prepare its edge.env opens a page that assembles the controller's environment file, field by field, beside a live preview. Its identity, the broker and the update key come filled in. The serial port and the GPIO chip come with a hint drawn from the topology: how many doors and readers are on the bus, how many on the pins. The page lists what would still stop the service from starting, and the file downloads or copies from there. Nothing typed on it is sent anywhere. The database key can be generated in the browser, but it is safest generated on the controller, where it never leaves the machine. A controller that has never reported links to the same page at the top of its own, inside the steps that connect it.
Doors¶
Every door, wherever it hangs off the fleet: reached directly, or filtered from a zone, a controller or a direction — zoneId, controllerId and direction in the query string, so a filtered view survives a refresh and can be pasted into a ticket.
The search box narrows on everything a row shows: name, description, zone, controller, direction and mode. Terms are ANDed, so north in finds what neither word alone would.
A door's mode is changed here, and it can be opened on the spot. Both are recorded against the operator's name. The screen names each mode by what it does rather than by its stored value:
| Shown as | Stored as | What the door does |
|---|---|---|
| Normal | normal | Opens on a granting right, refuses otherwise |
| Anyone who badges | unlocked | Opens for every read, known or not |
| Any known badge | free_access | Opens for a credential this controller holds a right for, even an expired one |
| Locked | locked | Refuses everyone |
None of them holds the door open. Every read is logged, and the strike closes again after each passage.
The relay pulse is how long the strike fires for a granted passage: long enough for the mechanism to release, short enough that it re-locks before someone thinks to lean on it.
The relay GPIO is the controller GPIO line that drives this door's relay — and it is the one field on this screen nothing can check for you. A dry contact has no return path: the controller can fire GPIO 3, and nothing on the wire says a door moved, let alone which one. So the door is marked not commissioned until it has a relay GPIO or an OSDP address, and the controller refuses every read at it with DOOR_NOT_COMMISSIONED in the meantime.
Once it has one, Open now followed by Confirm it opened records that a named person watched the right door move. That is the whole confirmation there is, and changing the terminal or the address clears it, because a confirmation is about an output rather than about a door.
Readers¶
Every identification device, wherever it's wired: a badge reader, an ANPR camera, a QR scanner, a keypad, or a device speaking more than one of these at once.
Its wiring is how it reaches its controller, and it decides who reads it:
| Wiring | Parameters | Who reads it |
|---|---|---|
| OSDP bus | an address, 0–126 | The controller polls it on the line it shares with the other peripherals, and can answer it there — LED, buzzer, screen |
| Wiegand | two pins, D0 and D1 | The controller watches its own header. The reader is never polled: it speaks when a card is presented and is silent the rest of the time, so an unplugged one and an idle one look identical |
| On the controller | none | Reader and controller are the same box (17. The all-in-one reader). There is no wire, so there is nothing to address and nothing to lose |
A reader off the bus can still be answered, if somebody wired the two pins for its LED and buzzer. Left empty, a refusal at that reader is silent — which is a wiring choice, and not a fault to chase. A reader on the bus is answered over the wire instead, so those two pins are not read for one.
One pin is one purpose: a door's relay GPIO, a reader's data lines and those two feedback pins all come off the same header, so the dashboard refuses a pin already claimed by any of them.
A reader's own page draws the wiring panel of the controller it hangs off (Controllers), with this reader ringed and the rest dimmed behind it. A reader is rarely wrong on its own — what is worth seeing is the line it shares, and what else is on it.
What a reader can report is not stored on the reader at all. It is read off the format assigned to it, which is the only answer that cannot drift away from what the reader actually emits: a stored second copy would go stale the first time a format is edited, and say so to nobody.
Every reader is assigned a reader format (readerFormatId, required at provisioning, reassignable from the reader's own drawer): one tree that answers both questions a raw frame raises — which identifier type is this, and what is its value. Its leaves are emit nodes, each naming a type and the value transform that extracts the value; its branches test what the frame itself carries.
A reader that always reads one kind of credential is a single emit; one whose frames carry their own type marker branches on it and emits a different type per case, which is how a multi reader is expressed at all.
This is enforced — a reader whose format only ever emits licensePlate cannot report a badgeNumber, because no leaf of its format names one. There is no way to opt out: a controller holds no decode of its own, so a reader reaching it without a format refuses every frame as UNREADABLE_FRAME rather than guessing at what the bits mean. The catalog's default format is the passthrough — the frame is the value, reported as one type — which is what a reader whose framing nobody has documented yet is provisioned with.
A branch with no otherwise is the one shape that can refuse a frame outright: nothing matched, so nothing could be read, and the passage is recorded as UNREADABLE_FRAME rather than as an unknown badge. The seeded "QR only, strict" format is exactly that, and the development dataset wires a reader to every format in the catalog, so each of these paths has a row you can look at rather than only a definition you can read.
The type is decided inside the tree rather than pinned beside it on the reader. Two parallel expressions — one for the value, one for the type — could disagree about which case a frame fell into; one traversal cannot.
A frame that does not fit the definition produces no value rather than an error. A substring reaching past the end of the frame, a regex that does not match, a branch that recognises nothing: each extracts the empty string, and the frame is still reported as the type its branch named. It is never the partial value the frame happens to hold, which a lookup could match against. At a door that reads as an unknown credential — refused and recorded like any other passage.
The unreadable frame outcome stays for the case where nothing at all could be resolved: no branch matched, or the definition itself cannot be read.
The other side of that rule is provisioning. A credential whose value normalises to nothing is refused, because stored it would sit under the hash of the empty string — the very key every unreadable frame at every door now reduces to.
Reader formats, and the two catalogs they build on, are their own screens: /reader-formats, /identifier-types and /transformers, shared across workspaces like /workspaces itself rather than living inside workspace scoping.
A format's definition is edited either as raw JSON or through a recursive visual tree editor, kept in sync with each other, and its drawer has a Preview panel that decodes a sample frame — naming the identifier type it resolved to as well as the value — without touching a reader.
While the editor holds something other than what is stored, that panel reports both: what the saved definition makes of the frame, and what the edit would. The same is true of a transformer's preview. An edit is judged against the frame that is actually failing before it is saved, rather than after — saving a reader format is what re-syncs every controller decoding through it.
At most one format is flagged default, from either the list or its own drawer. Flagging one clears the flag off any other, server-side, so there is never more than one. It changes nothing for readers already provisioned — it only preselects that format on the "add reader" form, so provisioning a batch that all decode the same way does not mean picking the same entry from the list every time.
A transformer is the smaller language a format's leaves are built from: raw, trim, uppercase, stripNonAlphanumeric, hexText, substring, bitfield, split, regex, wiegand, sequence, branch. It only ever produces a value; it never names a type.
A raw frame reaches this language as hex text — most significant bit first, padded to whole bytes — which is what a reader's OSDP payload becomes before a format ever sees it. bitfield reads that text as a bit sequence rather than a character sequence: bit 0 is the most significant bit of the first hex character, and {start, length} extracts a bit range the same way substring's {start, length} extracts a character range, re-encoded as binary, decimal or hex text.
wiegand is the same bit-range extraction shaped for a Wiegand-style card: a totalBits frame width, a cardNumber bit range, and a list of parityBits checks (a bit position, even or odd, and the dataStart/dataLength range it covers — the parity bit itself counts toward its own parity, per the standard Wiegand definition).
A frame shorter than totalBits, or a failing parity check, yields no value — the same rule as everywhere else in this language.
It never reports a site or facility code: that range identifies the site a reader is on, not the credential holder, and a field with no effect on the result would only invite configuring something that silently does nothing.
hexText is the way back when what a reader sent was text rather than bits: a QR code, or a token a phone handed over. It decodes the hex frame into the UTF-8 text it carries, so 4E46433A4244472D30303031 becomes NFC:BDG-0001. A frame that is not whole hex bytes, or whose bytes are not UTF-8 (a card UID usually is not), yields no value. Followed by a regex, it strips the prefix a reader stamped on the value.
bitfield and wiegand always read their input as hex, regardless of where they sit in a sequence. A stage upstream that already produced plain decimal digits will not raise an error — decimal digits are valid hex characters — it will silently misread them as nibbles instead. This is the same ambiguity substring's decimal/hex encodings already carry, not something new to this language.
A transform is saved under a technical name — lowercase, - and _, fixed once created — and a title that can be reworded freely. The name is what stored documents refer to, which is why it does not change: a rename would mean rewriting every definition that spells it. A format's leaf can either describe its extraction inline or call a saved transform by name (use), and an identifier type names one the same way.
Everything referenced from inside a stored document is referenced by its name: an identifier type by its key, a transformer by a use node. Only things referenced from a column — a reader pointing at its format — keep a uuid. What that buys is that the document stored in the database and the document a controller receives are the same document, with the transforms inlined into it on the way out and nothing else translated.
A transformer never names another transformer. That flat rule is what makes the reference graph two levels deep and acyclic by construction, with no cycle detection to write anywhere.
Normalisation¶
An identifier type names a transformer of its own, its normalisation: how a value of that type is reduced before it is hashed and matched.
It belongs to the type rather than to a reader because a value enters the system twice, and only once through a reader. An operator provisioning a credential types it by hand, with no reader and no format anywhere in sight; a badge presented at a door arrives through both. Those two have to land on the same string or the credential never matches, and the only thing they have in common is the type.
Every controller holds a copy of this catalog and applies it to what its readers produce — the rules it used to hardcode are now data the cloud owns. A type the controller's copy says nothing about falls back to a built-in rule, which is what an unsynced controller and an uncatalogued credentials.type both run on.
Repointing a type's normalisation is refused once a credential of that type exists. Their stored hashes were taken over the old rule and scrypt cannot be undone to re-derive them, so the rule is chosen when the type is created and fixed afterwards.
The same guard covers editing a transform's own definition while a credential was hashed through it. The title and description stay editable in both cases.
Deleting an identifier type is refused while any reader format can still report it, and while a credential is still provisioned under it; deleting a transform is refused while a type normalises through it or a format names it.
A reference inside a stored definition is a string, not a foreign key, so these checks take a shared advisory lock that format writes take too — otherwise a delete and a newly-saved reference could pass each other, each seeing a consistent snapshot.
What a definition is checked against¶
Both languages are described by one JSON Schema document, served by the API at GET /transformers/schema and fetched by the dashboard, which validates against the very bytes the API enforces rather than a second copy of the rules that could drift from it. It carries two versions of the value language: one that allows use, for a format's leaf, and one that refuses it, for a transformer's own definition.
Three rules no schema can carry are checked alongside it: that a regex compiles on the controller, and that the identifier type and transform names a definition uses exist in their catalogs — the last two need the database.
Four checks the schema can't express on its own:
- A
regexnode is refused if it uses look-ahead, look-behind or a backreference. The browser compiles those and the controller's own regex engine does not, and the difference would surface as every read on that reader failing at a door, long after the preview here reported the pattern as fine. - A
regexnode is refused too if it repeats a group with no ceiling, unless that group holds a single character class. This one is the mirror image: the controller's engine runs in linear time and would not care, but the server backtracks. What explodes is a body that can match the same text in more than one way, and a group is where that happens —(a+)+splits the input between the two repetitions,(a|a)+splits it between two branches that accept the same characters. Measured here, each takes tens of seconds against thirty non-matching characters, on the one thread serving every request — and a preview lets its caller choose the pattern and the sample both.(\d)+and(?:[A-F])*stay fine, since one atom covers a given length exactly one way; write[A-Z0-9]+rather than([A-Z]|[0-9])+. - A
substringoffset must be whole and non-negative, or the controller cannot read the definition back at all. - A
splitdelimiter has to be non-empty for the same reason — an empty one cuts a frame into different tokens here and there, so the token at index 2 is not the same token.
Checking the catalog after the fact¶
Those two catalog rules are the only ones with nothing behind them once a row is written. A reader format names a transformer and an identifier type by string inside JSONB, not by foreign key, so the API refusing a definition that dangles is all that holds them — and a seed, a migration, a restore or a hand-written UPDATE goes straight past it.
The consequence is worth a check. A format that stops resolving is not refused at sync time either: its readers are sent to their controller with no format at all, and every frame at them is then refused as UNREADABLE_FRAME — visible at the door rather than silent, but still a reader that has stopped working for a reason nothing else names.
Diagnostics re-runs both rules against what is stored, plus a third the API also enforces on the way in — that every stored definition is still one the current schema accepts, which stops holding the day the schema is tightened. It reports the size of each catalog when they hold, and names the offending rows when they don't.
make verify asserts the same two reference rules in SQL, so a seed that breaks one fails before it ships rather than showing up on this screen.
Users¶
People who hold access rights, not dashboard operators. Those exist only in the identity provider and are never listed here. A full name is optional: someone known only by the badge they were issued still needs a row to hold their rights and their passages, even if nobody ever typed their name in.
The list shows the external subject id rather than the workspace, which every visible row shares anyway since the screen is already scoped to one. It is the identifier the person carries in the system they were provisioned from, so it is what an operator arriving from an HR ticket searches on and what they copy back into it. The search box matches it alongside the name and the description. It is empty for anyone created here rather than synchronised in.
Test wallet, next to Add user, opens the QR code a test phone scans to carry this workspace's people and present their credentials at a door. It is the same export as the one at the bottom of a workspace, and the command palette reaches it too, as Show test wallet.
It lists the people a wallet can carry, those holding a badge, QR, PIN or mobile credential, with a filter on the name. Everyone starts selected in a workspace of five people or fewer, and no one past that. One QR code holds about a hundred people, so a large workspace is tested a few people at a time. Under the list, the bytes the selection takes at most against what a QR code holds. The figure adds up what each person takes alone, and together they take less, so a selection over the limit may still fit: the export says when it does not.
Access rights¶
Rights, their credentials, and their revisions. A right is never edited. Replacing a credential creates a new revision and deactivates the one before it, so a passage from last month is still explained by the right that was in force then.
Valid from/to are entered in the operator's own time and stored in UTC, same as everywhere else in wardn.
Unchecking grants access doesn't mean "no opinion". It creates an explicit denial, distinct from having no right at all. It is not the last word, though: a credential resolves to a grant the moment any right covering that door and instant grants it, denial or not. See 4. A decision for why refusing on ambiguity would be the wrong default.
Activity log¶
Filterable by zone, door, controller, 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.
Alerts¶
Operational alerts: a controller gone silent, a reader that stopped answering, a queue past its threshold, a rights delta that never landed. Repeats of the same condition on the same controller are folded into one line, which lists every occurrence when opened. What is still going on comes first, the most severe on top, and the rest follow by their latest occurrence. Each kind has a severity: critical (a silent controller, an untrusted clock, lost passages, an expiring certificate), warning, or notice (unfinished commissioning, lost telemetry). Only a silence and an untrusted clock record their end, so only they read as ongoing or resolved. An alert isn't acknowledged here. It stops being raised once the condition behind it clears. A controller that goes silent is one alert per outage, so how often it appears here is how often the controller went away, and each one says when it was over and how long it lasted once the controller has reported again. The same outages, drawn on a strip, are on the controller's own screen.
Workspaces¶
Where every zone, user and API key is issued from. Unlike the rest of the dashboard, this screen sits above workspace scoping rather than inside it. It's where a new workspace is created, and where its API keys are issued and revoked.
It sits in the sidebar's own Admin section, alongside Reader formats, Identifier types, Transformers (see Readers above) and Firmware — the five screens that are global rather than scoped to one workspace.
Each workspace also carries a default timezone: the one a zone of that workspace is created with when whoever created it named none.
Export test wallet, at the bottom of a workspace, shows a QR code holding the people picked above it and their badge, QR, PIN and mobile credentials in clear text. A test phone scans it to present them at a door. It stays on screen until Hide, and every export is kept in the audit trail. Full screen shows it alone on white: a code that dense reads in a moment that way, and slowly at the size of the panel. See 9. The API for what it contains.
Everything named is a link¶
A zone, a controller, a door, a reader or a person named on any screen, whether in the activity log, an alert, the home page or a controller's breadcrumb, opens its own screen. Reading a row and then searching for the thing it names is a step the dashboard should never ask for.
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 Controller
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 workspace confinement. Each message is filtered as it is sent: a signed-in operator sees every workspace, an API key attached to the same socket sees exactly one.
flowchart LR
B["Broadcast message<br/>workspace: Acme"] --> S1["Operator dashboard<br/>sees every workspace"]
B --> S2["Acme API key socket<br/>sees Acme only"]
B -.->|"filtered out"| S3["Northwind API key socket"] A message carrying no workspace reaches nobody who is confined. An unattributable payload is not a reason to widen what an API client can see.
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',
oidcAuthority: 'http://localhost:8080/realms/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.
Errors¶
When something goes wrong, the dashboard shows the message the server sent back, written to be read by a person rather than decoded. It also carries a reference id, meant to be pasted straight into a support ticket so whoever picks it up can find the exact request that failed.
The screen is one client. Let us look at the interface every client shares.
Next → 9. The API