28 KiB
Admin Web UI — OtOpcUa v2
Status: DRAFT — companion to
plan.md§4 andconfig-db-schema.md. Defines the Blazor Server admin app for managing the central config DB.Branch:
v2Created: 2026-04-17
Scope
This document covers the OtOpcUa Admin web app — the operator-facing UI for managing fleet configuration. It owns every write to the central config DB; OtOpcUa nodes are read-only consumers.
Out of scope here:
- Per-node operator dashboards (status, alarm acks for runtime concerns) — that's the existing Status Dashboard, deployed alongside each node, not the Admin app
- Driver-specific config screens — these are deferred to each driver's implementation phase per decision #27, and each driver doc is responsible for sketching its config UI surface
- Authentication of the OPC UA endpoint itself — covered by
Security.md(LDAP)
Tech Stack
Aligned with ScadaLink CentralUI (scadalink-design/src/ScadaLink.CentralUI) — operators using both apps see the same login screen, same sidebar, same component vocabulary. Same patterns, same aesthetic.
| Component | Choice | Reason |
|---|---|---|
| Framework | Blazor Server (.NET 10 Razor Components, AddInteractiveServerComponents) |
Same as ScadaLink; real-time UI without separate SPA build; SignalR built-in for live cluster status |
| Hosting | Co-deploy with central DB by default; standalone option | Most deployments run Admin on the same machine as MSSQL; large fleets can split |
| Auth | LDAP bind via LdapAuthService (sibling of ScadaLink.Security) + cookie auth + JwtTokenService for API tokens |
Direct parity with ScadaLink — same login form, same cookie scheme, same claim shape, same RoleMapper pattern. Operators authenticated to one app feel at home in the other |
| DB access | EF Core (same Configuration project that nodes use) |
Schema versioning lives in one place |
| Real-time | SignalR (Blazor Server's underlying transport) | Live updates on ClusterNodeGenerationState and crash-loop alerts |
| Styling | Bootstrap 5 vendored under wwwroot/lib/bootstrap/ |
Direct parity with ScadaLink; standard component vocabulary (card, table, alert, btn, form-control, modal); no third-party Blazor-component-library dependency |
| Shared components | DataTable, ConfirmDialog, LoadingSpinner, ToastNotification, TimestampDisplay, RedirectToLogin, NotAuthorizedView |
Same set as ScadaLink CentralUI; copy structurally so cross-app feel is identical |
| Reconnect overlay | Custom Bootstrap modal triggered on Blazor SignalR disconnect |
Same pattern as ScadaLink — modal appears on connection loss, dismisses on reconnect |
Code organization
Mirror ScadaLink's layout exactly:
src/
ZB.MOM.WW.OtOpcUa.Admin/ # Razor Components project (.NET 10)
Auth/
AuthEndpoints.cs # /auth/login, /auth/logout, /auth/token
CookieAuthenticationStateProvider.cs # bridges cookie auth to Blazor <AuthorizeView>
Components/
Layout/
MainLayout.razor # dark sidebar + light main flex layout
NavMenu.razor # role-gated nav sections
Pages/
Login.razor # server-rendered HTML form POSTing to /auth/login
Dashboard.razor # default landing
Clusters/
Generations/
Credentials/
Audit/
Shared/
DataTable.razor # paged/sortable/filterable table (verbatim from ScadaLink)
ConfirmDialog.razor
LoadingSpinner.razor
ToastNotification.razor
TimestampDisplay.razor
RedirectToLogin.razor
NotAuthorizedView.razor
EndpointExtensions.cs # MapAuthEndpoints + role policies
ServiceCollectionExtensions.cs # AddCentralAdmin
ZB.MOM.WW.OtOpcUa.Admin.Security/ # LDAP + role mapping + JWT (sibling of ScadaLink.Security)
The Admin.Security project carries LdapAuthService, RoleMapper, JwtTokenService, AuthorizationPolicies. If it ever makes sense to consolidate with ScadaLink's identical project, lift to a shared internal NuGet — out of scope for v2.0 to keep OtOpcUa decoupled from ScadaLink's release cycle.
Authentication & Authorization
Operator authentication
Identical pattern to ScadaLink CentralUI. Operators log in via LDAP bind against the GLAuth server. The login flow is a server-rendered HTML form POSTing to /auth/login (NOT a Blazor interactive form — data-enhance="false" to disable Blazor enhanced navigation), handled by a minimal-API endpoint that:
- Reads
username/passwordfrom form - Calls
LdapAuthService.AuthenticateAsync(username, password)— performs LDAP bind, returnsUsername,DisplayName,Groups - Calls
RoleMapper.MapGroupsToRolesAsync(groups)— translates LDAP groups → application roles + cluster-scope set - Builds
ClaimsIdentitywithName,DisplayName,Username,Role(multiple),ClusterIdscope claims (multiple, when not system-wide) HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal, ...)withIsPersistent = true,ExpiresUtc = +30 min(sliding)- Redirects to
/ - On failure, redirects to
/login?error={URL-encoded message}
A parallel /auth/token endpoint returns a JWT for API clients (CLI tooling, scripts) — same auth, different transport. Symmetric with ScadaLink's pattern.
CookieAuthenticationStateProvider bridges the cookie principal to Blazor's AuthenticationStateProvider so <AuthorizeView> and [Authorize] work in components.
LDAP group → role mapping
| LDAP group | Admin role | Capabilities |
|---|---|---|
OtOpcUaAdmins |
FleetAdmin |
Everything: cluster CRUD, node CRUD, credential management, publish/rollback any cluster |
OtOpcUaConfigEditors |
ConfigEditor |
Edit drafts and publish for assigned clusters; cannot create/delete clusters or manage credentials |
OtOpcUaViewers |
ReadOnly |
View-only access to all clusters and generations; cannot edit drafts or publish |
AuthorizationPolicies constants (mirrors ScadaLink): RequireFleetAdmin, RequireConfigEditor, RequireReadOnly. <AuthorizeView Policy="@AuthorizationPolicies.RequireFleetAdmin"> gates nav menu sections and page-level access.
Cluster-scoped grants (lifted from v2.1 to v2.0)
Because ScadaLink already has the site-scoped grant pattern (PermittedSiteIds claim, IsSystemWideDeployment flag), we get cluster-scoped grants essentially for free in v2.0 by mirroring it:
- A
ConfigEditoruser mapped to LDAP groupOtOpcUaConfigEditors-LINE3is grantedConfigEditorrole +ClusterId=LINE3-OPCUAscope claim only - The
RoleMapperreads a smallLdapGroupRoleMappingtable (Group → Role, Group → ClusterId scope) configured byFleetAdminvia the Admin UI - All cluster-scoped pages check both role AND
ClusterIdscope claim before showing edit affordances
System-wide users (no ClusterId scope claims, IsSystemWideDeployment = true) see every cluster.
Bootstrap (first-run)
Same as ScadaLink: a local-admin login configured in appsettings.json (or a local certificate-authenticated user) bootstraps the first OtOpcUaAdmins LDAP group binding before LDAP-only access takes over. Documented as a one-time setup step.
Audit
Every write operation goes through sp_* procs that log to ConfigAuditLog with the operator's principal. The Admin UI also logs view-only actions (page navigation, generation diff views) to a separate UI access log for compliance.
Visual Design — Direct Parity with ScadaLink
Every visual element is lifted from ScadaLink CentralUI's design system to ensure cross-app consistency. Concrete specs:
Layout
- Flex layout:
<div class="d-flex">containing<NavMenu />(sidebar) and<main class="flex-grow-1 p-3">(content) - Sidebar: 220px fixed width (
min-width: 220px; max-width: 220px), full viewport height (min-height: 100vh), background#212529(Bootstrap dark) - Main background:
#f8f9fa(Bootstrap light) - Brand: "OtOpcUa" in white bold (font-size: 1.1rem, padding 1rem, border-bottom
1px solid #343a40) at top of sidebar - Nav links: color
#adb5bd, padding0.4rem 1rem, font-size0.9rem. Hover: white text, background#343a40. Active: white text, background#0d6efd(Bootstrap primary) - Section headers ("Admin", "Configuration", "Monitoring"): color
#6c757d, uppercase, font-size0.75rem, font-weight600, letter-spacing0.05em, padding0.75rem 1rem 0.25rem - User strip at bottom of sidebar: display name (text-light small) + Sign Out button (
btn-outline-light btn-sm), separated from nav byborder-top border-secondary
Login page
Verbatim structure from ScadaLink's Login.razor:
<div class="container" style="max-width: 400px; margin-top: 10vh;">
<div class="card shadow-sm">
<div class="card-body p-4">
<h4 class="card-title mb-4 text-center">OtOpcUa</h4>
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<div class="alert alert-danger py-2" role="alert">@ErrorMessage</div>
}
<form method="post" action="/auth/login" data-enhance="false">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username"
required autocomplete="username" autofocus />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password"
required autocomplete="current-password" />
</div>
<button type="submit" class="btn btn-primary w-100">Sign In</button>
</form>
</div>
</div>
<p class="text-center text-muted mt-3 small">Authenticate with your organization's LDAP credentials.</p>
</div>
Exact same dimensions, exact same copy pattern, only the brand name differs.
Reconnection overlay
Same SignalR-disconnect modal as ScadaLink — #reconnect-modal overlay (rgba(0,0,0,0.5) backdrop, centered white card with spinner-border text-primary, "Connection Lost" heading, "Attempting to reconnect to the server. Please wait..." body). Listens for Blazor.addEventListener('enhancedload') to dismiss on reconnect. Lifted from ScadaLink's App.razor inline styles.
Shared components — direct copies
All seven shared components from ScadaLink CentralUI are copied verbatim into our Components/Shared/:
| Component | Use |
|---|---|
DataTable.razor |
Sortable, filterable, paged table — used for tags, generations, audit log, cluster list |
ConfirmDialog.razor |
Modal confirmation for destructive actions (publish, rollback, discard draft, disable credential) |
LoadingSpinner.razor |
Standard spinner for in-flight DB operations |
ToastNotification.razor |
Transient success/error toasts for non-modal feedback |
TimestampDisplay.razor |
Consistent UTC + relative-time rendering ("3 minutes ago") |
RedirectToLogin.razor |
Component used by pages requiring auth — server-side redirect to /login?returnUrl=... |
NotAuthorizedView.razor |
Standard "you don't have permission for this action" view, shown by <AuthorizeView> Not authorized branch |
If we discover an Admin-specific component need, add it to our Shared folder rather than diverging from ScadaLink's set.
Information Architecture
/ Fleet Overview (default landing)
/clusters Cluster list
/clusters/{ClusterId} Cluster detail
/clusters/{ClusterId}/nodes/{NodeId} Node detail
/clusters/{ClusterId}/draft Draft editor (drivers/devices/tags)
/clusters/{ClusterId}/draft/diff Draft vs current diff viewer
/clusters/{ClusterId}/generations Generation history
/clusters/{ClusterId}/generations/{Id} Generation detail (read-only view of any generation)
/clusters/{ClusterId}/audit Audit log filtered to this cluster
/credentials Credential management (FleetAdmin only)
/audit Fleet-wide audit log
/admin/users Admin role assignments (FleetAdmin only)
Core Pages
Fleet Overview (/)
Single-page summary intended as the operator landing page.
- Cluster cards, one per
ServerCluster, showing:- Cluster name, site, redundancy mode, node count
- Per-node status: online/offline (from
ClusterNodeGenerationState.LastSeenAt), current generation, RedundancyRole, ServiceLevel (last reported) - Drift indicator: red if 2-node cluster's nodes are on different generations, amber if mid-apply, green if converged
- Active alerts strip (top of page):
- Sticky crash-loop circuit alerts (per
driver-stability.md) - Stragglers: nodes that haven't applied the latest published generation within 5 min
- Failed applies (
LastAppliedStatus = 'Failed')
- Sticky crash-loop circuit alerts (per
- Recent activity: last 20 events from
ConfigAuditLogacross the fleet - Search bar at top: jump to any cluster, node, tag, or driver instance by name
Refresh: SignalR push for status changes; full reload every 30 s as a safety net.
Cluster Detail (/clusters/{ClusterId})
Tabbed view for one cluster.
Tabs:
- Overview — cluster metadata (name, site, redundancy mode, namespace URI), node table with online/offline/role/generation/last-applied-status, current published generation summary, draft status (none / in progress / ready to publish)
- Drivers — table of
DriverInstancerows in the current published generation, with per-row navigation to driver-specific config screens. "Edit in draft" button creates or opens the cluster's draft. - Devices — table of
Devicerows (where applicable), grouped byDriverInstance - Tags — paged, filterable table of all tags. Filters: driver, device, folder path, name pattern, data type. Bulk operations toolbar: export to CSV, import from CSV (validated against active draft).
- Generations — generation history list (see Generation History page)
- Audit — filtered audit log
The Drivers/Devices/Tags tabs are read-only views of the published generation; editing is done in the dedicated draft editor to make the publish boundary explicit.
Node Detail (/clusters/{ClusterId}/nodes/{NodeId})
Per-node view for ClusterNode management.
- Physical attributes form: Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, RedundancyRole
- ApplicationUri auto-suggest behavior (per decision #86):
- When creating a new node: prefilled with
urn:{Host}:OtOpcUa - When editing an existing node: changing
Hostshows a warning banner — "ApplicationUri is not updated automatically. Changing it will require all OPC UA clients to re-establish trust." Operator must explicitly click an "Update ApplicationUri" button to apply the suggestion.
- When creating a new node: prefilled with
- Credentials sub-tab: list of
ClusterNodeCredentialrows (kind, value, enabled, rotated-at). FleetAdmin can add/disable/rotate. Credential rotation flow is documented inline ("create new credential → wait for node to use it → disable old credential"). - Per-node overrides sub-tab: structured editor for
DriverConfigOverridesJson. Surfaces the cluster'sDriverInstancerows with their currentDriverConfig, and lets the operator add path → value override entries per driver. Validation: override path must exist in the current draft'sDriverConfig; loud failure if it doesn't (per the merge semantics in the schema doc). - Generation state: current applied generation, last-applied timestamp, last-applied status, last error if any
- Recent node activity: filtered audit log
Draft Editor (/clusters/{ClusterId}/draft)
The primary edit surface. Three-panel layout: tree on the left (drivers → devices → tags), edit form on the right, validation panel at the bottom.
- Drivers panel: add/edit/remove
DriverInstancerows in the draft. Each driver type opens a driver-specific config screen (deferred per #27). Generic fields (Name, NamespaceUri, Enabled) are always editable. - Devices panel: scoped to the selected driver instance (where applicable)
- Tags panel:
- Tree view by
FolderPath - Inline edit for individual tags (Name, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig JSON in a structured editor)
- Bulk operations: select multiple tags → bulk edit (change poll group, access level, etc.)
- CSV import: upload a CSV with
(DriverInstanceId, DeviceId?, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)columns. Preview shows additions/modifications/removals against current draft, with row-level validation errors. Operator confirms or cancels. - CSV export: emit the same shape from the current published generation, useful as a starting point for bulk edits in Excel
- Tree view by
- Validation panel runs
sp_ValidateDraftcontinuously (debounced) and surfaces FK errors, JSON schema errors, duplicate paths, missing references. Publish button is disabled while errors exist. - Diff link at top: opens the diff viewer comparing the draft against the current published generation
Diff Viewer (/clusters/{ClusterId}/draft/diff)
Three-column compare: previous published | draft | summary. Per-table sections (drivers, devices, tags, poll groups) with rows colored by change type:
- Green: added in draft
- Red: removed in draft
- Yellow: modified (with field-level diff on hover/expand)
Includes a publish dialog triggered from this view: required Notes field, optional "publish and apply now" vs. "publish and let nodes pick up on next poll" (the latter is the default; the former invokes a one-shot push notification, deferred per existing plan).
Generation History (/clusters/{ClusterId}/generations)
List of all generations for the cluster with: ID, status, published-by, published-at, notes, and a per-row "Roll back to this" action (FleetAdmin or ConfigEditor). Clicking a row opens the generation detail page (read-only view of all rows in that generation, with diff-against-current as a button).
Rollback flow:
- Operator clicks "Roll back to this generation"
- Modal: "This will create a new published generation cloned from generation N. Both nodes of this cluster will pick up the change on their next poll. Notes (required):"
- Confirm → invokes
sp_RollbackToGeneration→ immediate UI feedback that a new generation was published
Credential Management (/credentials)
FleetAdmin-only. Lists all ClusterNodeCredential rows fleet-wide, filterable by cluster/node/kind/enabled.
Operations: add credential to node, disable credential, mark credential rotated. Rotation is the most common operation — the UI provides a guided flow ("create new → confirm node has used it once via LastAppliedAt advance → disable old").
Fleet Audit (/audit)
Searchable / filterable view of ConfigAuditLog across all clusters. Filters: cluster, node, principal, event type, date range. Export to CSV for compliance.
Real-Time Updates
Blazor Server runs over SignalR by default. The Admin app uses two SignalR hubs:
| Hub | Purpose |
|---|---|
FleetStatusHub |
Push ClusterNodeGenerationState changes (LastSeenAt updates, applied-generation transitions, status changes) to any open Fleet Overview or Cluster Detail page |
AlertHub |
Push new sticky alerts (crash-loop circuit trips, failed applies) to all subscribed pages |
Updates fan out from a backend IHostedService that polls ClusterNodeGenerationState every 5 s and diffs against last-known state. Pages subscribe selectively (Cluster Detail page subscribes to one cluster's updates; Fleet Overview subscribes to all). No polling from the browser.
UX Rules
- Sticky alerts that don't auto-clear — per the crash-loop circuit-breaker rule in
driver-stability.md, alerts in the Active Alerts strip require explicit operator acknowledgment before clearing, regardless of whether the underlying state has recovered. "We crash-looped 3 times overnight" must remain visible the next morning. - Publish boundary is explicit — there is no "edit in place" path. All changes go through draft → diff → publish. The diff viewer is required reading before the publish dialog enables.
- Loud failures over silent fallbacks — if validation fails, the publish button is disabled and the failures are listed; we never publish a generation with warnings hidden. If a node override path doesn't resolve in the draft, the override editor flags it red, not yellow.
- No auto-rewrite of
ApplicationUri— see Node Detail page above. The principle generalizes: any field that OPC UA clients pin trust to (ApplicationUri, certificate thumbprints) requires explicit operator action to change, never silent updates. - Bulk operations always preview before commit — CSV imports, bulk tag edits, rollbacks all show a diff and require confirmation. No "apply" buttons that act without preview.
Per-Driver Config Screens (deferred)
Per decision #27, driver-specific config screens are added in each driver's implementation phase, not up front. The Admin app provides:
- A pluggable
IDriverConfigEditorinterface inConfiguration.Abstractions - Driver projects implement an editor that renders into a slot on the Driver Detail screen
- For drivers that don't yet have a custom editor, a generic JSON editor with schema-driven validation is used (better than nothing, ugly but functional)
The generic JSON editor uses the per-driver JSON schema from DriverTypeRegistry so even pre-custom-editor, validation works.
Workflows
Add a new cluster
- FleetAdmin:
/clusters→ "New cluster" - Form: Name, Site, NodeCount (1 or 2), RedundancyMode (auto-set based on NodeCount), NamespaceUri (auto-suggested from name)
- Save → cluster row created (
Status = Enabled, no generations yet) - Redirect to Cluster Detail; prompt to add nodes
Add a node to a cluster
- Cluster Detail → "Add node"
- Form: NodeId, RedundancyRole, Host (required), OpcUaPort (default 4840), DashboardPort (default 8081), ApplicationUri (auto-prefilled
urn:{Host}:OtOpcUa), ServiceLevelBase (auto: Primary=200, Secondary=150) - Save
- Prompt: "Add a credential for this node now?" → opens credential add flow
- The node won't be functional until at least one credential is added and the credential is provisioned on the node's machine (out-of-band step documented in deployment guide)
Edit drivers/tags and publish
- Cluster Detail → "Edit configuration" → opens draft editor (creates a draft generation if none exists)
- Operator edits drivers, devices, tags, poll groups
- Validation panel updates live; publish disabled while errors exist
- Operator clicks "Diff" → diff viewer
- Operator clicks "Publish" → modal asks for Notes, confirms
sp_PublishGenerationruns in transaction; on success, draft becomes new published generation; previous published becomes superseded- Within ~30 s (default poll interval), both nodes pick up the new generation; Cluster Detail page shows live progress as
LastAppliedAtadvances on each node
Roll back
- Cluster Detail → Generations tab → find target generation → "Roll back to this"
- Modal: explains a new generation will be created (clone of target) and published; require Notes
- Confirm →
sp_RollbackToGenerationruns - Same propagation as a forward publish — both nodes pick up the new generation on next poll
Override a setting per node
- Node Detail → Overrides sub-tab
- Pick driver instance from dropdown → schema-driven editor shows current
DriverConfigkeys - Add override row: select key path (validated against the driver's JSON schema), enter override value
- Save → updates
ClusterNode.DriverConfigOverridesJson - No new generation created — overrides are per-node metadata, not generation-versioned. They take effect on the node's next config-apply cycle.
The "no new generation" choice is deliberate: overrides are operationally bound to a specific physical machine, not to the cluster's logical config evolution. A node replacement scenario would copy the override to the replacement node via the credential/override migration flow, not by replaying generation history.
Rotate a credential
- Node Detail → Credentials sub-tab → "Add credential"
- Pick Kind, enter Value, save → new credential is enabled alongside the old
- Wait for
LastAppliedAton the node to advance (proves the new credential is being used by the node — operator-side work to provision the new credential on the node's machine happens out-of-band) - Once verified, disable the old credential → only the new one is valid
Deferred / Out of Scope
- Cluster-scoped admin grants (
ConfigEditorfor Cluster X only, not for Cluster Y) — surface in v2.1 - Per-driver custom config editors — added in each driver's implementation phase
- Tag template / inheritance — define a tag pattern once and apply to many similar device instances; deferred until the bulk import path proves insufficient
- Multi-cluster synchronized publish — push a configuration change across many clusters atomically. Out of scope; orchestrate via per-cluster publishes from a script if needed.
- Mobile / tablet layout — desktop-only initially
- Role grants editor in UI — initial v2 manages LDAP group → admin role mappings via
appsettings.json; UI editor surfaced later
Decisions / Open Questions
Decided (captured in plan.md decision log):
- Blazor Server tech stack (vs. SPA + API)
- Visual + auth parity with ScadaLink CentralUI — Bootstrap 5, dark sidebar, server-rendered login form, cookie auth + JWT API endpoint, copied shared component set, reconnect overlay
- LDAP for operator auth via
LdapAuthService+RoleMapper+JwtTokenServicemirrored fromScadaLink.Security - Three admin roles: FleetAdmin / ConfigEditor / ReadOnly, with cluster-scoped grants in v2.0 (mirrored from ScadaLink's site-scoped pattern)
- Draft → diff → publish is the only edit path; no in-place edits
- Sticky alerts require manual ack
- Per-node overrides are NOT generation-versioned
Resolved Defaults:
- Styling: Bootstrap 5 vendored (not MudBlazor or Fluent UI). Direct parity with ScadaLink CentralUI; standard component vocabulary; no Blazor-specific component-library dependency. Reverses an earlier draft choice — the cross-app consistency requirement outweighs MudBlazor's component conveniences.
- Theme: light only (single theme matching ScadaLink). ScadaLink ships light-only with the dark sidebar / light main pattern. Operators using both apps see one consistent aesthetic. Reverses an earlier draft choice that proposed both light and dark — cross-app consistency wins. Revisit only if ScadaLink adds dark mode.
- CSV import dialect: strict CSV (RFC 4180), UTF-8 BOM accepted. Excel "Save as CSV (UTF-8)" produces RFC 4180-compatible output and is the documented primary input format. TSV not supported initially; add only if operator feedback shows real friction with Excel CSV.
- Push notification deferred to v2.1; polling is initial model. SignalR-from-DB-to-nodes would tighten apply latency from ~30 s to ~1 s but adds infrastructure (SignalR backplane or SQL Service Broker) that's not earning its keep at v2.0 scale. The publish dialog reserves a disabled "Push now" button labeled "Available in v2.1" so the future UX is anchored.
- Auto-save drafts with explicit Discard button. Every form field change writes to the draft rows immediately (debounced 500 ms). The Discard button shows a confirmation dialog ("Discard all changes since last publish?") and rolls the draft generation back to empty. The Publish button is the only commit; auto-save does not publish.
- Cluster-scoped admin grants in v2.0 (lifted from v2.1 deferred list). ScadaLink already ships the equivalent site-scoped pattern, so we get cluster-scoped grants essentially for free by mirroring it.
RoleMapperreads anLdapGroupRoleMappingtable; cluster-scoped users carryClusterIdclaims and see only their permitted clusters.