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:
@@ -0,0 +1,292 @@
|
||||
# Central UI cleanup sweep (2026-08-11)
|
||||
|
||||
Applies the family admin-UI cleanup playbook (`../scadaproj/admin_ui_cleanup.md`) to the
|
||||
ScadaBridge Central UI. That playbook was distilled from two completed rounds — the ignitionoee
|
||||
router and the OtOpcUa AdminUI — both on the same ZB.MOM.WW.Theme 0.3.1 + Bootstrap 5.3 base.
|
||||
Same *classes* of defect, different instances per app; the discovery phase below was run before
|
||||
anything was changed.
|
||||
|
||||
Surface: `src/ZB.MOM.WW.ScadaBridge.CentralUI/` (99 `.razor` files, 43 routable pages) plus
|
||||
`src/ZB.MOM.WW.ScadaBridge.Host/Components/App.razor`.
|
||||
|
||||
## 0. Where ScadaBridge diverges from the playbook's premises
|
||||
|
||||
Recorded first because four of the playbook's assumptions are wrong for this repo, and acting on
|
||||
them unverified would have produced damage rather than cleanup.
|
||||
|
||||
| Playbook premise | ScadaBridge reality |
|
||||
|---|---|
|
||||
| "Theme 0.3.1 is light-only — don't chase dark mode." | **Inverted.** This app implements dark mode itself: `site.css` carries a `[data-bs-theme="dark"]` token override block and `App.razor` has a pre-paint inline script reading `localStorage['sb-theme']`. Hard-coded colours and phantom tokens are therefore *worse* here, not moot. |
|
||||
| "Check whether the scoped-CSS bundle is linked — OtOpcUa's was dead." | **CLEAN**, verified on disk rather than assumed. The RCL bundle carries all 8 scope ids and the Host bundle's entire content is the `@import` of it; `Program.cs` calls `MapStaticAssets()`. Adding a manual `<link>` would double-apply. |
|
||||
| "Neither app has bUnit; the final gate is a live browser pass." | **Wrong for this repo** — `tests/…CentralUI.Tests` references bUnit, and several tests render components. Page-level tests are still source-scan guards that grep `.razor` text. |
|
||||
| "The theme's `panel`/`panel-head` shell." | Not used here at all (`grep -c panel-head` = 0). The idiom is Bootstrap `card`/`card-header`/`card-body`. |
|
||||
|
||||
## 1. Discovery findings
|
||||
|
||||
### 1a. Foundation / CSS audit
|
||||
|
||||
- **Phantom `var(--…)` tokens: CLEAN, zero.** All 25 token usages resolve. The two that look
|
||||
undefined (`--audit-col-width`, `--tv-depth`) are set at runtime from
|
||||
`AuditResultsGrid.razor.cs` / `audit-grid.js` and `TreeView.razor`. No `var(--x, fallback)`
|
||||
masks a phantom.
|
||||
- **Ghost CSS classes: 26 distinct / 39 occurrences, but only ONE loses intent.**
|
||||
`form-label-sm` (×6, `SecuredWrites.razor:44,54,65,76,80,89`) — Bootstrap ships `.form-label`
|
||||
and `.col-form-label-sm` but not `.form-label-sm`, so the intended smaller label silently
|
||||
no-opped next to `form-select-sm`/`form-control-sm` controls. The other 25 are inert naming
|
||||
hooks whose geometry lives in an inline `style=` on the same element or is driven by JS interop.
|
||||
- **Button sizing.** Theme 0.3.1 ships no `.btn` rule, so Bootstrap's hard-coded
|
||||
`--bs-btn-font-size: 1rem` applied against the kit's 0.9rem body. 316 `.btn` elements: 244
|
||||
explicit `btn-sm`/`btn-lg`, 22 sized by an enclosing `btn-group-sm`, **50 full-size** —
|
||||
concentrated in the Transport wizard feet (18) and the shared form-shell Save/Cancel feet (15).
|
||||
13 further buttons carry `py-0 px-*` micro-sizing hacks, a symptom of the same gap.
|
||||
- **Hard-coded colours — one HIGH.** `MainLayout.razor:6` passed `Accent="#2f5fd0"` to
|
||||
`ThemeShell`. The kit emits that parameter as `style="--accent: …"` on the shell root, which is a
|
||||
descendant of `<html>` and therefore **beat the `[data-bs-theme="dark"]` override for the entire
|
||||
app — the dark accent was dead**, affecting the brand mark, the active rail-link border and
|
||||
`.rail-btn`. And it was a no-op in light, because `#2f5fd0` is already theme.css's own light
|
||||
default. Also MEDIUM: `AlarmTriggerEditor.razor:281-283` baked Bootstrap light subtle tints into
|
||||
a deadband-preview SVG, rendering as bright pastel bars on the dark page.
|
||||
- The playbook's repeated inline `width:18px` expander hack is essentially **absent** — the four
|
||||
named tree components size their expanders by class. One hit only, `Dialogs/TreeRow.razor:10`.
|
||||
|
||||
### 1b. Button inventory
|
||||
|
||||
**Four of the prior apps' defect classes simply do not exist here:** zero `·` action
|
||||
separators, zero `<a href="#">` actions, zero inline `style=` button sizing hacks, zero
|
||||
navigation-interceptor "must be a button" comments. And `btn-group` is not zero — 6 correct groups
|
||||
already exist and served as the exemplars.
|
||||
|
||||
Real inventory: ~45 grouping candidates (15 dialog feet, 15 form feet, 2 filter bars, 3 pagers,
|
||||
9 toolbars, ~14 row-action cells); **4** genuine link→action conversions; 4 `btn-link` Back buttons
|
||||
against 7 siblings already using `btn-outline-secondary btn-sm` (three idioms for one affordance);
|
||||
~26 inter-button spacers to drop (and 12 lookalikes that must be kept); exactly **one** inline
|
||||
arm→confirm flow (`TransportExport.razor:499-518`), restyle-only.
|
||||
|
||||
Biggest levers: `DialogHost.razor` (1 render site, 23 `ConfirmAsync` call sites),
|
||||
`MonacoEditor.razor` (6 files), `SchemaBuilder.razor` (4 files / 7 instances).
|
||||
|
||||
### 1c. Prose inventory
|
||||
|
||||
The UI was already lean: ~12 unconditionally-rendered explainer blocks across 98 files, **zero**
|
||||
rendered milestone labels, **zero** dead links, **zero** wrong config keys, **zero** wrong
|
||||
numerics. 10 DELETE, 4 RELOCATE-then-delete, ~120 KEEP.
|
||||
|
||||
The real defects were in `docs/`, not on the pages — and per the playbook, wrong facts outrank
|
||||
style:
|
||||
|
||||
- `Component-Transport.md` claimed a `NotificationList` pulls in its `SmtpConfiguration`. It does
|
||||
not — `DependencyResolver` only walks `ExportSelection.SmtpConfigurationIds`. The *UI banner* was
|
||||
the correct statement and the doc was wrong.
|
||||
- `Component-Transport.md` listed **API keys as exportable** in five places. They are deliberately
|
||||
not transportable (see the comment on `ExportSelection`), and the shipped **SMS configs** group
|
||||
was missing from the same lists.
|
||||
- `Component-Transport.md` described a "6-Step" import wizard; the UI renders 5 pills, with Map a
|
||||
sub-section inside Diff.
|
||||
- `Component-CentralUI.md` (×4) and `Component-ClusterInfrastructure.md` still said "via
|
||||
ClusterClient" — removed in the gRPC migration's Phase 4.
|
||||
- `Component-Commons.md` omitted `Expression` from `AlarmTriggerType`.
|
||||
- The HiLo **partial-merge vs whole-replace** override rule existed only in a June plan doc, never
|
||||
in `docs/requirements/`.
|
||||
|
||||
Plus one vestigial UI field: `SiteForm.razor` collects a per-site **Akka Address** that has had no
|
||||
runtime consumer since gRPC Phase 4 — only writers remain; every central→site dial resolves
|
||||
`GrpcNodeAAddress`/`GrpcNodeBAddress`.
|
||||
|
||||
#### Out-of-scope doc drift found while verifying the above (NOT fixed here)
|
||||
|
||||
Chasing the UI prose's citations surfaced a wider ClusterClient de-drift job that this sweep
|
||||
deliberately did **not** take on — it is a docs initiative, not a UI cleanup, and silently
|
||||
expanding into it would have made this change unreviewable. Recorded so it does not get lost:
|
||||
|
||||
- `docs/requirements/HighLevelReqs.md:49,51,52,405,523` still describes
|
||||
**ClusterClient/ClusterClientReceptionist as the live command/control transport**, including
|
||||
"Central creates a ClusterClient per site using both Akka addresses as contact points". All of
|
||||
that went in the migration's Phase 4.
|
||||
- `docs/requirements/Component-ManagementService.md` **contradicts itself**: `:27` says the
|
||||
ManagementActor is "**not** advertised via ClusterClientReceptionist", while `:16`, `:21`, and
|
||||
the whole `:48` "ClusterClientReceptionist Registration" section say it registers and that
|
||||
failover routes through ClusterClient. `:272` and `:278` repeat the claim.
|
||||
- `docs/requirements/Component-NotificationOutbox.md:31` labels the site→central store-and-forward
|
||||
edge "(ClusterClient)" in its diagram.
|
||||
- `docs/requirements/Component-Commons.md:292` — a namespace-tree comment reading
|
||||
"HTTP/ClusterClient management commands".
|
||||
|
||||
`Component-Host.md` is already correct (it has an explicit REQ-HOST-6a "no
|
||||
ClusterClientReceptionist" section) and is the model for what the others should say.
|
||||
|
||||
### 1d. Density / layout
|
||||
|
||||
The scan that matters most, and the one that fires hardest here.
|
||||
|
||||
- **Unbounded free-text columns — ~30 cells.** Worst: `SecuredWrites.razor:218`
|
||||
(`@row.ExecutionError`, raw device exception text, last of 11 columns, six `text-nowrap`
|
||||
neighbours), `TransportImport.razor:265` (`@item.FieldDiffJson`, externally-produced JSON in the
|
||||
only `<pre>` in the codebase without `pre-wrap`, inside a `colspan="6"` row), and
|
||||
`ConnectionCertificates.razor:63-64` (two adjacent remote X.509 DNs, bare, columns 1–2 of 7).
|
||||
Notable pattern: several rows had already bounded their `LastError` correctly but left a sibling
|
||||
column bare.
|
||||
- **Sections running together — 8 pages.** Worst: `Health.razor:234-434` (five `<h6>` groups in one
|
||||
card body per site) and `TemplateEdit.razor:436` (four heading+table groups in one card body,
|
||||
and that card is emitted *above* the page's own `<h4>` title).
|
||||
- **Identity slam — ~10.** Worst: `Health.razor:208` (name + machine id fused in one `<strong>`),
|
||||
`NotificationLists.razor:77` (recipient name + contact in one chip, uncapped count, one cell).
|
||||
- **Raw slices.** No unguarded ones in markup. Three unguarded on toast paths
|
||||
(`Topology.razor:905,963,964`) plus a parser bug at `ParameterValueForm.razor:382`.
|
||||
- **Detail surfaces holding the row OBJECT rather than its id — 5.** Latent today (none of those
|
||||
pages is on a timer) but wrong, and mandatory to fix before any lands on a timer page.
|
||||
|
||||
Reference pages already doing it right: `Admin/Sites.razor` (stacked identity),
|
||||
`Monitoring/ParkedMessages.razor` (line-clamped error + drawer).
|
||||
|
||||
### 1e. Tree tables — verdict: **REJECT**
|
||||
|
||||
Do not port the router's generic `TreeTable`/`TreeVisibility`. Zero `rowspan`, zero
|
||||
indent-by-padding table rows, zero repeated path-prefix columns, zero faked group-header rows. The
|
||||
only hierarchy-ish shape (dotted composed member names in `InstanceConfigure.razor`) is one column,
|
||||
flat for non-composed instances, carries per-row edit controls, and already has a proper tree
|
||||
presentation in `DebugView.razor`. The six nav/picker trees (`TreeView`, `TemplateFolderTree`,
|
||||
`NodeBrowserDialog`+`TreeRow`, `ExecutionTree`, `SchemaBuilder`) are the "different animal" the
|
||||
playbook says to leave alone. An unused component would be an orphan.
|
||||
|
||||
## 2. Changes made
|
||||
|
||||
### Foundation (landed first — page batches reference it by name)
|
||||
|
||||
`src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/css/site.css` (already loaded *after* `<ThemeHead />`,
|
||||
so cascade order needed no change):
|
||||
|
||||
- The `.btn` / `.btn-sm` **Bootstrap CSS-variable override block**, verbatim from the playbook, with
|
||||
the upstreaming header comment. Normalizes every unsized button app-wide without touching call
|
||||
sites. Deliberately overrides the *variables* only — `btn-group` seam and radius machinery
|
||||
depends on the box properties.
|
||||
- `--accent` moved into `:root` here, and `Accent="#2f5fd0"` **removed** from
|
||||
`MainLayout.razor`'s `ThemeShell`. As a `:root` declaration it loses to the dark block (equal
|
||||
specificity, later in the same file) and wins over theme.css (equal specificity, earlier sheet).
|
||||
Light mode is byte-identical because the value already matched theme.css's own light default.
|
||||
- `.form-label-sm` — the one ghost class that lost intent, given the rule it always implied.
|
||||
- Table-containment utilities the page batches use: `.cell-clip` (+ `-sm` / `-lg` width
|
||||
modifiers), `.cell-clamp-2`, `.detail-pre`. Modelled on the existing `.parked-error-clamp`
|
||||
pattern. **House rule: every clip/clamp is paired with a `title` so the full value stays
|
||||
reachable.**
|
||||
- `AlarmTriggerEditor.razor` deadband SVG fills → `var(--bad-bg)` / `var(--ok-bg)`.
|
||||
|
||||
Docs (landed before the page deletions, so no deletion raced its relocation):
|
||||
`Component-Transport.md` (API keys not transportable + the recovery hint, SMS configs added, the
|
||||
SMTP dependency-edge claim corrected, 6-Step → 5-Step, manifest samples and CLI synopsis),
|
||||
`Component-CentralUI.md` (ClusterClient → gRPC command channel ×4, Akka address marked legacy),
|
||||
`Component-ClusterInfrastructure.md` (ClusterClient → gRPC), `Component-Commons.md`
|
||||
(`AlarmTriggerType.Expression`), `Component-TemplateEngine.md` (the HiLo partial-merge rule).
|
||||
|
||||
### Page batches
|
||||
|
||||
Seven batches on disjoint file sets, each briefed with its own `file:line` inventory and the
|
||||
standing instruction to *verify by reading before editing and refuse items the markup contradicts*.
|
||||
Behavior preserved byte-for-byte throughout: `@onclick`, `disabled`, bindings, `AuthorizeView`
|
||||
gates, `data-test`/`data-testid` hooks, and the arm→confirm two-step.
|
||||
|
||||
| Batch | Files | Highlights |
|
||||
|---|---|---|
|
||||
| 1 — Transport + Ops | SecuredWrites, TransportImport/Export, ConnectionCertificates, TestBindingsDialog | The two worst unbounded cells bounded; 5 prose deletions + the 2 relocated banners; the app's only inline arm→confirm restyled (two-step preserved) |
|
||||
| 2 — Monitoring | Health, EventLogs, ParkedMessages, AlarmSummary | Health per-site rework (see refusal below); identity slam split; EventLogs expander re-keyed off the loop index onto the event GUID; ParkedMessages drawer converted from row-object to row-id + re-resolve |
|
||||
| 3 — Admin + form shells | 6 Admin pages, both endpoint editors, 4 Design forms | SiteForm/DataConnectionForm/ApiMethodForm split into one card per group; the 7-group OpcUaEndpointEditor given `fieldset`/`legend`; Back-link idiom unified; Akka address relabelled legacy |
|
||||
| 4 — Shared + dialogs | DialogHost, MonacoEditor, SchemaBuilder + 14 dialogs | `DialogHost` footer grouped — one edit reaching **23 confirm call sites**; 4 Monaco toolbar links → a real button group across 6 files; 12 dialog feet grouped |
|
||||
| 5 — Reports/Audit/Notifications | SiteCalls, 6 Notifications pages, 2 Audit pages, AuditResultsGrid/FilterBar, Dashboard | Row-action groups (anchor + buttons, destructive red); recipient chip slam capped at 5 + "N more"; **three** object-holding detail surfaces converted to id + re-resolve |
|
||||
| 6 — Deployment + Design | Deployments, InstanceCreate, Topology, TemplateCreate, SchemaLibrary, SharedScriptForm, Templates, DataConnections, AuditLogPage | Deployment identity split; **three unguarded `[..8]` revision-hash slices fixed** (a real latent `ArgumentOutOfRangeException` on the deploy path) |
|
||||
| 7 — The two guard-pinned giants | InstanceConfigure, TemplateEdit | TemplateEdit's inherited-members card moved *below* the page title (ordering bug) and split into four carded groups; all **67** pinned substrings re-verified present, both negative assertions still absent |
|
||||
|
||||
### Refusals worth recording (the briefs were wrong, the agents were right)
|
||||
|
||||
The playbook's instruction to *verify by reading before editing and refuse what the markup contradicts*
|
||||
earned its place four times:
|
||||
|
||||
1. **Health per-site groups (batch 2) — brief premise false.** The brief said "five headings stack flush
|
||||
in one card body" with "metadata paragraphs between heading and table". Neither was true: the headings
|
||||
sit in four `col-md-6` grid columns, three already behind collapse toggles, and no such paragraph
|
||||
exists. Following it literally would have nested two cards inside each of two collapse regions with the
|
||||
toggles floating outside any boundary. The agent restructured to one card per grid column, promoting
|
||||
each existing collapse toggle into a real `card-header`.
|
||||
2. **`ApiMethodForm` "Real I/O" strings (batch 3) — brief would have broken the suite.** The brief said to
|
||||
preserve `"Real I/O"` there. `TestRunWarningTests` asserts the opposite: `ApiMethodForm` must
|
||||
**not** contain it (the Inbound API sandbox has no side-effect surface). Verified against the test.
|
||||
3. **`AuditResultsGrid` Target column (batch 5) — `.cell-clip` was the wrong tool.** `white-space: nowrap`
|
||||
+ `max-width` raises a cell's min-content width, which would stop a *resized* column shrinking below the
|
||||
text width and break drag-to-resize at exactly the narrow widths the feature exists for. Fixed with a
|
||||
character cap instead, mirroring the grid's own `TruncateError`.
|
||||
4. **`dc-kebab` restyle (batch 6) — contextual, not accidental.** The page `<style>` block drives a
|
||||
hover-reveal with `!important` padding and an explicit colour override that `btn-outline-secondary`
|
||||
would fight. It is also the only kebab of 12 living in a hover-reveal tree row.
|
||||
|
||||
Two smaller ones: `DataConnections`' toolbar has just one plain button once the dropdown is excluded, so a
|
||||
`btn-group` there would be a no-op wrapper (no change made); and `NodeBrowserDialog`'s search label was
|
||||
reverted by its batch on discovering two tests selected it by tag — completed afterwards at the
|
||||
orchestration level, where the tests could be updated in the same change.
|
||||
|
||||
**One brief error I introduced and all three affected agents caught independently:** I specified
|
||||
`class="mono"` for stacked machine ids. `.mono` *does* exist (theme.css:117, with `tabular-nums`) — the
|
||||
agents' stated reason was wrong — but the app uses Bootstrap's `font-monospace` everywhere and has zero
|
||||
`mono` usages, so their substitution is the correct outcome for consistency.
|
||||
|
||||
## 3. Verification
|
||||
|
||||
`dotnet build ZB.MOM.WW.ScadaBridge.slnx` → **0 warnings, 0 errors.**
|
||||
|
||||
`dotnet test ZB.MOM.WW.ScadaBridge.slnx` → **8,750 passing**, including
|
||||
**`CentralUI.Tests` 990/990** (the bUnit + source-scan guard suites that pin this UI).
|
||||
|
||||
Two non-issues, both confirmed rather than assumed:
|
||||
|
||||
- **`SiteRuntime.Tests` 1 failure / `Host.Tests` 1 failure** — different tests on different runs, and both
|
||||
projects pass in isolation on re-run (540/540, 473/473). The sweep touched zero files in either project.
|
||||
Flakes.
|
||||
- **`CentralUI.PlaywrightTests` 159 failures — environmental and pre-existing.** `docker/docker-compose.yml`
|
||||
sets `ScadaBridge__Security__Auth__DisableLogin: "true"` on both central nodes (the SEC-36 GLAuth
|
||||
rotation workaround, uncommitted and not part of this change). Every test expecting a login flow fails
|
||||
regardless of this sweep; the visible errors are login-throttle lockouts and a 429.
|
||||
|
||||
### Test selectors updated (markup changed underneath them)
|
||||
|
||||
Folding per-member `btn-sm` into the group class — which the playbook mandates — moves the size class off
|
||||
the button and invalidates locators pinning it. Also the two node-picker labels became buttons. Updated,
|
||||
each with a comment naming this sweep:
|
||||
|
||||
- `TransportExportPageTests.cs` ×2 — assertions on `data-testid`s carried by deleted prose banners. The
|
||||
neighbouring `Assert.Empty(...group-api-keys)` and `Assert.NotNull(...group-sites)` survive and carry the
|
||||
real intent.
|
||||
- `TemplateCrudTests.cs` ×4, `SiteCrudTests.cs` ×4, `LdapMappingCrudTests.cs` ×5, `M9SurfaceTests.cs` ×1 —
|
||||
`button.btn-success.btn-sm` / `btn-outline-danger.btn-sm` → size class dropped.
|
||||
- `NodeBrowserDialogSelectionTests.cs` ×2, `NodeBrowserDialogSearchTests.cs` ×1 — anchor → `button.btn-link`.
|
||||
|
||||
### Merged-tree verification greps
|
||||
|
||||
- `btn-group` count **6 → 43**; **0** groups left with a redundant member `btn-sm` (5 were normalized at merge).
|
||||
- **35 `.cell-clip`/`.cell-clamp-2` usages, 35 carrying a `title`** — the house rule held across all seven batches.
|
||||
- `javascript:void(0)` — **0 remaining** in live markup (2 mentions survive inside comments explaining the removal).
|
||||
- Every pinned guard-test substring present; both negative assertions still absent.
|
||||
|
||||
## 4. Live browser gate
|
||||
|
||||
Rig redeployed via `docker/deploy.sh` — **all 8 nodes, both central instances**, so Traefik round-robin
|
||||
cannot serve a stale one. Both `/health/ready` 200; node-a active.
|
||||
|
||||
| # | Check | Result |
|
||||
|---|---|---|
|
||||
| 1 | Computed-style probes | **PASS** — body 14.4px vs `.btn-sm` 12.48px (0.78rem override live); `.form-label-sm` 12.8px (was a silent no-op); `.cell-clip` block/352px/nowrap/hidden; `.detail-pre` pre-wrap + 300px; scoped bundle **and** site.css both in `document.styleSheets` |
|
||||
| 1a | `.cell-clamp-2` genuinely clamping | **PASS** — computed `display` serializes as `flow-root`, which looked like a silent failure; measured instead: 40px clamped vs 500px unclamped. Working |
|
||||
| 1b | Dark accent (the headline fix) | **PASS** — brand mark `rgb(47,95,208)` light → `rgb(77,127,232)` dark; `--accent-deep` now flips with it (`#1e3f99` → `#7aa0ef`) instead of desyncing. The inline `--accent` on the shell root is gone |
|
||||
| 2 | Every page leads with data | **PASS** — Dashboard leads with KPI tiles; Export Bundle leads with the step indicator + artifact tree; all 10 deleted prose blocks absent |
|
||||
| 3 | Seamed `btn-group`s, destructive red | **PASS** — measured seam gap −1px (overlapping borders); Site Calls row group renders `View audit history │ Retry │ Discard` as one control with Discard red |
|
||||
| 4 | Arm→confirm, then **Cancel** | **PASS** — trigger is now an outline-danger button (was a bare red text link); arming flips it solid and reveals the guarded warning + seamed `Yes, export without encryption │ Cancel`; Cancel disarms, stays on Encrypt, **no export performed** |
|
||||
| 4a | `DialogHost` grouped footer (23 call sites) | **PASS** — Discard on a parked notification opens `Cancel │ Delete` seamed, danger labelling intact, backdrop covering; Cancel closed cleanly with all 31 parked rows intact and **no destructive write** |
|
||||
| 5 | Scoped-bundle resurrections | **N/A** — the bundle was already correctly linked here (discovery 1a-2); nothing to resurrect |
|
||||
| 6 | Legends / hints / empty states still render | **PASS** — ParkedMessages empty state ("Nothing has failed enough to give up on at this site"), the Encrypt-step secret-fields banner, and SiteForm's legacy-address `form-text` all render |
|
||||
| 7 | **Density with realistic faulted data** | **PASS** — exercised against a genuinely unhealthy rig: 31 parked notifications with live error text, 47 stuck site calls, 1 parked call. No horizontal scroll on any page; zero elements overflowing the viewport |
|
||||
| 7a | Clip actually engages under pressure | **PASS** — injecting a 150-char OPC UA node id into a clipped cell truncates it (1084px content in a 352px box) while the table grows only 75px and the viewport never overflows. Uncontained, that value would have pushed the actions column off-screen |
|
||||
| 7b | Sections read as separate cards | **PASS** — Health's three KPI groups and its four per-site groups now have real card boundaries; SiteForm renders as `Edit Site` / `Node A` / `Node B` |
|
||||
| 7c | Identity stacking | **PASS** — Health shows `Test Plant A` with `site-a` in mono beneath (was fused into one `<strong>`) |
|
||||
|
||||
**Not exercised live:** the ParkedMessages drawer re-key — site-a has no parked store-and-forward rows
|
||||
(the 31 parked are central-side notifications). It is covered by the build and by CentralUI.Tests; the
|
||||
equivalent re-key on NotificationReport was exercised through the confirm path above.
|
||||
|
||||
**Rig note:** `docker/docker-compose.yml` carries an uncommitted `DisableLogin: "true"` from the earlier
|
||||
EWS live-gate session (SEC-36). It predates this sweep and was left untouched.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user