Files
lmxopcua/docs/reviews/2026-10-21/04-adminui.md
T

199 lines
25 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.
# AdminUI review — `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` (master @ d51d35bd)
Scope: Blazor Server AdminUI — `/raw` tree, `/uns`, equipment page, live-status panels, SignalR bridges + in-process stores, ScriptAnalysis (Roslyn) endpoints + Monaco glue, driver browsers / address pickers, CSV import/export. Production code only. Every finding below was verified by reading the cited code; read-only review, nothing edited.
Overall: this slice is in good shape. The hard Blazor-Server lifecycle problems (timer drain, event unsubscribe, superseded-modal-open races, N+1 queries, Razor string slicing) have clearly been worked through already. What remains is mostly **scale limiters** (whole-tree renders, per-keystroke Roslyn compiles, unbounded fan-out) and a few **stubs still wired into the live UI**. One finding is a real correctness hazard (#P1).
---
## Memory / Resource leaks
### L1. Driver-status / resilience snapshot stores never evict — ghost drivers on `/hosts` until process restart
- **Severity:** Medium · **Confidence:** High
- **Location:** `Hubs/InMemoryDriverStatusSnapshotStore.cs:6-17`, `Hubs/InMemoryDriverResilienceStatusStore.cs:6-18`, consumer `Hosts/HostsDriverView.cs` (`Build` iterates `snapList`, i.e. `DriverStore.GetAll()`), `Components/Pages/Hosts.razor:452-453`.
- **Evidence:**
```csharp
private readonly ConcurrentDictionary<string, DriverHealthChanged> _byInstance = new();
public void Upsert(DriverHealthChanged snapshot) { _byInstance[snapshot.DriverInstanceId] = snapshot; ... }
public IReadOnlyCollection<DriverHealthChanged> GetAll() => _byInstance.Values.ToArray();
```
There is no `Remove`/TTL; `HostsDriverView.Build` derives the driver rows from the snapshot list, not from `DriverInstances`.
- **Failure scenario:** delete (or delete+recreate under a new id) a driver instance; its last `DriverHealthChanged` stays in the singleton forever. `/hosts` keeps rendering it as a frozen "stale" row (name/type fall back to id since the config join misses), and `HostsDriverView.Build` cost grows with every id ever seen since the admin node started. Long-lived admin nodes on a churny fleet accumulate.
- **Fix:** evict entries whose `DriverInstanceId` is absent from the config load Hosts already performs every 5 s (`_instances`), or add a `Remove` driven by the driver-removal / deploy message, or expire by `PublishedUtc` (e.g. > 10 min).
### L2. `DriverTestConnectButton` — `System.Timers.Timer` with an `async void` Elapsed handler, never drained
- **Severity:** Low · **Confidence:** Medium
- **Location:** `Components/Shared/Drivers/DriverTestConnectButton.razor:66-74, 82`
- **Evidence:**
```csharp
_clearTimer = new System.Timers.Timer(30_000) { AutoReset = false };
_clearTimer.Elapsed += async (_, _) => { _result = null; await InvokeAsync(StateHasChanged); };
...
public void Dispose() => _clearTimer?.Dispose();
```
- **Failure scenario:** `Timers.Timer.Dispose` does not wait for an in-flight callback, and an `async void` lambda's post-await exception (component/circuit already gone) cannot be observed by the timer's exception suppression — it surfaces as an unhandled thread-pool exception. The sibling components (`DriverStatusPanel`, `Alerts`) explicitly moved to `System.Threading.Timer` + `DisposeAsync` for this reason; this one was left behind.
- **Fix:** mirror `DriverStatusPanel.ShowOpResult` (`System.Threading.Timer`, `IAsyncDisposable`, token check for stale fires).
### L3. `AlarmsHistorian` page — sync timer dispose, no overlap guard, blanket `catch {}`
- **Severity:** Low · **Confidence:** High
- **Location:** `Components/Pages/AlarmsHistorian.razor:58-74, 87`
- **Evidence:** `_timer = new Timer(_ => _ = InvokeAsync(RefreshAsync), ...)` … `public void Dispose() => _timer?.Dispose();` and `RefreshAsync` wraps the `Ask` in a bare `catch { }`.
- **Failure scenario:** after dispose a queued tick still issues an actor `Ask` (2 s timeout) against a gone component; every failure (including "this role has no historian actor") is swallowed and the page sits on "Loading…" forever — the comment admits the role-gated message "lands when we add role gating to the UI".
- **Fix:** copy the `Fleet.razor` pattern (`IAsyncDisposable`, `_disposed`, overlap guard, cancel CTS) and surface the "no historian on this role" state.
### L4. Browse sessions can be orphaned when a picker is disposed mid-`OpenAsync` (bounded by the reaper)
- **Severity:** Low · **Confidence:** High
- **Location:** all `Components/Shared/Drivers/Pickers/*AddressPickerBody.razor` `OpenBrowseAsync` (e.g. `GalaxyAddressPickerBody.razor:146-157`) and `RawBrowseModal.razor` `OpenBrowseAsync`; disposal at e.g. `GalaxyAddressPickerBody.razor:217-225`.
- **Evidence:** `var result = await BrowserService.OpenAsync(...); if (result.Ok) _token = result.Token;` — if the hosting modal is closed/navigated away while the open is in flight, `DisposeAsync` has already run with `_token == Guid.Empty`, then the token lands on a dead component and nobody calls `CloseAsync`.
- **Failure scenario:** an OPC UA client session / MQTT connection / SQL connection stays open against the plant device for up to `IdleTtl` (2 min) + one reaper tick (30 s). Harmless at low volume; a flapping operator can hold several at once.
- **Fix:** in `OpenBrowseAsync`, after the await check a `_disposed` flag and immediately `CloseAsync(result.Token)`.
### L5. In-process stores invoke subscriber delegates synchronously on the bridge actor thread; one throwing subscriber aborts delivery and (for two bridges) restarts the actor
- **Severity:** Low · **Confidence:** High
- **Location:** `Hubs/InMemoryDriverStatusSnapshotStore.cs:13`, `Hubs/IInProcessBroadcaster.cs` (`Publish => Received?.Invoke(item)`), `Hubs/AlertSignalRBridge.cs` `ForwardAsync` (the `_broadcaster.Publish(msg)` sits **outside** the `try`), `Hubs/DriverResilienceStatusBridge.cs` (`Receive<…>(msg => _store.Upsert(msg))`, no catch).
- **Failure scenario:** every open circuit's handler runs inline on the actor thread (`InvokeAsync` is fire-and-forget so it is cheap, but any synchronous throw — e.g. a handler on a circuit whose renderer is mid-teardown — stops the multicast at that subscriber; remaining panels miss the update, and the alert/resilience bridge actor restarts and re-subscribes to DPS). It also means the DPS bridge's throughput is coupled to the number of open admin browser tabs.
- **Fix:** iterate `GetInvocationList()` with per-subscriber try/catch (or post to a channel), and move `Publish` inside the `try` in `AlertSignalRBridge`.
---
## Unimplemented / stubbed
### S1. `ctx.SetVirtualTag` is advertised by the editor but documented (by the editor itself) as inert at runtime
- **Severity:** Medium · **Confidence:** High
- **Location:** `ScriptAnalysis/ScriptAnalysisService.cs:349-353` (hover text), `TryGetTagPathLiteral` offers path completion for `SetVirtualTag` (`:295-300`).
- **Evidence:** `"⚠ Cross-tag SetVirtualTag writes are currently dropped in single-tag mode (the cascade engine is not wired into the host); this write will not take effect at runtime."`
- **Failure scenario:** an author gets completions + green diagnostics for a call that silently does nothing when deployed. Only a hover reveals it.
- **Fix:** either wire the cascade path or emit an `OTSCRIPT_*` warning marker from `Diagnose` for `ctx.SetVirtualTag(...)` so the Problems list shows it, not just hover.
### S2. Inlay-hints endpoint is a no-op, but the JS still POSTs the whole script to it
- **Severity:** Low · **Confidence:** High
- **Location:** `ScriptAnalysis/ScriptAnalysisService.cs:468-472` (`InlayHints(...) => new(Array.Empty<InlayHint>())`), `ScriptAnalysisEndpoints.cs:24`, `wwwroot/js/monaco-init.js:104-121` (`registerInlayHintsProvider` → `fetch("/api/script-analysis/inlay-hints", { body: model.getValue() })`).
- **Failure scenario:** Monaco re-requests inlay hints on content/viewport changes, so every edit costs an extra authenticated POST carrying the full source, for an always-empty answer.
- **Fix:** drop the provider registration (and endpoint) until the feature exists.
### S3. `MonacoEditor.MarkersChanged` / `RevealLineAsync` ("problems panel") have no consumer; marker array is still round-tripped JS→.NET after every diagnostics pass
- **Severity:** Low · **Confidence:** High
- **Location:** `Components/Shared/MonacoEditor.razor:39-44, 129-131, 134-138` ("The marker DTO is not modelled yet — typed as object[] until a later task…"); `monaco-init.js:209` (`dotNetRef.invokeMethodAsync("OnMarkersChanged", markers)`); `grep MarkersChanged` finds no consumer in `ScriptEdit.razor` / `VirtualTagModal.razor`.
- **Fix:** either build the problems panel or remove the callback + JS invoke.
### S4. Dead service surface: `IUnsTreeService.LoadTagsForEquipmentAsync` / `EquipmentTagRow` / `tagCounts`
- **Severity:** Low · **Confidence:** High
- **Location:** `Uns/UnsTreeService.cs` (`LoadTagsForEquipmentAsync` returns `Array.Empty<EquipmentTagRow>()`; `LoadStructureAsync` builds an always-empty `tagCounts`), `Uns/EquipmentChildRows.cs:7`. No production caller.
- **Fix:** delete the member, record and the zero-count plumbing (v3 retired the binding).
### S5. Deploy API `202 Accepted` Location points at a route that does not exist
- **Severity:** Low · **Confidence:** High
- **Location:** `Api/DeployApiEndpoints.cs:86-88` — `Results.Accepted($"/api/deployments/{r.DeploymentId?.Value}", …)`; the only route mapped is `MapPost("/api/deployments")`; no `MapGet` anywhere in `src/Server`.
- **Failure scenario:** a REST client that follows the `Location` for status polling gets a 404.
- **Fix:** add a GET that reads `Deployments`/`NodeDeploymentStates` for the id, or return `Accepted(value)` without a location.
### S6. Equipment CSV import is a naive comma split (no RFC-4180), unlike the raw-tag CSV import
- **Severity:** Low · **Confidence:** High
- **Location:** `Components/Shared/Uns/ImportEquipmentModal.razor` `ParseCsv` — `// NOTE: simple comma split — no RFC4180 quoting; values with commas are not supported.`
- **Failure scenario:** a quoted `Manufacturer` containing a comma silently shifts every later column (Model/SerialNumber…) with no error. The raw-tag importer (`CsvParser` + `CsvColumnMap`) already does this correctly.
- **Fix:** reuse the raw CSV parser.
### S7. Dev-only demo page ships in the production assembly
- **Severity:** Low · **Confidence:** High
- **Location:** `Components/Pages/Dev/ContextMenuDemo.razor:1-3` — `@page "/dev/context-menu-demo"`, `[Authorize(Policy = AuthenticatedRead)]`.
- **Fix:** `#if DEBUG` the route or move it to a test host.
---
## Performance limiters
### P1. Monaco ships the full editor text JS→.NET on every keystroke; Blazor Server's 32 KB `MaximumReceiveMessageSize` is never raised
- **Severity:** High · **Confidence:** Medium (limit + code path verified; failure mode on exceed is per ASP.NET Core SignalR behaviour)
- **Location:** `wwwroot/js/monaco-init.js:213-214` (`editor.onDidChangeModelContent(() => dotNetRef.invokeMethodAsync("OnValueChanged", editor.getValue()).catch(function () {}))`), `Components/Shared/MonacoEditor.razor:119-127`, `EndpointRouteBuilderExtensions.cs:56` (`AddRazorComponents().AddInteractiveServerComponents()` — no `AddHubOptions`; `grep MaximumReceiveMessageSize src` → nothing).
- **Failure scenario:** (a) cost: each keystroke transfers O(script size) over the circuit — a 20 KB script typed at 5 keys/s is 100 KB/s per author, before the separate `fetch` calls below. (b) correctness: once the script exceeds 32 KB (paste a large script), the JS→.NET invoke exceeds the hub's receive limit; the failure is swallowed by `.catch(function () {})`, so `_lastSentValue`/`Value` silently stop tracking the editor and **Save persists stale source** (ScriptEdit `SubmitAsync` reads `_form.SourceCode`, which only `ValueChanged` updates). SignalR may also tear the circuit down on the oversize frame.
- **Fix:** read the text back on demand (`MonacoBlazor.getValue` already exists) at Save / blur instead of per keystroke, or send `onDidChangeModelContent` *changes* and apply them server-side; and raise `HubOptions.MaximumReceiveMessageSize` (or document the cap).
### P2. Roslyn: a full `CSharpCompilation` + semantic model per request, per keystroke, with no cancellation anywhere
- **Severity:** Medium · **Confidence:** High
- **Location:** `ScriptAnalysis/ScriptAnalysisService.cs:60-67` (`Analyze` → `CSharpCompilation.Create(...)` every call), callers `CompleteAsync:220`, `Hover:323`, `SignatureHelp:422`, `Diagnose:97`; `ScriptAnalysisEndpoints.cs:19-24` (handlers do not take `CancellationToken`/`RequestAborted`); `monaco-init.js:32-72` (`quickSuggestions.strings = true`, providers ignore Monaco's cancellation token, no `AbortController`).
- **Evidence:** `var compilation = CSharpCompilation.Create("ScriptAnalysis", new[] { tree }, Sandbox.References, CompileOptions); return (tree, compilation, compilation.GetSemanticModel(tree), …)`.
- **Failure scenario:** typing inside `ctx.GetTag("…")` fires a completion request per character plus hover plus signature help plus the 500 ms diagnostics; each is a fresh compile against the full sandbox reference set on the admin node's thread pool, and an abandoned request runs to completion. Two or three concurrent authors on a big script make the admin node (which also hosts the control-plane actors) visibly busy.
- **Fix:** keep one `CSharpCompilation` per scoped service and use `compilation.ReplaceSyntaxTree` (cheap incremental re-bind); plumb `HttpContext.RequestAborted` through; honour Monaco's `CancellationToken` + `AbortController` in the providers; consider server-side debounce on completions.
### P3. `ScriptTagCatalog.GetPathsAsync` rebuilds a HashSet of every fleet tag path and sorts on every completion keystroke
- **Severity:** Medium · **Confidence:** High
- **Location:** `ScriptAnalysis/IScriptTagCatalog.cs` `GetPathsAsync` (`new HashSet<string>(entries.Select(e => e.Path), OrdinalIgnoreCase)` → `Where(StartsWith)` → `OrderBy` → `Take(200)`), `GetTagInfoAsync` (linear `FirstOrDefault` per hover).
- **Failure scenario:** the 5 s snapshot cache fixed the DB side, but the in-memory side is still O(n log n) allocation per keystroke; on a 50k-tag fleet that is tens of MB of garbage per typed character.
- **Fix:** materialise a sorted, de-duplicated `string[]` (and a path→info dictionary) inside the `Snapshot`; prefix-search with `Array.BinarySearch`.
### P4. `/hosts` rebuilds every cluster group and re-renders the whole page on every driver health publish
- **Severity:** Medium · **Confidence:** High
- **Location:** `Components/Pages/Hosts.razor:478-479` (`OnSnapshotChanged(DriverHealthChanged _) => InvokeAsync(() => { RebuildDriverGroups(); StateHasChanged(); })`), `Hosts/HostsDriverView.Build` (full re-projection: `ToList` + per-cluster `Where` scans), publish cadence documented at `Hosts.razor:301-302` ("5 s publish interval").
- **Failure scenario:** N enabled drivers ⇒ N full rebuilds + N full-page diffs every 5 s per open `/hosts` tab (200 drivers ≈ 40/s), on top of the page's own 5 s tick. Each `HostsDriverRow` ctor also re-sorts `Diagnostics` and `HostStatuses`.
- **Fix:** set a dirty flag in `OnSnapshotChanged` and let the existing 5 s `TickAsync` do the rebuild (or throttle to ≤1 rebuild/s), and rebuild only the affected cluster.
### P5. `RawTree` re-creates every node's context-menu list per render and re-renders a `ContextMenu` child per row; no virtualization
- **Severity:** Medium · **Confidence:** High
- **Location:** `Components/Shared/Raw/RawTree.razor:107-137` (`RenderNode` → `var items = MenuFor(node)` → `new List<ContextMenuItem> { new() { … OnClick = () => … } }` per node per render), `:129` (`<ContextMenu Items="items" …>` — `Items` is a reference-type parameter, so Blazor treats it as changed on every render), `grep Virtualize` → unused anywhere in AdminUI; `Uns/RawTreeService.cs` `LoadDeviceChildrenAsync` / `LoadTagGroupChildrenAsync` return every tag of the device/group.
- **Failure scenario:** expand a device with a few thousand root tags: every spinner toggle, checkbox click (picker mode `ToggleTagSelection` calls `StateHasChanged`), or modal open/close re-renders thousands of rows, allocates thousands of menu lists + closures, and re-renders thousands of `ContextMenu` children. Same shape in `DriverBrowseTree` (`RenderNode` per browse node) and `UnsTree`.
- **Fix:** cache the per-node menu on `RawNode` (invalidate on `Enabled` change), add `ShouldRender`/`@key` discipline, or wrap each expanded level in `<Virtualize>`; page tags from the service (e.g. 500 per level with a "show more").
### P6. Whole CSVs are rendered into the circuit: import review grid has no paging, export embeds the file twice in the DOM
- **Severity:** Medium · **Confidence:** High
- **Location:** `Components/Shared/Raw/RawCsvImportModal.razor:85` (`@foreach (var row in _result.Rows)` — every parsed row of a file up to the 16 MB cap at `:261`), `Components/Shared/Raw/RawCsvExportModal.razor:46` (`<textarea …>@_csv</textarea>`) and `:54` (`href="@_dataUri"` = base64 of the same text) with both strings retained in `_csv`/`_dataUri` while the modal is open.
- **Failure scenario:** exporting a 50k-tag device produces a multi-MB CSV; the render batch pushed over SignalR is ≈2.3× that size (textarea + base64) and the DOM holds it; importing a large file renders 100k+ `<tr>`s in one go and recomputes `OkCount/ErrorCount/RemainingCount` (three full scans) on every render.
- **Fix:** `<Virtualize>` the review grid; stream the export via `IJSRuntime` + `DotNetStreamReference` or a download endpoint; show a bounded preview (first N lines) in the textarea.
### P7. Alerts / ScriptLog: one `StateHasChanged` per received event; list re-filtered per render
- **Severity:** Low · **Confidence:** High
- **Location:** `Components/Pages/ScriptLog.razor:128-134` (`OnEntry` → `InvokeAsync(() => { _rows.Insert(0, entry); …; StateHasChanged(); })`), `:95-110` (`VisibleRows` filters + `ToList()` on every render), `Components/Pages/Alerts.razor:279-286`.
- **Failure scenario:** a chatty script (`log` per evaluation per tag) drives one full-page render per line on every open `/script-log` tab; `Insert(0)` on a `List<T>` of 500 is a memmove per line.
- **Fix:** batch via a 100–250 ms coalescing timer; use a ring buffer / `LinkedList`.
### P8. `/scripts` list loads every script *with its full `SourceCode`*
- **Severity:** Low · **Confidence:** High
- **Location:** `Components/Pages/Scripts.razor:117-123` (`db.Scripts.AsNoTracking().OrderBy(s => s.Name).ToListAsync()` — full entity), source only needed for the on-demand "view source" dialog (`FindSourceScript`).
- **Fix:** project `{ScriptId, Name, Language, SourceHash, RowVersion}` for the list; fetch `SourceCode` when the dialog opens.
### P9. Diagnostics responses are not sequenced — a slow older response can overwrite newer markers
- **Severity:** Low · **Confidence:** High
- **Location:** `wwwroot/js/monaco-init.js:204-212` (`scheduleDiagnostics` debounces the *start* but `fetchDiagnostics` results are applied whenever they arrive).
- **Failure scenario:** type, pause 500 ms (request A, slow — the admin node is busy per P2), type again, pause (request B, fast): B's markers land, then A's stale markers overwrite them; the editor shows errors for code that no longer exists until the next edit.
- **Fix:** generation counter captured before the fetch, ignore results whose generation != current; add `AbortController`.
---
## Other hazards
### H1. Destructive actions with no confirmation step
- **Severity:** Medium · **Confidence:** High
- **Location:** `Components/Pages/Uns/EquipmentPage.razor:243, 298` (per-row Delete buttons bound directly to `DeleteVirtualTag` / `DeleteAlarm`, which delete immediately at `:447-456`, `:492-501`); `Components/Pages/ScriptEdit.razor:72, 145-160` (`DeleteAsync` deletes the script on click). Contrast: `/uns` and `/raw` route every delete through a confirm modal (`GlobalUns.razor` `_confirmNode`, `RawTree.razor` `ShowConfirm`).
- **Failure scenario:** a mis-click on a dense row deletes a scripted alarm / virtual tag (and, after the next deploy, its OPC UA node and any ScadaBridge subscription to it) with no undo.
- **Fix:** reuse `RawConfirmDialog`.
### H2. Modal parameter dictionaries and `EventCallback`s are re-created on every render
- **Severity:** Low · **Confidence:** High
- **Location:** `Components/Shared/Drivers/DriverConfigModal.razor` (`_formParameters => new() { … EventCallback.Factory.Create(...) }`), `DeviceModal.razor` (`_deviceFormParameters`), `RawTagModal.razor` `BuildEditorParameters()`.
- **Failure scenario:** `DynamicComponent` sees new parameter values every render, so the hosted driver/device/tag-config form runs `OnParametersSet` on every parent render. Correctness is protected only by each form's `_lastParsed` guard (verified present in all 12 driver forms + 9 device forms); any future form that forgets the guard will clobber in-progress edits on the first live-status push. This is the exact bug class the `MqttDriverForm` note describes.
- **Fix:** build the dictionary once per open (in `LoadAsync`) and cache the callbacks in fields.
### H3. `monaco.editor.setTheme` is global
- **Severity:** Low · **Confidence:** High
- **Location:** `monaco-init.js:218-221` (comment acknowledges it) + `MonacoEditor.razor:158-162`.
- **Failure scenario:** `VirtualTagModal` and the host page can each mount an editor; toggling theme on one flips the other, and the per-component `_dark` flags drift from reality. Cosmetic.
---
## Checked-and-fine (looked suspicious, verified OK)
- **Timer/event lifecycle on the live pages** — `Fleet`, `Hosts`, `DriverStatusPanel`, `Alerts`, `ScriptLog`, `PageErrorBoundary`, `Certificates` all unsubscribe singleton events, drain `System.Threading.Timer` via `DisposeAsync`, cancel a page CTS, and guard `StateHasChanged` after teardown (`_disposed`, `_tickRunning`). `ToastNotification` binds auto-dismiss delays to a disposal CTS.
- **Browse pipeline** — `BrowseSessionRegistry` + `BrowseSessionReaper` (2 min idle TTL, 30 s tick, drain on shutdown), 20 s per-call timeout, "Browse" buttons hidden while `_token != Guid.Empty` (no double-open leak), `RawBrowseModal.CloseAsync` always closes the session, `RefreshTree` re-keys the tree without opening a second session, rebirth is authorized server-side and re-probed before publish.
- **EF query shapes** — `RawTreeService` / `UnsTreeService` / `ScriptTagCatalog` / `AclScopeService` use `AsNoTracking` + projections and batch child counts with `GroupBy`/`Contains` (no N+1 found); rename script scan runs server-side in batches; `ImportTagsAsync` / `ImportEquipmentAsync` pre-load lookup sets and `SaveChanges` once; `Deployments` pages `Take(50)`, `ClusterAudit` pages 200.
- **Razor string slicing (#504 class)** — every `[..N]` is either guarded by a `Length > N` check or applied to a fixed-width `Guid.ToString("N")`; DB values go through `DisplayText.Abbreviate`.
- **Superseded-open races** — `RawBrowseModal`, `RawCsvImportModal`, `RawCsvExportModal`, `DeviceModal`, `DriverConfigModal`, `RawTagModal`, `VirtualTagModal` all carry an open-generation / loaded-key guard so a late await cannot install another target's state.
- **Caches** — `ScriptTagCatalogSnapshotCache` (5 s TTL, single-flight gate, keeps old snapshot on failure) and `DeploymentDriftProbe` (watermark-keyed, single-flight) are bounded single-entry caches, not growing maps. No static mutable state in components (only `JsonSerializerOptions`).
- **Roslyn references** — `Sandbox.References` / `Preamble` are process-static and shared; no `AdhocWorkspace`/`MetadataReference` is created per request (the per-request cost is the compilation itself, see P2).
- **JS interop** — the single `DotNetObjectReference` (`MonacoEditor`) is disposed; `MonacoBlazor.dispose` is called under `JSDisconnectedException` handling; prerender `InvalidOperationException` is handled.
- **TelemetryDialSupervisor** — the `Task.Run` dial loop only `Tell`s `Self`; `Dialer.Cancel()` cancels **and disposes** the CTS; reconnect timer per node is replaced, not stacked.
- **Auth on the push/analysis surfaces** — `AlertHub`/`ScriptLogHub`/`DriverStatusHub` are `[Authorize]`; `/api/script-analysis/*` requires `ConfigEditor`; `/api/deployments` is API-key gated with constant-time compare.
- **Driver-type coverage** — all 12 `DriverTypeNames` have a driver form (`DriverFormMaps`), tag-config editor + validator; Sql/MTConnect/Calculation deliberately have no device form (`SingleConnectionDriverTypes`), so nothing is "registered but unauthorable" in this slice.
- **`GlobalUns` "Delete for this node kind is not yet available"** — unreachable; `UnsTree.RenderActions` never renders Delete for Tag/VirtualTag.
- **`ClusterNamespaces.razor` placeholder** — intentional redirect stub so old links don't 404.
- **`InputFile`** — streamed with a 16 MB cap (not subject to the 32 KB hub limit in P1).
- **`TwinCATDriverForm` "PLC diagnostics… not yet live-gated"** — persisted and consumed by the driver; the note is about test coverage, not a dangling option (driver slice).