diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor index 68e7736..d4ba08f 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor @@ -26,6 +26,47 @@ } 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) + { + + } +
+
+
@@ -52,17 +93,13 @@ { } else { - - - Live - }
@@ -73,9 +110,25 @@ @* Attribute Values *@
-
- Attribute Values - @_attributeValues.Count values +
+
+ Attribute Values + @FilteredAttributeValues.Count latest (cap @MaxRows) +
+
+ + + +
@@ -87,16 +140,20 @@ - - @foreach (var av in _attributeValues.Values.OrderBy(a => a.AttributeName)) + + @foreach (var av in FilteredAttributeValues) { + - } @@ -108,9 +165,25 @@ @* Alarm States *@
-
- Alarm States - @_alarmStates.Count alarms +
+
+ Alarm States + @FilteredAlarmStates.Count latest (cap @MaxRows) +
+
+ + + +
Timestamp
@av.AttributeName @ValueFormatter.FormatDisplayValue(av.Value) - @av.Quality + @av.Quality + + @av.Timestamp.LocalDateTime.ToString("HH:mm:ss") @av.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")
@@ -122,16 +195,20 @@ - - @foreach (var alarm in _alarmStates.Values.OrderBy(a => a.AlarmName)) + + @foreach (var alarm in FilteredAlarmStates) { - + } @@ -140,11 +217,6 @@ - -
- Snapshot received: @_snapshot.SnapshotTimestamp.LocalDateTime.ToString("HH:mm:ss") | - @_attributeValues.Count attributes, @_alarmStates.Count alarms -
} else if (_connected) { @@ -154,6 +226,8 @@ @code { + private const int MaxRows = 200; + private List _sites = new(); private List _siteInstances = new(); private int _selectedSiteId; @@ -161,11 +235,36 @@ 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!; @@ -195,8 +294,15 @@ _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); } } @@ -235,11 +341,11 @@ switch (evt) { case AttributeValueChanged av: - _attributeValues[av.AttributeName] = av; + UpsertWithCap(_attributeValues, av.AttributeName, av); _ = InvokeAsync(StateHasChanged); break; case AlarmStateChanged al: - _alarmStates[al.AlarmName] = al; + UpsertWithCap(_alarmStates, al.AlarmName, al); _ = InvokeAsync(StateHasChanged); break; } @@ -296,11 +402,51 @@ 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", diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/Deployments.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/Deployments.razor index 28f22dd..32f6c76 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Deployment/Deployments.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/Deployments.razor @@ -12,9 +12,12 @@

Deployment Status

-
- Auto-refresh: 10s - +
+ +
@@ -30,7 +33,7 @@ { @* Summary cards *@
-
+

@_records.Count(r => r.Status == DeploymentStatus.Pending)

@@ -38,7 +41,7 @@
-
+

@_records.Count(r => r.Status == DeploymentStatus.InProgress)

@@ -46,7 +49,7 @@
-
+

@_records.Count(r => r.Status == DeploymentStatus.Success)

@@ -54,7 +57,7 @@
-
+

@_records.Count(r => r.Status == DeploymentStatus.Failed)

@@ -64,37 +67,51 @@
-
Timestamp
@alarm.AlarmName - @alarm.State + @alarm.State @alarm.Priority@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff") + @alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss") +
+ @if (_records.Count == 0) + { +
+

No deployments recorded.

+
+ } + else + { +
- + - - + - @if (_records.Count == 0) - { - - - - } @foreach (var record in _pagedRecords) { - - + var rowId = $"deploy-row-{record.DeploymentId}"; + var errorCollapseId = $"deploy-err-{record.DeploymentId}"; + var isFailed = record.Status == DeploymentStatus.Failed; + var idShort = record.DeploymentId[..Math.Min(12, record.DeploymentId.Length)]; + var revShort = record.RevisionHash?[..Math.Min(8, record.RevisionHash?.Length ?? 0)]; + + @@ -112,12 +129,33 @@ β€” } - - + + @if (isFailed && !string.IsNullOrEmpty(record.ErrorMessage) && IsErrorExpanded(record.DeploymentId)) + { + + + + } }
Deployment IDDeployment Instance Status Deployed By Started CompletedRevisionErrorActions
No deployments recorded.
@record.DeploymentId[..Math.Min(12, record.DeploymentId.Length)]...
+ @idShort@(string.IsNullOrEmpty(revShort) ? "" : $"@{revShort}") + @GetInstanceName(record.InstanceId) - + @if (isFailed) + { + + } + @record.Status @if (record.Status == DeploymentStatus.InProgress) { - + } @(record.RevisionHash?[..Math.Min(8, record.RevisionHash?.Length ?? 0)])@(record.ErrorMessage ?? "") + @if (isFailed && !string.IsNullOrEmpty(record.ErrorMessage)) + { + + } +
+
+ Error: +
@record.ErrorMessage
+
+
+ } @if (_totalPages > 1) { @@ -149,22 +187,56 @@ private bool _loading = true; private string? _errorMessage; private Timer? _refreshTimer; + private bool _autoRefresh = true; + private readonly HashSet _expandedErrors = new(); private int _currentPage = 1; private int _totalPages; private const int PageSize = 25; + private static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(10); protected override async Task OnInitializedAsync() { await LoadDataAsync(); + StartTimer(); + } + + private void StartTimer() + { + _refreshTimer?.Dispose(); _refreshTimer = new Timer(_ => { InvokeAsync(async () => { + if (!_autoRefresh) return; await LoadDataAsync(); StateHasChanged(); }); - }, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10)); + }, null, RefreshInterval, RefreshInterval); + } + + private void ToggleAutoRefresh() + { + _autoRefresh = !_autoRefresh; + if (_autoRefresh) + { + StartTimer(); + } + else + { + _refreshTimer?.Dispose(); + _refreshTimer = null; + } + } + + private bool IsErrorExpanded(string deploymentId) => _expandedErrors.Contains(deploymentId); + + private void ToggleErrorExpansion(string deploymentId) + { + if (!_expandedErrors.Remove(deploymentId)) + { + _expandedErrors.Add(deploymentId); + } } private async Task LoadDataAsync() diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/Topology.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/Topology.razor index f360b8a..55262d1 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Deployment/Topology.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/Topology.razor @@ -19,10 +19,11 @@ @inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager NavigationManager @inject IJSRuntime JSRuntime +@implements IDisposable
- + Topology -
+
+

Topology

+
+
@@ -69,13 +72,22 @@ - - - + + + +
+
+ +
-
+
+ @_allAreas.Count area(s) Β· @_allInstances.Count instance(s) across @_sites.Count site(s). +
+ +
-
- @_allInstances.Count instance(s) across @_sites.Count site(s). -
- - @* Diff Modal β€” ported from Instances.razor *@ - @if (_showDiffModal) - { - - } + }
@@ -167,6 +128,12 @@ private ToastNotification _toast = default!; private ConfirmDialog _confirmDialog = default!; + private DiffDialog _diffDialog = default!; + + // ---- Live updates ---- + private bool _liveUpdates = true; + private Timer? _liveUpdatesTimer; + private static readonly TimeSpan LiveUpdatesInterval = TimeSpan.FromSeconds(15); private TreeView _tree = default!; private object? _selectedKey; @@ -199,6 +166,41 @@ protected override async Task OnInitializedAsync() { await LoadDataAsync(); + StartLiveUpdatesTimer(); + } + + private void StartLiveUpdatesTimer() + { + _liveUpdatesTimer?.Dispose(); + if (!_liveUpdates) return; + _liveUpdatesTimer = new Timer(_ => + { + InvokeAsync(async () => + { + if (!_liveUpdates) return; + await LoadDataAsync(); + StateHasChanged(); + }); + }, null, LiveUpdatesInterval, LiveUpdatesInterval); + } + + private void OnLiveUpdatesToggled(ChangeEventArgs e) + { + _liveUpdates = e.Value is bool b && b; + if (_liveUpdates) + { + StartLiveUpdatesTimer(); + } + else + { + _liveUpdatesTimer?.Dispose(); + _liveUpdatesTimer = null; + } + } + + public void Dispose() + { + _liveUpdatesTimer?.Dispose(); } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -364,6 +366,7 @@ @if (_renamingKey == node.Key) { @node.Instance!.State @if (node.Instance!.State != InstanceState.NotDeployed) { - - @(node.IsStale ? "Stale" : "Current") - + @if (node.IsStale) + { + + Stale + + } + else + { + Current + } } break; } @@ -787,36 +797,64 @@ } // ---- Diff modal ---- - private bool _showDiffModal; - private bool _diffLoading; - private string? _diffError; - private string _diffInstanceName = string.Empty; - private DeploymentComparisonResult? _diffResult; - private async Task ShowDiff(Instance inst) { - _showDiffModal = true; - _diffLoading = true; - _diffError = null; - _diffResult = null; - _diffInstanceName = inst.UniqueName; + DeploymentComparisonResult? diffResult = null; + string? diffError = null; try { var result = await DeploymentService.GetDeploymentComparisonAsync(inst.Id); if (result.IsSuccess) { - _diffResult = result.Value; + diffResult = result.Value; } else { - _diffError = result.Error; + diffError = result.Error; } } catch (Exception ex) { - _diffError = $"Failed to load diff: {ex.Message}"; + diffError = $"Failed to load diff: {ex.Message}"; } - _diffLoading = false; + + RenderFragment body = builder => + { + if (diffError != null) + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "alert alert-danger"); + builder.AddContent(2, diffError); + builder.CloseElement(); + } + else if (diffResult != null) + { + var stale = diffResult.IsStale; + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "mb-2"); + builder.OpenElement(2, "span"); + builder.AddAttribute(3, "class", stale ? "badge bg-warning text-dark" : "badge bg-success"); + builder.AddContent(4, stale ? "Stale β€” changes pending" : "Current"); + builder.CloseElement(); + builder.OpenElement(5, "span"); + builder.AddAttribute(6, "class", "text-muted small ms-2"); + builder.AddContent(7, + $"Deployed: {diffResult.DeployedRevisionHash[..8]} | " + + $"Current: {diffResult.CurrentRevisionHash[..8]} | " + + $"Deployed at: {diffResult.DeployedAt.LocalDateTime:yyyy-MM-dd HH:mm}"); + builder.CloseElement(); + builder.CloseElement(); + + builder.OpenElement(8, "p"); + builder.AddAttribute(9, "class", "text-muted small mb-0"); + builder.AddContent(10, stale + ? "The deployed revision hash differs from the current template-derived hash. Redeploy to apply changes." + : "No differences between deployed and current configuration."); + builder.CloseElement(); + } + }; + + await _diffDialog.ShowAsync($"Deployment Diff β€” {inst.UniqueName}", body); } // ---- Dropdown option helpers ---- diff --git a/src/ScadaLink.CentralUI/Components/Shared/DiffDialog.razor b/src/ScadaLink.CentralUI/Components/Shared/DiffDialog.razor new file mode 100644 index 0000000..f9824a7 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Shared/DiffDialog.razor @@ -0,0 +1,158 @@ +@* Reusable diff/comparison dialog using Bootstrap modal. + Mirrors the ConfirmDialog API: callers invoke ShowAsync(title, before, after) + via @ref to display a side-by-side or simple before/after comparison. + z-index ladder follows ConfirmDialog: modal 1055 > backdrop 1040 (toasts at 1090). *@ +@inject IJSRuntime JS +@implements IAsyncDisposable + +@if (_visible) +{ + + +} + +@code { + private bool _visible; + private bool _bodyLocked; + private TaskCompletionSource? _tcs; + private ElementReference _modalRef; + + [Parameter] public string Title { get; set; } = "Diff"; + [Parameter] public string Before { get; set; } = string.Empty; + [Parameter] public string After { get; set; } = string.Empty; + /// + /// Optional custom body content. When supplied, it replaces the default + /// before/after panes β€” useful when the caller wants to render a richer + /// comparison (e.g. metadata badges, file lists, etc.). + /// + [Parameter] public RenderFragment? BodyContent { get; set; } + + /// + /// Show the dialog with the supplied title and before/after text. + /// Returns when the user dismisses the dialog. + /// + public Task ShowAsync(string title, string before, string after) + { + Title = title; + Before = before; + After = after; + BodyContent = null; + return OpenAsync(); + } + + /// + /// Show the dialog with a custom body. Useful when the diff is not a + /// simple before/after string pair (e.g. a deployment comparison summary). + /// + public Task ShowAsync(string title, RenderFragment body) + { + Title = title; + BodyContent = body; + return OpenAsync(); + } + + private Task OpenAsync() + { + _visible = true; + _tcs = new TaskCompletionSource(); + StateHasChanged(); + return _tcs.Task; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_visible && !_bodyLocked) + { + _bodyLocked = true; + await TryLockBodyAsync(); + try { await _modalRef.FocusAsync(); } + catch { /* prerender or detached: ignore */ } + } + } + + private async Task OnKeyDownAsync(KeyboardEventArgs e) + { + if (e.Key == "Escape") + { + Close(); + await Task.CompletedTask; + } + } + + private void Close() + { + _visible = false; + _ = TryUnlockBodyAsync(); + _tcs?.TrySetResult(true); + } + + private async Task TryLockBodyAsync() + { + try + { + await JS.InvokeVoidAsync("document.body.classList.add", "modal-open"); + } + catch + { + try { await JS.InvokeVoidAsync("console.debug", "DiffDialog: JS interop unavailable for body lock."); } + catch { /* swallow */ } + } + } + + private async Task TryUnlockBodyAsync() + { + _bodyLocked = false; + try + { + await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open"); + } + catch + { + try { await JS.InvokeVoidAsync("console.debug", "DiffDialog: JS interop unavailable for body unlock."); } + catch { /* swallow */ } + } + } + + public async ValueTask DisposeAsync() + { + if (_bodyLocked) + { + await TryUnlockBodyAsync(); + } + } +}