0c3837c778
ManagementService (role table: queries any-auth, area mutations Designer; audit contract exception), CLI (missing instance/api-key subcommands; server JSON printed verbatim; bundle preview timeout), Transport (BundleFormatVersion exact-match gate; dependency scan fields; three flushes), CentralUI (/api/script-analysis endpoints; LoginLayout minimal; Health tile components), TreeView (Topology no RevealNode; ContextMenu Site branch; InitiallyExpanded).
224 lines
18 KiB
Markdown
224 lines
18 KiB
Markdown
# 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:
|
|
|
|
```csharp
|
|
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 `SiteId` claims from the circuit principal. Deployment users whose LDAP mapping carries site-scope rules carry one integer `SiteId` claim per permitted site; Admin and Design users carry none (system-wide). Pages that enumerate sites or issue cross-site commands call `SiteScopeService.FilterSitesAsync` and `IsSiteAllowedAsync` to enforce the grant before any operation.
|
|
|
|
### 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.
|
|
|
|
```razor
|
|
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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
// 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.
|
|
|
|
### Audit Log page deep links
|
|
|
|
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)](./Security.md) — `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)](./ConfigurationDatabase.md) — 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)](./Communication.md) — `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)](./DeploymentManager.md) — `IDeploymentStatusNotifier` for real-time deployment status push; deployment command routing.
|
|
- [Template Engine (#1)](./TemplateEngine.md) — `TemplateService`, `TemplateFolderService`, `ValidationService` for template authoring, on-demand validation, and diff calculation.
|
|
- [Health Monitoring (#11)](./HealthMonitoring.md) — `ICentralHealthAggregator` for in-memory site health state on the Health Dashboard and audit backlog KPI tile.
|
|
- [Audit Log (#23)](./AuditLog.md) — `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)](./NotificationOutbox.md) — Notification Report page queries and Retry/Discard actions; Notification KPI tiles on the Health Dashboard.
|
|
- [Site Call Audit (#22)](./SiteCallAudit.md) — Site Calls page queries and Retry/Discard relay; Site Call KPI tiles on the Health Dashboard.
|
|
- [Transport (#24)](./Transport.md) — `IBundleExporter` and `IBundleImporter` for the Export Bundle and Import Bundle multi-step wizards.
|
|
- [TreeView](./TreeView.md) — 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](../requirements/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.
|
|
|
|
## Related Documentation
|
|
|
|
- [Central UI design specification](../requirements/Component-CentralUI.md)
|
|
- [Security](./Security.md)
|
|
- [Configuration Database](./ConfigurationDatabase.md)
|
|
- [Communication](./Communication.md)
|
|
- [Deployment Manager](./DeploymentManager.md)
|
|
- [Template Engine](./TemplateEngine.md)
|
|
- [Health Monitoring](./HealthMonitoring.md)
|
|
- [Audit Log](./AuditLog.md)
|
|
- [Notification Outbox](./NotificationOutbox.md)
|
|
- [Site Call Audit](./SiteCallAudit.md)
|
|
- [Transport](./Transport.md)
|
|
- [TreeView](./TreeView.md)
|