Files
ScadaBridge/docs/components/CentralUI.md
T
Joseph Doherty 9f67147e7b refactor(deployments): one paged read of DeploymentRecords + close its three drifts
Deferred-work register rows 37, 86, 87, 88 — the structural duplication and the
three behavioral divergences it had accumulated, fixed together.

Row 37 (structural). `IDeploymentManagerRepository.QueryDeploymentSummariesAsync`
is DELETED. It and `QueryDeploymentListPageAsync` were two paged reads of the same
insert-only table with different contracts — raw offset vs page number, no total vs
total, no instance join vs joined, instance-id-list scoping vs site-id scoping. The
CLI/HTTP `QueryDeployments` handler now goes through `QueryDeploymentListPageAsync`
with a `DeploymentListFilter`, exactly like the Central UI page. The
`DeploymentRecordSummary` projection went with it (`DeploymentListRow` is the single
row shape); its `MaxPageSize` const moved to `DeploymentListPage`, which also gains
`DefaultPageSize`.

Row 86 (Medium, authorization — the headline). `SiteScopeService.ResolveAsync` was
claims-only, so an Administrator carrying `SiteId` claims saw the whole fleet through
the CLI (`ManagementActor.EnforceSiteScope` has always exempted Administrator) and
only their granted sites on the Deployments page. One user, one dataset, two answers.
Administrator = system-wide is the intended semantic on both sides; the UI was the
defect. The role claim is read explicitly with an OrdinalIgnoreCase comparison rather
than via `IsInRole`, which compares claim VALUES ordinally and only resolves when the
identity carries the matching roleType — the two sides must not disagree on a
differently-cased role name. An Administrator's site claims are not collected at all,
so the invariant "system-wide => empty permitted-id set" holds for every caller.
Designer is NOT exempt: the class doc claimed "any Admin/Design user" is system-wide,
which was true of neither side, so the doc was corrected rather than the bypass
widened.

Row 87 (Low). The scoped branch used to resolve the site grant into an instance-id
list by loading EVERY instance in the system, non-transactionally, on every scoped
query; the grant now rides as `SiteIdScope` and is resolved by the query's own join.
An unparseable permitted-site id is DROPPED, never ignored — dropping can only narrow
a grant, discarding it would widen it to fleet-wide. And `PageSize <= 0` no longer
means UNLIMITED down at the repository (`--page-size 0` materialized the whole
retention window): it means "unspecified" and takes the CLI's own default of 50,
capped at 1000. Bounding without telling would be silent truncation, so the handler
now returns a `DeploymentQueryResult` envelope carrying `totalCount` plus the page
coordinates actually applied — with a CLI table projection (mirroring row 57's
`AuditConfigTableProjection`) that unwraps it and appends a "showing N of M" note.

Row 88 (Low, UX). An unrecognised `--status` fell back to "no filter", so a typo like
`--status Sucess` returned every status — a complete-looking answer to a question the
caller never asked. It is now a `ManagementError` naming the valid values. Parsing
stays case-insensitive; a numeric string is rejected too, since `Enum.TryParse`
accepts "7" and yields an undefined value (guarded with `Enum.IsDefined`). Loud
failure over silent full results, per row 46.

Tests: 7 new `SiteScopeServiceTests` pinning Administrator = system-wide (verified
failing 6/7 against the pre-fix service, the Designer pin passing both ways); the
ManagementActor deployment suite migrated onto the consolidated query with new pins
for the status error, case-insensitive parse, numeric rejection, page-size default/
cap/floor, empty-scope-on-garbage-grant, and "reads no instances at all"; a repository
test for site-scope+status composing over a bounded page; and 6 CLI tests for the new
table projection.
2026-08-17 05:15:18 -04:00

20 KiB

Central UI

The Central UI is a Blazor Server web application that hosts every management, configuration, deployment, monitoring, and audit workflow for the ScadaBridge system. It runs on the central cluster only; sites have no user interface.

Overview

Central UI (#9) is built on ASP.NET Core + Blazor Server with Bootstrap CSS. All UI logic executes server-side; updates reach the browser through Blazor's built-in SignalR circuit. No third-party component frameworks are used — tables, grids, forms, and custom controls are implemented directly as Blazor + Bootstrap components.

The component code lives in src/ZB.MOM.WW.ScadaBridge.CentralUI/, split into:

  • Auth/ — cookie authentication state provider, login/logout/ping Minimal API endpoints, site-scope service.
  • Audit/ — CSV export Minimal API endpoint.
  • Components/Layout/ — MainLayout, LoginLayout, and NavMenu (the policy-gated rail navigation).
  • Components/Pages/ — pages, grouped by nav section: Admin/, Audit/, Deployment/, Design/, Monitoring/, Notifications/, SiteCalls/.
  • Components/Shared/ — reusable non-page components (DataTable, MonacoEditor, ToastNotification, SessionExpiry, dialog infrastructure, and others).
  • Components/Health/ — KPI tile components: SiteCallKpiTiles and AuditKpiTiles. Notification Outbox KPIs are rendered inline in Health.razor, not as a separate tile component.
  • Components/Audit/ — the AuditFilterBar, AuditResultsGrid, AuditDrilldownDrawer, and execution-tree components used by the Audit Log page.
  • ScriptAnalysis/ — Roslyn-backed script analysis service and Minimal API endpoint group (/api/script-analysis) used by the Monaco editor, exposing seven POST endpoints: /diagnostics, /completions, /hover, /signature-help, /format, /inlay-hints, and /run.
  • Services/ — scoped UI services: AuditLogQueryService, AuditLogExportService, BrowseService, and BindingTester.

The single DI entry point is ServiceCollectionExtensions.AddCentralUI, registered only by the central-role Host composition root. EndpointExtensions.MapCentralUI<TApp> wires the Minimal API endpoints and the Razor component routes onto the ASP.NET Core pipeline.

Key Concepts

Authentication and session model

Authentication is a standard LDAP bind via ILdapAuthService (from Security, #10). On success, POST /auth/login maps the user's LDAP groups to ScadaBridge roles via IGroupRoleMapper<string>, constructs a ClaimsIdentity carrying role claims and site-scope SiteId claims, and calls context.SignInAsync to write an HttpOnly/Secure ASP.NET Core cookie with IsPersistent = true and SlidingExpiration = true. No fixed ExpiresUtc is stamped — the idle timeout is owned by the cookie middleware configured in the Security component.

POST /auth/token offers the same LDAP flow and returns a JWT bearer token for the CLI.

Because Blazor Server's HttpContext is only valid during the initial HTTP request that establishes the SignalR circuit, CookieAuthenticationStateProvider snapshots the authenticated ClaimsPrincipal once at construction time and serves that snapshot for the entire circuit lifetime:

public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor)
{
    var user = httpContextAccessor.HttpContext?.User
        ?? new ClaimsPrincipal(new ClaimsIdentity());

    _circuitAuthState = Task.FromResult(new AuthenticationState(user));
}

public override Task<AuthenticationState> GetAuthenticationStateAsync()
    => _circuitAuthState;

Reading IHttpContextAccessor on each GetAuthenticationStateAsync call would return null (or a stale context) for the lifetime of a long-lived circuit, causing <AuthorizeView> re-renders to see an unauthenticated principal.

Session expiry detection

Because CookieAuthenticationStateProvider serves a frozen principal, the circuit can never observe a server-side cookie expiry by polling Blazor's auth state. SessionExpiry (a headless component rendered in MainLayout) polls GET /auth/ping via a JavaScript fetch call every minute. The ping endpoint returns 200 while the cookie is still valid and 401 once it lapses. A 401 redirects the browser to /login with a full-page navigation. The cookie middleware re-validates (and slides) the cookie on every ping, so the poll itself does not artificially extend a genuinely idle session beyond the configured timeout.

Site-scoped authorization

SiteScopeService (scoped, registered in AddCentralUI) reads the role and SiteId claims off the circuit principal. Deployment users whose LDAP mapping carries site-scope rules carry one integer SiteId claim per permitted site; a user with no SiteId claims is system-wide. Pages that enumerate sites or issue cross-site commands call SiteScopeService.FilterSitesAsync and IsSiteAllowedAsync to enforce the grant before any operation.

A user holding the Administrator role is system-wide regardless of the SiteId claims they carry (deferred-work register row 86). ManagementActor.EnforceSiteScope has always exempted Administrator, so before this the same user could act fleet-wide through the CLI/HTTP surface while the UI hid the same sites — one user, one dataset, two answers. The role claim is read explicitly (JwtTokenService.RoleClaimType, OrdinalIgnoreCase) rather than via ClaimsPrincipal.IsInRole, because IsInRole compares claim values ordinally and only resolves at all when the identity was built with the matching roleType; the actor compares case-insensitively, and the two sides must not disagree on a differently-cased role name. Designer is NOT exempt — the enforced rule has only ever bypassed Administrator, and this document previously claimed "Admin and Design users carry none (system-wide)", which was true of neither side. An Administrator's SiteId claims are not collected at all, so the invariant system-wide ⇒ empty permitted-id set holds for every caller and a consumer reading the id set without consulting IsSystemWideAsync cannot silently narrow an Administrator to a subset.

Repository access pattern

CentralUI pages read and write the configuration database directly through ICentralUiRepository and other scoped EF Core repositories — there is no Akka round-trip for design-time CRUD. Operations that must reach site actors (deployment commands, debug stream subscriptions, OPC UA browse, KPI queries) go through CommunicationService (from Communication, #5).

Architecture

Layout and navigation

MainLayout wraps every authenticated page in ThemeShell (from the shared ZB.MOM.WW.Theme package), which provides the brand bar, responsive hamburger, and side-rail chassis. NavMenu fills the rail's <Nav> slot with policy-gated navigation groups:

Nav group Policy gate Key pages
Dashboard Authenticated /
Admin RequireAdmin LDAP Mappings, Sites, API Keys, Import Bundle
Design RequireDesign Templates, Shared Scripts, Connections, External Systems, Export Bundle
Deployment RequireDeployment Topology, Deployments, Debug View
Notifications Mixed (per item) SMTP Configuration (Admin), Notification Lists (Design), Notification Report / KPIs (Deployment)
Site Calls RequireDeployment Site Calls
Monitoring Health: all; Event Logs + Parked: Deployment Health Dashboard, Event Logs, Parked Messages
Audit OperationalAudit Audit Log, Configuration Audit Log

LoginLayout is a minimal layout used by /login — it renders only @Body with no nav sidebar, session-expiry watchdog, or dialog host. SessionExpiry is rendered exclusively in MainLayout; it self-guards against redirect loops by checking whether the current URL is already the login page (IsOnLoginPage) and skipping polling if so.

DialogHost — a single instance rendered in MainLayout — is the rendering target for all modal dialogs raised via the IDialogService / DialogService scoped service. Pages call Dialog.ConfirmAsync or Dialog.PromptAsync and await the result without managing modal state themselves.

Real-time update channels

Three distinct mechanisms push live data to the browser:

Deployment status (SignalR push): The Deployments page subscribes to IDeploymentStatusNotifier.StatusChanged on OnInitializedAsync. DeploymentManager raises this event on every status write; the handler marshals to the Blazor renderer via InvokeAsync and calls StateHasChanged, pushing the re-render over the existing SignalR circuit. No polling timer is needed.

protected override async Task OnInitializedAsync()
{
    await LoadDataAsync();
    DeploymentStatusNotifier.StatusChanged += OnDeploymentStatusChanged;
}

Debug view (gRPC streaming + SignalR relay): DebugStreamService (from Communication, #5) creates a DebugStreamBridgeActor per session. The bridge actor opens a gRPC server-streaming subscription to the site's SiteStreamGrpcServer for the selected instance, then requests an initial DebugViewSnapshot via ClusterClient. Ongoing AttributeValueChanged and AlarmStateChanged events flow via the gRPC stream to the bridge actor, which delivers them to the DebugView page via callbacks that call InvokeAsync(StateHasChanged). A pulsing "Live" badge in the status strip indicates active streaming. Streams are subscribe-on-demand and stop when the page closes.

Health dashboard and KPI tiles (10-second polling timer): The Health page initializes a System.Threading.Timer at 10-second intervals. Each tick calls ICentralHealthAggregator.GetAllSiteStates() for in-memory site state and issues three async KPI queries:

private async Task RefreshNow()
{
    _siteStates = HealthAggregator.GetAllSiteStates();
    await LoadOutboxKpis();
    await LoadSiteCallKpis();
    await LoadAuditKpis();
}

KPI queries go through CommunicationService (Notification Outbox and Site Calls KPIs via actor Ask) and IAuditLogQueryService (Audit KPIs via IAuditLogRepository). A transient fault on any one KPI group degrades only that group's tiles to em dashes — the rest of the dashboard continues to render.

Script analysis endpoint

ScriptAnalysisService compiles user script fragments as Roslyn C# Scripting globals against SandboxScriptHost (template/shared scripts) or InboundScriptHost (Inbound API methods). It surfaces diagnostics and completions in the shape Monaco's provider APIs expect. Diagnostics are cached by code hash via IMemoryCache (200-entry limit) to short-circuit repeated requests for the same content. ScriptAnalysisEndpoints registers an endpoint group under /api/script-analysis with seven POST endpoints (/diagnostics, /completions, /hover, /signature-help, /format, /inlay-hints, /run); each is called by the Monaco JS providers in monaco-init.js on a 500 ms debounce (diagnostics) or on editor events (hover, completions, etc.).

The sandbox enforces the script trust model: a SemanticModel check raises a diagnostic for any use of forbidden APIs (System.IO, Process, Thread, reflection, raw networking).

Audit Log query service

AuditLogQueryService (registered with an explicit factory to resolve the IServiceScopeFactory constructor) opens a fresh DI scope — and therefore a fresh ScadaBridgeDbContext — per operation. This prevents the page's query-string auto-load from racing the filter bar's site enumeration on the shared circuit-scoped context:

await using var scope = _scopeFactory!.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
var result = await repository.QueryAsync(filter, effective, ct);
return result.Select(AuditEventView.From).ToList();

GetKpiSnapshotAsync aggregates audit volume and error rate over a trailing one-hour window from the repository, then sums SiteAuditBacklog.PendingCount across all site states from ICentralHealthAggregator to fill the backlog tile.

GetDistinctSourceNodesAsync caches the result for 60 seconds behind an in-memory lock to keep filter bar rendering cheap during failover events when node membership changes.

CSV export endpoint

GET /api/centralui/audit/export is gated on the AuditExport policy (a superset of OperationalAudit). The endpoint streams directly to Response.Body via IAuditLogExportService.ExportAsync — no buffering through the Blazor circuit. The default row cap is 100,000; a maxRows= query-string override is accepted. The Content-Disposition: attachment header and Cache-Control: no-store are stamped before the first body write.

Usage

Registration (Host)

The central-role Host calls AddCentralUI during composition to register all Blazor and UI services, then MapCentralUI<App> on the endpoint builder to wire routes:

// In Host's central-role composition root:
services.AddCentralUI();

// In the endpoint pipeline:
app.MapCentralUI<App>();

MapCentralUI registers MapAuthEndpoints, MapScriptAnalysisEndpoints, MapAuditExportEndpoints, and MapRazorComponents<TApp> with interactive server render mode.

Accessing the UI

The UI is fronted by the Traefik reverse proxy (component #20), which routes to the active central node. In the Docker topology the management port is 9000 (Traefik), with direct access at 9001 (central-a) and 9002 (central-b).

On central failover the SignalR circuit is interrupted. Blazor's built-in reconnection logic re-establishes the circuit on the new active node. Because authentication state is in the cookie (not server memory), the user's session survives failover without re-login — provided the new node shares the same ASP.NET Data Protection keys (stored in the configuration database).

Active debug view streams and in-progress deployment status subscriptions are lost on failover and must be re-opened by the user.

Any page that wants to pre-filter the Audit Log passes query-string parameters to /audit/log:

Parameter Example Effect
correlationId ?correlationId=<guid> Pins to a single operation (notification, cached call, inbound request)
executionId ?executionId=<guid> Shows all rows for one script execution
channel ?channel=ApiOutbound Pre-selects channel filter
actor ?actor=my-key Pre-fills actor search

The same keys are accepted by GET /api/centralui/audit/export for filtered CSV export.

Dependencies & Interactions

  • Security (#10) — ILdapAuthService for LDAP bind at login; IGroupRoleMapper<string> for LDAP-group → role mapping; JwtTokenService for bearer-token generation (/auth/token) and claim-type constants; AuthorizationPolicies for every [Authorize(Policy = …)] attribute. The Security component owns the cookie middleware configuration (sliding window, idle timeout).
  • Configuration Database (#17) — all central CRUD (templates, instances, sites, external systems, notifications, audit log, etc.) via scoped EF Core repositories (ICentralUiRepository, IAuditLogRepository, ISiteRepository, ITemplateEngineRepository, and others). The Data Protection keys that make sessions portable across failover are also stored here.
  • Communication (#5) — CommunicationService for cross-site commands (deploy, disable, enable, delete, browse OPC UA nodes, read tag values); DebugStreamService for gRPC-backed debug stream sessions; KPI request/response messages for Notification Outbox and Site Calls KPIs.
  • Deployment Manager (#2) — IDeploymentStatusNotifier for real-time deployment status push; deployment command routing.
  • Template Engine (#1) — TemplateService, TemplateFolderService, ValidationService for template authoring, on-demand validation, and diff calculation.
  • Health Monitoring (#11) — ICentralHealthAggregator for in-memory site health state on the Health Dashboard and audit backlog KPI tile.
  • Audit Log (#23) — IAuditLogRepository (via AuditLogQueryService) for the Audit Log page query/drilldown/export; IAuditLogQueryService.GetKpiSnapshotAsync for the three Audit KPI tiles on the Health Dashboard.
  • Notification Outbox (#21) — Notification Report page queries and Retry/Discard actions; Notification KPI tiles on the Health Dashboard.
  • Site Call Audit (#22) — Site Calls page queries and Retry/Discard relay; Site Call KPI tiles on the Health Dashboard.
  • Transport (#24) — IBundleExporter and IBundleImporter for the Export Bundle and Import Bundle multi-step wizards.
  • TreeView — the template folder tree sidebar on /design/templates uses the shared TreeView component; see that document for the reusable tree implementation.
  • Design spec: Component-CentralUI.md.

Troubleshooting

Circuit reconnects but user sees "Not Authorized"

This indicates the shared ASP.NET Data Protection keys are not available to the node that the circuit reconnected to. The keys are stored in the configuration database; verify the central nodes share the same key ring and that the configuration database is reachable on both nodes.

Health Dashboard KPI tiles show "—"

Each KPI group degrades independently. A "—" on the Notification Outbox tiles means CommunicationService.GetNotificationKpisAsync threw or the actor returned Success = false; the inline error message below the tiles carries the detail. Audit KPI tiles degrade if IAuditLogRepository.GetKpiSnapshotAsync fails — check the central SQL connection.

Debug view connects but shows no events

The debug stream bridge actor opens a gRPC subscription to the site's SiteStreamGrpcServer. If the gRPC port is unreachable (e.g., GrpcNodeAAddress or GrpcNodeBAddress misconfigured on the site entity), the bridge actor logs the failure and the stream never starts. Check the site's gRPC address configuration on the Sites admin page and verify port 8083 is accessible from the central nodes.

Session expires unexpectedly

The idle timeout is a sliding window — any authenticated HTTP request (including the /auth/ping from SessionExpiry) slides the expiry. If sessions lapse before the configured idle timeout, confirm that the Blazor circuit is producing at least one HTTP request per timeout window (the SessionExpiry poll interval is one minute). If the cookie middleware's ExpireTimeSpan is misconfigured, check the Security component's AddCookie call.

Script editor shows no diagnostics

ScriptAnalysisService uses Roslyn from in-process IMemoryCache (200-entry size limit). If the cache is evicted under memory pressure, the next keystroke re-analyzes. If diagnostics never appear, check that ScriptAnalysisEndpoints is registered (it is mapped via MapCentralUI), and that the Monaco JS module's network requests to /api/script-analysis/diagnostics (and related endpoints) are completing successfully.