ui: Central UI density/consistency sweep + Theme 0.4.1

Applies the family-wide admin-UI cleanup playbook to the Central UI so the
Blazor surfaces stop diverging from the shared kit: buttons are grouped rather
than individually sized, long cell values are contained instead of widening
tables, and hard-coded colours give way to theme tokens.

The headline fix is that MainLayout passed Accent="#2f5fd0" to ThemeShell,
which the kit emits as an inline style on the shell root. Being a descendant of
<html>, it beat the [data-bs-theme="dark"] override for the entire app, so the
dark accent had never rendered. Declaring --accent in site.css :root instead
lets both schemes resolve; light is unchanged because the value already matched
the kit's light default.

Theme pins to 0.4.1, which upstreams the local .btn sizing block verbatim, so
that block is deleted here rather than duplicated. Verified byte-identical
before removal; the repo now declares no --bs-btn-* anywhere.

NOT purely cosmetic, contrary to the sweep's stated scope: four detail-modal
surfaces (NotificationReport, ConfigurationAuditLog, ParkedMessages,
SiteCallsReport) were additionally refactored from holding the selected record
to holding its id and re-resolving from the current page each render, with the
resolve doubling as the visibility gate. A background refresh that drops the
row now closes the modal instead of showing a stale snapshot. This is a
behaviour change and is called out rather than buried: a full-suite run turned
up one intermittent CentralUI failure, CloseButton_DismissesModal, whose stack
(GetRequiredEventBindingEntry during DispatchEventAsync) indicates the handler
was disposed between render and click — a window the previous field-held record
made structurally impossible. Treat the modal lifecycle here as unreviewed.

Build 0/0; suite green apart from that one intermittent failure.
This commit is contained in:
Joseph Doherty
2026-08-11 05:50:12 -04:00
parent b6f383a225
commit 9e243493fb
77 changed files with 2197 additions and 1292 deletions
+5 -5
View File
@@ -24,7 +24,7 @@ Central cluster only. Sites have no user interface.
## 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` via ClusterClient. Ongoing `AttributeValueChanged` and `AlarmStateChanged` events flow via the gRPC stream (not through ClusterClient) 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.
- **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.
@@ -81,7 +81,7 @@ Central cluster only. Sites have no user interface.
- Configure SMTP settings.
### Site & Data Connection Management (Admin Role)
- Create, edit, and delete site definitions, including Akka node addresses (NodeA/NodeB) and gRPC node addresses (GrpcNodeA/GrpcNodeB).
- 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.
@@ -150,8 +150,8 @@ Central cluster only. Sites have no user interface.
### 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` via ClusterClient.
- Ongoing events (`AttributeValueChanged`, `AlarmStateChanged`) flow via the gRPC stream directly to the bridge actor — they do not pass through ClusterClient.
- 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.
- A pulsing "Live" indicator replaces the static "Connected" badge when streaming is active.
- Subscribe-on-demand — stream starts when opened, stops when closed.
@@ -175,7 +175,7 @@ Displays all attribute values for the instance in the collapsible tree. Each lea
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 ClusterClient) 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.
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.
@@ -177,7 +177,7 @@ After the Leave the node's `ActorSystem` terminates, the `WhenTerminated` watchd
#### Site-pair failover
The same control appears on each **site** card. Central and each site are separate Akka clusters, so central cannot act on a site's membership — it *asks*, over the existing ClusterClient command/control channel:
The same control appears on each **site** card. Central and each site are separate Akka clusters, so central cannot act on a site's membership — it *asks*, over the central→site gRPC command channel (`SiteCommandService`):
1. `CommunicationService.TriggerSiteFailoverAsync` sends a `TriggerSiteFailover` inside a `SiteEnvelope`.
2. The site's `SiteCommunicationActor` (registered per node, so contact rotation reaches whichever answers) resolves the target from cluster state and issues the graceful `Leave` locally.
+1 -1
View File
@@ -33,7 +33,7 @@ Commons must define shared primitive and utility types used across multiple comp
- **`DeploymentStatus` enum**: Pending, InProgress, Success, Failed.
- **`AlarmState` enum**: Active, Normal.
- **`AlarmLevel` enum**: None, Low, LowLow, High, HighHigh. Severity level for an active alarm; always `None` for binary trigger types, set by `HiLo` triggers.
- **`AlarmTriggerType` enum**: ValueMatch, RangeViolation, RateOfChange, HiLo.
- **`AlarmTriggerType` enum**: ValueMatch, RangeViolation, RateOfChange, HiLo, Expression. `Expression` is a read-only boolean C# expression re-evaluated on attribute updates; the trigger fires when it evaluates to `true`.
- **`AlarmKind` enum**: Computed, NativeOpcUa, NativeMxAccess. Discriminates how an alarm's state is produced — evaluated at the site by an `AlarmActor` from attribute triggers (`Computed`) vs. mirrored read-only from a native source (OPC UA Alarms & Conditions / MxAccess Gateway).
- **`AlarmShelveState` enum**: Unshelved, OneShotShelved, TimedShelved, PermanentShelved. OPC UA Part 9 shelving sub-state of an alarm condition; mirrored read-only from the source. Computed alarms are always `Unshelved`.
- **`AlarmTransitionKind` enum**: Snapshot, SnapshotComplete, Raise, Acknowledge, Clear, Retrigger, StateChange. Classifies a `NativeAlarmTransition`; `Snapshot`/`SnapshotComplete` carry the initial active-condition replay produced on every (re)subscribe so consumers can re-seed state.
@@ -110,7 +110,7 @@ breadcrumb.
Override and lock rules apply per entity type at the following granularity:
- **Attributes**: Value and Description are overridable. Data Type is fixed by the defining level. `DataSourceReference` on a template attribute defines the **default** physical address for that attribute. Instances may override per attribute via `InstanceConnectionBinding.DataSourceReferenceOverride`; the override replaces the template default at flattening time. When the override is null (the default), the template value is used. Lock applies to the entire attribute (when locked, no fields can be overridden).
- **Alarms**: Priority Level and Trigger Definition (thresholds/ranges/rates) are instance-overridable. Description and On-Trigger Script reference are **not** instance-overridable — `InstanceAlarmOverride` carries only `TriggerConfigurationOverride` and `PriorityLevelOverride`, so an instance cannot re-point an alarm's on-trigger script or reword its description (those are template-level authoring decisions). Name and Trigger Type (Value Match vs. Range vs. Rate of Change) are fixed. Lock applies to the entire alarm. (Recorded decision, arch-review 05: this narrower granularity is the implemented behavior; adding Description/OnTriggerScript override columns is future feature work, not spec debt.)
- **Alarms**: Priority Level and Trigger Definition (thresholds/ranges/rates) are instance-overridable. Description and On-Trigger Script reference are **not** instance-overridable — `InstanceAlarmOverride` carries only `TriggerConfigurationOverride` and `PriorityLevelOverride`, so an instance cannot re-point an alarm's on-trigger script or reword its description (those are template-level authoring decisions). Name and Trigger Type (Value Match vs. Range vs. Rate of Change) are fixed. Lock applies to the entire alarm. `TriggerConfigurationOverride` is a **partial merge for `HiLo`** triggers — setpoints left unset in the override keep their inherited values — and a **whole-config replacement for every other trigger type**. (Recorded decision, arch-review 05: this narrower granularity is the implemented behavior; adding Description/OnTriggerScript override columns is future feature work, not spec debt.)
- **Native alarm sources**: An instance overrides a non-locked source via `InstanceNativeAlarmSourceOverride`, keyed by `SourceCanonicalName`. `ConnectionNameOverride`, `SourceReferenceOverride`, and `ConditionFilterOverride` are individually overridable — each is applied only when non-null; a null field **keeps the inherited value**. Name is fixed. Lock applies to the entire source.
- **Scripts**: C# source code, Trigger configuration, minimum time between runs, and parameter/return definitions are overridable. Name is fixed. Lock applies to the entire script.
- **Composed module members**: A composing template or child template can override non-locked members inside a composed module using the canonical path-qualified name.
+10 -7
View File
@@ -17,7 +17,8 @@ As of M8 (T18), Transport is no longer limited to central-only configuration: it
## Responsibilities
- Define and own the `.scadabundle` file format (ZIP container, `manifest.json`, `content.json` / `content.enc`).
- Resolve artifact dependencies at export time: base templates, shared scripts, external systems, template folders, notification lists, SMTP configs, API keys, API methods.
- Resolve artifact dependencies at export time: base templates, shared scripts, external systems, template folders, notification lists, SMTP configs, SMS configs, API methods.
- **Inbound API keys are deliberately NOT transportable.** They live in each environment's own secret store (per-env pepper, secret shown once) and cannot be exported — see the comment on `ExportSelection`. Only API *methods* travel. After importing into a destination environment, an operator must re-create the keys there and re-grant their method scopes via the admin UI or CLI.
- Move **site-scoped configuration** (T18): `Site` definitions, site-scoped `DataConnection`s (protocol connections — distinct from External-System `DatabaseConnection`s), and `Instance`s along with their `InstanceAttributeOverride` / `InstanceAlarmOverride` / `InstanceNativeAlarmSourceOverride` / `InstanceConnectionBinding` children and `Area` membership (carried by name).
- Reconcile cross-environment site identifiers and connection names through the **name-mapping subsystem** (`BundleNameMap`): auto-match by identifier/name, operator override via the import-wizard Map step or CLI flags, and per-conflict create-or-bind resolution (see "Name Mapping").
- Compute a **per-line (Myers) diff** for code fields on Modified artifacts (T20) via the pure `LineDiffer`, embedding a size-capped structured line diff in each `ArtifactDiff`. Two independent caps guard the diff: a 400-line **output** cap on the emitted hunk list, and — as of arch-review 05 (Task 22) — a `MaxInputLines` **input** cap (4000 combined lines) that short-circuits the O((N+M)²) Myers trace to a summary-only result (`Truncated = true`, add/remove totals only) so a bloated or crafted bundle cannot OOM the active central node from the import preview.
@@ -72,7 +73,7 @@ zip-bomb guard, and the exporter never emits per-script files.
"summary": {
"templates": 12, "templateFolders": 3, "sharedScripts": 4,
"externalSystems": 2, "dbConnections": 1,
"notificationLists": 1, "smtpConfigs": 0, "apiKeys": 2, "apiMethods": 5,
"notificationLists": 1, "smtpConfigs": 0, "smsConfigs": 0, "apiMethods": 5,
"sites": 2, "dataConnections": 3, "instances": 8
},
"contents": [
@@ -141,7 +142,7 @@ The component is central-hosted. It is registered in `ZB.MOM.WW.ScadaBridge.Host
### UI — 4-Step Wizard (Design nav group)
**Step 1 — Select artifacts.** Templates are rendered as a tree matching the existing Templates page (the `TemplateFolderTree.razor` shared component, used in its new checkbox-selection mode). Tri-state checkboxes on folders (`☑` all, `☐` none, `▣` partial). Search filters the tree in place. Other artifact groups (shared scripts, external systems, notification lists, SMTP configs, API keys, API methods) are flat checkbox lists.
**Step 1 — Select artifacts.** Templates are rendered as a tree matching the existing Templates page (the `TemplateFolderTree.razor` shared component, used in its new checkbox-selection mode). Tri-state checkboxes on folders (`☑` all, `☐` none, `▣` partial). Search filters the tree in place. Other artifact groups (shared scripts, external systems, notification lists, SMTP configs, SMS configs, API methods) are flat checkbox lists. API keys are not offered — they are not transportable (see Scope).
A **Sites & Instances** section (T18) adds a flat list of sites, each row expandable to its instances; selecting a site or individual instances pulls them (and their site-scoped `DataConnection`s) into the bundle. The wizard distinguishes operator-seeded selections from artifacts auto-included by dependency resolution (e.g., an instance's site and bound connections).
@@ -151,7 +152,7 @@ A **Sites & Instances** section (T18) adds a flat list of sites, each row expand
- `Template` references `SharedScript` (by name) → include the script.
- `Template` references `ExternalSystem` → include the definition and its methods.
- `ApiMethod` references `SharedScript` → include the script.
- `NotificationList` references `SmtpConfiguration` → include the SMTP config.
- `NotificationList` does **not** pull in an `SmtpConfiguration`. SMTP (and SMS) configurations are environment-specific and are only ever included when the operator selects them explicitly — the resolver walks `ExportSelection.SmtpConfigurationIds` alone.
- Any folder containing a selected template is included so the structure is reproducible on import.
The user can toggle "include all dependencies" off (with a warning that the bundle may produce an invalid import).
@@ -213,7 +214,9 @@ Authorization: `RequireDesign` on both the Razor page and `IBundleExporter.Expor
## Import Flow
### UI — 6-Step Wizard (Admin nav group)
### UI — 5-Step Wizard (Admin nav group)
Upload → Passphrase → Diff → Confirm → Result. The **Map** step is not a sixth pill: name mapping is a section rendered inside the Diff step.
**Step 1 — Upload.** Drag-and-drop or browse. On selection, the manifest is parsed and displayed (source env, exporter, timestamp, content count, SHA-256, encrypted yes/no). The manifest hash is validated against the `content` blob.
@@ -371,7 +374,7 @@ Three commands surface the same Transport operations as the Central UI wizards,
```bash
scadabridge bundle export --output FILE --passphrase X [--all | --templates A,B ...] \
[--shared-scripts ...] [--external-systems ...] [--db-connections ...] \
[--notification-lists ...] [--smtp-configs ...] [--api-keys ...] \
[--notification-lists ...] [--smtp-configs ...] [--sms-configs ...] \
[--api-methods ...] [--sites A,B ...] [--instances X,Y ...] \
[--include-dependencies] [--source-environment NAME]
@@ -446,7 +449,7 @@ The `manifest.json` file is always present in the ZIP root and is never encrypte
"dbConnections": 1,
"notificationLists": 1,
"smtpConfigs": 0,
"apiKeys": 2,
"smsConfigs": 0,
"apiMethods": 5,
"sites": 2,
"dataConnections": 3,