@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) {
@* Attribute Values *@
Attribute Values @FilteredAttributeValues.Count latest (cap @MaxRows)
@foreach (var av in FilteredAttributeValues) { }
Attribute Value Quality Timestamp
@av.AttributeName @ValueFormatter.FormatDisplayValue(av.Value) @av.Quality @av.Timestamp.LocalDateTime.ToString("HH:mm:ss")
@* Alarm States *@
Alarm States @FilteredAlarmStates.Count latest (cap @MaxRows)
@foreach (var alarm in FilteredAlarmStates) { }
Alarm Kind State Sev Level Timestamp
@alarm.AlarmName @if (!string.IsNullOrEmpty(alarm.Message)) { πŸ’¬ } @if (!string.IsNullOrEmpty(alarm.SourceReference)) {
@alarm.SourceReference
}
@FormatKind(alarm.Kind) @alarm.State @if (alarm.Kind != AlarmKind.Computed) { @if (alarm.Condition.Active && !alarm.Condition.Acknowledged) { Unacked } @if (alarm.Condition.Shelve != AlarmShelveState.Unshelved) { Shelved } @if (alarm.Condition.Suppressed) { Suppressed } } @alarm.Condition.Severity @if (alarm.Level != AlarmLevel.None) { @FormatLevel(alarm.Level) } else { β€” } @alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss")
} else if (_connected) { } }
@code { private const int MaxRows = 200; [SupplyParameterFromQuery] public int? SiteId { get; set; } [SupplyParameterFromQuery] public int? InstanceId { get; set; } private List _sites = new(); private List _siteInstances = new(); private int _selectedSiteId; private int _selectedInstanceId; private bool _loading = true; private bool _connected; private bool _connecting; private bool _connectedFromStorage; private DebugViewSnapshot? _snapshot; // Keyed dictionaries hold the latest value per attribute/alarm; insertion order // is preserved so we can trim the oldest when the count exceeds MaxRows. private Dictionary _attributeValues = new(); private Dictionary _alarmStates = new(); // Filters and scroll-lock state per table. private string _attrFilter = string.Empty; private string _alarmFilter = string.Empty; private bool _attrScrollLocked; private bool _alarmScrollLocked; 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(); private IReadOnlyList FilteredAlarmStates => string.IsNullOrWhiteSpace(_alarmFilter) ? _alarmStates.Values.OrderBy(a => a.AlarmName).ToList() : _alarmStates.Values .Where(a => a.AlarmName.Contains(_alarmFilter, StringComparison.OrdinalIgnoreCase) || a.SourceReference.Contains(_alarmFilter, StringComparison.OrdinalIgnoreCase)) .OrderBy(a => a.AlarmName) .ToList(); private DebugStreamSession? _session; 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.Clear(); _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(); } catch (Exception ex) { _toast.ShowError($"Failed to load instances: {ex.Message}"); } } private void OnInstanceSelectionChanged() { // No-op; selection is tracked via _selectedInstanceId binding } private async Task Connect() { if (_selectedInstanceId == 0 || _selectedSiteId == 0) return; _connecting = true; try { var session = await DebugStreamService.StartStreamAsync( _selectedInstanceId, onEvent: HandleStreamEvent, onTerminated: () => { _connected = false; _session = null; // 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; } _session = session; // 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 = 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; } private async Task Disconnect() { if (_session != null) { DebugStreamService.StopStream(_session.SessionId); _session = null; } // 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(); } /// /// 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.Clear(); _toast.ShowInfo("Cleared previous session β€” select a site and instance to begin.", autoDismissMs: 5000); } private void ClearAttributes() { _attributeValues.Clear(); } private void ClearAlarms() { _alarmStates.Clear(); } /// /// Handles one debug-stream event. The callback is invoked on an Akka/gRPC /// thread, but / are /// instances also enumerated by the /// render thread via / /// . Dictionary is not thread-safe /// (CentralUI-021): a write racing an enumeration can throw or corrupt the /// buckets. The mutation () is therefore /// marshalled onto the renderer's dispatcher via /// so every access to the dictionaries β€” read and write β€” happens on the /// render 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; _ = SafeInvokeAsync(() => { if (_disposed) return; switch (evt) { case AttributeValueChanged av: UpsertWithCap(_attributeValues, av.AttributeName, av); break; case AlarmStateChanged al: UpsertWithCap(_alarmStates, al.AlarmName, al); break; default: return; } StateHasChanged(); }); } /// /// Replace or insert a value keyed by name, then trim the oldest entries /// (queue-style) so the table size never exceeds MaxRows. Dictionary /// preserves insertion order, so the first key is always the oldest. /// /// Must be called on the render thread only (CentralUI-021) β€” see /// . The cap-trim loop is in the same /// critical section as the upsert so the dictionary is never observed /// over-capacity. /// /// private static void UpsertWithCap(Dictionary map, string key, T value) { map[key] = value; while (map.Count > MaxRows) { var oldest = map.Keys.First(); map.Remove(oldest); } } private static string GetQualityBadge(string quality) => quality switch { "Good" => "bg-success", "Bad" => "bg-danger", "Uncertain" => "bg-warning text-dark", _ => "bg-secondary" }; private static string GetAlarmStateBadge(AlarmState state) => state switch { AlarmState.Active => "bg-danger", AlarmState.Normal => "bg-success", _ => "bg-secondary" }; private static string GetAlarmRowClass(AlarmState state) => state switch { AlarmState.Active => "table-danger", _ => "" }; /// /// Severity-tinted badge class for HiLo alarm levels. The critical bands /// (HighHigh / LowLow) get the danger class; warning bands get amber. /// private static string GetAlarmLevelBadge(AlarmLevel level) => level switch { AlarmLevel.HighHigh or AlarmLevel.LowLow => "bg-danger", AlarmLevel.High or AlarmLevel.Low => "bg-warning text-dark", _ => "bg-secondary" }; /// Badge class distinguishing computed (neutral) from native (info) alarms. private static string GetKindBadge(AlarmKind kind) => kind switch { AlarmKind.Computed => "bg-secondary", _ => "bg-info text-dark" }; /// Short display label for the alarm kind. private static string FormatKind(AlarmKind kind) => kind switch { AlarmKind.NativeOpcUa => "OPC UA", AlarmKind.NativeMxAccess => "MxAccess", _ => "Computed" }; /// /// 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); } private static string FormatLevel(AlarmLevel level) => level switch { AlarmLevel.HighHigh => "HiHi", AlarmLevel.High => "Hi", AlarmLevel.Low => "Lo", AlarmLevel.LowLow => "LoLo", _ => "β€”" }; /// /// 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. _disposed = true; if (_session != null) { DebugStreamService.StopStream(_session.SessionId); } } }