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

32 KiB
Raw Blame History

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.

  • 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=trueAutoLoginAuthenticationHandler 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 onDidChangeModelContentinvokeMethodAsync("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-161FleetAdmin = 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:

    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):

    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…AsyncDelete…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.