ad2b5c4044
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
199 lines
25 KiB
Markdown
199 lines
25 KiB
Markdown
# 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).
|