35ce14138c
The Deployment Status page client-materialized the whole deployment list. It read EVERY DeploymentRecord — an insert-only table, one row per deploy attempt for the retention window — plus EVERY Instance, then site-scoped, sorted, counted the four status tiles and sliced a 25-row page in the Blazor circuit's memory. That ran on first render AND on every IDeploymentStatusNotifier push, so the cost scaled with the age of the system rather than the size of the page. All four jobs move into SQL: - `IDeploymentManagerRepository.QueryDeploymentListPageAsync(filter, page, size)` returns one page of `DeploymentListRow` — DeploymentRecord INNER JOINed to Instance, so the instance display name and site travel with the rows that need them — plus the total count of the filtered set. The join is exact: the FK is Restrict and DeleteInstanceAsync removes the records first, so no orphan exists. - `GetDeploymentStatusCountsAsync(filter)` returns the tile counts from ONE grouped aggregation, deliberately ignoring the filter's Status: the tiles are the status BREAKDOWN of the filtered set, so honouring it would zero three of four tiles the moment an operator clicked one. - Site scoping runs in the query as `SiteIdScope` resolved through the record's instance (DeploymentRecord has no SiteId of its own). An EMPTY grant stays a real filter matching nothing, never "unconstrained". - The now-callerless whole-table `GetAllDeploymentRecordsAsync` is deleted. OFFSET paging, not the Audit Log's keyset cursor, and deliberately so: this page's pager is numbered and jump-to-any-page, so it needs a page count, which only a total can give it — a keyset cursor can express neither, and the total is required for the tiles regardless. The deep-offset cost that pushes high-volume tables to keyset is bounded here by the terminal-record retention purge, unlike the 365-day AuditLog. This mirrors the Notification Outbox, offset-paged for the same reason. Ordering is DeployedAt DESC, Id DESC — the Id tie-break is load-bearing, because DeployedAt ties on rapid redeploys and an unstable sort key makes offset paging repeat or drop rows. UI: the four status tiles become the status filter (click to apply, click again to clear, aria-pressed, phrasing-only content so a <button> stays valid), plus a free-text search matched DB-side against instance name, deployment id, revision hash and initiating user. Search is TRAILING-edge debounced at 500ms — the same Timer + lock + _disposed idiom as the existing leading-edge push coalescer, minus the leading edge, because a search box must not query on the first keystroke. A filter change resets to page 1; a page past the end falls back to the last real page. Bootstrap only, existing PagerWindow pager retained. The WP2.4 push coalescing is unchanged and still earns its keep: server paging shrank what a reload costs, not how many arrive — it now bounds database round-trips rather than table scans. Tests: 19 new SQLite repository tests (paging slice + total, tie-break stability across pages, page/size clamping, past-the-end, the joined projection, every filter dimension incl. the empty-scope security case, and the grouped counts' status-blind contract); 15 new bUnit page tests (page-1 request, server total drives the pager, Next re-queries, tiles show server counts not page counts, tile filter + toggle, system-wide vs site-scoped scope push, debounce collapses a keystroke burst to one query, clear-filters, dispose with an armed timer). The two existing Deployments suites re-point their reload assertions at the new query. Doc: Component-CentralUI.md Deployment section — the "no server-side paging" known residual is replaced by the shipped design.
301 lines
48 KiB
Markdown
301 lines
48 KiB
Markdown
# Component: Central UI
|
||
|
||
## Purpose
|
||
|
||
The Central UI is a web-based management interface hosted on the central cluster. It provides all configuration, deployment, monitoring, and troubleshooting workflows for the SCADA system. There is no live machine data visualization — the UI is focused on system management, with the exception of on-demand debug views.
|
||
|
||
## Location
|
||
|
||
Central cluster only. Sites have no user interface.
|
||
|
||
## Technology
|
||
|
||
- **Framework**: Blazor Server (ASP.NET Core). UI logic executes on the server, updates pushed to the browser via SignalR.
|
||
- Keeps the entire stack in C#/.NET, consistent with the rest of the system (Akka.NET, EF Core).
|
||
- SignalR provides built-in support for real-time UI updates.
|
||
|
||
## Failover Behavior
|
||
|
||
- A **load balancer** sits in front of the central cluster and routes to the active node.
|
||
- On central failover, the Blazor Server SignalR circuit is interrupted. The browser automatically attempts to reconnect via SignalR's built-in reconnection logic.
|
||
- Since sessions use **authentication cookies** carrying an embedded JWT (not server-side state), the user's authentication survives failover — the new active node validates the same cookie-embedded JWT. No re-login required if the token is still valid.
|
||
- Active debug view streams and in-progress deployment status subscriptions are lost on failover and must be re-opened by the user.
|
||
- Both central nodes share the same **ASP.NET Data Protection keys** (stored in the configuration database or shared configuration) so that tokens and anti-forgery tokens remain valid across failover.
|
||
|
||
## Real-Time Updates
|
||
|
||
- **Debug view**: Real-time display of attribute values and alarm states via **gRPC streaming**. When the user opens a debug view, a `DebugStreamBridgeActor` on the central side opens a gRPC server-streaming subscription to the site's `SiteStreamGrpcServer` for the selected instance, then requests an initial `DebugViewSnapshot` over the central→site gRPC command channel (`SiteCommandService`, `QueryReply.DebugViewSnapshot`). Ongoing `AttributeValueChanged` and `AlarmStateChanged` events flow via the gRPC data stream to the bridge actor, which delivers them to the Blazor component via callbacks that call `InvokeAsync(StateHasChanged)` to push UI updates through the built-in SignalR circuit.
|
||
- **Health dashboard**: Site status, connection health, error rates, and buffer depths update via a **10-second auto-refresh timer**. Since health reports arrive from sites every 30 seconds, a 10s poll interval catches updates within one reporting cycle without unnecessary overhead.
|
||
- **Deployment status**: Pending/in-progress/success/failed transitions **push to the UI immediately** via SignalR (built into Blazor Server). No polling required for deployment tracking.
|
||
|
||
## Responsibilities
|
||
|
||
- Provide authenticated access to all management workflows.
|
||
- Enforce role-based access control in the UI (Admin, Design, Deployment with site scoping).
|
||
- Present data from the configuration database, and from site clusters via remote queries.
|
||
|
||
## Workflows / Pages
|
||
|
||
### Template Authoring (Design Role)
|
||
- The `/design/templates` page uses a **split-pane layout**: a folder/template tree sidebar on the left and the editor on the right.
|
||
- The tree shows nested `TemplateFolder` entities with their templates underneath; composition children render inline beneath their owning template (right-click "Open composed template" reveals and selects the target). Compositions are shown by **effective set** (own + inherited): a derived/composed member surfaces the slots it inherits from its base — e.g. `LeakTest` composed onto base `ReactorSide` appears under each derived `…ReactorSide` member — badged **"inherited"** and read-only (Rename/Delete are offered on the base's own slot, not the inherited copy).
|
||
- **Per-kind context menus** on folder, template, and composition nodes expose the relevant operations (new folder, new template, rename, move, delete, move to folder). Root-level folders also carry a context menu. **Folder sibling reorder** is done via **Move up / Move down** menu items (M9/T23, `ReorderTemplateFolderCommand`); drag-drop is **not implemented** (permanently deferred). Tree expansion state persists in `sessionStorage`, and deep links (`/design/templates/{id}`) reveal and select the target node.
|
||
- A **search box** above the tree (M9/T22) filters visible nodes by substring match; it is wired to `TemplateFolderTree.Filter`.
|
||
- The `TemplateEdit` page shows a read-only **"Inherited members" panel** (M9/T26) listing the full multi-level effective inherited member set (origin, locked state, merged HiLo config) resolved by `GetResolvedTemplateMembersCommand` / `TemplateInheritanceResolver`. A **"Base changed" banner** appears when the resolver's staleness summary indicates the parent template has changed since the child was last edited. This is read-only — no "update-derived" mutation is exposed; the child is redeployed through the normal flow to pick up base changes.
|
||
- Create, edit, and delete templates.
|
||
- **Template deletion** is blocked if any instances or child templates reference the template. The UI displays the references preventing deletion.
|
||
- Manage template hierarchy (inheritance) — visual tree of parent/child relationships.
|
||
- Manage composition — add/remove feature module instances within templates. **Naming collision detection** provides immediate feedback if composed modules introduce duplicate attribute, alarm, or script names.
|
||
- Define and edit attributes, alarms, and scripts on templates.
|
||
- **Member authoring dialogs are hosted, not page-embedded** (M10 residual, 2026-08-01): the template editor's Attribute, Alarm, Native Alarm Source, and Script forms all open through `IDialogService.ShowAsync`, so the single `DialogHost` in `MainLayout` owns the backdrop, focus trap, Escape, and focus restoration. Each form body is its own component beside the page (`TemplateAttributeDialog`, `TemplateAlarmDialog`, `TemplateNativeAlarmSourceDialog`, `TemplateScriptDialog`), following the `MoveDataConnectionDialog` pattern — the body renders validation and server errors **inline and stays open**, closing only on a successful save; persistence stays on the page (which owns `TemplateService`) behind an `OnSaveAsync` delegate.
|
||
- **Native Alarms tab** (`TemplateEdit`): a tab alongside Attributes / Alarms / Scripts / Compositions that lists the template's **native alarm source bindings** — the OPC UA Alarms & Conditions / MxAccess Gateway sources whose alarm state the instance mirrors. Each binding carries Name, Connection, Source Reference, optional Condition Filter, Description, and a Lock flag. Add / edit / delete go through a **hosted dialog** (`TemplateNativeAlarmSourceDialog`):
|
||
- **Name** — unique within the template (lock/inherit bookkeeping mirrors `TemplateAlarm`).
|
||
- **Connection** — a dropdown filtered to **alarm-capable connections only** (OPC UA and MxGateway protocols).
|
||
- **Source Reference** — the native key (OPC UA SourceNode / notifier nodeId, or MxAccess object/area).
|
||
- **Condition Filter** (optional) — blank mirrors *all* conditions under the source.
|
||
- **Description** (optional) and **Lock** (prevents instance-level override, like locked alarms/attributes).
|
||
- CRUD is **repository-direct** (Blazor Server runs in-process against `ICentralUiRepository`); no Akka round-trip is needed for design-time authoring.
|
||
- Set lock flags on attributes, alarms, and scripts.
|
||
- Visual indicator showing inherited vs. locally defined vs. overridden members.
|
||
- **On-demand validation**: A "Validate" action allows Design users to run comprehensive pre-deployment validation (flattening, naming collisions, script compilation, trigger references) without triggering a deployment. Provides early feedback during authoring.
|
||
- **Last-write-wins** editing — no pessimistic locks or conflict detection on templates.
|
||
|
||
### Shared Script Management (Design Role)
|
||
- Create, edit, and delete shared (global) scripts.
|
||
- Shared scripts are not associated with any template.
|
||
- On-demand validation (compilation check) available.
|
||
|
||
### External System Management (Design Role)
|
||
- Define external system contracts: connection details, API method definitions (parameters, return types).
|
||
- Define retry settings per external system (max retry count, fixed time between retries).
|
||
- The external system detail page includes a **"Recent activity"** link that opens the Audit Log page pre-filtered to `Channel = ApiOutbound` and `Target` starts-with the system name — surfacing the system's recent outbound API audit history.
|
||
|
||
### Database Connection Management (Design Role)
|
||
- Define named database connections: server, database, credentials.
|
||
- Define retry settings per connection (max retry count, fixed time between retries).
|
||
|
||
### Notification List Management (Design Role)
|
||
- Create, edit, and delete notification lists.
|
||
- Each notification list has a **`Type`** — `Email` or `Sms` (SMS via Twilio shipped 2026-06-19; a Teams adapter was evaluated and dropped). The type determines the type-specific targets a list carries.
|
||
- Manage recipients (name + email) within each `Email` list.
|
||
- Configure SMTP settings.
|
||
|
||
### Site & Data Connection Management (Admin Role)
|
||
- Create, edit, and delete site definitions, including gRPC node addresses (GrpcNodeA/GrpcNodeB). The legacy Akka node addresses (NodeA/NodeB) are still stored and editable but have had **no runtime consumer** since the ClusterClient→gRPC migration's Phase 4 — every central→site dial resolves the gRPC pair. The form labels them as legacy so an operator does not mistake them for a live setting.
|
||
- Define data connections and assign them to sites (name, protocol type, connection details).
|
||
- **Data connection form**: "Primary Endpoint Configuration" (required JSON text area) and optional "Backup Endpoint Configuration" (collapsible section, hidden by default, revealed via "Add Backup Endpoint" button; "Remove Backup" button when editing an existing backup). "Failover Retry Count" numeric input (default 3, min 1, max 20) is visible only when a backup endpoint is configured.
|
||
- **Verify endpoint** (OPC UA): the OPC UA endpoint editor (in the data connection form) carries a **"Verify endpoint"** button that asks the target site to probe the configured endpoint — a temporary, short-lived connect against the live (or edited-but-unsaved) config. The result reports success or a typed failure kind (e.g. unreachable, untrusted certificate, server error). When the failure is an **untrusted server certificate**, the probe captures the cert (Subject / Issuer / Thumbprint / validity / DER) and the editor shows a detail panel with a **"Trust certificate"** button. The probe itself **never trusts** the cert — trusting is an explicit, Admin-gated action (see Server certificate management). After a Trust, Verify re-runs automatically and should then succeed.
|
||
- **Data connection list page**: Shows Primary Config and Backup Config columns. Active Endpoint column populated from health reports.
|
||
- **Connection live-status indicators** (M9/T25): the design DataConnections page polls `ConnectionHealthQueryService` (~10 s interval) which reads `SiteHealthReport.DataConnectionStatuses` already delivered by Health Monitoring; no new site-side code. Each connection row displays its current status badge (Connected / Disconnected / Unknown).
|
||
- **Move data connection between sites** (M9/T24): a **Move…** action on a data connection opens `MoveDataConnectionDialog`. The underlying `MoveDataConnectionCommand` guards against: target site does not exist, name collision at the target, any `InstanceConnectionBinding` referencing the connection, and any `InstanceNativeAlarmSourceOverride.ConnectionNameOverride` reference at the source site's templates. Blocked connections display the blocking reason.
|
||
- **Server certificate management** (`/design/connections/{id}/certificates`, Admin role): a per-connection page that lists the contents of the site's OPC UA trusted-peer and rejected certificate stores (Subject / Issuer / Thumbprint / validity / Trusted-or-Rejected status) with a **Remove** action. The page makes clear the store is **node-wide for the site** (shared by every site node), not per data connection — trusting or removing a certificate affects all OPC UA connections at that site. Trust and Remove are central commands relayed to **both** site nodes so the node-local PKI stores stay consistent across failover (see Component-SiteRuntime.md, Component-DataConnectionLayer.md).
|
||
- The site detail page exposes a new **"Audit feed"** tab that hosts the Audit Log page pre-filtered to `Site = <site>` — an in-context view of every operational audit event for that site.
|
||
|
||
### Schema Library (Design Role)
|
||
- A **Schema Library** page (M9/T32, under the Design nav group) provides CRUD for `SharedSchema` entries — named, reusable JSON Schema definitions. Each entry has a name (globally unique) and a JSON Schema body.
|
||
- Schemas are referenced from parameter/return definitions using `{"$ref":"lib:Name"}`. The resolver expands refs at deploy-time, design-time validation, and inbound-API runtime (cycle and depth guards enforced; a dangling ref blocks deployment).
|
||
- The Central UI also uses the `ParameterValueForm` (M9/T30) to render `object` and `list` parameters as typed nested inputs when a JSON Schema (or `$ref`) is attached to the parameter definition. Monaco-based JSON fields gain hover and completion (M9/T31) from the attached schema.
|
||
|
||
### Inbound API Management (Admin Role for keys, Design Role for methods)
|
||
- Manage inbound API keys (create, enable / disable, delete) and define API methods (name, parameters, return values, approved keys, implementation script).
|
||
- The API key detail page includes a **"Recent calls"** link that opens the Audit Log page pre-filtered to `Actor = <key name>` and `Channel = ApiInbound` — surfacing the key's recent inbound-call audit history.
|
||
|
||
### Bundle Export / Import (Design Role for export, Admin Role for import)
|
||
- `Export Bundle` (`/design/transport/export`) — multi-step wizard for exporting a `.scadabundle` artifact containing templates, shared scripts, external systems, and central-only configuration. Visible to users with `RequireDesign`.
|
||
- `Import Bundle` (`/design/transport/import`) — multi-step wizard for uploading and applying a `.scadabundle` into the current environment, with per-artifact diff review and conflict resolution. Visible to users with `RequireAdmin`.
|
||
|
||
### Area Management (Designer or Deployer Role)
|
||
- Areas are authored inside the Deployment Topology workflow (`Topology.razor`, `RequireDeployment`); managing them is gated **any-of [Designer, Deployer]**, not Administrator (arch-review C6).
|
||
- Define hierarchical area structures per site.
|
||
- Parent-child area relationships.
|
||
- Assign areas when managing instances.
|
||
|
||
### Instance Management (Deployment Role)
|
||
- Create instances from templates at a specific site.
|
||
- Assign instances to areas.
|
||
- Bind data connections — **per-attribute binding** where each attribute with a data source reference individually selects its data connection from the site's available connections. **Bulk assignment** supported: select multiple attributes and assign a data connection to all of them at once. Each row also exposes:
|
||
- **Override** — optional per-attribute OPC UA node id (or other protocol address). When set, replaces the template's `DataSourceReference` at flattening time; when blank, the template default is used. The greyed placeholder shows the template default for context.
|
||
- **Browse…** — opens the OPC UA Tag Browser dialog, populated live from the site's OPC UA server via `BrowseOpcUaNodeCommand`. Visible only when the row's connection uses the OPC UA protocol; disabled until a connection is picked on that row. The dialog lazy-loads the address space, supports manual node-id entry as a fallback, and remains usable when the site or its OPC UA session is offline (the manual-paste field stays active even on error). The dialog adds:
|
||
- **Load more** — when a browse level is truncated, a "Load more" affordance fetches the next page using the server's continuation point (`BrowseNext`); an expired continuation point falls back to a fresh browse.
|
||
- **Search** — a search box runs a bounded recursive address-space search (depth + result caps) at the site, matching a case-insensitive substring against node DisplayName/path; clicking a result selects it. The dialog surfaces a "showing first N — refine" note when a result cap is hit.
|
||
- **Type column** — Variable rows display best-effort type info (data type friendly name, scalar/array value rank, writable flag) read from the server during browse.
|
||
- Set instance-level attribute overrides (non-locked attributes only).
|
||
- **Bulk override CSV import** (`InstanceConfigure`): a Blazor `InputFile` upload accepts a CSV of `AttributeName, Value, ElementType?` rows (`ElementType` only for `List` attributes). Each row is validated against the instance's flattened attribute schema (name exists + value type-compatible, reusing the existing override validation); the import is **all-or-nothing** — any per-row error aborts the whole upload with a per-row error summary and nothing is applied. On success the rows are upserted through the same ManagementActor add/update-override handlers used by the inline editor. The same import is available from the CLI (`instance import-overrides --file`, see Component-CLI.md).
|
||
- **Native Alarm Source Overrides card** (`InstanceConfigure`): a card placed **after the Alarm Overrides card**, listing the template's native alarm sources for per-instance binding. Each row offers **inline override** of the three fields that typically vary per physical instance:
|
||
- **Connection** — a dropdown (same alarm-capable filtering as the template editor).
|
||
- **Source Reference** — the concrete native key for this instance.
|
||
- **Filter** — the per-instance condition filter.
|
||
- A **blank field inherits** the template default (the greyed placeholder shows the inherited value for context, mirroring the per-attribute Override field). **Save** and **Clear** act per row — Save persists the row's overrides, Clear reverts the row to the template-inherited binding. Locked template sources are not overridable.
|
||
- **Bulk retarget CSV import**: an `InputFile` upload on the card header accepts a CSV of `SourceName, Connection, SourceReference, Filter` rows — the same file format and batch rules as the CLI `instance native-alarm-source import --file` (see Component-CLI.md), parsed with the shared `NativeAlarmSourceOverrideCsvParser`. Each row is validated against the template's source bindings (name resolves, source not template-locked, no duplicate source in the file); the import is **all-or-nothing** — any error aborts the whole upload with a per-line error summary and nothing is applied. Semantics are **merge, not full replace**: sources absent from the file keep their existing override, a blank field keeps the inherited value, and a row with all three fields blank clears that source's override. On success the rows are upserted through the same repository path the inline Save uses.
|
||
- Filter/search instances by site, area, template, or status.
|
||
- **Disable** instances — stops data collection, script triggers, and alarm evaluation at the site while retaining the deployed configuration.
|
||
- **Enable** instances — re-activates a disabled instance.
|
||
- **Delete** instances — removes the running configuration from the site. Blocked if the site is unreachable. Store-and-forward messages are not cleared.
|
||
- The instance detail page exposes a new **"Audit feed"** tab that hosts the Audit Log page pre-filtered to the instance (`Site = <site>` and the `Instance / Script` filter set to the instance unique name) — an in-context view of every operational audit event involving that instance.
|
||
|
||
### Deployment (Deployment Role)
|
||
- View list of instances with staleness indicators (deployed config differs from template-derived config).
|
||
- Filter by site, area, template.
|
||
- View diff between deployed and current template-derived configuration.
|
||
- Deploy updated configuration to individual instances. **Pre-deployment validation** runs automatically before any deployment is sent — validation errors are displayed and block deployment.
|
||
- Track deployment status (pending, in-progress, success, failed).
|
||
- **Server-side paging + server-computed status counts (residual R3).** The page is a **database-paged** list, not a client-materialized one. It used to read *every* `DeploymentRecord` (an insert-only table — one row per deploy attempt for the whole retention window) plus *every* `Instance`, then site-scope, sort, count the four status tiles and slice a 25-row page in the Blazor circuit's memory, on first render **and on every deployment-status push**. All four jobs are now SQL:
|
||
- `IDeploymentManagerRepository.QueryDeploymentListPageAsync(filter, pageNumber, pageSize)` returns one page of `DeploymentListRow` — `DeploymentRecord` **inner-joined to `Instance`**, so the instance's display name and site travel with the rows that need them (the join is exact: the FK is `Restrict` and instance deletion removes the records first) — plus the **total count** of the filtered set.
|
||
- `GetDeploymentStatusCountsAsync(filter)` returns the tile counts from **one grouped aggregation**. It deliberately **ignores the filter's `Status`** — the tiles are the status *breakdown* of the otherwise-identically-filtered set, so each keeps its own total while it is the selected one.
|
||
- **Site scoping runs in the query.** The permitted-site grant is pushed in as `SiteIdScope` and resolved through the record's instance; an **empty** grant is a real filter matching nothing, never "unconstrained". `DeploymentRecord` has no `SiteId` of its own — the join is what makes this expressible.
|
||
- **Offset paging, not the Audit Log's keyset cursor — deliberately.** This page's pager is numbered and jump-to-any-page, so it needs a page count, which only a total can give it; a keyset cursor can express neither. The total is required for the tiles regardless. The deep-offset cost that pushes high-volume tables to keyset is bounded here by the terminal-record retention purge, unlike the 365-day central `AuditLog`. This mirrors the Notification Outbox, which is offset-paged for the same reason. Ordering is `DeployedAt DESC, Id DESC`; the `Id` tie-break is load-bearing, because `DeployedAt` ties on rapid redeploys and an unstable sort key makes offset paging repeat or drop rows between pages.
|
||
- The whole-table `GetAllDeploymentRecordsAsync` read was **deleted** with its last caller.
|
||
- **Filtering.** Status filter (the tiles double as the control — clicking one applies it, clicking it again clears) plus a free-text search matched DB-side against instance unique name, deployment id, revision hash and initiating user. A filter change resets to page 1. Search input is **trailing-edge debounced at 500 ms**, so a typed term is one query rather than one per keystroke.
|
||
- **Push-reload coalescing (arch-review WP2.4).** The notifier fires per status *write*, so a site-wide bulk deploy of N instances drove 2N+ back-to-back reloads on the same circuit. Pushes are leading-edge debounced (500 ms): the first push after an idle gap reloads immediately (a single deployment stays as responsive as before), and every push inside the window collapses into one trailing reload. Server-side paging shrank what a reload *costs* but not how many *arrive*, so the coalescing still holds — it now bounds round-trips to the database rather than table scans.
|
||
|
||
### System-Wide Artifact Deployment (Deployment Role)
|
||
- Explicitly deploy shared scripts, external system definitions, database connection definitions, and data connection definitions to all sites or to an individual site. (Notification lists and SMTP configuration are central-only and are not deployed.)
|
||
- **Per-site deployment**: A "Deploy Artifacts" button on the Sites admin page allows deploying all artifacts to an individual site.
|
||
- **Deploy all**: A bulk action deploys artifacts to all sites at once.
|
||
- This is a **separate action** from instance deployment — system-wide artifacts are not automatically pushed when definitions change.
|
||
- Track per-site deployment status.
|
||
|
||
### Debug View (Deployment Role)
|
||
- Select a deployed instance and open a live debug view.
|
||
- Real-time streaming of all attribute values (with quality and timestamp) and alarm states for that instance.
|
||
- The `DebugStreamService` creates a `DebugStreamBridgeActor` on the central side. The bridge actor opens a **gRPC server-streaming subscription** to the site's `SiteStreamGrpcServer` for the selected instance, then requests an initial `DebugViewSnapshot` over the central→site gRPC command channel (`SiteCommandService`).
|
||
- Ongoing events (`AttributeValueChanged`, `AlarmStateChanged`) flow via the gRPC data stream directly to the bridge actor — they do not travel on the command channel.
|
||
- Events are delivered to the Blazor component via callbacks, which call `InvokeAsync(StateHasChanged)` to push UI updates through the built-in SignalR circuit.
|
||
- **Render coalescing (arch-review WP2.4).** Streamed events no longer trigger an individual dispatcher marshal + `StateHasChanged()` each — a chatty instance used to drive one full render (and two full tree rebuilds) per value change. Events now land in a thread-safe pending map keyed by attribute/alarm name (repeated updates to the same tag inside one window collapse to the latest), and exactly one dispatcher marshal per **250ms coalesce window** drains the map, bumps a version stamp, and renders once. This also fixes a latent thread-safety issue: the render dictionaries were plain (non-concurrent) `Dictionary`s enumerated by the render thread while written from the Akka/gRPC callback thread.
|
||
- A pulsing "Live" indicator replaces the static "Connected" badge when streaming is active.
|
||
- Subscribe-on-demand — stream starts when opened, stops when closed.
|
||
- Read-only per-instance view (one instance per connection); no alarm acknowledgement is available from Debug View.
|
||
|
||
#### Tabbed Layout
|
||
|
||
The Debug View page uses a **two-tab layout** — an **Attributes** tab and an **Alarms** tab — replacing the earlier side-by-side flat tables. Each tab renders its data as a **collapsible hierarchy tree** using the existing generic `TreeView<TItem>` component. As of 2026-08-01 that component implements the full **WAI-ARIA tree keyboard pattern** — roving tabindex (one Tab stop per tree), Arrow/Home/End movement, Enter/Space activation, plus `aria-level`/`aria-posinset`/`aria-setsize` — so every tree surface in the Central UI (Debug View, Data Connections, Topology, the template folder browser) is keyboard-navigable. See [`docs/components/TreeView.md`](../components/TreeView.md).
|
||
|
||
**Tree hierarchy** — the hierarchy is derived from the path-qualified canonical names already present in the debug snapshot (e.g. `Motor1.Compressor.Pump`). The instance is the root node; composed modules are collapsible branch nodes; individual attributes (Attributes tab) or alarms/native-source bindings (Alarms tab) are leaf nodes.
|
||
|
||
**Branch-level status roll-up** — a collapsed branch shows a summary badge so the operator can assess health without expanding:
|
||
- *Alarms tab*: worst alarm state among descendants + count of active conditions.
|
||
- *Attributes tab*: a bad/uncertain-quality indicator when any descendant attribute has non-Good quality.
|
||
|
||
#### Attributes Tab
|
||
|
||
Displays all attribute values for the instance in the collapsible tree. Each leaf shows the attribute value, quality, and timestamp. The existing `[InstanceUniqueName].[AttributePath].[AttributeName]` canonical path is the basis for the tree structure.
|
||
|
||
#### Alarms Tab (Computed + Native)
|
||
|
||
The Alarms tab is the **only** runtime surface for native OPC UA Alarms & Conditions and MxAccess Gateway alarms (no dedicated operator/alarm-summary page). **All configured alarms are shown with current status, even when quiet/Normal** — no alarm is hidden simply because it has not fired.
|
||
|
||
Both enriched `AlarmStateChanged` events (live, via the gRPC stream) and the initial `DebugViewSnapshot` (via the gRPC command channel) carry the unified alarm shape, so all alarms appear on the first paint and update in place. Native alarms are a **read-only mirror** — the source system owns the alarm lifecycle (ack / shelve / suppress); the Debug View never offers ack-back or any command action.
|
||
|
||
**Native source binding nodes** — a configured native alarm source binding is itself a tree node, placed by its canonical name in the hierarchy. Its live mirrored conditions nest as child rows beneath it. A quiet binding (no currently active conditions) renders a "no active conditions" placeholder row — it is never hidden, so the operator can see every configured binding regardless of alarm state. This requires the backend to emit a placeholder `AlarmStateChanged` with `IsConfiguredPlaceholder = true` for each idle binding (see Component-SiteRuntime.md — Instance Actor Wiring). The `NativeSourceCanonicalName` field on `AlarmStateChanged` events identifies which binding node a live condition belongs to.
|
||
|
||
Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms, and the alarm itself for computed alarms):
|
||
- **Kind badge** — distinguishes **Computed** alarms from native ones (**OPC UA** or **MxAccess**), driven by the event's `AlarmKind` discriminator.
|
||
- **Sev** — the unified **0–1000 severity** (`AlarmConditionState.Severity`). Computed rows surface their integer priority on the same scale.
|
||
- **Source reference subtitle** — for native rows, the `SourceReference` (e.g. `Tank01.Level.HiHi`) renders as a monospace subtitle. Computed rows have no subtitle.
|
||
- **State badges** — orthogonal condition sub-states: **Unacked**, **Shelved**, and **Suppressed** appear only when the corresponding `AlarmConditionState` flag is set. Computed alarms are auto-acked and never shelved/suppressed.
|
||
- **Row tooltip** — surfaces native metadata not warranting its own column: `AlarmTypeName`, category, operator user and comment, original raise time, current/limit value.
|
||
- **Computed alarms render unchanged** from the prior flat-table style; the enrichment is purely additive for native rows.
|
||
|
||
### Alarm Summary (Deployment Role)
|
||
- A dedicated operator **Alarm Summary** page (`/monitoring/alarms`, `RequireDeployment`) gives a **cross-instance, read-only** roll-up of live alarm state at a site — the operator-facing complement to the per-instance Debug View.
|
||
- **Data path** — no new site-side code and no central alarm store. The page selects a site, queries its deployed instances, then fans out the existing per-instance `DebugViewSnapshot` Ask **concurrently** (capped with a `SemaphoreSlim`) and aggregates the returned `AlarmStates` client-side. The fan-out is **partial-results tolerant**: instances that time out are listed as "not reporting" while the rest still render. This snapshot fan-out now doubles as the **seed** for a near-real-time live feed (see **Live updates** below) rather than the sole refresh mechanism.
|
||
- **Live updates** — the page is driven by a **transient, per-site central live alarm cache** (`ISiteAlarmLiveCache`, owned by the Communication component; see [Component-Communication](Component-Communication.md)). On site select the page subscribes to the cache; the cache runs one shared, reference-counted per-site aggregator that **seeds** from the snapshot fan-out and then stays warm on a single **site-wide, alarm-only** `SubscribeSite` gRPC stream (seed-then-stream, dedup by `(InstanceUniqueName, AlarmName, SourceReference)`). Applied deltas raise an in-process change event (mirroring `IDeploymentStatusNotifier`) that the Blazor circuit pushes to the browser via `StateHasChanged()` — no new SignalR hub. `AlarmSummaryService.BuildFromLiveAlarms` rebuilds the roll-up + rows from the cache's current alarm set. The cache is **purely in-memory on the active central node** — there is still **no persisted central alarm store**; on a NodeA↔NodeB failover the new active node re-seeds from scratch.
|
||
- **View** — roll-up tiles (total active, worst severity, unacked count, per-`AlarmKind` counts) plus a flat, sortable, filterable table. Filters cover instance, `AlarmKind` (Computed / NativeOpcUa / NativeMxAccess), state, acked/unacked, severity threshold, and name search.
|
||
- **Row virtualization (arch-review WP2.4).** Above `VirtualizeThreshold` (150) visible rows the table switches from a plain `foreach` to Blazor `Virtualize` (`ItemSize=37`, `<tr>` spacers), so a large-site alarm burst renders only the on-screen rows instead of every row in the DOM. Below the threshold the plain `foreach` stays — cheaper than the virtualization machinery and needs no JS interop. Both paths share one row-template, so the switch is invisible to styling/behavior.
|
||
- **Read-only** — there are no ack / shelve / suppress controls (native alarms remain read-only by design).
|
||
- **Refresh** — manual refresh button plus the 15s poll timer (mirroring the Health dashboard), now retained as a **fallback + `NotReporting` authority** behind the live cache: when the cache reports `IsLive`, the page renders live-cache state; when a stream is unhealthy, **the aggregator has died (deathwatch resets `IsLive`)**, or a site has not yet seeded, the poll keeps the page fresh so a stream failure never blanks it. Since WP2.3 `IsLive` also tracks the *stream* itself: a site-wide stream that faults or ends gracefully drops `IsLive` on the spot, so the page falls back to polling for the reopen window instead of rendering a snapshot that has quietly stopped updating. When live, the poll updates only the `NotReporting` list and leaves the row set to the delta path, so a slow fan-out can never momentarily revert a fresher live delta (R2 N5). (Aggregated live stream **delivered 2026-07-10** — see `docs/plans/2026-07-10-aggregated-live-alarm-stream-plan.md`.)
|
||
- **Poll fan-out is deduplicated too (arch-review WP2.4).** The cold-cache/fallback poll runs through `SharedAlarmSummaryService`, a process-level memoizing façade over `AlarmSummaryService`: one memo slot per site, single-flight, just-under-15s window, so N operators watching the same site's fallback poll cost the node ONE per-instance debug-snapshot fan-out per window, not N. While the live cache is serving a site there is no poll fan-out at all — the façade instead answers straight from `ISiteAlarmLiveCache` (same `BuildFromLiveAlarmsCore` path the live subscription uses), so the aggregator's own seed/reconcile fan-out is the only one running.
|
||
- **Reuse** — the alarm badge/formatter markup is factored out of Debug View into a shared `AlarmStateBadges` component consumed by both Debug View and this page.
|
||
|
||
### Parked Message Management (Deployment Role)
|
||
- Query sites for parked messages (external system calls, cached DB writes). (Parked notifications are managed centrally on the Notification Outbox page, not here.)
|
||
- View message details (target, payload, retry count, timestamps).
|
||
- Retry or discard individual parked messages.
|
||
|
||
### Notification Outbox (Deployment Role)
|
||
- Monitor and manage centrally-delivered notifications. The Notification Outbox dispatches every notification store-and-forwarded from sites and logs each one to the central `Notifications` table.
|
||
- **KPI tiles** at the top of the page: queue depth (`Pending` + `Retrying`), stuck count, parked count, delivered in the last interval, and oldest pending age. The KPIs are central-computed on demand from the `Notifications` table.
|
||
- A **queryable notification list** filterable by status, type, source site, notification list, and time range, with a **stuck-only toggle** and keyword search on subject. Each row shows the notification's status, retry count, last error, and key timestamps.
|
||
- **Retry** and **Discard** actions are available on parked notifications: Retry returns the notification to `Pending` and resets `RetryCount` / `NextAttemptAt`; Discard moves it to `Discarded`. The row is retained either way so the table stays a complete audit record.
|
||
- Each row exposes a **"View audit history"** action that opens the Audit Log page pre-filtered to `CorrelationId = NotificationId`, surfacing every operational audit event recorded for that notification.
|
||
- **Stuck rows are visually badged** — a notification is stuck if it is `Pending` or `Retrying` and older than the configurable stuck-age threshold. Stuck detection is display-only; there is no automated escalation or alerting.
|
||
- All queries are served from the central `Notifications` table — no remote per-site queries are needed, unlike the Parked Message Management page.
|
||
|
||
### Secured Writes (Operator / Verifier / Administrator Roles)
|
||
- A **Secured Writes** page (`/operations/secured-writes`) drives the **two-person** authorization workflow for writes through the MxAccess Gateway: an **Operator** initiates the write, a separate **Verifier** approves it, and only an approved write reaches the site.
|
||
- The **page itself is gated by `RequireSecuredWriteAccess`** — satisfied by any of `Operator` / `Verifier` / `Administrator`. Although the pending/history lists are read-only, they expose process-sensitive tag values, so the page is not open to every authenticated user; this aligns with the ManagementActor `ListSecuredWritesCommand` any-of gate (arch-review UA1). The submit and approve/reject sub-actions are further gated by `RequireOperator` / `RequireVerifier` respectively (below).
|
||
- **Operator (submit)** — a submit form gated by `RequireOperator`: pick the site → an **MxGateway** connection on that site → the tag path → a typed value → an optional comment. Submission inserts a `Pending` `PendingSecuredWrite` row centrally; it does **not** write anything yet.
|
||
- **Verifier (approve / reject)** — a pending queue gated by `RequireVerifier` with **Approve** / **Reject** (+comment) actions. Approve shows a confirmation of the exact site / connection / tag / value before firing, and surfaces the **submission age** (`Submitted <age> ago (<UTC timestamp>)`) so a verifier can catch a stale setpoint before it reaches the device. The verifier's **own submissions are disabled in the UI and rejected server-side** (no self-approval). On approve, central marks the row `Approved` and relays the write to the site MxGateway (records `Executed` / `Failed`); reject moves it to `Rejected` with a reason. The pending queue is **unpaged** — it is bounded by the submission **TTL**: a `Pending` row that is neither approved nor rejected before the TTL elapses expires to the terminal **`Expired`** status and drops out of the queue into History.
|
||
- **History** — terminal rows (Executed / Failed / Rejected / **Expired**, where `Expired` is a real TTL-driven terminal status) with the full who/when/outcome trail (operator, verifier, comments, timestamps, any execution error). Terminal rows accumulate without bound, so the History table is **offset-paged** (page-size 50, Prev/Next) driven by the server's unpaged `TotalCount` (`ListSecuredWritesCommand` `Skip`/`Take`); the pager buttons disable at the real bounds.
|
||
- Every lifecycle event (submit / approve / reject / execute) is written to the central Audit Log; the rows share the `PendingSecuredWrite.Id` as `CorrelationId` so they join into one operation (see Component-ManagementService.md, Component-AuditLog.md).
|
||
- **Dev caveat**: with `DisableLogin` on, the auto-login identity holds all roles, so the two-person flow cannot be exercised end-to-end by a single user via the dev UI — no-self-approval is covered by handler tests; real two-person use requires two real identities.
|
||
|
||
### Site Calls (Deployment Role)
|
||
- Monitor cached calls store-and-forwarded from sites — `ExternalSystem.CachedCall()` and `Database.CachedWrite()` operations. Scoped to the `ExternalCall` and `DatabaseWrite` kinds only; notifications keep their separate Notification Outbox page and are not merged here.
|
||
- A **queryable cached-call list** filterable by site, kind, status, and time range. Each row shows the call's timestamp, site, kind, target summary, status badge, retry count, and last error.
|
||
- **Retry** and **Discard** actions are available on `Parked` rows only — `Failed` rows are not actionable, since a permanent failure would simply fail again and its error was already returned synchronously to the calling script. The actions issue central→site commands to the owning site; if the site is offline the UI surfaces a "site unreachable" message.
|
||
- Each row exposes a **"View audit history"** action that opens the Audit Log page pre-filtered to `CorrelationId = TrackedOperationId`, showing every operational audit event recorded for that cached call.
|
||
- Data is served from the central Site Call Audit component's `SiteCalls` table. The page is **read-mostly** — an eventually-consistent mirror of site state; the site remains the source of truth.
|
||
|
||
### Health Monitoring Dashboard (All Roles)
|
||
- Overview of all sites with online/offline status.
|
||
- Per-site detail: active/standby node status, data connection health, script error rates, alarm evaluation error rates, store-and-forward buffer depths.
|
||
- Headline **Notification Outbox KPI tiles** — queue depth, stuck count, and parked count. These are central-computed by the Notification Outbox from the central `Notifications` table (not part of any site health report). The full outbox view is on the dedicated Notification Outbox page.
|
||
- Headline **Site Call Audit KPI tiles** — buffered count, parked count, and failed-last-interval. These are central-computed by the Site Call Audit component from the central `SiteCalls` table (not part of any site health report). The full cached-call view is on the dedicated Site Calls page.
|
||
- Headline **Audit KPI tiles** — three tiles in a new "Audit" KPI group: **Audit volume**, **Audit error rate**, and **Audit backlog**. These are sourced from the Audit Log component (#23) and Health Monitoring per the metric definitions in Component-HealthMonitoring.md; the dashboard simply surfaces them. The full audit query view is on the dedicated Audit Log page.
|
||
- **Shared KPI cache (arch-review WP2.4).** All four KPI families above — Notification Outbox, Site Call Audit, Audit, and their per-site/per-node breakdowns — are read through a process-level `IKpiSnapshotCache`, not queried per page load. Each accessor is independently memoized (8s TTL) with single-flight production, so N Blazor circuits polling the same KPI inside one window (e.g. ten operators with the Health dashboard open) cost the node ONE aggregate SQL round trip per KPI per window, not N. A failed query is never memoized — the next caller re-attempts it, and the calling page's existing per-tile "unavailable" degradation is unchanged. Every KPI-tile page (Health, Notification Outbox, Site Calls, Audit Log) shares the same cache instance.
|
||
|
||
### Site Event Log Viewer (Deployment Role)
|
||
- Query site event logs remotely.
|
||
- Filter by event type, time range, instance.
|
||
- View script executions, alarm events (activations, clears, evaluation errors), deployment events (including script compilation results), connection status changes, store-and-forward activity, instance lifecycle events (enable, disable, delete).
|
||
|
||
### Audit Log (Admin / Audit Role)
|
||
- Lives under a **new top-level "Audit" nav group** (sibling to Notifications). In v1 the Audit nav group contains this single Audit Log page; the pre-existing Configuration Audit Log Viewer remains its own page below.
|
||
- Global query / filter / drilldown over the central `AuditLog` table maintained by the Audit Log component (#23). Read-only — the table is append-only, so there are no edit actions on rows.
|
||
- Read access to the page requires the `OperationalAudit` permission (Security & Auth #10). Per-site row scoping reuses the existing site-permission model: a user sees only rows for sites they are authorized to operate. Bulk export (see below) additionally requires `AuditExport`. The split mirrors the CLI's permission model (see Component-CLI.md).
|
||
- **Filter bar** (top of page, collapses to a single row when not focused):
|
||
- Time range — relative (15m / 1h / 24h / 7d) or custom.
|
||
- Channel — multi-select: `ApiOutbound`, `DbOutbound`, `Notification`, `ApiInbound`.
|
||
- Kind — multi-select; the available options are filtered by the selected Channels.
|
||
- Status — multi-select.
|
||
- Site — multi-select, scoped to the user's authorized sites.
|
||
- Instance / Script — text search with autocomplete.
|
||
- Target — text search (system + method, DB connection, list name).
|
||
- Actor — text search (inbound API key name).
|
||
- CorrelationId — paste a `TrackedOperationId` / `NotificationId` / request-id to see the full event sequence for one operation.
|
||
- "Errors only" toggle — shorthand for `Status NOT IN (Success, Delivered, Enqueued)`.
|
||
- **Results grid** (custom Blazor + Bootstrap component, consistent with the rest of the UI — no third-party grid):
|
||
- Columns, all resizable and reorderable, persisted per user: `OccurredAtUtc`, `Site`, `Channel`, `Kind`, `Status`, `Target`, `Actor`, `DurationMs`, `HttpStatus`, `ErrorMessage`.
|
||
- Keyset pagination ordered by `(OccurredAtUtc desc, EventId desc)`. Default page size 100.
|
||
- Clicking a row opens the drilldown drawer.
|
||
- **Drilldown drawer**:
|
||
- Pretty-prints `RequestSummary` / `ResponseSummary` — JSON is auto-detected and syntax-highlighted; SQL is syntax-highlighted.
|
||
- Surfaces **redaction indicators** wherever headers or fields were stripped at write time, per the Audit Log component's "Payload Capture Policy".
|
||
- **"Copy as cURL"** action on `ApiOutbound` and `ApiInbound` rows.
|
||
- **"Show all events for this operation"** link — re-applies the current view filtered by the row's `CorrelationId`.
|
||
- **Export** button on the page header streams a server-side CSV of the current filter (default cap 100k rows; larger exports go through the CLI). Requires the `AuditExport` permission.
|
||
|
||
### Configuration Audit Log Viewer (Admin Role)
|
||
- Pre-existing viewer for the `IAuditService` configuration-change log (template / instance / site / etc. before-after edits). Lives under the same **Audit** nav group as the operational Audit Log above.
|
||
- Query the central configuration audit log.
|
||
- Filter by user, entity type, action type, time range.
|
||
- View before/after state for each change.
|
||
|
||
### LDAP Group Mapping (Admin Role)
|
||
- Map LDAP groups to system roles (Admin, Design, Deployment).
|
||
- Configure site-scoping for Deployment role groups.
|
||
|
||
## Dependencies
|
||
|
||
- **Template Engine**: Provides template and instance data models, flattening, diff calculation, and validation.
|
||
- **Deployment Manager**: Triggers deployments, system-wide artifact deployments, and instance lifecycle commands. Provides deployment status.
|
||
- **Communication Layer**: Routes debug view subscriptions, remote queries to sites.
|
||
- **Security & Auth**: Authenticates users and enforces role-based access.
|
||
- **Configuration Database**: All central data, including audit log data for the audit log viewer. Accessed via `ICentralUiRepository`.
|
||
- **Health Monitoring**: Provides site health data for the dashboard.
|
||
- **Notification Outbox**: Provides notification delivery KPIs and serves the `Notifications` table queries and Retry/Discard actions for the Notification Outbox page.
|
||
- **Site Call Audit**: Serves the `SiteCalls` table queries and relays Retry/Discard actions to sites for the Site Calls page.
|
||
- **Audit Log (#23)**: Serves all `AuditLog` table queries (filter / grid / drilldown / CSV export) for the new Audit Log page and the drill-in surfaces on Notifications, Site Calls, External Systems, Inbound API keys, Sites, and Instances. Payload capture, redaction, and per-site authorization follow the Audit Log component's "Payload Capture Policy" and "Security & Tamper-Evidence" sections.
|
||
- **KPI History (#26)**: The Central UI hosts the `KpiHistoryQueryService` (scoped-repository read over `IKpiHistoryRepository.GetRawSeriesAsync` + `KpiSeriesBucketer`, dual-ctor test seam) and renders the reusable custom-SVG `KpiTrendChart` fed by it. Trend sections appear on the Notification Outbox, Site Calls, and Audit Log pages and in a per-site panel on the Health Monitoring dashboard; a query failure degrades to an unavailable-chart placeholder rather than breaking the page. See [Component-KpiHistory.md](Component-KpiHistory.md).
|