Skip to content

Web UI Access and Authentication

The DCS Gateway serves a built-in web UI split across three role-aligned endpoints that match ISA-88 control-activity domains: engineering, operations, and production information. This page covers administrator topics: endpoint routing, authentication, role-based access, and the web terminal. For the per-endpoint user guides, see:

Endpoints and Roles

Endpoint Purpose Allowed Roles
/system Engineering: configuration + diagnostics dcs-engineer, dcs-supervisor, dcs-admin
/hmi Operations: live process + batch execution dcs-operator, dcs-lead-operator, dcs-engineer, dcs-supervisor, dcs-admin
/data Production information: records, trends, audit Any DCS role including dcs-viewer (every role grants read; a user in no DCS group gets 403). It is also where a dcs-viewer session lands, since it is the only app that role can open
/login Authentication (OIDC / LDAP) Unauthenticated
/ Redirect Redirects to /system
/robots.txt Crawl policy: refuses all search-engine indexing Unauthenticated

When a user navigates to an endpoint they lack permissions for (for example an operator accessing /system), the gateway redirects them to their default endpoint based on role.

/robots.txt is unauthenticated because a crawler that receives a 401 concludes the host publishes no crawl policy and indexes it anyway. See Security Hardening for what the file covers on a host that also serves the documentation container.

Shared Layout

All three endpoints share a consistent layout:

  • Top bar — sidebar toggle, page title, endpoint navigation tabs (System, HMI, Data) in the center, system health indicator, cross-cutting alarm badge, user name, and logout button
  • Sidebar — navigation between views (top section) and site/equipment list (bottom section). Drag the right edge to resize, and drag the divider between sections to adjust the vertical split.
  • Main area — content for the currently selected view

The alarm badge is visible on all pages regardless of endpoint. Clicking it navigates to the HMI alarms view. It counts the alarms that are actually annunciating: both acknowledged and unacknowledged, as long as the condition is still present. Shelved alarms are excluded, deliberately suppressed from annunciation (ADR 0014), and remain visible in the alarms view's Shelved tab. The count refreshes every 15 seconds. If a refresh fails, the badge keeps the last count and takes a dashed outline, since a reported zero would be a lie. Hover it for the reason.

Screen size

The supported floor is 1280x720 with no browser chrome, which is the full kiosk viewport of a 9" panel PC (ADR 0046). Every operator surface fits that width without a sideways scroll. Where a table cannot, it drops its lowest-priority columns as the pane narrows, keeping the rest on screen, and the columns that carry the action always stay: Alarm Response sheds Type, then the message excerpt, then Source and Age, and keeps the Acknowledge button through every reduction. The full value of a truncated cell is always in its tooltip, and the record it came from carries it in full.

Vertical scrolling is normal and is how these pages are meant to be driven. Content taller than the screen is not a fault. An expanded equipment faceplate is deliberately taller than a 720px panel, and every control on it is reached by scrolling the dashboard beneath it.

A faceplate opens no dialog over itself. A write from its TAGS table expands an editor row under the tag it acts on (ADR 0058). That tag keeps showing its live value and quality in the row above the field, and the equipment mode stays on screen beside them. Escape closes the editor and leaves the faceplate open. A second Escape closes the faceplate.

Panels narrower than 1280, such as a 1024x600 unit, work as far as the column reductions allow, but they are not a size we test against.

System health and live-feed freshness

Two different questions are answered in the top-right corner, and they are deliberately given different shapes (ADR 0023):

  • System health — the coloured dot, labelled Healthy, Degraded, or Critical. It is a cluster-wide, server-side rollup over the control-plane services, every site's unit runtimes, and the servers underneath them. A server that is not healthy reads Degraded, and a cluster that has lost etcd quorum reads Critical (ADR 0037). Present on all three endpoints. Clicking it opens System → Diagnostics. This is the only coloured status light in the top bar, and on the HMI it is the whole of what the operator interface says about the hardware: the Servers surface is an engineering view, and infrastructure actions are engineering actions.
  • Live-feed freshness — plain muted text on the HMI, reading Live · 2s ago: the age of the most recent reading to reach this browser session. It is not a status light and carries no colour while healthy.

They can legitimately disagree. A laptop on failing Wi-Fi shows a stale screen while the plant is perfectly healthy. Conversely, live values keep arriving from a cluster whose historian or an operator has gone degraded.

An age is shown on purpose, where a "connected" indicator would mislead: a connection can stay open while no data flows, and a frozen screen that still claims to be connected is the failure an operator most needs to catch.

The age is measured on whichever live-data path paints the view you are looking at (ADR 0028). Process displays are fed by the live tag feed, so their readout is the age of the last reading off it. The Batch Execution and Alarm Response views refresh over their own periodic request instead, so theirs is the age of the last refresh. The two legitimately differ, and the readout's tooltip names which one it is quoting.

When the live tag feed is interrupted (disconnected, reconnecting, or the gateway has lost its broker connection), a banner spans the top of every process display stating that on-screen values are the last known readings and no longer current. Individual values also dim and carry a stale badge when they go stale. The banner does not appear on the Batch Execution and Alarm Response views, because their data is not arriving over that feed. If their own refresh stops, their readout's age climbs instead. If the gateway's broker connection is the cause, it additionally registers as a Degraded Message Bus in system health, so it is visible in Diagnostics and not only to operators.

Alarm annunciation during a feed interruption

Alarm annunciation (the sidebar alarm cue, the unit-card and faceplate alarm indicators) does not stop when the live tag feed does. The live feed delivers alarms as they happen, but the annunciation cues are refreshed from the same 15-second request that drives the alarm badge (ADR 0029). While the feed is interrupted, they update on that period, and a new alarm still lights the cue.

The one case that does interrupt annunciation is the alarm request itself failing, which is what the badge's dashed outline reports.

Acknowledging an alarm clears its cue immediately. The acknowledgement takes a moment to settle on the server, and the HMI holds the operator's answer over the refresh until it does. The cue therefore does not flicker back on.

What counts as stale depends on how the tag publishes, which the tag declares (ADR 0025). The HMI never guesses it from the tag's name:

  • cyclic — the tag republishes every scan whether or not its value changed, so a value older than 10 seconds means the device stopped publishing. It dims and carries a stale badge.
  • onChange — the tag publishes only when its value changes, so a device in a steady state may legitimately not republish for hours. Its age is not a staleness signal, and it is judged on whether the live feed as a whole is still delivering. It dims only when the feed itself falls silent.

A tag that declares no publish mode is treated as cyclic. That is the conservative reading: a value wrongly marked stale is annoying, whereas one wrongly presented as current hides genuinely dead data.

Declare the mode that matches how the tag's values actually arrive. What the tag means says nothing about it, and an interlock tag is not automatically onChange. Tags driven by this product's own unit runtime are published cyclically and need no declaration. publishMode: onChange is for tags fed by a publisher that emits only on change, such as an OPC UA server or a third-party MQTT gateway. Those will dim while perfectly current until the tag declares it. Declaring onChange on a genuinely cyclic tag has the opposite cost: it suppresses the signal that the device has stopped publishing.

On the HMI, an amber Respond badge appears beside the alarm badge whenever an operator prompt is waiting on a running phase. Clicking it expands the waiting prompts as a band under the top bar. The view below moves down to make room for it, and each row is a route to the parked phase, where the response is given on the phase's own prompt card (see Operator messages and prompts).

The badge holds its last count when a poll cannot reach a site. A waiting prompt is the only ambient sign that a batch has stopped for a human. The annunciation therefore stays up while the gateway is unhealthy, which is when an operator is most likely to be looking at it. After two consecutive failed polls the badge takes a dashed border and the band names the sites whose lists it could not read. Both clear on the first poll that reaches the site again.

Servers

In the System app's sidebar, each site's Infrastructure section carries three sub-trees. Controllers holds the control-zone compute adopted for unit runtime, with each controller's I/O modules beneath it. Servers lists the remaining Kubernetes nodes that run the control software: control-plane nodes and any workers that have no role in the control zone. For a customer, this view is the only management surface for that hardware. The plant engineer does not need kubectl to see a control-plane node reboot. Field Devices holds the networked things this product exchanges process data with and does not run, which today means the I/O modules that name no controller. A Wago coupler reached over Modbus TCP, an EtherNet/IP drop or an OPC UA endpoint belongs to no chassis. It hangs off the site directly. A sub-tree appears only when the site has something to put in it. The section's last row is Device Discovery, the commissioning wizard that fills the sub-trees above it. The row stays present when they are empty, because a site with no infrastructure yet is exactly where discovery is the next step. See Device Discovery.

An I/O module whose controllerRef names a controller the site does not have is a configuration error, and it stays out of Field Devices for that reason. It appears under Controllers, beneath a row carrying the name it expects and cannot find, and that row is marked Unresolved. Hovering it gives the whole explanation. Two things reach this state: a controller deleted while its modules still name it, and a typo in an authored controllerRef. Either is repaired by creating the controller or correcting controllerRef on the modules listed under the name. The site's I/O module list groups the same modules under the same name and marks the group the same way.

A reference that has been dangling for less than five minutes is marked Pending. Applying the modules ahead of their controller is an ordering GitOps recovers from on its next reconcile, and a fault badge on a cluster that is converging normally would read as a permanent fault.

Each header in the section is a landing page, and each lands at the scope of the branch it sits in. Clicking Infrastructure opens the site's Infrastructure overview tab, with one summary card per branch: controller join state, I/O module health, and the site's server rollup. Controllers opens the site's controller list, and Servers opens the site's server list. Field Devices opens the site's I/O module list, which carries every module the site has whether or not a controller owns it. The arrow beside each header collapses the branch without leaving the current page.

A server belongs to the site named by its dcs.io/site node label, the same label the node-adoption contract already uses to discover controllers. The deployment layer applies it and the product only reads it (kubectl label node cp-a4f2 dcs.io/site=plant-01). A node carrying no such label belongs to no site: it is never claimed for whichever site happens to be open, and the site's Servers branch reports how many such nodes the cluster holds so a missing label reads as a missing label.

Facts that are genuinely cluster-wide keep a cluster-wide home. The Servers view at #/servers lists every node in the cluster with the site each one belongs to, and it is where etcd quorum and the node totals live:

  • Cluster summary counts nodes by classification and derives etcd quorum health from control-plane readiness (quorum = ⌊members/2⌋+1). The gateway reads only the Kubernetes API. The quorum chip is a derivation, and its tooltip says so.
  • Node tables show health, roles, abnormal conditions (memory, disk, and PID pressure), kubelet version, OS image, and internal IP. A cordoned node carries a Cordoned chip.

Health reads in the same four words a Controller reports: Healthy, Degraded, Offline, Unknown. A node that is Ready and reporting a pressure condition is Degraded, one whose kubelet has stopped posting status is Offline, and cordoning changes nothing, because taking a node out of scheduling is an operator's own act and no fault at all. The grading is the same one the server alarms use (ADR 0032), so the dot on the page and the alarm on the console agree. The raw Kubernetes condition status stays on the node detail's Conditions table for kubectl correlation. - Locality is a second classification beside control plane, worker and controller, and it answers a different question: where the chassis is, and whether a fault on it is repaired or resolved by replacing the machine. A node reads On-premises, Cloud with the provider named beside it, or Unknown, and a node whose lifecycle is owned by a managed node group or an autoscaler carries a Replaceable chip. Every cloud deployment is hybrid: a controller needs physical I/O attached and is on-premises by definition, while a server may be anywhere. The column answers what keeps running if the WAN drops. - Usage sits beside capacity, because a node that has 32 GB and a node that has 1 GB left read identically without it. Each node shows CPU and memory used against allocatable, and names the source that produced the number: measured where a metrics stack is deployed, or requested where there is none, which is the sum of the pod resource requests the scheduler has reserved. The two mean different things and the word travels with the percentage, because a node whose pods reserved everything and use nothing reads full under one source and idle under the other. A node with neither source reads unknown, since 0% used would be an invention. - Clock reports whether the chassis agrees with the rest of the cluster about what time it is. Kubernetes publishes no condition for this, so a node with a dead RTC cell boots years in the past and passes every readiness check. The gateway compares the Lease each kubelet renews with its own clock against its own. The answer therefore needs no monitoring stack and is available on every node that runs a kubelet, including the device nodes ADR 0031 taints. A node reads In sync, Drifted with the magnitude beside it, or Unknown, and unknown never collapses into in sync: a node that is not Ready is reported unknown because a lease that stopped being renewed cannot be told apart from a clock that is wrong. - Node detail adds full conditions, hardware capacity, versions, and the PersistentVolumes pinned to the node by hostname affinity. A node with bound volumes cannot lose its workloads to a reschedule, which is exactly what to check before taking a chassis down for maintenance. Its Properties block carries provider-appropriate identity: the instance, its type, its region and zone, and the label naming the owner for a cloud node, and the provider ID and system UUID for a physical chassis. - Service is where a chassis leaves service and returns to it. A node in service offers Cordon and Drain. Cordon stops new work being scheduled onto it and moves nothing that is already running. Drain cordons and then evicts everything that can move. Where the deployment has a power channel, it also offers Reboot and, from a channel that can power a machine back on, Shut down and Power on. A node this product cordoned offers Return to service. A drain, reboot or shutdown in flight offers Abandon and return to service, and the panel says what stopping there would and would not undo. A machine that has been shut down offers Power on beside it, where the channel can start one: those are two acts, since one starts the machine and the other closes the record and lifts the cordon. In every case the panel names the maintenance, its phase, who asked for it and why. The same panel appears on the Controller detail for the node that Controller is bound to.

Every action requires a justification meeting the deployment's reason policy, because a chassis leaving service is never anonymous, and every action is recorded in the audit trail. Above that floor the ceremony grows with the consequence: a drain shows the impact check and asks you to acknowledge it, a reboot or shutdown also asks for the node name typed exactly, and any action that would consume the last of the etcd quorum margin takes the typed name and the warning treatment whichever verb it is. An action that would drop etcd below quorum is refused outright and is never made available by clicking through a sufficiently frightening dialog.

The click writes a NodeMaintenance request and the physical operator is what touches the node, so the phase the panel reports is the operator's (ADR 0034). The operator returns only what it took out of service: a node somebody cordoned with kubectl reads as cordoned outside this product and offers no button, because a button that silently did nothing would be worse than none. A simulation fixture offers no button either, since it has no hardware to service.

A shutdown reaches its PoweredOff phase the moment the power channel accepts the request. Whether the machine reached off is a second question, and only an out-of-band channel can answer it. A baseboard management controller keeps answering while the machine is dark, so there the operator reads the power state back and the panel reports a shutdown the channel confirmed. An in-band channel goes silent with the machine it stopped, and there the panel says the request was accepted and that this channel cannot see whether the machine reached off. The reading is taken for the one machine this product just stopped and only until that shutdown lands, so nothing here polls a rack of management controllers to keep a list fresh.

Which power verbs appear at all follows from the configured provider and from how a fault on this chassis is resolved. A deployment with no power provider shows no power verbs and says so. An in-band channel such as the Talos machine API offers Reboot always, and Shut down where the deployment sets powerProvider.attendedRack to declare that somebody can be at the rack. Talos performs a graceful power-off. What it cannot do is bring the machine back. The verb is therefore offered only where something can (an out-of-band channel, or a person pressing the power button). The panel says which of the two applies, because they are different recoveries. Power on appears only from an out-of-band channel such as a BMC over Redfish, and no declaration substitutes for one: somebody at the rack makes a shutdown recoverable, and it does not give the product a way to start a machine. A node whose lifecycle is owned by a managed node group or an autoscaler is offered no power verb at all, because the recovery there is to replace the instance. Every one of these is enforced where the request is admitted as well as where the button is drawn, so a direct API call meets the same refusal.

All eight actions sit at the admin tier by default, and a deployment that wants a narrower grant adds the ones it wants (servers:cordon, servers:uncordon, servers:drain, servers:reboot, servers:shutdown, servers:power-on, servers:maintenance-abandon) to a role's allow list (ADR 0024). They are separate names on purpose, so a plant can let a wider set of roles restore service than can withdraw it. - If this node goes away answers the whole of that question, well past the storage part of it. It reports whether etcd quorum survives losing the node and how much margin is left afterwards, the unit runtimes hosted here with any batch in flight, the pinned volumes and the workloads they back, the pods that cannot move with the mechanism holding each one (a hostPath, node affinity, a bound volume, or a PodDisruptionBudget that would refuse the eviction), and the alarms the loss would raise on every site's console.

It also answers whether the rest of the cluster has room for the work, comparing what this node is carrying against the free allocatable on every other Ready, schedulable node, with both sides measured using the same source. That comparison is an aggregate and says so: it does not model taints, affinity, or the fact that one large pod needs its room on a single node. A verdict of no room is conclusive, a verdict of room is necessary and not sufficient, and anything the panel already listed as unable to move will not move whatever the headroom says.

A question the gateway could not answer says so. An unreadable PodDisruptionBudget list reads as unknown, because "nothing blocking" would be a claim: an operator acts on this panel, and a pre-flight that under-reports is worse than none. - Recent Node Events is the history behind the current conditions: kubelet restarts, image pull failures, eviction decisions, pressure transitions, and a reboot showing up as a fresh registration. Rows read newest first, each carrying the reporting component so a fault the machine reported about itself is distinguishable from one the control plane observed about it, and a repeated event carries its count. The list is capped at the 50 most recent, and says so when the cap bit. The same panel appears on the Controller detail for the node that Controller is bound to, because a controller is a node too.

Kubernetes discards events after about an hour by default. An empty list therefore means nothing recent, at most, and the panel says which of the two it is. A read that fails says that too. A quiet machine and a failed read are different answers, and the panel keeps them apart.

Locality is read at request time from the Node itself, so it needs no configuration and cannot drift from what the cluster reports. An empty .spec.providerID, or a Talos one, is bare metal. An aws:///, gce:// or azure:// prefix names the provider and usually the instance, and node.kubernetes.io/instance-type with topology.kubernetes.io/region and /zone fill in the rest. Two readings are deliberately conservative. A providerID prefix the gateway does not recognise reads Unknown, with no filing under the nearer half. Replaceable requires a label that positively names an owner, so a lone cloud instance nobody is going to recreate is never described as disposable. Recovery reads Repairable when the same machine returns under the same name after a reboot, Replaceable when the recovery is to terminate it and let a fresh instance join, and Unknown when the Node does not say.

Nodes render under their Kubernetes name by default. Production hostnames are rarely chosen for readability, so a dcs.io/display-name annotation on the Node names the chassis the way the plant refers to it. The sidebar, the node tables, and the detail title all show the display name. Hovering a node reveals the Kubernetes name, and the detail page keeps a Node name row so operations can correlate kubectl output.

The Plant name panel on a server's detail page sets it. A save records the name on a NodeIdentity and the physical operator applies it to the node, because nothing in the gateway may write a Node (ADR 0040). The panel therefore reports a request in flight, since the rename has not finished when the save lands. Two nodes get no editor and say why: a simulation fixture, which is not a machine, and a controller-bound node, which is named on its Controller instead and is linked from the panel.

A name the deployment layer set at enrolment is equally authoritative and is reported as not this product's to remove. Typing a new one overrides it. Clearing the field cannot take it away, because it was never applied here.

The rest of the view is read-only and refreshes every 15 seconds. It requires a live cluster and the read tier, so every role sees it. Renaming is PermAdmin.

Nobody watches an inventory page, so the servers also annunciate. A node leaving Ready, a node under resource pressure, and a lost etcd quorum each raise an ISA-18.2 alarm in every site, reaching the operator through the Alarms cue, the topbar badge and the alarm list. See Alarm Management → Server alarms for the rules and severities, and ADR 0032 for the decision record.

Login

The login page at /login supports OIDC (with PKCE) and LDAP authentication. After successful login, the user is redirected to the page they originally requested. When none was requested, the landing is the highest-privilege endpoint their role allows. Engineers, supervisors, and admins land on /system, operators and lead operators on /hmi, and everyone else on /data. Tokens are stored in localStorage and shared across all endpoints.

Login on camera: the unauthenticated request bounced to /login, the system-use notification shown before any credential, the PKCE hop to the plant's identity provider, and the return with the header badge naming identity and role.

OIDC registration: OIDC providers must have /login registered as an allowed redirect URI.

LDAP: see Security Operations for the full LDAP and OIDC configuration reference, group-to-role mapping, and electronic signature requirements for 21 CFR Part 11 environments.

Web Terminal

A browser-based terminal is available at /terminal. It provides shell access with the dcs CLI pre-configured for the authenticated user's site.

  • Auth: uses the same token as the dashboard (stored in localStorage)
  • Protocol: WebSocket at /api/v1/terminal — binary messages for terminal I/O, JSON text messages for resize control
  • PTY: the backend spawns a shell with a pseudo-terminal for full readline, color, and cursor support
  • Frontend: xterm.js with auto-fit and web link detection, self-hosted by the gateway (no CDN dependency, works air-gapped) and themed from the product design tokens
  • Reconnect: a dropped session shows a reconnect overlay with a countdown. The terminal retries automatically every 3 seconds and a "Reconnect now" button retries immediately
  • Session end: a terminal whose session is revoked, or whose token expires, is closed by the gateway, and a live prompt never outlives its session (#1445). The reason is printed into the terminal and the overlay reads "Session ended" with a Sign in button. This one does not retry, because every reconnect would answer 401. The HMI's live data socket ends the same way and sends the operator back to the login page.

The terminal is intended for sandbox and low-security lab environments. For production deployments, replace the gateway-local shell with per-user Kubernetes pod exec to avoid shared-kernel namespacing concerns.

Accessing the UI

The gateway web UI is accessible at the configured gateway URL (https://<gateway>). The root URL redirects to /system. Direct endpoint access:

  • https://<gateway>/system
  • https://<gateway>/hmi
  • https://<gateway>/data
  • https://<gateway>/login
  • https://<gateway>/terminal

Color Semantics

The UI uses a minimal color palette where each color has one clear meaning. Green and red are reserved exclusively for healthy/unhealthy indication and are never used for general actions or decorative purposes. See the UI Color Guide for the complete palette, usage rules, and the Stylelint enforcement that bans raw hex values in gateway CSS.

Accessibility

All three endpoints share a common accessibility baseline:

  • Keyboard navigation — sidebar items, collapsible groups, and the equipment trees are focusable and activate with Enter or Space. Keyboard focus is marked with a visible 2 px outline. Mouse clicks do not paint focus rings.
  • Focus-trapped dialogs — the ceremonies that stay modal by ruling (deletion of authored content, node and site outage verbs, failover, interlock bypass, hot-swap override, re-authentication) keep Tab cycling inside the dialog until it is dismissed.
  • Inline expansions — every other detail view, entry form and plain confirmation expands in place instead (ADR 0058). The trigger carries aria-expanded and aria-controls, focus moves into the expanded region on open and returns to the trigger on close, and Escape closes the most recently opened expansion.
  • Contrast — state badges and severity pills render dark text on their state-color fills, and every combination meets WCAG AA (4.5:1) on the dark theme.
  • Reduced motion — looping animations (spinners, transitional-state pulses, toast slide-ins) are stilled when the operating system requests prefers-reduced-motion: reduce. Transitional states remain identifiable from the badge text alone.

Browser Cache

After deploying UI changes, hard-refresh (Ctrl+Shift+R) to bypass browser cache. The gateway serves static files with long cache lifetimes, so in-place UI updates without a cache-bust often appear as stale layout to users.