Every round-2 finding re-verified against code (73 closed / 31 partial / 97 carried); matrix 19/28 cells up, one down. New Critical 03/S14 (AdminUI FallbackPolicy 401s every mesh gRPC map on a login-enabled central; masked on every rig gate by DisableLogin=true). New Highs 03/S15, 05/STAB-19, 05/STAB-18, 03/S16 — the stale-Good family at four seams.
28 KiB
Architecture Review 04 — AdminUI (Blazor Server)
- Date: 2026-08-19
- Commit:
12b0035a(master, clean tree) - Updates: the 2026-07-12 re-review at
f6eaa267(which updated the 2026-07-08 original at9cad9ed0). This is the second update, and unlike the first it lands on a heavily-changed surface: 177 commits touchedsrc/Server/ZB.MOM.WW.OtOpcUa.AdminUIsincef6eaa267— the R2 remediation wave (R2-02/-05/-10), the ACL authoring surface (#520 stage 7), the/apikeys+ Secrets pages, the whole/rawproject tree (RawTree + 9 modals + CSV import/export + browse-commit), four new driver editor families (Calculation, Sql, Mqtt, MTConnect), the 2026-08-10 UI sweep, the density pass, and a run of targeted perf commits (drift watermark, projections, snapshot caches). Every prior finding was re-verified against code at12b0035a; no remediation claim was carried on trust. - Scope:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI(111 razor components — up from 77; ScriptAnalysis backend, minimal APIs, 3 SignalR bridges — the fleet-status chain was deleted end-to-end in #542, UNS/Raw/Acl service layer), plustests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests(978[Fact]/[Theory]cases, up from 443).
Prior-finding status (f6eaa267 → 12b0035a)
| ID | One-liner | Status @ 12b0035a |
|---|---|---|
| S-1 (Med) | Three components don't drain in-flight timer callbacks on dispose | PARTIALLY CLOSED — Fleet.razor fully fixed (63081d52/4b6a93bb/445bb855: IAsyncDisposable + page-lifetime CTS + Interlocked tick-overlap guard + drain, Fleet.razor:131–210; pinned by PollTimerTeardownOrderingTests). The other two stragglers unchanged: AlarmsHistorian.razor:85 is still public void Dispose() => _timer?.Dispose(); and DriverTestConnectButton.razor:68–81 still runs System.Timers.Timer with an async Elapsed lambda + sync dispose. |
| S-2 (Med) | Fleet.LoadAsync no catch; AlarmsHistorian bare catch { } leaves "Loading…" forever |
PARTIALLY CLOSED — Fleet now catches, but only OperationCanceledException/ObjectDisposedException (Fleet.razor:167–171); a transient SQL fault still faults the discarded InvokeAsync task silently (state no longer wedges — the finally at 172–181 resets the guard — but no error surfaces). AlarmsHistorian.razor:68–74 still bare-catches with the admitted "lands when we add role gating" TODO. |
| S-3 (Med) | No <ErrorBoundary> anywhere |
STILL OPEN — grep -rn ErrorBoundary over the project returns nothing, now across 111 components. |
| S-4 (Med) | Delete flows re-fetch RowVersion at click time, defeating the concurrency guard | STILL OPEN on the UNS pages, NOT propagated to new code — GlobalUns.razor:280–311 ("Loads the entity's RowVersion first") and EquipmentPage.razor:449–452, 494–497 ("Load … fresh to capture its current RowVersion") unchanged. But every NEW delete carries the rendered RowVersion: RawTree.razor:462/469/498/546/640/659, AclEdit.razor:264/288, and the reference remove EquipmentPage.razor:407. The codebase now demonstrates the correct pattern beside the wrong one. |
| S-5 (Med) | No confirmation on destructive actions | STILL OPEN — surface grew. Unchanged: EquipmentPage.razor:242/297 (vtag/alarm delete), ScriptEdit.razor:70→142. New unconfirmed instances: ApiKeys.razor:102–107 (Revoke + Delete of a live data-plane credential, single click), AclEdit.razor:150 (grant delete), EquipmentPage.razor:189 (reference Remove). Counter-example now in-tree: RawTree routes every delete through RawConfirmDialog (RawTree.razor:466–470 etc.). |
| S-6 (Low) | DriverStatusPanel result-chip timer lacks the stale-fire token guard Alerts has |
STILL OPEN — Alerts.razor:260–268 has the ReferenceEquals(_opResultClearToken, token) guard; DriverStatusPanel.razor:382–390 clears unconditionally (its comment even says "mirrors DriverStatusPanel" in Alerts — the mirror is one-way). |
| S-7 (Low) | AdminOperationsClient.AskAsync bypasses the 10 s ask timeout |
STILL OPEN — Clients/AdminOperationsClient.cs:57–58 forwards only the caller's token; the three typed methods still double-guard (:33–54). |
| P-1 (Med) | Monaco pushes the full document to .NET on every keystroke, undebounced | STILL OPEN — wwwroot/js/monaco-init.js:213–214. |
| P-2 (Med) | ScriptAnalysis recompiles from scratch per request | PARTIALLY CLOSED — the catalog half is fixed: ScriptTagCatalogSnapshotCache (3795899d, singleton, 5 s TTL, gate-collapsed rebuild — ScriptAnalysis/ScriptTagCatalogSnapshotCache.cs) removed the six-table scan per keystroke that tag-path completion used to pay. The Roslyn (text → tree/compilation) memoization is still absent (ScriptAnalysisService.cs:60–65 builds a fresh CSharpCompilation per call). |
| P-3 (Low) | Per-circuit DB polling on dashboard pages | STILL OPEN (accepted) — Hosts 5 s, Fleet 10 s per circuit; the overlap/teardown hazards around those ticks are now fixed (see S-1). |
| P-4 (Low) | Deployments runs a full config snapshot + hash per page load for the drift badge |
CLOSED — dbc9a132 lists a projection (Deployments.razor:94–105, no artifact blobs) and 684e59d8 added DeploymentDriftProbe (Deployments/DeploymentDriftProbe.cs, singleton at EndpointRouteBuilderExtensions.cs:112): recompose only when the ConfigEdit (MAX(EditedAtUtc), COUNT(*)) watermark or the sealed hash moves, gate-collapsed, staleness trade documented. SQL-Server-backed pins in c149410b. |
| P-5 (Low) | Alerts rows lack @key while prepending |
STILL OPEN — Alerts.razor:281 _rows.Insert(0, evt), row markup carries no @key (Hosts/EquipmentPage tables do). |
| P-6 (Low) | inlay-hints round-trips for a documented no-op |
STILL OPEN — monaco-init.js:107 posts; ScriptAnalysisService.cs:472 returns empty. |
| C-1 (High) | Authorization idioms / ungated mutating surface | CLOSED AND HELD. R2-05's one-idiom state survived 177 commits: all 30 routable pages carry an explicit named-policy @attribute (only Login exempt, [AllowAnonymous]); the six pages added since were consciously classified — ApiKeys = FleetAdmin ("minting a key hands out a data-plane credential"), SecretsAdmin = the Secrets RCL's own ManagePolicy, GlobalRaw/AclEdit = ConfigEditor, ClusterAcls = AuthenticatedRead (verified read-only: ClusterAcls.razor:107–108 is the only DB touch, AsNoTracking), Dev/ContextMenuDemo = AuthenticatedRead. PageAuthorizationGuardTests still reflects over typeof(Routes).Assembly and its census includes every new page (Authorization/PageAuthorizationGuardTests.cs:29–79). /api/script-analysis/* = ConfigEditor (ScriptAnalysisEndpoints.cs:18). Certificates keeps markup + imperative FleetAdmin defense-in-depth (Certificates.razor:85, 180–186). The anti-drift guard did its job. |
| C-2 (Med) | CLAUDE.md ScriptAnalysis policy drift | STILL FIXED — CLAUDE.md now says "gated by the ConfigEditor policy (Administrator or Designer)", matching ScriptAnalysisEndpoints.cs:18 (the policy constant replaced the role string; semantics identical). |
| C-3 (Good) | Driver-typed tag-editor pattern complete and consistent | HOLDS, EXTENDED — TagConfigEditorMap.cs:17–27 and TagConfigValidator.cs:19–29 now cover 11 drivers (added Calculation, Sql, Mqtt, MTConnect) with parallel keys, all keyed off DriverTypeNames constants (the casing-drift class is structurally closed; #558 consolidated the browse-commit parity guard onto one list, 17130d9c). Authoring↔runtime parity got a new backstop: TagConfigParseGate makes authoring models consult the driver's own runtime parse (#554/#549/#551). |
| C-4 (Good) | Numeric-enum serialization bug class closed | HOLDS — every new enum-carrying driver form carries JsonStringEnumConverter (SqlDriverForm.razor:121, MTConnectDriverForm.razor:170, CalculationDriverForm.razor:52, …); MqttDriverForm routes through the driver's own shared MqttJson.Options (MqttDriverForm.razor:7–8) — same discipline, different mechanism. Per-driver *FormSerializationTests extended. |
| C-5 (Med) | Two data-access idioms: service seam vs EF-in-page | STILL OPEN, GREW — 15 components now inject IDbContextFactory directly (was 5 named): all the prior offenders plus Reservations, ClusterOverview/Redundancy/Edit/Acls/Audit/List, NewCluster, NodeEdit, AclEdit. Meanwhile the new Raw/Acl surfaces went through real seams (IRawTreeService, IAclScopeService, IApiKeyAdminService) — the split is now seam-for-new, EF-for-old. |
| C-6 (Low) | Naming/marker nits | STILL OPEN (reduced) — "GalaxyMxGateway" literal survives in DriverIdentitySection.razor:38 and GalaxyAddressPickerBody.razor:136 (though RawDriverTypeDialog.razor:60 now documents + guards the mapping); dispatch maps moved onto DriverTypeNames. |
| C-7 (Med) | ResilienceFormModel lossy round-trip; malformed JSON silently overwritten |
CLOSED (R2-02) — ResilienceFormModel.cs:13–17/43–48/106–124: JsonObject bag preserves unknown top-level keys, unknown capabilities, unknown per-policy fields; a malformed blob sets ParseFailed, ToJson returns the stored text verbatim, and DriverResilienceSection.razor:9–14 shows the warning + explicit discard button while every input is disabled="@_m.ParseFailed" (:40–42) and EmitAsync early-returns (:93). ResilienceFormModelTests pins it. |
| U-1 | Strong seam tests, zero component rendering coverage | IMPROVED IN VOLUME, SAME SHAPE — 978 cases (443 → 978): Acls, Raw (CSV mapper, tree service, modals), Api, Browsing, Grpc, Telemetry, Hosts suites added. Still no bUnit (documented posture) — but a reflection-driven component-test harness now exists: RawBrowseModalStaleOpenTests drives a real modal class end-to-end (inert Renderer subclass for the render handle, reflection for [Parameter]s), proving modal @code logic is testable here without bUnit. |
| U-2 | Stub/known-gap behaviors | STILL OPEN — InlayHints no-op (ScriptAnalysisService.cs:472 + active provider monaco-init.js:107); AlarmsHistorian.razor:68–74 role-gating TODO; MonacoEditor.razor:44 MarkersChanged still object[]; GlobalUns.razor:327 "Delete for this node kind is not yet available." |
| U-3 | Raw-JSON fallback unvalidated | SUBSTANTIALLY CLOSED — typed client validation covers 11 drivers and is now service-enforced, not just modal-side: RawTreeService.cs:977 (create/update), :1273 (import), RawTagCsvMapper.cs:228 (CSV rows), RawTagModal.razor:242 / RawManualTagEntryModal.razor:243 (modals) — the #535→#559 authoring-vs-deploy-gate arc. Residual: the schemaless textarea for unmapped drivers (Galaxy only) still takes any text ("Validated server-side at deploy", RawTagModal.razor:90) — the deploy gate is the only check there. |
| U-4 | Accessibility gaps | STILL OPEN — tree expanders are still text glyphs without aria-expanded (RawTree.razor:176–179, UnsTree), modals have no focus trap/Escape, live tails no aria-live. New code at least marks decorative emoji aria-hidden (RawTree.razor:229). |
| U-5 | Resilience runtime status has no AdminUI reader | CLOSED (R2-10 + #546) — DriverStatusPanel.razor:15/77–92/203/263–264/292–303 renders breaker-OPEN / consecutive-failures / in-flight / staleness chips off IDriverResilienceStatusStore (in-process, self-HubConnection ban honored), and #546 (d33e4e20 + e669fb34) wired the panel into every /hosts driver row via the Controls dialog — with the Enabled-in-@key recycle trick documented in the markup (Hosts.razor:283–299) so a live Enabled flip re-mounts rather than orphaning subscriptions. R2-10's live gate ran (per the R2-10 memory/plans register). |
Maturity Ratings
| Dimension | 2026-07-08 | 2026-07-12 | 2026-08-19 | Justification |
|---|---|---|---|---|
| Stability | 4 | 4 | 4 | The dominant open items moved: Fleet's timer/teardown fully fixed, C-7's silent data loss closed, staleness guards + confirm dialogs are the norm in the new Raw surface, and the tick-overlap class got a dedicated test file. Docked at 4 (not higher) because the fixes were applied N−1-wide — five sibling modals still carry the exact superseded-open race that three others were just fixed for (S-8), three EditForms silently no-op on invalid input (S-9), and ErrorBoundary/AlarmsHistorian/DriverTestConnectButton stragglers persist. |
| Performance | 3 | 3 | 4 | A deliberate, well-engineered perf sweep landed: drift-watermark probe (P-4 closed), /deployments and raw-tree projections that stop shipping blobs, the 5 s tag-catalog snapshot cache, server-side rename script-scan with chunked OR-chains, one guard round-trip per bulk reference add. What remains (Monaco keystroke push, per-request Roslyn compile, per-circuit polling) is bounded at single-admin scale and known. |
| Conventions | 3 | 3 | 4 | C-1 closed and held under drift pressure — the reflection guard forced conscious classification of all six new pages. C-7 closed to the tag-editor discipline. The editor/validator/parity pattern extended cleanly to four new drivers, keyed off constants. Docked for the EF-in-page split growing (C-5) and the naming residue (C-6). |
| Underdeveloped | 3 | 3 | 3.5 | U-5 closed (resilience observability shipped and wired to /hosts), U-3 substantially closed (validation now service-enforced), tests 443→978 with a component-harness pattern emerging. Still: no bUnit (by policy) with most new modal orchestration verified only by /run, the same four U-2 stubs, and accessibility untouched. |
Architecture Overview (delta since f6eaa267)
- Page map changes:
+/apikeys(FleetAdmin),+/secrets(Secrets RCL wrapper),+/raw(GlobalRaw→RawTree, the v3 Batch-2 authoring surface),+/clusters/{id}/aclslist +AclEdit+AclScopePickerModal/AclScopeTree(#520 stage 7),+Dev/ContextMenuDemo. Retired: the routed/clusters/{id}/driversflow (DriverTypePicker,DriverEditRouter, 8*DriverPageshells) — form bodies live on inside the/rawmodals (DriverConfigModal/DeviceModal+Forms//DeviceForms/); the UNSTagModal(Batch 3 made equipment Tags reference-only viaAddReferenceModal). - Hubs: 3 (
alerts,scriptlog,driverstatus) —FleetStatusHub+ bridge + broadcaster deleted as a zero-client chain (#542,f1c18811). The in-process singleton pattern (DPS bridge → store/broadcaster → component event) unchanged and still honored everywhere. - New service seams:
IRawTreeService(1701 lines, RowVersion-aware, warn-on-rename substring scan — now server-side filtered + chunked,fe8d132c/ca87329a),IAclScopeService(mirrorsDraftValidator.ValidateNodeAclScopesrule-for-rule so page and deploy gate cannot disagree; blocks save on unresolvable scope, re-checked on submit —AclEdit.razor:238–242),IApiKeyAdminService(server-side validation, hash-only storage),RawTagCsvExportReader,DeploymentDriftProbe(singleton),ScriptTagCatalogSnapshotCache(singleton). - UI sweep held: the scoped-CSS bundle is linked (
App.razor:23), chip variants documented against the Theme kit (site.css:20–22), empty-string columns guarded (Hosts.razorLast-error usesAbbreviate … is { Length: > 0 } … : "—"),#504slicing discipline held — every[..N]in the tree is length-guarded or Guid-sourced.
1. STABILITY
S-8 (Medium, NEW) — The superseded-open staleness guard was applied to three modals; five siblings share the unguarded shape
The 2026-08-05/08 fixes (87637e4e RawBrowseModal, 2c19a50b/4e3a6e84 RawManualTagEntryModal + RawTagModal) established the class: Blazor does not serialize OnParametersSetAsync against its own outstanding task, and a bool open-flag cannot tell two opens apart — so a close-and-reopen onto another target while a load is in flight lets the first open's late continuation install its state under the second open's displayed modal. The fixed modals carry generation counters and post-await re-checks (RawBrowseModal.razor:362–446, RawManualTagEntryModal.razor:183–197, RawTagModal.razor:202), and RawBrowseModalStaleOpenTests pins one of them.
Five sibling modals have the identical shape with no guard:
RawCsvImportModal.razor:209–240— the sharpest instance.ResolveTargetAsyncawaitsResolveGroupContextAsyncand then writes_effectiveDeviceId+_groupPrefixunconditionally;_resolvedis a bool, not a generation. A stale resolve retargets both the parse (_groupPrefixis baked into every row'sEffectiveGroupPath,:250) and the commit itself —Commit()callsSvc.ImportTagsAsync(_effectiveDeviceId, rows)(:293), andImportTagsAsynctakes a device id with no RowVersion, so unlike the edit modals nothing downstream catches the mistake: the all-or-nothing import lands in the wrong device.RawCsvExportModal.razor:88–129— same shape, read-only consequence (another device's tags exported under this device's filename).DeviceModal.razor:126–171—_openHandledbool;LoadAsyncwrites_name/_deviceConfigJson/_rowVersion/_parentDriverConfigafter two awaits with no re-check. A stale install shows device A's config under device B's title; Save is accidentally blocked by the RowVersion mismatch, but Test-connect probes the wrong endpoint with no error.DriverConfigModal.razor:132–165— identical to DeviceModal (_driverConfigJson/_resilienceConfig/_rowVersioninstalled post-await).AddReferenceModal.razor:83–101—_wasVisiblebool; a staleLoadReferencePickerRootAsyncinstalls equipment A's picker root under equipment B's open; picks then commit as B's references (CommitAsync:116uses the currentEquipmentId), which the cluster-scope server check will accept whenever A and B share a cluster.
Recommendation: port the _openGeneration + post-await IsStale idiom (and its reflection-harness test — the pattern is proven) to all five. This is the "ForTest-seam / N−1 dispatch sites" failure mode in modal form: the fix commits named three modals; the class has eight members.
S-9 (Medium, NEW) — Three EditForms validate with no validation display: invalid input = silent no-op Save
NodeEdit.razor:42 and AclEdit.razor:36 show the house convention (DataAnnotationsValidator + <ValidationSummary>); the UNS modals render ValidationMessages. Three pages drifted:
ClusterEdit.razor:35–36—DataAnnotationsValidator,[Required] Name(:195), noValidationSummary/ValidationMessageanywhere in the form.NewCluster.razor:18–19— worse:[Required]×3 plus a[RegularExpression(..., ErrorMessage = "Use uppercase letters, digits, dash, underscore.")](:132–136) whose message can never render.ScriptEdit.razor:32–33—[Required] Name+[Required] SourceCode(:167–168), no display.
Consequence: clear the Name (or type a lowercase cluster id) and click Save/Create — OnValidSubmit never fires, no message appears, the button just does nothing. This is precisely the #505-adjacent "EditForm with no ValidationSummary = silent no-op Save" class the project memory documents, and it passes every unit test because there is no rendering coverage.
Recommendation: one-line fix per page; add the three <ValidationSummary class="alert alert-danger" /> lines, and consider a guard-style scan (the pages are compiled classes — a reflection test could assert every component that renders DataAnnotationsValidator also renders a validation display, mirroring PageAuthorizationGuardTests).
S-1 / S-2 residue (Medium, carried) — Two straggler timers and two swallowed-error paths
Fleet is now the model citizen (Fleet.razor:113–210: CTS-before-drain, Interlocked overlap guard, tick-holding-CTS dispose reasoning — all commented). Still owed:
AlarmsHistorian.razor:85syncDispose;:68–74bare catch that leaves "Loading…" forever on admin-only nodes (the TODO is now over a year of review cycles old).DriverTestConnectButton.razor:68–81System.Timers.Timer+ asyncElapsed(effectively async-void) + sync dispose.Fleet.razor:167–171catches only OCE/ODE — a transient SQL fault is still an unobserved task exception with no error surface (theHostscatch-log-degrade shape was never copied over).
S-3 (Medium, carried) — No <ErrorBoundary>; S-4 (Medium, carried) — UNS delete RowVersion refetch; S-5 (Medium, carried) — unconfirmed destructive actions
All three persist as described in the table above. Two aggravations worth noting:
- S-4 is now an internal inconsistency, not just a defect:
RawTreeandAclEditprove the rendered-RowVersion delete works fine in this codebase;GlobalUns/EquipmentPagesit beside them doing the opposite. - S-5's sharpest new instance is
ApiKeys.razor:106–107: single-click Delete of an API key destroys a live machine credential (revocation is immediate on ConfigDb nodes) with no confirm and no undo — and unlike Revoke it also erases the row. At minimum, Delete here deserves theRawConfirmDialogtreatment; arguably Delete should be gated behind Revoke-first.
S-6 / S-7 (Low, carried)
DriverStatusPanel.razor:382–390 still lacks the stale-fire token guard its own Alerts twin has (Alerts.razor:260–268 — whose comment claims to "mirror DriverStatusPanel"); AdminOperationsClient.cs:57–58 still forwards only the caller's token.
Positive stability notes
- The three fixed modals +
RawBrowseModalStaleOpenTestsshow the harness problem (no bUnit) is solvable for modal state machines — an inertRenderersubclass + reflection drives the real component class. RawTreeis a well-built coordinator: one modal set for the whole recursive tree,@keyper row, lazy loads with captured errors, confirm dialogs on every delete, rendered-RowVersion mutations, warnings surfaced through a shared message modal (RawTree.razor:705–723).Hosts' Controls dialog documents its own@key-recycle rationale in markup (Hosts.razor:283–299) — the "Enabled flips must recycle the panel or its subscriptions dangle" trap is pinned in words where the next editor will read them.AlarmsHistorianaside, every new page reports load errors (ApiKeys.ReloadAsynccatches and surfaces;AclEditcatchesDbUpdateConcurrencyExceptionwith a human message on both save and delete).
2. PERFORMANCE
P-1 / P-2 / P-3 / P-5 / P-6 (carried — see table)
The two Mediums still worth scheduling: Monaco's undebounced full-document push (monaco-init.js:213–214) and the per-request Roslyn compile (ScriptAnalysisService.cs:60–65 — a size-1 LRU keyed on normalized text would collapse the completions-after-diagnostics case; the catalog half is done).
Positive performance notes (the sweep that closed P-4)
DeploymentDriftProbeis the standout: watermark =(MAX(EditedAtUtc), COUNT(*))over the append-onlyConfigEditaudit (the COUNT half defends same-timestamp edits), volatile-published verdict, gate-collapsed recompose, and an honest "accepted staleness" paragraph naming the exact condition under which the chip lies and why that's tolerable (Deployments/DeploymentDriftProbe.cs)./deploymentslists a projection (Deployments.razor:94–105) instead of 50 artifact blobs; the raw tree renders tag rows withoutTagConfig(ba977dbe); the rename script-scan filters server-side and chunks its OR-chain with SQL-Server-backed pins (fe8d132c/ca87329a/c149410b); bulk reference adds pay one guard round-trip (fa36d64e);ScriptTagCatalogSnapshotCachebounds editor-assist DB load at one six-table scan per 5 s process-wide.- CSV import bounds the upload at 16 MB via
OpenReadStream(maxAllowedSize:)(RawCsvImportModal.razor:251) and stages all-or-nothing review before any write.
3. CONVENTIONS
C-1 — CLOSED AND HELD (see table). The reflection guard proved its worth
The census in PageAuthorizationGuardTests was maintained, not bypassed: each of the six post-R2-05 pages appears with a policy and, for the interesting ones, a rationale comment (ApiKeys→FleetAdmin "same authority level as editing role grants"; SecretsAdmin carrying the RCL's own policy). This is the anti-drift outcome R2-05 was designed for.
C-5 (Medium, carried) — The seam/EF-in-page split is now generational
New surfaces (Raw, Acls, ApiKeys, drift, catalog) all went through injectable services with real test suites; the older cluster/script/dashboard pages still run EF in @code (15 components inject IDbContextFactory). The convention argument has effectively been won by the new code — declare it and migrate the old pages opportunistically (ScriptEdit's save/delete still belongs in the existing script service methods).
C-6 (Low, carried) — reduced to two "GalaxyMxGateway" literals (DriverIdentitySection.razor:38, GalaxyAddressPickerBody.razor:136); the dispatch-map keying moved to DriverTypeNames fleet-wide.
C-8 (Low, NEW) — A dev demo page ships in the production route table
Components/Pages/Dev/ContextMenuDemo.razor is routable in every deployment (gated AuthenticatedRead, consciously classified in the guard census — so this is a deliberate choice, recorded here as a nit). If it's meant as a pattern gallery, name it so; if not, #if DEBUG or delete.
Positive conventions notes
- The tag-editor discipline scaled to 11 drivers without a single map/validator drift, because the keys are constants and the parity tests are consolidated onto one list (#558).
- The authoring↔runtime parity arc (#549/#550/#551/#554/#557) gave the AdminUI a genuinely novel convention: authoring models consult the driver's own runtime parse (
TagConfigParseGate) so the editor can never accept a config the runtime rejects — enforced at modal, CSV, and service layers (one authority, service-enforced — thereference_authoring_vs_deploy_gate_driftlesson applied). AclScopeServicemirrorsDraftValidator.ValidateNodeAclScopesrule-for-rule with better operator wording, and the picker makes invalid grants unauthorable (Cluster ⇒ null ScopeId at projection,AclScopeService.cs:265–267; UNS tags deliberately unofferable with the rationale in code,:317–320).
4. UNDERDEVELOPED AREAS
U-1 (carried, improved) — 978 seam tests, no rendering coverage — but the harness excuse is gone
The reflection-driven component harness (RawBrowseModalStaleOpenTests — inert Renderer subclass for the render handle, reflection past BL0005 for parameters) demonstrates modal @code machines are testable without bUnit. S-8's five unguarded modals and S-9's three silent forms are exactly the tests that harness should now grow.
U-2 (carried) — the same four stubs
InlayHints wired-no-op (client provider still POSTs, monaco-init.js:107; ScriptAnalysisService.cs:472); AlarmsHistorian role-gating TODO; MonacoEditor.MarkersChanged untyped object[]; GlobalUns "not yet available" delete branch. None moved in ~1300 commits — either schedule or annotate as accepted.
U-3 (carried, substantially closed) — residual: the Galaxy schemaless textarea
Only unmapped drivers (today: Galaxy) reach the raw textarea, and only the deploy gate validates it. A JsonDocument.Parse in the service create/update path for unmapped types remains a cheap backstop.
U-4 (carried) — accessibility unchanged; the new tree components repeated the text-glyph expander pattern without aria-expanded (RawTree.razor:176–179, AclScopeTree).
Top Recommendations (ordered)
- S-8: Port the superseded-open generation guard to the five unguarded modals —
RawCsvImportModalfirst (its stale_effectiveDeviceIdretargets a commit that nothing downstream can catch). - S-9: Add the missing
ValidationSummarytoClusterEdit/NewCluster/ScriptEdit; consider a reflection guard for the class. - S-5 (ApiKeys): Confirm-dialog the API-key Delete (and AclEdit/EquipmentPage deletes) — a live credential should not die to one click.
- S-4: Carry rendered RowVersions on the UNS deletes (the codebase's own new code shows how) or document delete as last-writer-wins.
- S-3:
ErrorBoundaryaround@Body— still absent at 111 components. - P-1/P-2: Debounce Monaco's value push; memoize
(text → compilation)size-1 inScriptAnalysisService. - S-1/S-2 stragglers:
AlarmsHistorian+DriverTestConnectButtontimers; giveFleet(andAlarmsHistorian) a real error surface. - C-5: Declare the service-seam convention won; migrate
ScriptEditfirst.