@page "/deployment/debug-view" @using ZB.MOM.WW.ScadaBridge.Security @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories @using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView @using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming @using ZB.MOM.WW.ScadaBridge.Commons.Types @using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums @using ZB.MOM.WW.ScadaBridge.Communication @attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)] @inject ITemplateEngineRepository TemplateEngineRepository @inject ISiteRepository SiteRepository @inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope @inject DebugStreamService DebugStreamService @inject IJSRuntime JS @implements IDisposable

Debug View

@if (_loading) { } else { @* Status strip — connection state, instance, last snapshot. *@
@if (_connected) { var inst = _siteInstances.FirstOrDefault(i => i.Id == _selectedInstanceId); @(inst?.UniqueName ?? "Connected") } else { Not connected } @if (_connected) { Live } else { Disconnected }
@if (_snapshot != null) { Last snapshot: @_snapshot.SnapshotTimestamp.LocalDateTime.ToString("HH:mm:ss") } @if (_connected && _connectedFromStorage) { }
@if (!_connected) { } else { }
@if (_connected && _snapshot != null) {
@* Tabbed layout — Attributes / Alarms, each a composition tree. *@
@* ── Attributes tab ── *@
@if (node.Attribute is not null) { @* Leaf — one attribute value. *@ @node.Segment @ValueFormatter.FormatDisplayValue(node.Attribute.Value) @node.Attribute.Quality @node.Attribute.Timestamp.LocalDateTime.ToString("HH:mm:ss") } else { @* Branch — composition member. *@ @node.Segment @if (node.HasBadQuality) { ⚠ bad quality } }
No attributes.
@* ── Alarms tab ── *@
@if (node.Alarm is not null) { @* Leaf — computed alarm or native condition. *@ @node.Segment @if (!string.IsNullOrEmpty(node.Alarm.Message)) { 💬 } } else if (node.IsNativeBinding) { @* Native source binding branch. *@ @node.Segment @if (node.ActiveCount > 0) { @node.ActiveCount active } else if (!node.HasChildren) { no active conditions } } else { @* Generic composition branch. *@ @node.Segment @if (node.WorstState == AlarmState.Active) { @node.ActiveCount active } }
No alarms.
} else if (_connected) { } }
@code { [SupplyParameterFromQuery] public int? SiteId { get; set; } [SupplyParameterFromQuery] public int? InstanceId { get; set; } private List _sites = new(); private List _siteInstances = new(); // The site's areas, backing the InstancePicker's tree. Both lists are REPLACED // (never mutated in place) on a site change — the picker compares them by // reference and treats a new object as "the site changed". private List _siteAreas = new(); private int _selectedSiteId; private int _selectedInstanceId; private bool _loading = true; private bool _connected; private bool _connecting; private bool _connectedFromStorage; // Which tab pane is visible — "attributes" (default) or "alarms". private string _activeTab = "attributes"; private DebugViewSnapshot? _snapshot; // Keyed dictionaries hold the latest value per attribute/alarm — the // current-status source of truth the composition trees are built from. private Dictionary _attributeValues = new(); private Dictionary _alarmStates = new(); // Per-tab name-contains filters. private string _attrFilter = string.Empty; private string _alarmFilter = string.Empty; private TreeView? _attrTree; private TreeView? _alarmTree; // Retained as a named enumeration target for DebugViewStreamRaceTests (reflection-based); // not consumed in the Blazor markup — the trees render from AttributeForest/AlarmForest. // Still enumerates the live _attributeValues dict on the render thread (CentralUI-021). private IReadOnlyList FilteredAttributeValues => string.IsNullOrWhiteSpace(_attrFilter) ? _attributeValues.Values.OrderBy(a => a.AttributeName).ToList() : _attributeValues.Values .Where(a => a.AttributeName.Contains(_attrFilter, StringComparison.OrdinalIgnoreCase)) .OrderBy(a => a.AttributeName) .ToList(); // ── Tree memoization (arch-review WP2.4) ────────────────────────────────── // Both forests used to be rebuilt from scratch on EVERY render — and a render // fired per streamed event. A busy instance therefore rebuilt two whole // composition trees hundreds of times a second, on the circuit's single // dispatcher thread. The rebuild now sits behind a version stamp bumped // whenever the underlying dictionaries change (a coalesced stream flush, // Connect, Disconnect), so an unrelated re-render (tab switch, toast, status // strip tick) reuses the forest instead of rebuilding it. Reusing the same // instance also lets Blazor skip re-rendering the TreeView subtree entirely. // // The cached key includes the dictionary Count as well as the version: the // trees must never go stale if the dictionaries are populated by some path // that forgets to bump the stamp. private int _dataVersion; private IReadOnlyList? _attrForestCache; private (int Version, int Count, string Filter) _attrForestKey = (-1, -1, string.Empty); private IReadOnlyList? _alarmForestCache; private (int Version, int Count, string Filter) _alarmForestKey = (-1, -1, string.Empty); /// Attribute composition forest, rebuilt only when the underlying data or filter changed. private IReadOnlyList AttributeForest { get { var key = (_dataVersion, _attributeValues.Count, _attrFilter); if (_attrForestCache is null || _attrForestKey != key) { _attrForestCache = DebugTreeBuilder.BuildAttributeTree(_attributeValues.Values, _attrFilter); _attrForestKey = key; } return _attrForestCache; } } /// Alarm composition forest, rebuilt only when the underlying data or filter changed. private IReadOnlyList AlarmForest { get { var key = (_dataVersion, _alarmStates.Count, _alarmFilter); if (_alarmForestCache is null || _alarmForestKey != key) { _alarmForestCache = DebugTreeBuilder.BuildAlarmTree(_alarmStates.Values, _alarmFilter); _alarmForestKey = key; } return _alarmForestCache; } } private DebugStreamSession? _session; /// /// Guards the slot together with /// (arch-review 2026-08-19 P6-6). Connect awaits StartStreamAsync for up /// to 30s; the circuit can be torn down inside that window, and Dispose may run /// on a non-UI thread, so publishing the session and observing disposal must be one /// atomic step. Whoever loses the race is the one that stops the stream. /// private readonly object _sessionLock = new(); private ToastNotification _toast = default!; private string? _initError; // CentralUI-009: the stream callbacks (onEvent/onTerminated) run on an // Akka/gRPC thread and capture `this` and `_toast`. Once the component is // disposed, an in-flight callback must no-op rather than touch a disposed // component (InvokeAsync would throw ObjectDisposedException) or a disposed // ToastNotification. private volatile bool _disposed; protected override async Task OnInitializedAsync() { try { // Site scoping (CentralUI-002): a scoped Deployment user may only // debug sites they are permitted on. _sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync()); } catch (Exception ex) { _initError = $"Failed to load sites: {ex.Message}"; } _loading = false; } protected override async Task OnAfterRenderAsync(bool firstRender) { if (!firstRender) return; if (_initError != null) { _toast.ShowError(_initError); _initError = null; } if (SiteId is > 0 && InstanceId is > 0) { _selectedSiteId = SiteId.Value; await LoadInstancesForSite(); if (_siteInstances.Any(i => i.Id == InstanceId.Value)) { _selectedInstanceId = InstanceId.Value; await Connect(); } else { _toast.ShowError("Requested instance is not available for debug streaming."); } StateHasChanged(); return; } var storedSiteId = await JS.InvokeAsync("localStorage.getItem", "debugView.siteId"); var storedInstanceId = await JS.InvokeAsync("localStorage.getItem", "debugView.instanceId"); if (!string.IsNullOrEmpty(storedSiteId) && int.TryParse(storedSiteId, out var siteId) && !string.IsNullOrEmpty(storedInstanceId) && int.TryParse(storedInstanceId, out var instanceId)) { _selectedSiteId = siteId; await LoadInstancesForSite(); _selectedInstanceId = instanceId; _connectedFromStorage = true; StateHasChanged(); await Connect(); // Auto-reconnect notice — the user didn't initiate this connection. var inst = _siteInstances.FirstOrDefault(i => i.Id == instanceId); _toast.ShowInfo( $"Auto-reconnected to {inst?.UniqueName ?? "instance"} from previous session.", autoDismissMs: 8000); } } private async Task LoadInstancesForSite() { _siteInstances = new(); _siteAreas = new(); _selectedInstanceId = 0; if (_selectedSiteId == 0) return; // Site scoping (CentralUI-002): re-check the claim server-side — a query // string or stale localStorage value could name a site outside the grant. if (!await SiteScope.IsSiteAllowedAsync(_selectedSiteId)) { _selectedSiteId = 0; _toast.ShowError("You are not permitted to debug instances on that site."); return; } try { _siteInstances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)) .Where(i => i.State == InstanceState.Enabled) .ToList(); _siteAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList(); } catch (Exception ex) { _toast.ShowError($"Failed to load instances: {ex.Message}"); } } /// /// The label the InstancePicker renders on a row and in its field: /// the instance's unique name with its lifecycle state. /// /// The instance to label. /// The display label, e.g. Line1.Mixer (Enabled). private static string InstanceLabel(Instance instance) => $"{instance.UniqueName} ({instance.State})"; /// /// Records the instance the operator picked in the InstancePicker. /// Everything downstream (the Connect button's enablement, Connect() /// itself) reads _selectedInstanceId, so there is nothing else to do. /// /// Id of the picked instance. private void OnInstancePicked(int id) => _selectedInstanceId = id; /// /// When the attribute filter changes, expand the tree so any branches holding /// freshly-matched leaves are visible (the builder already prunes non-matches). /// private void OnAttrFilterChanged() => _attrTree?.ExpandAll(); /// As , for the alarm tree. private void OnAlarmFilterChanged() => _alarmTree?.ExpandAll(); private async Task Connect() { if (_selectedInstanceId == 0 || _selectedSiteId == 0) return; _connecting = true; try { var session = await DebugStreamService.StartStreamAsync( _selectedInstanceId, onEvent: HandleStreamEvent, onTerminated: () => { _connected = false; _ = TakeSession(); // CentralUI-009: skip the toast/render if already disposed. if (_disposed) return; _ = SafeInvokeAsync(() => { if (_disposed) return; _toast.ShowError("Debug stream terminated (site disconnected)."); StateHasChanged(); }); }); // M2.11: the site returns InstanceNotFound=true when the instance is // not deployed there (e.g. deployment not yet pushed, or wrong site). if (session.InitialSnapshot.InstanceNotFound) { DebugStreamService.StopStream(session.SessionId); _toast.ShowError( "Instance not found on the selected site — check the deployment target."); _connecting = false; return; } // P6-6: the component may have been disposed while StartStreamAsync was in // flight. Dispose() would have found _session still null and stopped nothing, // so this session would stay registered in the singleton DebugStreamService — // whose keepalive renews registered sessions forever, pinning the site // subscription, the bridge actor and this dead circuit until process restart. // Publish and re-check disposal atomically so exactly one side tears it down. bool abandoned; lock (_sessionLock) { abandoned = _disposed; if (!abandoned) _session = session; } if (abandoned) { DebugStreamService.StopStream(session.SessionId); _connecting = false; return; } // Populate initial state from snapshot _attributeValues.Clear(); foreach (var av in session.InitialSnapshot.AttributeValues) _attributeValues[av.AttributeName] = av; _alarmStates.Clear(); foreach (var al in session.InitialSnapshot.AlarmStates) _alarmStates[al.AlarmName] = al; // Snapshot seeding is a data change like any other — bump the stamp so // the memoized forests rebuild from it. _dataVersion++; _snapshot = session.InitialSnapshot; _connected = true; // Persist selection to localStorage for auto-reconnect on refresh await JS.InvokeVoidAsync("localStorage.setItem", "debugView.siteId", _selectedSiteId.ToString()); await JS.InvokeVoidAsync("localStorage.setItem", "debugView.instanceId", _selectedInstanceId.ToString()); var instance = _siteInstances.FirstOrDefault(i => i.Id == _selectedInstanceId); _toast.ShowSuccess($"Streaming {instance?.UniqueName ?? "instance"}"); } catch (Exception ex) { _toast.ShowError($"Connect failed: {ex.Message}"); } _connecting = false; } /// /// Atomically clears the slot and returns what was there, so /// only the caller that actually took the session stops it (P6-6). /// private DebugStreamSession? TakeSession() { lock (_sessionLock) { var session = _session; _session = null; return session; } } private async Task Disconnect() { var session = TakeSession(); if (session != null) { DebugStreamService.StopStream(session.SessionId); } // Clear persisted selection — user explicitly disconnected await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.siteId"); await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.instanceId"); _connected = false; _connectedFromStorage = false; _snapshot = null; _attributeValues.Clear(); _alarmStates.Clear(); // Drop anything the stream buffered for the session we just tore down, so a // late flush can't repopulate a disconnected view. _pendingAttributes.Clear(); _pendingAlarms.Clear(); _dataVersion++; } /// /// Disconnect and forget the persisted selection. Surfaces in the status /// strip whenever the page auto-reconnects from localStorage so the user /// can opt out of the carry-over session. /// private async Task StartFresh() { await Disconnect(); _selectedSiteId = 0; _selectedInstanceId = 0; _siteInstances = new(); _siteAreas = new(); _toast.ShowInfo("Cleared previous session — select a site and instance to begin.", autoDismissMs: 5000); } // ── Render coalescing (arch-review WP2.4) ───────────────────────────────── // Every streamed event used to marshal onto the dispatcher and call // StateHasChanged individually, so a chatty instance drove one full render // (and two full tree rebuilds) per value change. Events now land in a // thread-safe pending map keyed by name — repeated updates to the same tag // inside one window collapse to the latest — and exactly ONE dispatcher // marshal per window drains them, bumps the version stamp and renders once. private const int RenderCoalesceMs = 250; private readonly System.Collections.Concurrent.ConcurrentDictionary _pendingAttributes = new(); private readonly System.Collections.Concurrent.ConcurrentDictionary _pendingAlarms = new(); /// 0/1 flag: whether a coalesce window is already armed. Interlocked-guarded. private int _flushArmed; /// /// Handles one debug-stream event. The callback is invoked on an Akka/gRPC /// thread, but / are /// instances also enumerated by the /// render thread (the tree forests + are /// built from them). Dictionary is not thread-safe (CentralUI-021): a /// write racing an enumeration can throw or corrupt the buckets. The event is /// therefore parked in a concurrent pending map here and applied on the /// renderer's dispatcher by , so every access to /// the render dictionaries — read and write — still happens on one thread. /// private void HandleStreamEvent(object evt) { // CentralUI-009: the component may have been disposed while this event // was in flight on the Akka/gRPC thread. if (_disposed) return; switch (evt) { case AttributeValueChanged av: _pendingAttributes[av.AttributeName] = av; break; case AlarmStateChanged al: _pendingAlarms[al.AlarmName] = al; break; default: // Unknown event type — nothing to apply, no render needed. return; } // Arm the window if it isn't already. Losers of the CAS have nothing to do: // their event is in the pending map and the armed flush will pick it up. if (Interlocked.CompareExchange(ref _flushArmed, 1, 0) == 0) { _ = FlushPendingAsync(); } } /// /// Waits out the coalesce window, then applies everything buffered in one /// dispatcher pass and renders once. /// private async Task FlushPendingAsync() { try { await Task.Delay(RenderCoalesceMs); } catch { // Delay never faults in practice; if it ever did, fall through and drain. } if (_disposed) { Volatile.Write(ref _flushArmed, 0); return; } await SafeInvokeAsync(() => { // Disarm BEFORE draining: an event arriving mid-drain then arms a fresh // window rather than being stranded until the next unrelated event. Volatile.Write(ref _flushArmed, 0); if (_disposed) return; var applied = false; foreach (var key in _pendingAttributes.Keys) { if (_pendingAttributes.TryRemove(key, out var av)) { _attributeValues[av.AttributeName] = av; applied = true; } } foreach (var key in _pendingAlarms.Keys) { if (_pendingAlarms.TryRemove(key, out var al)) { _alarmStates[al.AlarmName] = al; applied = true; } } if (!applied) return; _dataVersion++; StateHasChanged(); }); } private static string GetQualityBadge(string quality) => quality switch { "Good" => "bg-success", "Bad" => "bg-danger", "Uncertain" => "bg-warning text-dark", _ => "bg-secondary" }; /// /// State badge class shared by the alarm leaf (via AlarmStateBadges) /// and the native/composition branch summary badges below (worst-state roll-up). /// private static string GetAlarmStateBadge(AlarmState state) => state switch { AlarmState.Active => "bg-danger", AlarmState.Normal => "bg-success", _ => "bg-secondary" }; /// /// Builds the row tooltip from the alarm's operator message plus native /// metadata (type, category, operator, raise time, current/limit value). /// Returns null when there is nothing extra to show. /// private static string? BuildAlarmTooltip(AlarmStateChanged a) { var parts = new List(); if (!string.IsNullOrEmpty(a.Message)) parts.Add(a.Message); if (!string.IsNullOrEmpty(a.AlarmTypeName)) parts.Add($"Type: {a.AlarmTypeName}"); if (!string.IsNullOrEmpty(a.Category)) parts.Add($"Category: {a.Category}"); if (!string.IsNullOrEmpty(a.OperatorUser)) parts.Add($"By: {a.OperatorUser}"); if (!string.IsNullOrEmpty(a.OperatorComment)) parts.Add($"Comment: {a.OperatorComment}"); if (a.OriginalRaiseTime.HasValue) parts.Add($"Raised: {a.OriginalRaiseTime.Value.LocalDateTime:HH:mm:ss}"); if (!string.IsNullOrEmpty(a.CurrentValue)) parts.Add($"Value: {a.CurrentValue}"); if (!string.IsNullOrEmpty(a.LimitValue)) parts.Add($"Limit: {a.LimitValue}"); return parts.Count == 0 ? null : string.Join(" · ", parts); } /// /// Runs on the render thread, guarded against the /// component being disposed mid-flight (CentralUI-009): InvokeAsync /// throws once the circuit is gone. /// private async Task SafeInvokeAsync(Action action) { if (_disposed) return; try { await InvokeAsync(action); } catch (ObjectDisposedException) { // Component disposed between the guard and the dispatch — ignore. } } public void Dispose() { // CentralUI-009: mark disposed first so any in-flight stream callback // sees the flag and no-ops, then stop the stream synchronously. // P6-6: the flag is set and the session taken under the same lock Connect // publishes through, so a Connect still awaiting StartStreamAsync either // hands the session over here or stops it itself — never neither. DebugStreamSession? session; lock (_sessionLock) { _disposed = true; session = _session; _session = null; } if (session != null) { DebugStreamService.StopStream(session.SessionId); } } }