Files
lmxopcua/docs/v2/admin-ui.md

28 KiB

Admin Web UI — OtOpcUa v2

Status: DRAFT — companion to plan.md §4 and config-db-schema.md. Defines the Blazor Server admin app for managing the central config DB.

Branch: v2 Created: 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:

  1. Reads username / password from form
  2. Calls LdapAuthService.AuthenticateAsync(username, password) — performs LDAP bind, returns Username, DisplayName, Groups
  3. Calls RoleMapper.MapGroupsToRolesAsync(groups) — translates LDAP groups → application roles + cluster-scope set
  4. Builds ClaimsIdentity with Name, DisplayName, Username, Role (multiple), ClusterId scope claims (multiple, when not system-wide)
  5. HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal, ...) with IsPersistent = true, ExpiresUtc = +30 min (sliding)
  6. Redirects to /
  7. 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 ConfigEditor user mapped to LDAP group OtOpcUaConfigEditors-LINE3 is granted ConfigEditor role + ClusterId=LINE3-OPCUA scope claim only
  • The RoleMapper reads a small LdapGroupRoleMapping table (Group → Role, Group → ClusterId scope) configured by FleetAdmin via the Admin UI
  • All cluster-scoped pages check both role AND ClusterId scope 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.

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, padding 0.4rem 1rem, font-size 0.9rem. Hover: white text, background #343a40. Active: white text, background #0d6efd (Bootstrap primary)
  • Section headers ("Admin", "Configuration", "Monitoring"): color #6c757d, uppercase, font-size 0.75rem, font-weight 600, letter-spacing 0.05em, padding 0.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 by border-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')
  • Recent activity: last 20 events from ConfigAuditLog across 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:

  1. 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)
  2. Drivers — table of DriverInstance rows 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.
  3. Devices — table of Device rows (where applicable), grouped by DriverInstance
  4. 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).
  5. Generations — generation history list (see Generation History page)
  6. 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 Host shows 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.
  • Credentials sub-tab: list of ClusterNodeCredential rows (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's DriverInstance rows with their current DriverConfig, and lets the operator add path → value override entries per driver. Validation: override path must exist in the current draft's DriverConfig; 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 DriverInstance rows 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
  • Validation panel runs sp_ValidateDraft continuously (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:

  1. Operator clicks "Roll back to this generation"
  2. 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):"
  3. 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 IDriverConfigEditor interface in Configuration.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

  1. FleetAdmin: /clusters → "New cluster"
  2. Form: Name, Site, NodeCount (1 or 2), RedundancyMode (auto-set based on NodeCount), NamespaceUri (auto-suggested from name)
  3. Save → cluster row created (Status = Enabled, no generations yet)
  4. Redirect to Cluster Detail; prompt to add nodes

Add a node to a cluster

  1. Cluster Detail → "Add node"
  2. Form: NodeId, RedundancyRole, Host (required), OpcUaPort (default 4840), DashboardPort (default 8081), ApplicationUri (auto-prefilled urn:{Host}:OtOpcUa), ServiceLevelBase (auto: Primary=200, Secondary=150)
  3. Save
  4. Prompt: "Add a credential for this node now?" → opens credential add flow
  5. 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

  1. Cluster Detail → "Edit configuration" → opens draft editor (creates a draft generation if none exists)
  2. Operator edits drivers, devices, tags, poll groups
  3. Validation panel updates live; publish disabled while errors exist
  4. Operator clicks "Diff" → diff viewer
  5. Operator clicks "Publish" → modal asks for Notes, confirms
  6. sp_PublishGeneration runs in transaction; on success, draft becomes new published generation; previous published becomes superseded
  7. Within ~30 s (default poll interval), both nodes pick up the new generation; Cluster Detail page shows live progress as LastAppliedAt advances on each node

Roll back

  1. Cluster Detail → Generations tab → find target generation → "Roll back to this"
  2. Modal: explains a new generation will be created (clone of target) and published; require Notes
  3. Confirm → sp_RollbackToGeneration runs
  4. Same propagation as a forward publish — both nodes pick up the new generation on next poll

Override a setting per node

  1. Node Detail → Overrides sub-tab
  2. Pick driver instance from dropdown → schema-driven editor shows current DriverConfig keys
  3. Add override row: select key path (validated against the driver's JSON schema), enter override value
  4. Save → updates ClusterNode.DriverConfigOverridesJson
  5. 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

  1. Node Detail → Credentials sub-tab → "Add credential"
  2. Pick Kind, enter Value, save → new credential is enabled alongside the old
  3. Wait for LastAppliedAt on 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)
  4. Once verified, disable the old credential → only the new one is valid

Deferred / Out of Scope

  • Cluster-scoped admin grants (ConfigEditor for 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 + JwtTokenService mirrored from ScadaLink.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. RoleMapper reads an LdapGroupRoleMapping table; cluster-scoped users carry ClusterId claims and see only their permitted clusters.