@page "/deployment/debug-view" @using ScadaLink.Security @using ScadaLink.Commons.Entities.Instances @using ScadaLink.Commons.Entities.Sites @using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Commons.Messages.DebugView @using ScadaLink.Commons.Messages.Streaming @using ScadaLink.Commons.Types @using ScadaLink.Commons.Types.Enums @using ScadaLink.Communication @attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)] @inject ITemplateEngineRepository TemplateEngineRepository @inject ISiteRepository SiteRepository @inject ScadaLink.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 State Level Priority Timestamp
@alarm.AlarmName @if (!string.IsNullOrEmpty(alarm.Message)) { πŸ’¬ } @alarm.State @if (alarm.Level != AlarmLevel.None) { @FormatLevel(alarm.Level) } else { β€” } @alarm.Priority @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)) .OrderBy(a => a.AlarmName) .ToList(); private DebugStreamSession? _session; private ToastNotification _toast = default!; private string? _initError; 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: evt => { switch (evt) { case AttributeValueChanged av: UpsertWithCap(_attributeValues, av.AttributeName, av); _ = InvokeAsync(StateHasChanged); break; case AlarmStateChanged al: UpsertWithCap(_alarmStates, al.AlarmName, al); _ = InvokeAsync(StateHasChanged); break; } }, onTerminated: () => { _connected = false; _session = null; _ = InvokeAsync(() => { _toast.ShowError("Debug stream terminated (site disconnected)."); StateHasChanged(); }); }); _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(); } /// /// 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. /// 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" }; private static string FormatLevel(AlarmLevel level) => level switch { AlarmLevel.HighHigh => "HiHi", AlarmLevel.High => "Hi", AlarmLevel.Low => "Lo", AlarmLevel.LowLow => "LoLo", _ => "β€”" }; public void Dispose() { if (_session != null) { DebugStreamService.StopStream(_session.SessionId); } } }