Files
lmxopcua/archreview/plans/04-adminui-plan.md
T
Joseph Doherty e31d29c04c docs(archreview): add STATUS.md handoff + per-plan status banners
Track remediation progress: 2 of 4 Criticals done (03/S1 split-brain resolver;
02/U2+U3 VT timeout+ALC) + both CLAUDE.md doc-drifts fixed. STATUS.md is the
single source of truth (branch topology, completed items, task list, next-up,
resume facts). Each plan carries a status banner.
2026-07-08 16:25:39 -04:00

296 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Architecture Review 04 — AdminUI: Design + Implementation Plan
> **Status (2026-07-08):** not started. Top item: C-1 (ungated mutating pages → `ConfigEditor` policy +
> reflection guard). No bUnit → live-verify on docker-dev. See [`STATUS.md`](STATUS.md).
- **Source report:** `archreview/04-adminui.md` (commit `9cad9ed0`)
- **Plan author date:** 2026-07-08 · verified against current tree at `9cad9ed0`
- **Scope:** AdminUI pages, ScriptAnalysis backend, tag editors, authorization
- **Repo constraint that shapes every test recipe:** there is **no bUnit**. Razor `@code`/binding logic and page-level `[Authorize]` gating are only observable by live-verify on the docker-dev rig (AdminUI `http://localhost:9200`). *And* on docker-dev **login is disabled** (`Security:Auth:DisableLogin=true``AutoLoginAuthenticationHandler` grants `DevAuthRoles.All` = every role incl. Administrator/Designer/Operator). **Consequence: a Viewer-denied path cannot be observed on the default rig** — negative authz must be proven by (a) a pure policy-registration unit test, (b) a reflection test over page attributes, and (c) a manual real-login pass with a Viewer LDAP bind. This is called out in every authz recipe below.
## Verification summary
| ID | Sev | Status | Evidence |
|---|---|---|---|
| C-1 | High | **Confirmed** | 30+ pages carry fully-qualified bare `@attribute [Microsoft.AspNetCore.Authorization.Authorize]`; only `Deployments`/`Scripts`/`ScriptEdit` use `Roles="Administrator,Designer"`, `RoleGrants` uses `Policy="FleetAdmin"`, `Certificates`/`Alerts`/`DriverStatusPanel` use imperative `"DriverOperator"`/`"FleetAdmin"`. `FallbackPolicy` is only `RequireAuthenticatedUser()`. |
| C-2 | Med | **Confirmed** | `ScriptAnalysisEndpoints.cs:17-18` = `Roles="Administrator,Designer"`; `CLAUDE.md:223` + `docs/plans/2026-06-11-adminui-disable-login-design.md:141` say "FleetAdmin". |
| C-5 | Med | **Confirmed** | `ScriptEdit.razor` / `Deployments.razor` / `Fleet.razor` / `Hosts.razor` inject `IDbContextFactory` and run EF + `SaveChangesAsync` in `@code`; UNS pages use `IUnsTreeService`. |
| S-1 | Med | **Confirmed** | `Fleet.razor:171` + `AlarmsHistorian.razor:90` sync `Dispose() => _timer?.Dispose()`; `DriverTestConnectButton.razor:69` `System.Timers.Timer` with `async (_,_)` `Elapsed` + sync `Dispose`. (`DriverStatusPanel`/`Alerts`/`Hosts` already correct via `System.Threading.Timer`+`DisposeAsync`.) |
| S-2 | Med | **Confirmed** | `Fleet.LoadAsync` (119-159) `try/finally` no `catch`; `AlarmsHistorian` (73-78) bare `catch {}` leaving "Loading…". |
| S-3 | Med | **Confirmed** | `grep ErrorBoundary` = none; `MainLayout.razor:53` `<ChildContent>@Body</ChildContent>` unwrapped. |
| S-4 | Med | **Confirmed** | `GlobalUns.ConfirmDeleteAsync` re-`Load…Async` then passes fresh `.RowVersion`; `EquipmentPage.DeleteTag/VirtualTag/Alarm` (370/415/460) same ("Load … fresh to capture its current RowVersion"). |
| S-5 | Med | **Confirmed** | `EquipmentPage.razor:172/217/263` single-click `@onclick="() => DeleteTag(...)"`; `ScriptEdit.DeleteAsync` single-click. `GlobalUns` has a confirm modal; these don't. |
| S-6 | Low | **Partial** | `DriverStatusPanel` *does* drain via `DisposeAsync` (287-310), but `ShowOpResult` replaces the chip timer with a sync `Dispose()` and no token guard → an already-queued callback can clear a newer message. Race is real but narrow. |
| S-7 | Low | **Confirmed** | `AdminOperationsClient.cs:58-59` `AskAsync<T>` forwards only caller `ct`; typed methods double-guard with `AskTimeout`. |
| P-1 | Med | **Confirmed** | `monaco-init.js:205-207` `onDidChangeModelContent``invokeMethodAsync("OnValueChanged", editor.getValue())` no debounce; diagnostics ARE debounced (500 ms). |
| P-2 | Med | **Confirmed** | `ScriptAnalysisService.Analyze` (60-67) re-`ParseText` + `CSharpCompilation.Create` per call; refs/preamble static (good), no `(text→tree)` memo. |
| P-3/P-4/P-5 | Low | **Confirmed** (spot-checked) | Per-circuit polling / `SnapshotAndFlatten` per Deployments render / no `@key` on `Alerts` rows — accepted as bounded. |
| P-6 | Low | **Confirmed** | `monaco-init.js:99-117` registers inlay-hints provider that POSTs every change; `InlayHints` endpoint always empty. |
| C-3 / C-4 | Good | **Confirmed good** | Tag-editor map covers 7 drivers + Galaxy raw path; all driver pages carry `JsonStringEnumConverter`, pinned by `*FormSerializationTests`. No action. |
| U-1..U-4 | — | **Confirmed** | No bUnit; documented stubs; unvalidated raw-JSON fallback; thin a11y. |
**Nothing in this report is stale/already-fixed.** All actionable findings reproduce at `9cad9ed0`. One nuance correction: the task brief mentioned a `DriverAdmin` policy — **no such policy exists**; only `FleetAdmin` and `DriverOperator` are registered (`Security/ServiceCollectionExtensions.cs:155-160`).
---
## Priority order
1. **C-1** (High, overall action-item #5) — gate the mutating surface; standardize on one policy idiom with constants.
2. **C-2** (Med) — folds into C-1 (converge ScriptAnalysis gate + fix the doc).
3. **S-4 + S-5** (Med) — delete concurrency + confirmation.
4. **S-3** (Med) — `ErrorBoundary`.
5. **S-1 + S-2** (Med) — timer/error-handling convergence.
6. **P-1** (Med) — debounce Monaco value push.
7. **P-2** (Med) — ScriptAnalysis compilation memo.
8. **C-5** (Med) — declare service-seam canonical; migrate `ScriptEdit`.
9. **Batch Lows** — S-6, S-7, P-3/4/5, P-6, C-6, U-2, U-3, U-4.
---
## 1. C-1 (High) — Standardize authorization; gate the mutating surface
**Restated:** Three authz idioms coexist and the largest mutating surface (UNS, equipment, cluster/node/namespace/ACL editors, all 8 driver pages, Reservations) carries only bare `[Authorize]`, so any authenticated user — incl. a read-only Viewer — can create/edit/delete config. Only the deploy/scripts pages are role-gated.
**Verification (confirmed):**
- Policies defined: `Security/ServiceCollectionExtensions.cs:143-161``FleetAdmin = RequireRole("Administrator")`, `DriverOperator = RequireRole("Operator","Administrator")`, `FallbackPolicy = RequireAuthenticatedUser()`. No "write" policy exists.
- Roles: `AdminRole` enum = `Viewer`, `Designer`, `Administrator` (`Configuration/Enums/AdminRole.cs`); `Operator` is an appsettings-only control-plane role (`DevAuthRoles.cs:14`).
- Idiom census (all string literals, no constants):
- `Roles="Administrator,Designer"`: `Deployments.razor:12`, `Scripts.razor:2`, `ScriptEdit.razor:5`, `ScriptAnalysisEndpoints.cs:18`.
- `Policy="FleetAdmin"`: `RoleGrants.razor:2` (page), `Certificates.razor:90` (`AuthorizeView`), `Certificates.razor:186` (imperative).
- Imperative `"DriverOperator"`: `Alerts.razor:165`, `DriverStatusPanel.razor:166`, `GalaxyAddressPickerBody.razor:124`, `OpcUaClientAddressPickerBody.razor:75`.
- **Bare `[Authorize]`** (= FallbackPolicy, any authenticated user): `GlobalUns`, `EquipmentPage`, `ClusterEdit`, `NodeEdit`, `NamespaceEdit`, `NewCluster`, `AclEdit`, `ClusterAcls/Namespaces/Drivers/Overview/Audit/Redundancy`, `ClustersList`, all 8 driver pages + `DriverEditRouter` + `DriverTypePicker`, `Reservations`, plus the read-only dashboards.
**Root cause:** Pages were authored by copying the nearest neighbor; the fused Host's `FallbackPolicy` makes bare `[Authorize]` *look* protective (you must be logged in) while providing zero role separation. No policy-name constants exist to make the correct gate discoverable, so drift is the path of least resistance. Project posture (memory: roles are global, "simplest authz") was never actually applied to the mutating pages.
### Proposed design
**Standardize on the policy idiom with named constants**, and introduce one write policy.
1. **Add a policy-constants type** in the Security project (co-located with the definitions), e.g. `Security/Auth/AdminUiPolicies.cs`:
```csharp
public static class AdminUiPolicies
{
public const string FleetAdmin = "FleetAdmin"; // RequireRole(Administrator)
public const string DriverOperator = "DriverOperator"; // RequireRole(Operator, Administrator)
public const string ConfigEditor = "ConfigEditor"; // NEW: RequireRole(Administrator, Designer)
}
```
Keep the string *values* identical to today's literals so nothing else has to change atomically.
2. **Register the new `ConfigEditor` policy** in `AddOtOpcUaAuth` (`ServiceCollectionExtensions.cs:143`):
```csharp
o.AddPolicy(AdminUiPolicies.ConfigEditor, p => p.RequireRole("Administrator", "Designer"));
```
`ConfigEditor` is exactly the semantic already spelled out four times as `Roles="Administrator,Designer"` — so `Deployments`/`Scripts`/`ScriptEdit`/`ScriptAnalysisEndpoints` converge onto it too, collapsing idiom #1 into idiom #2.
3. **Apply `[Authorize(Policy = AdminUiPolicies.ConfigEditor)]`** to every *mutation-dedicated* page. Read-only dashboards keep the FallbackPolicy (bare `[Authorize]`).
**Gate with `ConfigEditor` (mutation pages):**
`GlobalUns`, `EquipmentPage`, `ClusterEdit`, `NodeEdit`, `NamespaceEdit`, `NewCluster`, `AclEdit`, `ClusterRedundancy`, `DriverEditRouter`, `DriverTypePicker`, all 8 driver pages (`Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas/OpcUaClient/Galaxy`), `Reservations`, plus converge `Deployments`/`Scripts`/`ScriptEdit`.
**Keep FleetAdmin:** `RoleGrants` (already), `Certificates` mutating actions (already per-action).
**Leave read-only (FallbackPolicy):** `Home`, `Fleet`, `Hosts`, `Alerts` (view; the Ack/Shelve buttons stay imperative-`DriverOperator`), `ScriptLog`, `AlarmsHistorian`, `Account`, `ClusterOverview`, `ClustersList`, `ClusterAudit`, `Certificates` (view).
4. **Mixed list pages** (`ClustersList`, `ClusterAcls`, `ClusterNamespaces`, `ClusterDrivers`) contain both read content and destructive buttons (New/Edit/Delete). Two options:
- **(A, recommended) Keep the page viewable, wrap destructive controls in `<AuthorizeView Policy="@AdminUiPolicies.ConfigEditor">`** so a Viewer sees the fleet but can't mutate. Preserves read access, matches least-surprise. Slightly more than one attribute per page.
- **(B, minimal) Page-level `ConfigEditor`** — a Viewer loses read access to those lists entirely. Cheaper, but hides visibility a Viewer arguably should have.
Recommend **(A)** for the four list pages, **page-level `ConfigEditor`** for the pure edit/create forms and driver pages. This is the correct read/write split for a global-role model.
5. **Convert the imperative/AuthorizeView literals to the constants** (`Alerts`, `DriverStatusPanel`, both pickers, `Certificates`, `RoleGrants`) — mechanical: `"DriverOperator"` → `AdminUiPolicies.DriverOperator`, etc. In razor markup use `Policy="@AdminUiPolicies.ConfigEditor"`.
**Alternatives considered / rejected:**
- *Keep role-strings everywhere* — rejected: the canonical role rename (`ConfigEditor→Designer`, `FleetAdmin→Administrator`) already shows role strings are brittle; policies decouple the gate from the role vocabulary in one place.
- *Fine-grained per-action policies (WriteTag/WriteCluster/…)* — rejected as over-engineering against the deliberate global-role posture (memory: "roles are global; simplest authz that works").
- *Move gating into the `IUnsTreeService`/EF layer* — good defense-in-depth but out of scope here and doesn't fix the surface-level page problem; note as a future hardening.
### Implementation steps
1. Add `Security/Auth/AdminUiPolicies.cs` (constants).
2. `ServiceCollectionExtensions.cs`: register `ConfigEditor`; switch the two existing `AddPolicy` names to the constants.
3. Edit page `@attribute` lines (one line each) for the mutation pages listed above → `[Authorize(Policy = AdminUiPolicies.ConfigEditor)]`. Add `@using ZB.MOM.WW.OtOpcUa.Security.Auth` to `Components/_Imports.razor` so the constant resolves in every razor file.
4. Wrap destructive controls in the four list pages with `<AuthorizeView Policy="@AdminUiPolicies.ConfigEditor">` (option A).
5. Replace imperative/AuthorizeView literals with constants (`Alerts`, `DriverStatusPanel`, pickers, `Certificates`, `RoleGrants`, `ScriptAnalysisEndpoints`).
6. Confirm `AutoLoginAuthenticationHandler` still satisfies `ConfigEditor` (it grants `DevAuthRoles.All` incl. `Administrator`+`Designer` — yes, no dev-rig regression).
### Tests + live-verify
- **Unit (Security.Tests, CI-runnable, cheap):** build a `ServiceProvider` via `AddOtOpcUaAuth`, resolve `IAuthorizationService`, and assert the `ConfigEditor` policy:
- `ClaimsPrincipal` with role `Viewer` only → **denied**.
- with `Designer` → allowed; with `Administrator` → allowed; with `Operator` only → denied.
This is the only automated proof of the *policy semantics* (mirrors the existing `Configuration.Tests/AuthorizationTests.cs` style).
- **Reflection guard (AdminUI.Tests, CI-runnable) — closes the "built-but-never-wired" gap for authz:** enumerate all page component types (`typeof(App).Assembly` types with a `[Route]` attribute), and assert every type in a hard-coded `MutatingPages` set carries an `AuthorizeAttribute` whose `Policy == AdminUiPolicies.ConfigEditor` (and that no mutating page has a bare `[Authorize]`). This is the cheapest possible substitute for bUnit and catches a new page copying the wrong idiom — directly implements OVERALL cross-cutting theme #1 ("assert production wiring").
- **Live-verify (docker-dev, manual, negative path needs real login):**
1. Positive smoke on the default rig (auto-admin): confirm the gated pages still load and mutate — deploy a config, open `/uns`, add/delete a tag, open a driver page. Auto-login grants all roles so everything must still work (regression check that the gate didn't over-block admins).
2. **Negative path requires disabling auto-login.** Set `Security:Auth:DisableLogin=false`, bring the AdminUI up against the shared GLAuth (`10.100.0.35:3893`), bind a **Viewer** LDAP user (group→`Viewer` via `Security:Ldap:GroupToRole`), and verify: `/uns`, `/uns/equipment/*`, `/clusters/*/edit`, driver pages, `/reservations` all render the "You do not have permission" `NotAuthorized` slot (from `Routes.razor:19`); the four list pages render but hide New/Edit/Delete. Then bind a **Designer** user and confirm access is restored. Document this recipe in the PR — it is the only way to observe the deny, because the default rig can't.
**Effort:** M. **Risk/blast-radius:** Medium — touches ~25 page files but each change is one line; the real risk is *over-gating a read-only dashboard* (hiding it from Viewers) or *under-gating a mutation page*. The reflection guard + the manual Viewer pass are the mitigations. No runtime/data-plane impact (OPC UA data-plane auth is independent LDAP).
---
## 2. C-2 (Med) — ScriptAnalysis gate doc/code drift
**Restated:** `CLAUDE.md` says `/api/script-analysis/*` is "gated by the `FleetAdmin` policy"; code uses `Roles="Administrator,Designer"`.
**Verification (confirmed):** `ScriptAnalysisEndpoints.cs:17-18` `RequireAuthorization(new AuthorizeAttribute { Roles = "Administrator,Designer" })`; `CLAUDE.md:223` + design plan `…adminui-disable-login-design.md:141` claim FleetAdmin. The original monaco plan (`2026-06-09-monaco-script-editor.md:309`) *intended* `RequireAuthorization("FleetAdmin")` but the implementation diverged to match the Scripts page.
**Root cause:** implementation deliberately matched the Scripts/ScriptEdit page gate (which is `Administrator,Designer`) but the docs were written from the original plan and never reconciled.
**Design/fix:** fold into C-1. When `ScriptAnalysisEndpoints` converges onto `AdminUiPolicies.ConfigEditor` (= `Administrator,Designer`), update the doc to state the truth: **script-analysis and the Scripts/ScriptEdit/Deployments pages are gated by the `ConfigEditor` policy (Administrator or Designer)**, distinct from `FleetAdmin` (Administrator-only). This also fixes OVERALL cross-cutting theme #5 for this claim.
**Implementation:** edit `CLAUDE.md:223`; the design-plan files are historical (leave, or add a one-line "superseded" note). **Tests:** the C-1 policy unit test covers the endpoint's effective gate; add one endpoint-level assertion in `ScriptAnalysis` tests if a `WebApplicationFactory` is already in use there (check first — the suite is service-level today).
**Effort:** S. **Risk:** trivial (doc + one converged literal).
---
## 3. S-4 + S-5 (Med) — Delete concurrency + confirmation
**Restated:** Delete paths re-fetch a *fresh* RowVersion at click time (defeating optimistic concurrency → last-writer-wins), and `EquipmentPage`/`ScriptEdit` deletes fire on a single click with no confirmation.
**Verification (confirmed):**
- S-4: `GlobalUns.ConfirmDeleteAsync` (Area/Line/Equipment branches) `Load…Async` → `Delete…Async(id, loaded.RowVersion)`; `EquipmentPage.DeleteTag/DeleteVirtualTag/DeleteAlarm` (370/415/460) each comment "Load … fresh to capture its current RowVersion". *Update* paths correctly carry the modal-load RowVersion (`EquipmentPage.razor:555`), so the contract is genuinely inconsistent. Note `ScriptEdit.DeleteAsync` is the exception — it uses `_form.RowVersion` (the page-load value), so it is *not* a fresh-refetch; S-4 applies to GlobalUns + EquipmentPage only.
- S-5: `EquipmentPage.razor:172/217/263` `@onclick="() => DeleteTag(...)"` immediate; `ScriptEdit` delete button immediate. `GlobalUns` has a confirm modal (`GlobalUns.razor:71-98`); `DriverStatusPanel` has an inline Restart confirm.
**Root cause:** the list DTOs the rows render from don't carry RowVersion, so the delete handlers re-load to get *a* RowVersion — silently making delete unconditional. Confirmation was added to GlobalUns/DriverStatusPanel but not propagated to the per-equipment tables or ScriptEdit.
### Proposed design
- **S-4 — carry the rendered RowVersion:** add `RowVersion` (byte[]/base64) to the tag/vtag/alarm *list* DTOs returned by `IUnsTreeService` (they already exist for the edit path; extend the list projections). The delete handlers pass the row's RowVersion straight into `Delete…Async` — no re-load. Then a concurrent edit between render and click yields the existing `DbUpdateConcurrencyException` → surfaced as "changed by another user", matching the update contract (first-writer-wins). Same for GlobalUns Area/Line/Equipment (the tree nodes would carry RowVersion, or keep GlobalUns re-load but **document it as intentional** — the tree is a coarser surface).
- *Alternative:* explicitly document delete as unconditional last-writer-wins. Rejected for EquipmentPage (inconsistent with its own updates); acceptable as a documented fallback for the GlobalUns tree if threading RowVersion through the tree DTO is disproportionate.
- **S-5 — shared confirm:** extract a small reusable `ConfirmButton`/`ConfirmModal` component (generalize the `GlobalUns` confirm modal) and use it for `EquipmentPage` tag/vtag/alarm deletes and `ScriptEdit` delete. Reduces duplication and gives every destructive action a consistent guard.
### Implementation steps
1. Extend list DTOs in `Uns/UnsTreeService.cs` + `IUnsTreeService.cs` (tag/vtag/alarm list projections) with `RowVersion`.
2. `EquipmentPage` delete handlers: drop the fresh-load, pass `row.RowVersion`.
3. Add `Components/Shared/ConfirmModal.razor` (extract from GlobalUns) or a `ConfirmButton`; wire into `EquipmentPage` (×3) and `ScriptEdit`.
4. Optionally thread RowVersion into the GlobalUns tree node DTO, or add a one-line "delete is unconditional here" doc-comment.
### Tests + live-verify
- **Unit (UnsTreeService.Tests — the strong seam):** list projections now include RowVersion; add a test asserting `DeleteTagAsync(id, staleRowVersion)` returns a concurrency failure (the service already has RowVersion tests to mirror).
- **Live-verify:** on docker-dev, open `/uns/equipment/{id}` Tags tab, click Delete → confirm modal appears → cancel leaves the tag → confirm deletes. For concurrency: open the equipment in two tabs, edit+save a tag in tab A, then Delete it in tab B → expect the "changed by another user" error, not a silent delete. Repeat for ScriptEdit delete-confirm.
**Effort:** M. **Risk:** Low-Medium — DTO shape change ripples to a few call sites; the confirm-modal extraction is additive.
---
## 4. S-3 (Med) — No `ErrorBoundary`
**Restated:** A single unhandled exception in any handler/render tears down the whole circuit; there's no `ErrorBoundary` anywhere in a 77-component console.
**Verification (confirmed):** `grep ErrorBoundary` = none; `MainLayout.razor:53` renders `@Body` bare inside `ThemeShell`.
**Root cause:** never added; the shared `ThemeShell` chassis doesn't provide one.
**Design:** wrap `@Body` in `MainLayout` in an `<ErrorBoundary>` with an error view + a "Reload page" / `Recover()` action; on error, log via the injected logger and show a themed panel instead of the framework's error UI. Consider a second, tighter boundary around the live-tail panels (`Alerts`, `ScriptLog`, `DriverStatusPanel`) so a bridge/render fault there doesn't blank the dashboard — but the single `MainLayout` boundary is the high-value 80%.
**Implementation:** edit `MainLayout.razor` `ChildContent` to `<ErrorBoundary Context="ex">…<ErrorContent>` + recover button; add a `_boundary.Recover()` on navigation if desired.
**Tests + live-verify:** no bUnit — live-verify by temporarily throwing in a page handler (or triggering a known-faulting path) and confirming the boundary panel + Recover works instead of a dead circuit. Leave a note that this can't be unit-tested.
**Effort:** S. **Risk:** Low (purely additive resilience).
---
## 5. S-1 + S-2 (Med) — Timer disposal + refresh error handling
**Restated:** Three components don't drain in-flight timer callbacks and `Fleet`/`AlarmsHistorian` mishandle refresh errors.
**Verification (confirmed):**
- S-1: `Fleet.razor:171` + `AlarmsHistorian.razor:90` sync `Dispose() => _timer?.Dispose()`; `DriverTestConnectButton.razor:66-81` `System.Timers.Timer` + `async (_,_) => {…}` Elapsed (effectively async-void) + sync `Dispose`. The house pattern (`System.Threading.Timer` + `IAsyncDisposable`) is used correctly in `DriverStatusPanel`/`Alerts`/`Hosts`.
- S-2: `Fleet.LoadAsync` `try/finally` no `catch` (a faulted `_ = InvokeAsync(LoadAsync)` is unobserved); `AlarmsHistorian` bare `catch {}` → permanent "Loading…".
**Root cause:** three stragglers predate the async-dispose convention; `Fleet` copied the timer-schedule but not the `Hosts` catch-log-degrade shape.
**Design:** converge all three timers onto `System.Threading.Timer` + `public async ValueTask DisposeAsync()` that awaits `_timer.DisposeAsync()` (and unsubscribes first, per the `DriverStatusPanel` template). For `DriverTestConnectButton`, replace `System.Timers.Timer`/`Elapsed` with a `System.Threading.Timer` one-shot (`Timeout.InfiniteTimeSpan` period) — kills the async-void. Give `Fleet.LoadAsync` a `catch (Exception ex) { _error = …; Log … }` (copy `Hosts.LoadConfigAsync`) and add an explicit `_error` surface. Give `AlarmsHistorian` an explicit "no historian on this node role" state instead of infinite "Loading…" (also closes U-2's admitted TODO).
**Implementation:** edit the three components; make them `@implements IAsyncDisposable`; add error fields + rendering. Reuse the exact `DriverStatusPanel.DisposeAsync` shape.
**Tests + live-verify:** live-verify on docker-dev — open `/fleet`, `/alarms-historian` on an admin-only vs driver node and confirm graceful states; navigate away rapidly during a refresh tick to confirm no disposed-component `StateHasChanged` warnings in the circuit log. Not unit-testable without bUnit.
**Effort:** S. **Risk:** Low.
---
## 6. P-1 (Med) — Debounce Monaco value push
**Restated:** `OnValueChanged(editor.getValue())` crosses the SignalR circuit on every keystroke and re-renders the parent.
**Verification (confirmed):** `monaco-init.js:205-207` fires `invokeMethodAsync("OnValueChanged", …)` inside `onDidChangeModelContent` with no debounce; diagnostics use a 500 ms `setTimeout` debounce already.
**Root cause:** the value sync was wired for immediacy; the .NET side only consumes it for Save + the inline problems panel, neither of which needs per-keystroke fidelity.
**Design:** debounce the value push ~200-300 ms (reuse the existing `diagTimer` debounce shape → add a `valueTimer`), *or* push on the diagnostic tick + on blur/save. Ensure Save reads the latest value (either via a final flush on blur/save-trigger or by having Save request `editor.getValue()` through a JS interop call synchronously). Keep the model-content event driving diagnostics as-is.
**Implementation:** edit `wwwroot/js/monaco-init.js` (`onDidChangeModelContent`), add a debounced value push + a flush path invoked before Save. Verify `ScriptEdit` + the virtual-tag modal Save both see current text.
**Tests + live-verify:** live-verify on docker-dev `/scripts/{id}` — type rapidly and confirm (network panel) `OnValueChanged` fires at debounce cadence not per keystroke; save and confirm the persisted source matches the editor. No unit coverage (JS interop).
**Effort:** S. **Risk:** Low-Medium — the one hazard is a lost final edit if the flush-before-save is missed; the flush path must be verified.
---
## 7. P-2 (Med) — Memoize ScriptAnalysis compilation
**Restated:** Each of the six endpoints re-parses + creates a fresh `CSharpCompilation` for the same text during a typing burst.
**Verification (confirmed):** `ScriptAnalysisService.Analyze` (60-67) `CSharpSyntaxTree.ParseText(full)` + `CSharpCompilation.Create(...)` per call; `Sandbox.References`/`Preamble`/`CompileOptions` are static (already cached).
**Root cause:** no `(normalized text → tree/compilation/model)` memo; a 500 ms diagnostics tick immediately followed by completions/hover/signature-help all recompile identical text.
**Design:** add a **size-1 LRU** (or a tiny `MemoryCache` keyed on the normalized source hash) inside `ScriptAnalysisService` returning the `(Tree, Compilation, Model, PreambleLength)` tuple, shared by all six endpoints. Size-1 collapses the common completions-after-diagnostics case (same text) at negligible memory. Bound it and note it's single-admin-scale; flag before multi-user. Keep it thread-safe (`lock`/`ConcurrentDictionary`) since the service is a shared registration.
**Implementation:** wrap `Analyze(userSource)` with a cache check keyed on the source (or a SHA of it) → last-value cache field. No API change; endpoints unchanged.
**Tests + live-verify:** **unit (ScriptAnalysis.Tests — a strong existing seam):** assert two successive `Analyze`/diagnose+complete calls on identical text produce identical diagnostics (correctness preserved) and that the cache returns the same compilation instance (a memoization assertion via an internal hook). Live-verify: type in the editor, confirm completions/hover latency drops and diagnostics stay correct.
**Effort:** S. **Risk:** Low — correctness risk is stale cache after an edit; keying on the full text (or hash) eliminates it.
---
## 8. C-5 (Med) — Service-seam vs EF-in-page duality
**Restated:** UNS pages go through the testable `IUnsTreeService`; `ScriptEdit`/`Scripts`/`Deployments`/`Fleet`/`Hosts` run EF + `SaveChangesAsync` directly in `@code`, untestable without a host.
**Verification (confirmed):** `ScriptEdit.razor` `DeleteAsync`/save use `DbFactory` + `db.Scripts.Remove` + `SaveChangesAsync` in-page; UNS pages use `Svc`. `IUnsTreeService.UpdateScriptSourceAsync` already exists (`IUnsTreeService.cs:502`).
**Root cause:** new pages copy the nearest file; both idioms are "blessed" by precedent.
**Design:** **declare the `IUnsTreeService` service-seam canonical for all config mutation.** Migrate `ScriptEdit`'s save+delete into the service (an `UpdateScriptSourceAsync` already exists; add `CreateScriptAsync`/`DeleteScriptAsync` if missing) so the logic gains the same RowVersion + test coverage the UNS methods have. `Fleet`/`Hosts` are *read-only* projections — leave their EF-in-page (or move to a thin read service later); the priority is the *mutating* EF-in-page (`ScriptEdit`, and the `Deployments` deploy trigger, which already has service seams). This dovetails with S-4 (list DTOs) and OVERALL theme #4 (verification).
**Implementation:** add/confirm `IUnsTreeService` script CRUD methods; rewrite `ScriptEdit` save/delete to call them; keep read pages as-is with a note.
**Tests:** the migrated script CRUD gets unit coverage in `UnsTreeService.Tests` (delete-with-stale-RowVersion, create, update-source) — turning previously untestable `@code` into covered seam logic. Live-verify script create/edit/delete on `/scripts`.
**Effort:** M. **Risk:** Low-Medium — behavior-preserving refactor of one page; regression risk mitigated by the new seam tests.
---
## 9. Batched Lows / Underdeveloped
Group into one cleanup PR (or fold opportunistically into the above):
- **S-6 (Low):** `DriverStatusPanel.ShowOpResult` — add the `Alerts`-style stale-fire token compare so a queued chip-clear timer can't wipe a newer result. (`DisposeAsync` drain is already correct.) *Effort S.*
- **S-7 (Low):** `AdminOperationsClient.AskAsync<T>` — wrap with a linked CTS + `AskTimeout` to match the three typed methods' contract. *Effort S.*
- **P-3/P-4 (Low):** per-circuit dashboard polling + `Deployments` `SnapshotAndFlattenAsync` per render. Defer; optionally back with `IMemoryCache` keyed on the deploy event before any multi-viewer rollout. Document as accepted-at-current-scale.
- **P-5 (Low):** add `@key` to `Alerts` rows (it `Insert(0, …)` + renders without `@key`, forcing whole-table diffs). *Effort S.*
- **P-6 (Low):** stop registering the Monaco inlay-hints provider until the endpoint is non-empty (`monaco-init.js:99-117`); it round-trips for a documented no-op (`ScriptAnalysisService.InlayHints` always returns empty). *Effort S.*
- **C-6 (Low):** replace the `"GalaxyMxGateway"` literal (`TagModal.razor:311`) with the driver-type constant; dedupe the `DataTypes` array; normalize `TwinCat`/`FOCAS`/`Focas` casing. *Effort S.*
- **U-2 (stubs):** `AlarmsHistorian` role message (folded into S-2); `MonacoEditor.MarkersChanged` `object[]` — model the DTO; `GlobalUns` default-branch "not yet available" message — reword to not leak not-implemented posture. *Effort S each.*
- **U-3 (raw-JSON fallback):** in `TagModal.SaveAsync`, `JsonDocument.Parse` the fallback textarea before submit so malformed JSON is caught client-side instead of at deploy. `_form.TagConfig` is `[Required]` but never validated for well-formed JSON. *Effort S — good defensive win; add a validator unit test.*
- **U-1 / U-4:** strategic, not this-PR. **U-1:** either adopt bUnit for the modal/table state machines, or keep extracting `@code` into plain classes (`HostsDriverView`/`VirtualTagModalHelpers` precedent) — the reflection authz guard (§1) is a cheap down payment. **U-4:** targeted a11y pass on `UnsTree` expander `aria-expanded`/labels, modal focus-trap/Escape, `aria-live` on `Alerts`, table `scope`/captions — do if operator diversity matters.
**Effort:** S each; **Risk:** Low across the batch.
---
## Cross-cutting notes for the implementer
- **The docker-dev auto-login (`DisableLogin=true`) makes every negative-authz test impossible on the default rig** — it grants `DevAuthRoles.All`. Every authz claim in this plan must be proven by the policy unit test + reflection guard (CI) *and* one manual real-login Viewer pass (§1 recipe). Do not accept "it loads for me on docker-dev" as authz verification.
- **No bUnit** means S-1/S-2/S-3/S-5/P-1 are only observable by live `/run`. Prefer, wherever cheap, extracting logic into plain classes (C-5 direction) so the untested `@code` residue stays trivial — this is the repo's stated posture and OVERALL theme #4.
- **The reflection authz guard (§1) is the single highest-leverage test in this report**: it converts "which policy is on which page" from a live-only fact into a CI invariant, directly countering the "built-but-never-wired" house failure mode (OVERALL theme #1).
- Sequencing: land **C-1 + C-2 together** (shared constants/policy), then **S-4/S-5**, **S-3**, **S-1/S-2**, then the perf pair **P-1/P-2**, then **C-5**, then the Lows batch. C-1 and C-5 both touch page files — do C-1 first (attributes) to avoid churn.