33 KiB
Architecture Review 04 — AdminUI (Blazor Server)
- Date: 2026-07-12
- Commit:
f6eaa267(master, clean tree) - Updates: the 2026-07-08 review at
9cad9ed0. Zero AdminUI source files changed between the two commits (git diff 9cad9ed0..f6eaa267 -- src/Server/ZB.MOM.WW.OtOpcUa.AdminUIis empty; the ~40 intervening commits are the arch-review remediation merges in Cluster/Runtime/Core/Drivers, a fixdocs XML-doc sweep, and a HistorianGateway package bump). Every prior finding was re-verified against the code rather than carried over on trust; all line references below re-checked atf6eaa267. - Scope:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI(77 razor components, ScriptAnalysis backend, minimal APIs, SignalR bridges, UNS service layer), plustests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Testsfor coverage assessment. Auth policy definitions live inZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.csand are referenced where the AdminUI consumes them.
Prior-finding status (9cad9ed0 → f6eaa267)
| ID | One-liner | Status @ f6eaa267 |
|---|---|---|
| S-1 (Med) | Three components don't drain in-flight timer callbacks on dispose | STILL OPEN — Fleet.razor:171 and AlarmsHistorian.razor:90 are still public void Dispose() => _timer?.Dispose();; DriverTestConnectButton.razor:68–81 still uses System.Timers.Timer with an async Elapsed lambda + sync dispose |
| S-2 (Med) | Fleet.LoadAsync has no catch; AlarmsHistorian bare catch { } leaves "Loading…" forever |
STILL OPEN — Fleet.razor:120–159 is try/finally with no catch; AlarmsHistorian.razor:73–78 still admits the role-gating message is a TODO |
| S-3 (Med) | No <ErrorBoundary> anywhere |
STILL OPEN — grep -rn ErrorBoundary over the project returns nothing |
| S-4 (Med) | Delete flows re-fetch RowVersion at click time, defeating the concurrency guard | STILL OPEN — GlobalUns.razor:279–326 (Area/Line/Equipment branches) and EquipmentPage.razor:370–377/415–422/460–467 still load fresh + pass the fresh RowVersion ("Load the tag fresh…" comments intact) |
| S-5 (Med) | No confirmation on EquipmentPage/ScriptEdit deletes |
STILL OPEN — EquipmentPage.razor:172/217/263 single-click delete buttons; ScriptEdit.razor:68→140 DeleteAsync with no confirm |
| S-6 (Low) | DriverStatusPanel result-chip timer lacks the stale-fire token guard Alerts has |
STILL OPEN — Alerts.razor:244–257 has the token compare; DriverStatusPanel.razor:293–299 clears unconditionally |
| S-7 (Low) | AdminOperationsClient.AskAsync bypasses the 10 s ask timeout |
STILL OPEN — Clients/AdminOperationsClient.cs:58–59 forwards only the caller's token; the three typed methods still double-guard |
| P-1 (Med) | Monaco pushes the full document to .NET on every keystroke, undebounced | STILL OPEN — wwwroot/js/monaco-init.js:205–208 |
| P-2 (Med) | ScriptAnalysis recompiles from scratch per request, six endpoints deep | STILL OPEN — ScriptAnalysis/ScriptAnalysisService.cs:60–67, no (text → compilation) memoization |
| P-3 (Low) | Per-circuit DB polling on dashboard pages | STILL OPEN — Hosts.razor:229–245 (5 s), Fleet.razor:112 (10 s) |
| P-4 (Low) | Deployments runs a full config snapshot + hash per page load for the drift badge |
STILL OPEN — Deployments.razor:98 SnapshotAndFlattenAsync |
| P-5 (Low) | Alerts rows lack @key while prepending; no <Virtualize> (bounded by design) |
STILL OPEN — Alerts.razor:63–70 <tr> without @key |
| P-6 (Low) | inlay-hints round-trips for a documented no-op |
STILL OPEN — monaco-init.js:99–117 provider active; ScriptAnalysisService.cs:415 returns empty |
| C-1 (High) | Three authorization idioms; the largest mutating surface is bare [Authorize] |
FIXED (R2-05, branch r2/05-adminui-authz) — one idiom now: all 38 routable pages carry an explicit named-policy @attribute via new AdminUiPolicies constants. New ConfigEditor (Administrator+Designer) gates the 20-page config-authoring surface incl. all 8 driver pages (live ResilienceConfig) + /api/script-analysis/*; new AuthenticatedRead on the 16 read pages; FleetAdmin/DriverOperator literals→constants (unchanged semantics). Anti-drift reflection guard PageAuthorizationGuardTests + AdminUiPoliciesTests semantics matrix (both green). Two classification corrections vs. this row: Reservations and ClusterRedundancy are read-only (→AuthenticatedRead, not ConfigEditor); Home carried no attribute at all (now AuthenticatedRead). Offline gate green; docker-dev live positive/regression pass deferred (auto-admin can't observe deny). |
| C-2 (Med) | CLAUDE.md said ScriptAnalysis was FleetAdmin-gated; code uses Roles="Administrator,Designer" |
FIXED — docs branch 9fadead6 (merged to master via b67bd9e8) corrected CLAUDE.md, which now reads "gated to the Administrator,Designer roles via RequireAuthorization" (CLAUDE.md:223); code unchanged and still correct |
| C-3 (Good) | Driver-typed tag-editor pattern complete and consistent | STILL HOLDS — map/validator/editors unchanged |
| C-4 (Good) | Numeric-enum serialization bug class closed and test-pinned | STILL HOLDS — pages + *FormSerializationTests unchanged |
| C-5 (Med) | Two data-access idioms: service seam vs EF-in-page | STILL OPEN — ScriptEdit.razor:87/109/132/146/151, Scripts.razor:60, Deployments.razor:83, Fleet.razor:125, Hosts.razor:273 still run EF directly in @code |
| C-6 (Low) | Naming/marker nits ("GalaxyMxGateway" literal, DataTypes duplication, casing drift) |
STILL OPEN — TagModal.razor:98/262/275 etc. unchanged |
| U-1 | Strong C# seam tests, zero component rendering coverage | STILL OPEN — still exactly 443 [Fact]/[Theory] cases, still no bUnit (documented repo posture) |
| U-2 | Stub/known-gap behaviors (InlayHints no-op, AlarmsHistorian TODO, MarkersChanged untyped, GlobalUns not-implemented message) |
STILL OPEN — all four verified in place |
| U-3 | Raw-JSON fallback textarea unvalidated for well-formed JSON | STILL OPEN — TagModal.razor:130–136 unchanged |
| U-4 | Accessibility gaps (tree expander aria, modal focus trap, live regions) | STILL OPEN — unchanged |
New findings this pass: C-7 (ResilienceFormModel lossy round-trip) and U-5 (resilience runtime status has no AdminUI reader) — both on the driver resilience-override surface, which existed at 9cad9ed0 but was not covered by the prior review and became load-bearing since: the remediation merges (#10 bacea1a4, #13 75403caa) wired CapabilityInvoker + per-instance ResilienceConfig into production dispatch, so what this form emits now changes runtime retry/breaker behavior.
Architecture Overview
Packaging and mounting
The AdminUI is a Razor Class Library, not an app: the fused Host calls AddAdminUI() / MapAdminUI<TApp>() (EndpointRouteBuilderExtensions.cs:23–63). All pages render InteractiveServer. Routes.razor uses AuthorizeRouteView (fixing the historical Admin-001 inert-attribute bug) with login redirect + a permission-denied slot. MainLayout delegates to the shared ZB.MOM.WW.Theme ThemeShell chassis.
Page map
| Area | Pages |
|---|---|
| Dashboards | Home, Fleet (per-node deploy state, 10 s poll), Hosts (Akka topology + driver health, 5 s poll + event push), AlarmsHistorian (sink status, 5 s poll) |
| Live tails | Alerts (alarm transitions + DriverOperator Ack/Shelve), ScriptLog |
| Config: clusters | ClustersList, ClusterEdit/Overview/Namespaces/Acls/Audit/Redundancy/Drivers, NodeEdit, NamespaceEdit, AclEdit, NewCluster, 8 driver pages behind DriverEditRouter + DriverTypePicker (each embedding DriverResilienceSection for per-instance resilience overrides) |
| Config: UNS | GlobalUns (tree + Area/Line modals + paste-CSV import), EquipmentPage (tabbed Details/Tags/VirtualTags/Alarms with TagModal/VirtualTagModal/ScriptedAlarmModal) |
| Scripting | Scripts, ScriptEdit (Monaco), ScriptLog |
| Ops/admin | Deployments, Certificates, Reservations, RoleGrants, Account, Login |
API surface
POST /api/deployments(Api/DeployApiEndpoints.cs) — headless deploy trigger;AllowAnonymousbut self-gated onSecurity:DeployApiKeyviaX-Api-Keywith fixed-time compare, 503 when unconfigured. Clean, well tested.POST /api/script-analysis/{diagnostics,completions,hover,signature-help,format,inlay-hints}(ScriptAnalysis/ScriptAnalysisEndpoints.cs) — Roslyn-backed Monaco language services, gatedRoles = "Administrator,Designer"(CLAUDE.md now matches — prior C-2 fixed).- SignalR hubs
/hubs/{alerts,driverstatus,fleetstatus,scriptlog}— all[Authorize]; retained for out-of-process clients only.
State / broadcast patterns
The load-bearing pattern is DPS topic → per-node bridge actor → in-process singleton → component event subscription:
AlertSignalRBridge/ScriptLogSignalRBridgepublish toIInProcessBroadcaster<T>(stream feeds, withIsConnectedhealth for the "live" pill).DriverStatusSignalRBridgefeedsIDriverStatusSnapshotStore(last-value feed).- Components subscribe to the singleton's .NET events and marshal via
InvokeAsync; no component anywhere opens a server-sideHubConnectionto its own hub — the known forbidden pattern is documented inIInProcessBroadcaster.cs:9–15and honored consistently across all four live surfaces (Alerts,ScriptLog,DriverStatusPanel,Hosts).
Data access is IDbContextFactory<OtOpcUaConfigDbContext> per call. UNS goes through a real service seam (Uns/UnsTreeService.cs, 1531 lines, scoped, RowVersion-aware). Driver browse uses a singleton BrowseSessionRegistry + BrowseSessionReaper (2 min idle TTL, 30 s tick, dispose-outside-dictionary) + per-call 20 s timeouts — a tidy lifecycle design.
1. STABILITY
S-1 (Medium) — Timer disposal is inconsistent; three components don't drain in-flight callbacks
The codebase's own best practice (stated in comments) is System.Threading.Timer + await DisposeAsync() so an in-flight tick can't call StateHasChanged on a disposed component. Hosts.razor:348–355, Alerts.razor:291–299, and DriverStatusPanel.razor:301–310 do this correctly. Three components don't:
Components/Pages/Fleet.razor:171—public void Dispose() => _timer?.Dispose();(sync, no drain).Components/Pages/AlarmsHistorian.razor:90— same.Components/Shared/Drivers/DriverTestConnectButton.razor:68–81— usesSystem.Timers.Timerwith anasync (_, _)Elapsedhandler (line 69; effectively async-void) and a syncDispose, directly contradicting the comment convention inAlerts.razor:242–243.
Recommendation: converge on the IAsyncDisposable + Threading.Timer idiom in all three; it is already the documented house pattern.
S-2 (Medium) — Fleet.razor auto-refresh has no error handling; DB faults become unobserved task exceptions
Fleet.razor:112 schedules _ = InvokeAsync(LoadAsync); LoadAsync (Fleet.razor:120–159) has try/finally but no catch — a transient SQL failure faults the discarded task silently and the page shows stale data with no error surface. Contrast Hosts.razor:269–287, which catches, logs, and degrades gracefully. AlarmsHistorian.razor:73–78 has the opposite problem: a bare catch { } that leaves the page on "Loading…" forever (the comment admits the role-gating message is a TODO).
Recommendation: copy the Hosts.LoadConfigAsync catch-log-degrade shape into Fleet.LoadAsync; give AlarmsHistorian an explicit "no historian on this node role" state.
S-3 (Medium) — No <ErrorBoundary> anywhere
Routes.razor / MainLayout.razor wrap nothing in ErrorBoundary; a single unhandled exception in any event handler or render tears down the entire circuit to the framework error UI. For a 77-component operations console this is a coarse failure mode.
Recommendation: wrap @Body in MainLayout in an ErrorBoundary with a recover button; consider per-panel boundaries around the live-tail surfaces.
S-4 (Medium) — Delete flows re-fetch RowVersion at click time, defeating the optimistic-concurrency guard
UnsTreeService implements RowVersion concurrency rigorously (22 OriginalValue sites, 10 DbUpdateConcurrencyException handlers), and the edit paths carry the RowVersion loaded when the modal opened — correct. But the delete paths load a fresh DTO immediately before deleting and pass its current RowVersion:
Components/Pages/Uns/GlobalUns.razor:279–326(ConfirmDeleteAsync— Area/Line/Equipment branches, e.g. line 296).Components/Pages/Uns/EquipmentPage.razor:370–377, 415–422, 460–467(DeleteTag/DeleteVirtualTag/DeleteAlarm).
The guard therefore always passes: a concurrent edit between rendering the row and clicking Delete is silently destroyed. The comments ("Load the tag fresh to capture its current RowVersion") show this is deliberate but it makes delete a last-writer-wins operation while updates are first-writer-wins — an inconsistent contract.
Recommendation: carry the RowVersion from the rendered row/DTO into the delete call (the list DTOs would need to carry it), or document delete as intentionally unconditional.
S-5 (Medium) — Destructive actions on EquipmentPage and ScriptEdit have no confirmation
GlobalUns has a proper delete-confirm modal (GlobalUns.razor:71–98) and DriverStatusPanel has an inline Restart confirm (DriverStatusPanel.razor:119–131). But EquipmentPage.razor:172/217/263 delete a tag/virtual-tag/alarm on a single click, and ScriptEdit.razor:68 (DeleteAsync, line 140) deletes a script the same way.
Recommendation: reuse the GlobalUns confirm-modal pattern (or a shared ConfirmButton component) for all deletes.
S-6 (Low) — DriverStatusPanel result-chip timer lacks the stale-fire token guard Alerts added
Alerts.razor:244–257 guards against a disposed-but-already-queued timer callback clearing a newer result via a token compare. DriverStatusPanel.razor:287–299 has the identical construct without the guard. Harmless-looking but the race the Alerts comment describes applies equally here.
S-7 (Low) — AdminOperationsClient.AskAsync bypasses the 10 s ask timeout
Clients/AdminOperationsClient.cs:58–59 — the generic AskAsync (used by DriverStatusPanel Reconnect/Restart) forwards only the caller's token; the three typed methods double-guard with AskTimeout. Callers do pass a 15 s CTS today, so this is latent, but the seam's contract is inconsistent.
Positive stability notes
- The forbidden self-
HubConnectionpattern is absent; every live surface reads the in-process singleton, and the rationale is documented at the seam. - Zero
async voidin the project (the one async event-handler lambda is S-1'sSystem.Timerscase). - Event-handler hygiene is excellent: subscribe-before-read TOCTOU ordering (
Alerts.razor:154–159), unsubscribe-before-timer-drain dispose ordering, capture-then-invoke inInProcessBroadcaster.SetConnected(IInProcessBroadcaster.cs:86–103). AdminOperationsActoraccess is exclusively viaAskthrough the singleton proxy — no shared mutable state in the AdminUI layer itself; the snapshot store and broadcaster are the only singletons and both are lock/ConcurrentDictionary-guarded.MonacoEditor.razordisposes cleanly and distinguishesJSDisconnectedException(silent) from realJSException(logged).- No
MarkupString/raw-HTML rendering anywhere — no XSS-via-render surface. The CSV import is paste-textarea (ImportEquipmentModal.razor:30–32), so upload-size abuse is bounded by the SignalR message limit rather than an unbounded file stream.
2. PERFORMANCE
P-1 (Medium) — Monaco pushes the full document to .NET on every keystroke
wwwroot/js/monaco-init.js:205–208: onDidChangeModelContent → invokeMethodAsync("OnValueChanged", editor.getValue()) with no debounce — the entire script text crosses the SignalR circuit per keystroke, and ValueChanged.InvokeAsync re-renders the parent (ScriptEdit, virtual-tag modal). Diagnostics are correctly debounced at 500 ms, but the value sync is not.
Recommendation: debounce the value push (~200–300 ms) or send it only on blur/save/diagnostic-tick; the .NET side only needs the value for Save and the inline problems panel.
P-2 (Medium) — ScriptAnalysis recompiles from scratch on every request, six endpoints deep
ScriptAnalysis/ScriptAnalysisService.cs:60–67: each call to any endpoint re-parses and creates a fresh CSharpCompilation; Diagnose additionally runs full GetDiagnostics(). A typing user triggers diagnostics (500 ms debounce) + completions + hover + signature-help against the same text, each paying a full compile. Static caching of the sandbox references and preamble is already done (good); there is no memoization of (text → tree/compilation).
Recommendation: add a small LRU (even size-1 keyed on the normalized text) shared by the endpoints; this collapses the common completions-after-diagnostics case. Fine at single-admin scale — flag before multi-user rollout.
P-3 (Low) — Per-circuit DB polling on the dashboard pages
Hosts.razor:229–245 reloads ClusterNodes + DriverInstances from SQL every 5 s per open circuit; Fleet.razor runs a GroupBy over NodeDeploymentStates every 10 s. Driver health itself is event-pushed (good) — only the config enrichment polls. Acceptable today; a shared IMemoryCache (or reacting to the deploy event) would decouple cost from viewer count.
P-4 (Low) — Deployments.razor:98 runs ConfigComposer.SnapshotAndFlattenAsync (full config snapshot + hash) on every page load and after every deploy, purely to compute the drift badge. On a large fleet this is the page's dominant cost.
P-5 (Low) — No <Virtualize> anywhere; lists are instead bounded by design (Alerts cap 200, Deployments Take(50), cluster-scoped tables). Adequate. Two nits: Alerts.razor:63 renders rows without @key while prepending (Insert(0, …)), forcing whole-table diffs per event (EquipmentPage tables do use @key); the UnsTree filter re-runs the recursive VisibleUnder walk per render (self-documented as bounded/cheap — agreed).
P-6 (Low) — inlay-hints round-trips for a documented no-op
monaco-init.js:99–117 registers an inlay-hints provider that POSTs on every model change; ScriptAnalysisService.InlayHints (ScriptAnalysisService.cs:415) always returns empty. Skip registering the provider until the feature exists.
3. CONVENTIONS
C-1 (High) — Three authorization idioms coexist, and the largest mutating surface is effectively ungated
(Re-verified at f6eaa267 by enumerating every @attribute [Authorize…] in Components/Pages/ — unchanged. STATUS.md confirms no remediation branch targeted this; it was action-list item 5, unscheduled.)
Observed gates:
| Idiom | Where |
|---|---|
Roles = "Administrator,Designer" |
Deployments.razor:12, Scripts.razor:2, ScriptEdit.razor:5, ScriptAnalysisEndpoints.cs:18 |
Policy = "FleetAdmin" |
RoleGrants.razor:2 (page), Certificates.razor:90,186 (per-action) |
Policy "DriverOperator" (imperative) |
Alerts.razor:165, DriverStatusPanel.razor:166, OpcUaClientAddressPickerBody.razor:75, GalaxyAddressPickerBody.razor:124 |
bare [Authorize] |
everything else — GlobalUns, EquipmentPage, ClusterEdit (+ all cluster sub-pages), NodeEdit, NamespaceEdit, AclEdit, NewCluster, all 8 driver pages + DriverEditRouter/DriverTypePicker, Reservations, Certificates (page level) |
Consequence: any authenticated user — including a read-only viewer — can create/edit/delete areas, lines, equipment, tags, virtual tags, scripted alarms, driver instances, ACLs, and namespaces; only the deploy button is role-gated. Project memory says global-roles/simplest-authz is a deliberate posture, but the current state isn't "simplest" — it's three idioms plus an unguarded majority, which is exactly the drift that produces surprises later. Note the stakes rose since the prior review: driver pages now author ResilienceConfig that (post-#13, 75403caa) directly shapes production retry/breaker behavior — still behind bare [Authorize].
Recommendation: centralize policy names as constants (they exist only as string literals today), pick policies over role strings (FleetAdmin already means RequireRole("Administrator")), and apply one write-gate (Administrator,Designer equivalent) to every config-mutating page. This is a one-attribute-per-page change.
C-3 (Good) — Driver-typed tag-editor pattern is complete and consistent
Uns/TagEditors/TagConfigEditorMap.cs:10–20 and TagConfigValidator.cs:12–22 cover all seven non-Galaxy drivers (Modbus, S7, AbCip, AbLegacy, TwinCat, Focas, OpcUaClient) with parallel keys; Galaxy intentionally uses the live-browse picker + raw {"FullName": …} JSON (TagModal.razor:96–125). Every editor follows the same shell-over-pure-model shape (FromJson/ToJson/Validate, preserve-unknown-keys), with the parse-only-on-real-change guard against parent re-renders (ModbusTagConfigEditor.razor:52–57). The composable root-key seams (TagHistorizeConfig, TagArrayConfig, NativeAlarmModel) merge onto the same JSON without clobbering — a genuinely good design.
C-4 (Good) — The numeric-enum serialization bug class is closed
All 8 driver pages carry JsonStringEnumConverter in their serializer options; tag editors bind enums as strings via Enum.GetValues/TryParse; and the fix is pinned by per-driver *FormSerializationTests plus DriverPageJsonConverterTests in the test project — regression-proofed, not just patched.
C-5 (Medium) — Two data-access idioms: service seam vs. EF-in-page
UNS pages go through IUnsTreeService (testable, RowVersion-aware, 12 test files). But ScriptEdit.razor:87–156, Scripts.razor:60, Deployments.razor:83–100, Fleet.razor:125, and Hosts.razor:273 inject IDbContextFactory and run EF (including entity mutation and SaveChangesAsync) directly in @code. The service-seam pages are the ones with meaningful test coverage; the EF-in-page logic is untestable without a host. New pages copy whichever file is nearest.
Recommendation: declare the service-seam the convention; migrate ScriptEdit's save/delete into the existing UnsTreeService script methods (UpdateScriptSourceAsync already exists at IUnsTreeService.cs:502).
C-6 (Low) — Naming/marker nits
"GalaxyMxGateway" is a bare string literal in TagModal.razor (comments/branching at lines 98, 262, 275 and the dispatch site); the DataTypes array (TagModal.razor:244) duplicates type lists that exist elsewhere; casing drifts across TwinCat (map key) / TwinCATTagConfigEditor / FOCASAddressPickerBody / FocasTagConfigEditor. CSS is disciplined (one site.css + shared theme kit; chip/panel classes reused consistently, chip-warn vs chip-caution both exist — verify both are defined in the theme).
C-7 (Medium, NEW) — ResilienceFormModel round-trip is lossy: malformed JSON silently discarded on next edit, unknown keys stripped, and the promised raw-text view never landed
The per-driver resilience override editor (Components/Shared/Drivers/DriverResilienceSection.razor, embedded in all 8 driver pages via @bind-ResilienceConfig) violates the preserve-unknown-keys convention its sibling tag editors (C-3) established:
ResilienceFormModel.FromJson(ResilienceFormModel.cs:52–74) maps only the known top-level fields and only capability keys present in the fixedCapabilitieslist (line 67TryGetValuedrops the rest);ToJson(:78–101) emits only the model's own fields. Any unknown top-level key or unrecognized capability name in the stored JSON is silently stripped the moment a user edits any resilience field (EmitAsync,DriverResilienceSection.razor:60–66, replaces the bound value withToJson()).- Worse for the malformed case:
FromJsoncatchesJsonExceptionand returns an empty form (ResilienceFormModel.cs:59), with the comment "malformed -> empty form; raw view (next task) shows the text". That task never landed — the "Raw JSON (advanced)" pane (DriverResilienceSection.razor:37–40) is a read-only<pre>of_m.ToJson(), i.e. the re-serialized model, not the stored text. So a malformed/hand-editedDriverInstance.ResilienceConfigrenders as an apparently-blank form, and the first keystroke in any resilience field overwrites the stored override with the empty model's output — silent data loss with no indication the original existed.
This mattered less when the column was write-only; since #13 (75403caa) the artifact read path applies these overrides to production dispatch, so a silently-emptied override silently reverts a driver to tier defaults.
Recommendation: adopt the tag-editor round-trip discipline (deserialize to JsonObject, overlay known fields, preserve the rest), surface a "stored JSON could not be parsed — editing will replace it" warning when FromJson falls back, and make the raw pane show (or edit) the actual stored text. ResilienceFormModelTests.cs exists — extend it to pin unknown-key preservation.
4. UNDERDEVELOPED AREAS
U-1 — Test coverage: strong C# seams, zero component rendering coverage
443 [Fact]/[Theory] cases (unchanged since the prior review) covering: all tag-config models + validator, ResilienceFormModel, UnsTreeService (structure/tags/vtags/import/equip-token/script CRUD against a test DB), browse session service/registry/reaper, CertificateStoreManager, audit writers, DeployApiEndpoints auth matrix, driver-page form serialization (the enum bug class), 8 ScriptAnalysis suites (diagnose/completions/hover/format/tag-path/equip-token), InProcessBroadcaster, HostsDriverView, address builders. That is a deliberately shaped portfolio around the pure seams.
There is no bUnit — 77 razor components have no render/binding coverage, which is the documented repo posture ("no bUnit — live-verify"; the @-binding bug class passes all unit tests). The consequence is that everything in @code blocks that isn't extracted (e.g. all of EquipmentPage's tab/delete handlers, GlobalUns's modal orchestration, Alerts' row-action state machine) is verified only by manual /run sessions.
Recommendation: either adopt bUnit for the modal/table state machines, or keep extracting page logic into plain classes (the HostsDriverView/VirtualTagModalHelpers precedent) so the untested residue in @code stays trivial.
U-2 — Stub/known-gap behaviors
InlayHintsis a wired no-op endpoint + active client provider (P-6); documented inScriptAnalysisService.cs:22–25.AlarmsHistorian.razor:73–78— admits the missing role-gated "no historian on this node" message; page silently shows "Loading…" forever on admin-only nodes.MonacoEditor.razor:33–37—MarkersChangedis typedobject[]with a comment that the DTO "is not modelled yet".GlobalUns.ConfirmDeleteAsyncdefault branch returns "Delete for this node kind is not yet available." for Tag/VirtualTag nodes (GlobalUns.razor:~324) — consistent with equipment-page ownership, but the message leaks a not-implemented posture into the UI.DriverResilienceSection.razor:59— the "raw view (next task)" comment inResilienceFormModel.csdocuments a follow-up that never shipped (see C-7).
U-3 — Raw-JSON fallback surfaces
TagModal.razor:130–136 falls back to a schemaless textarea for unmapped drivers ("Validated server-side at deploy" — i.e., no client validation), and the Galaxy path allows direct JSON editing beside the picker. With all current drivers mapped (C-3), the fallback today only serves Galaxy + future drivers, but it remains the escape hatch through which invalid configs reach deploy time. _form.TagConfig is [Required] yet never checked for well-formed JSON in the fallback path.
Recommendation: at minimum, JsonDocument.Parse the fallback textarea in SaveAsync before submitting.
U-4 — Accessibility
~31 aria-*/role= attributes across 77 components, concentrated in the modals (role="dialog", btn-close aria-labels). Gaps: text-glyph expander buttons (▼/▶ in UnsTree.razor:49) with no aria-expanded/label; modals have no focus trap or Escape handling; live tails (Alerts) have no aria-live region; tables lack captions/scope. Form labeling is decent (most inputs have for/id pairs). Acceptable for an internal ops tool; worth a targeted pass on the tree and the modals if operator diversity matters.
U-5 (NEW) — Resilience runtime status has no AdminUI surface; overrides are authored blind — ✅ REMEDIATED (R2-10)
Remediated on branch r2/10-resilience-observability: DriverResilienceStatusTracker now flows to the AdminUI through exactly the recommended idiom — a per-node DPS bridge (DriverResilienceStatusBridge) → in-process store (IDriverResilienceStatusStore, registered beside IDriverStatusSnapshotStore in AddOtOpcUaDriverStatusServices) → a resilience section on DriverStatusPanel.razor (embedded on all 8 driver pages, right where the ResilienceConfig overrides are authored — closing the feedback loop). It renders breaker-OPEN / consecutive-failures / in-flight / staleness chips, read in-process (self-HubConnection ban honored). The driver-node feed is a periodic 5 s full-snapshot publisher (DriverResilienceStatusPublisherService) with a wiring guard. Live gate (T10) — docker-dev breaker-open on both centrals + staleness — pending. (Original finding below.)
The remediation merges made per-driver resilience live in production: #10 (bacea1a4) wired CapabilityInvoker into every DriverInstanceActor dispatch site, and #13 (75403caa) applies the AdminUI-authored ResilienceConfig overrides from the deploy artifact. But DriverResilienceStatusTracker (src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceStatusTracker.cs) still has no reader — no AdminUI component, hub, or endpoint consumes it (grep -rn Resilience src/Server/ZB.MOM.WW.OtOpcUa.AdminUI matches only the authoring form + driver pages). The planned /hosts Stream E.2/E.3 status panels never shipped; the interim observability surface is retry/breaker log lines added during #10. archreview/plans/STATUS.md explicitly records this as "still open as an Admin-UI follow-on."
Consequence: an operator can now author retry counts, breaker thresholds, and bulkhead limits per driver instance (all 8 driver pages) with zero UI feedback on whether a breaker is open, a bulkhead is queuing, or retries are firing — the only diagnosis path is server logs.
Recommendation: ship the deferred /hosts resilience panel: expose DriverResilienceStatusTracker snapshots through the existing DriverStatusSignalRBridge/IDriverStatusSnapshotStore pattern (the plumbing idiom already exists) and render breaker/bulkhead state chips beside the driver health chips.
Maturity Ratings
| Dimension | 2026-07-08 | 2026-07-12 | Justification |
|---|---|---|---|
| Stability | 4 | 4 | Unchanged code, unchanged assessment: disciplined disposal/marshaling with documented race reasoning and the self-HubConnection ban honored everywhere; docked for three non-draining timers, Fleet's uncaught refresh path, no ErrorBoundary, and the RowVersion-refetch delete pattern — all still open. |
| Performance | 3 | 3 | Unchanged: event-push where it matters and bounded lists, but keystroke-grain Monaco interop, per-request Roslyn recompiles ×6 endpoints, per-circuit DB polling, and a full config snapshot per Deployments render. |
| Conventions | 3 | 3 | C-2 (doc drift) fixed, but the dominant C-1 three-idiom/ungated-majority finding is untouched, C-5/C-6 remain, and the new C-7 shows the preserve-unknown-keys convention wasn't carried into the resilience editor. |
| Underdeveloped | 3 | 3 | 443 well-aimed seam tests but still zero rendering coverage (by policy), the same admitted stubs, the unvalidated raw-JSON escape hatch — and the new U-5: production-live resilience behavior authored with no status surface. |
Top Recommendations (ordered)
- C-1: Centralize policy constants and put a write-role gate on every config-mutating page (
/uns, equipment, cluster/driver/ACL/namespace editors, Reservations) — now also guarding production-live resilience overrides. - C-7: Make
ResilienceFormModelround-trips non-lossy (preserve unknown keys, warn on unparseable stored JSON) before an edit silently reverts a live driver to tier defaults. - S-4 + S-5: Make deletes carry the rendered RowVersion (or document last-writer-wins) and add confirm dialogs on
EquipmentPage/ScriptEditdeletes. - S-3: Add an
ErrorBoundaryaround@Body. - U-5: Ship the deferred
/hostsresilience status panel via the existing snapshot-store idiom. - P-1: Debounce Monaco's value push to .NET.
- S-1/S-2: Converge the three straggler timers on the async-dispose idiom and give
Fleet.LoadAsynctheHosts-style catch. - C-5/U-1: Declare the service-seam pattern canonical and keep extracting
@codelogic into testable classes.