diff --git a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor index 9a2e208..97df4b9 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor @@ -3,6 +3,7 @@ @using ScadaLink.Commons.Entities.Sites @using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Commons.Messages.RemoteQuery +@using ScadaLink.Commons.Types.Enums @using ScadaLink.Communication @inject ISiteRepository SiteRepository @inject CommunicationService CommunicationService @@ -10,186 +11,383 @@ @inject IDialogService Dialog
-

Parked Messages

- -
-
- - -
-
- + @if (FilteredCount != _messages.Count) + { + (showing @FilteredCount of @_messages.Count) + } + + } +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ @if (_selectedIds.Count > 0) + { +
+ @_selectedIds.Count selected + + + +
+ } + @if (_errorMessage != null) {
@_errorMessage
} - @if (_messages != null) + @if (_messages == null) { - - - - - - - - - - - - - - - - @if (_messages.Count == 0) - { - - } - @for (int i = 0; i < _messages.Count; i++) - { - var idx = i; - var msg = _messages[idx]; - var idShort = msg.MessageId[..Math.Min(12, msg.MessageId.Length)]; - var expanded = _expandedRows.Contains(idx); - var retryActive = _actionInProgress && _activeMessageId == msg.MessageId && _activeAction == "Retry"; - var discardActive = _actionInProgress && _activeMessageId == msg.MessageId && _activeAction == "Discard"; + @if (!string.IsNullOrEmpty(_selectedSiteId) && _searching) + { +
Loading…
+ } + } + else if (_messages.Count == 0) + { +
+
+
No parked messages
+
Nothing has failed enough to give up on at this site.
+
+
+ } + else + { + var filtered = FilteredMessages; +
+
Message IDTarget SystemMethodErrorAttemptsOriginalLast AttemptActions
No parked messages.
+ - - - - - - - - - + + + + + + - @if (expanded) + + + @if (filtered.Count == 0) { - - + } + @foreach (var msg in filtered) + { + var isSelected = _selectedIds.Contains(msg.MessageId); + + + + + + + } - } - -
- - - @idShort… - - @msg.TargetSystem@msg.MethodName@msg.ErrorMessage@msg.AttemptCount - - - + + Target / MethodOriginErrorAttemptsLast attempt
-
@msg.ErrorMessage
+
No messages match the current filters.
+ + +
@msg.TargetSystem
+
@msg.MethodName
+
+ @if (!string.IsNullOrEmpty(msg.OriginInstance)) + { + @msg.OriginInstance + } + else + { + + } + +
@msg.ErrorMessage
+
+
+ @msg.AttemptCount/@msg.MaxAttempts +
+
+
+
+
+
+ @Relative(msg.LastAttemptTimestamp) +
+ + +
- @if (_totalCount > 0) + @if (_totalCount > _pageSize) {
- Page @_pageNumber of @((_totalCount + _pageSize - 1) / _pageSize) (@_totalCount total) + + Page @_pageNumber of @((_totalCount + _pageSize - 1) / _pageSize) · @_totalCount total +
- - + +
} }
+@if (_drawerMessage != null) +{ +
+
+
+
+
Parked message
+
@_drawerMessage.TargetSystem
+
@_drawerMessage.MethodName
+
+ +
+
+
+
Message ID
+
+ @_drawerMessage.MessageId + +
+
Category
+
@CategoryLabel(_drawerMessage.Category)
+
Origin instance
+
+ @if (!string.IsNullOrEmpty(_drawerMessage.OriginInstance)) + { + @_drawerMessage.OriginInstance + } + else + { + + } +
+
Attempts
+
@_drawerMessage.AttemptCount / @_drawerMessage.MaxAttempts
+
Originally enqueued
+
+ @Relative(_drawerMessage.OriginalTimestamp) + · @AbsoluteUtc(_drawerMessage.OriginalTimestamp) +
+
Last attempt
+
+ @Relative(_drawerMessage.LastAttemptTimestamp) + · @AbsoluteUtc(_drawerMessage.LastAttemptTimestamp) +
+
+ +
Error
+
@_drawerMessage.ErrorMessage
+
+
+ + +
+
+} + + + @code { private List _sites = new(); private string _selectedSiteId = string.Empty; private List? _messages; private int _totalCount; private int _pageNumber = 1; - private int _pageSize = 25; + private int _pageSize = 50; private bool _searching; private string? _errorMessage; + // Filters + private string _categoryFilter = string.Empty; + private string _targetFilter = string.Empty; + private string _originFilter = string.Empty; + private string _ageFilter = "All"; + private string _searchFilter = string.Empty; + + // Selection + private readonly HashSet _selectedIds = new(); + private bool _bulkInProgress; + private string? _bulkAction; + + // Per-row action state private bool _actionInProgress; - private string? _activeMessageId; private string? _activeAction; + + // Drawer + private ParkedMessageEntry? _drawerMessage; + private ToastNotification _toast = default!; - private readonly HashSet _expandedRows = new(); protected override async Task OnInitializedAsync() { _sites = (await SiteRepository.GetAllSitesAsync()).ToList(); } + private async Task OnSiteChanged(ChangeEventArgs e) + { + _selectedSiteId = e.Value?.ToString() ?? string.Empty; + if (!string.IsNullOrEmpty(_selectedSiteId)) + { + await Search(); + } + else + { + _messages = null; + _selectedIds.Clear(); + _drawerMessage = null; + } + } + private async Task Search() { _pageNumber = 1; - _expandedRows.Clear(); + _selectedIds.Clear(); + _drawerMessage = null; await FetchPage(); } - private async Task PrevPage() { _pageNumber--; await FetchPage(); } - private async Task NextPage() { _pageNumber++; await FetchPage(); } - - private void ToggleRow(int idx) - { - if (!_expandedRows.Add(idx)) - { - _expandedRows.Remove(idx); - } - } - - private async Task CopyAsync(string text) - { - try - { - await JS.InvokeVoidAsync("navigator.clipboard.writeText", text); - _toast.ShowSuccess("Copied to clipboard."); - } - catch - { - _toast.ShowError("Copy failed."); - } - } + private async Task PrevPage() { _pageNumber--; _selectedIds.Clear(); await FetchPage(); } + private async Task NextPage() { _pageNumber++; _selectedIds.Clear(); await FetchPage(); } private async Task FetchPage() { @@ -205,12 +403,10 @@ Timestamp: DateTimeOffset.UtcNow); var response = await CommunicationService.QueryParkedMessagesAsync(_selectedSiteId, request); - if (response.Success) { _messages = response.Messages.ToList(); _totalCount = response.TotalCount; - _expandedRows.Clear(); } else { @@ -224,73 +420,302 @@ _searching = false; } - private async Task RetryMessage(ParkedMessageEntry msg) + private void ClearFilters() { - _actionInProgress = true; - _activeMessageId = msg.MessageId; - _activeAction = "Retry"; - try - { - var request = new ParkedMessageRetryRequest( - CorrelationId: Guid.NewGuid().ToString("N"), - SiteId: _selectedSiteId, - MessageId: msg.MessageId, - Timestamp: DateTimeOffset.UtcNow); - var response = await CommunicationService.RetryParkedMessageAsync(_selectedSiteId, request); - if (response.Success) - { - _toast.ShowSuccess($"Message {msg.MessageId[..Math.Min(12, msg.MessageId.Length)]} queued for retry."); - await FetchPage(); - } - else - { - _toast.ShowError(response.ErrorMessage ?? "Retry failed."); - } - } - catch (Exception ex) - { - _toast.ShowError($"Retry failed: {ex.Message}"); - } - _activeMessageId = null; - _activeAction = null; - _actionInProgress = false; + _categoryFilter = string.Empty; + _targetFilter = string.Empty; + _originFilter = string.Empty; + _ageFilter = "All"; + _searchFilter = string.Empty; } - private async Task DiscardMessage(ParkedMessageEntry msg) + private bool HasActiveFilters => + !string.IsNullOrEmpty(_categoryFilter) || + !string.IsNullOrEmpty(_targetFilter) || + !string.IsNullOrEmpty(_originFilter) || + _ageFilter != "All" || + !string.IsNullOrEmpty(_searchFilter); + + private List FilteredMessages { + get + { + if (_messages == null) return new(); + IEnumerable q = _messages; + + if (!string.IsNullOrEmpty(_categoryFilter) && + Enum.TryParse(_categoryFilter, out var cat)) + q = q.Where(m => m.Category == cat); + + if (!string.IsNullOrEmpty(_targetFilter)) + q = q.Where(m => m.TargetSystem == _targetFilter); + + if (_originFilter == "__none__") + q = q.Where(m => string.IsNullOrEmpty(m.OriginInstance)); + else if (!string.IsNullOrEmpty(_originFilter)) + q = q.Where(m => m.OriginInstance == _originFilter); + + if (_ageFilter != "All") + { + var cutoff = _ageFilter switch + { + "LastHour" => DateTimeOffset.UtcNow.AddHours(-1), + "LastDay" => DateTimeOffset.UtcNow.AddDays(-1), + "LastWeek" => DateTimeOffset.UtcNow.AddDays(-7), + _ => DateTimeOffset.MinValue + }; + q = q.Where(m => m.LastAttemptTimestamp >= cutoff); + } + + if (!string.IsNullOrEmpty(_searchFilter)) + { + var s = _searchFilter.Trim(); + q = q.Where(m => + m.MessageId.Contains(s, StringComparison.OrdinalIgnoreCase) || + m.TargetSystem.Contains(s, StringComparison.OrdinalIgnoreCase) || + m.MethodName.Contains(s, StringComparison.OrdinalIgnoreCase) || + m.ErrorMessage.Contains(s, StringComparison.OrdinalIgnoreCase) || + (m.OriginInstance ?? string.Empty).Contains(s, StringComparison.OrdinalIgnoreCase)); + } + + return q.ToList(); + } + } + + private int FilteredCount => FilteredMessages.Count; + private int DistinctTargets => _messages?.Select(m => m.TargetSystem).Distinct().Count() ?? 0; + + private IEnumerable DistinctTargetsList => + _messages?.Select(m => m.TargetSystem).Distinct().OrderBy(s => s).ToList() + ?? (IEnumerable)Array.Empty(); + + private IEnumerable DistinctOriginsList => + _messages?.Where(m => !string.IsNullOrEmpty(m.OriginInstance)) + .Select(m => m.OriginInstance!).Distinct().OrderBy(s => s).ToList() + ?? (IEnumerable)Array.Empty(); + + private ParkedMessageEntry? OldestMessage => + _messages?.OrderBy(m => m.LastAttemptTimestamp).FirstOrDefault(); + + // ── Selection ── + + private bool AllFilteredSelected + { + get + { + var filtered = FilteredMessages; + return filtered.Count > 0 && filtered.All(m => _selectedIds.Contains(m.MessageId)); + } + } + + private void ToggleSelect(string id, bool isChecked) + { + if (isChecked) _selectedIds.Add(id); + else _selectedIds.Remove(id); + } + + private void ToggleSelectAll(ChangeEventArgs e) + { + var on = (bool)e.Value!; + var filtered = FilteredMessages; + if (on) + { + foreach (var m in filtered) _selectedIds.Add(m.MessageId); + } + else + { + foreach (var m in filtered) _selectedIds.Remove(m.MessageId); + } + } + + private void ClearSelection() => _selectedIds.Clear(); + + // ── Drawer ── + + private void OpenDrawer(ParkedMessageEntry msg) => _drawerMessage = msg; + private void CloseDrawer() => _drawerMessage = null; + + private async Task RetryFromDrawer() + { + if (_drawerMessage == null) return; + var msg = _drawerMessage; + await RetrySingle(msg); + CloseDrawer(); + } + + private async Task DiscardFromDrawer() + { + if (_drawerMessage == null) return; + var msg = _drawerMessage; + var ok = await DiscardSingle(msg); + if (ok) CloseDrawer(); + } + + // ── Bulk ── + + private async Task BulkRetry() + { + var ids = _selectedIds.ToList(); + if (ids.Count == 0) return; + var confirmed = await Dialog.ConfirmAsync( - "Discard Parked Message", - $"Permanently discard message {msg.MessageId[..Math.Min(12, msg.MessageId.Length)]}? This cannot be undone.", + "Retry parked messages", + $"Move {ids.Count} message{(ids.Count == 1 ? "" : "s")} back to the pending queue?"); + if (!confirmed) return; + + _bulkInProgress = true; + _bulkAction = "Retry"; + int success = 0, failed = 0; + foreach (var id in ids) + { + try + { + var req = new ParkedMessageRetryRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, id, DateTimeOffset.UtcNow); + var resp = await CommunicationService.RetryParkedMessageAsync(_selectedSiteId, req); + if (resp.Success) success++; else failed++; + } + catch { failed++; } + } + _toast.ShowSuccess($"{success} queued for retry" + (failed > 0 ? $", {failed} failed" : ".")); + _selectedIds.Clear(); + _bulkInProgress = false; + _bulkAction = null; + await FetchPage(); + } + + private async Task BulkDiscard() + { + var ids = _selectedIds.ToList(); + if (ids.Count == 0) return; + + var confirmed = await Dialog.ConfirmAsync( + "Discard parked messages", + $"Permanently discard {ids.Count} message{(ids.Count == 1 ? "" : "s")}? This cannot be undone.", danger: true); if (!confirmed) return; + _bulkInProgress = true; + _bulkAction = "Discard"; + int success = 0, failed = 0; + foreach (var id in ids) + { + try + { + var req = new ParkedMessageDiscardRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, id, DateTimeOffset.UtcNow); + var resp = await CommunicationService.DiscardParkedMessageAsync(_selectedSiteId, req); + if (resp.Success) success++; else failed++; + } + catch { failed++; } + } + _toast.ShowSuccess($"{success} discarded" + (failed > 0 ? $", {failed} failed" : ".")); + _selectedIds.Clear(); + _bulkInProgress = false; + _bulkAction = null; + await FetchPage(); + } + + // ── Single actions ── + + private async Task RetrySingle(ParkedMessageEntry msg) + { _actionInProgress = true; - _activeMessageId = msg.MessageId; - _activeAction = "Discard"; + _activeAction = "Retry"; try { - var request = new ParkedMessageDiscardRequest( - CorrelationId: Guid.NewGuid().ToString("N"), - SiteId: _selectedSiteId, - MessageId: msg.MessageId, - Timestamp: DateTimeOffset.UtcNow); - var response = await CommunicationService.DiscardParkedMessageAsync(_selectedSiteId, request); - if (response.Success) + var req = new ParkedMessageRetryRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, msg.MessageId, DateTimeOffset.UtcNow); + var resp = await CommunicationService.RetryParkedMessageAsync(_selectedSiteId, req); + if (resp.Success) { - _toast.ShowSuccess($"Message {msg.MessageId[..Math.Min(12, msg.MessageId.Length)]} discarded."); + _toast.ShowSuccess($"Message {ShortId(msg.MessageId)} queued for retry."); await FetchPage(); } - else - { - _toast.ShowError(response.ErrorMessage ?? "Discard failed."); - } + else _toast.ShowError(resp.ErrorMessage ?? "Retry failed."); } - catch (Exception ex) - { - _toast.ShowError($"Discard failed: {ex.Message}"); - } - _activeMessageId = null; - _activeAction = null; + catch (Exception ex) { _toast.ShowError($"Retry failed: {ex.Message}"); } _actionInProgress = false; + _activeAction = null; } + + private async Task DiscardSingle(ParkedMessageEntry msg) + { + var confirmed = await Dialog.ConfirmAsync( + "Discard parked message", + $"Permanently discard message {ShortId(msg.MessageId)}? This cannot be undone.", + danger: true); + if (!confirmed) return false; + + _actionInProgress = true; + _activeAction = "Discard"; + bool ok = false; + try + { + var req = new ParkedMessageDiscardRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, msg.MessageId, DateTimeOffset.UtcNow); + var resp = await CommunicationService.DiscardParkedMessageAsync(_selectedSiteId, req); + if (resp.Success) + { + _toast.ShowSuccess($"Message {ShortId(msg.MessageId)} discarded."); + ok = true; + await FetchPage(); + } + else _toast.ShowError(resp.ErrorMessage ?? "Discard failed."); + } + catch (Exception ex) { _toast.ShowError($"Discard failed: {ex.Message}"); } + _actionInProgress = false; + _activeAction = null; + return ok; + } + + private async Task CopyAsync(string text) + { + try + { + await JS.InvokeVoidAsync("navigator.clipboard.writeText", text); + _toast.ShowSuccess("Copied to clipboard."); + } + catch { _toast.ShowError("Copy failed."); } + } + + // ── Helpers ── + + private static string ShortId(string id) => id[..Math.Min(12, id.Length)]; + + private static string Relative(DateTimeOffset t) + { + var diff = DateTimeOffset.UtcNow - t; + if (diff.TotalSeconds < 0) return "just now"; + if (diff.TotalSeconds < 60) return "just now"; + if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago"; + if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago"; + if (diff.TotalDays < 30) return $"{(int)diff.TotalDays}d ago"; + return t.UtcDateTime.ToString("yyyy-MM-dd"); + } + + private static string AbsoluteUtc(DateTimeOffset t) => + $"{t.UtcDateTime:yyyy-MM-dd HH:mm:ss} UTC"; + + private static string SeverityClass(ParkedMessageEntry msg) + { + var exhausted = msg.MaxAttempts > 0 && msg.AttemptCount >= msg.MaxAttempts; + if (!exhausted) return "sev-secondary"; + var age = DateTimeOffset.UtcNow - msg.LastAttemptTimestamp; + return age < TimeSpan.FromHours(1) ? "sev-danger" : "sev-warning"; + } + + private static int AttemptPercent(ParkedMessageEntry msg) + { + if (msg.MaxAttempts <= 0) return 100; + var pct = (int)Math.Round(msg.AttemptCount * 100.0 / msg.MaxAttempts); + return Math.Clamp(pct, 0, 100); + } + + private static string AttemptBarClass(ParkedMessageEntry msg) => + msg.AttemptCount >= msg.MaxAttempts ? "bg-danger" : "bg-warning"; + + private static string CategoryLabel(StoreAndForwardCategory c) => c switch + { + StoreAndForwardCategory.ExternalSystem => "External system", + StoreAndForwardCategory.Notification => "Notification", + StoreAndForwardCategory.CachedDbWrite => "Cached DB write", + _ => c.ToString() + }; } diff --git a/src/ScadaLink.Commons/Messages/RemoteQuery/ParkedMessageQueryResponse.cs b/src/ScadaLink.Commons/Messages/RemoteQuery/ParkedMessageQueryResponse.cs index 944b444..cb4f405 100644 --- a/src/ScadaLink.Commons/Messages/RemoteQuery/ParkedMessageQueryResponse.cs +++ b/src/ScadaLink.Commons/Messages/RemoteQuery/ParkedMessageQueryResponse.cs @@ -1,3 +1,5 @@ +using ScadaLink.Commons.Types.Enums; + namespace ScadaLink.Commons.Messages.RemoteQuery; /// @@ -10,7 +12,10 @@ public record ParkedMessageEntry( string ErrorMessage, int AttemptCount, DateTimeOffset OriginalTimestamp, - DateTimeOffset LastAttemptTimestamp); + DateTimeOffset LastAttemptTimestamp, + int MaxAttempts = 0, + StoreAndForwardCategory Category = StoreAndForwardCategory.ExternalSystem, + string? OriginInstance = null); public record ParkedMessageQueryResponse( string CorrelationId, diff --git a/src/ScadaLink.StoreAndForward/ParkedMessageHandlerActor.cs b/src/ScadaLink.StoreAndForward/ParkedMessageHandlerActor.cs index aba0b89..8cf5aff 100644 --- a/src/ScadaLink.StoreAndForward/ParkedMessageHandlerActor.cs +++ b/src/ScadaLink.StoreAndForward/ParkedMessageHandlerActor.cs @@ -44,7 +44,10 @@ public class ParkedMessageHandlerActor : ReceiveActor ErrorMessage: m.LastError ?? string.Empty, AttemptCount: m.RetryCount, OriginalTimestamp: m.CreatedAt, - LastAttemptTimestamp: m.LastAttemptAt ?? m.CreatedAt)) + LastAttemptTimestamp: m.LastAttemptAt ?? m.CreatedAt, + MaxAttempts: m.MaxRetries, + Category: m.Category, + OriginInstance: m.OriginInstanceName)) .ToList(); return new ParkedMessageQueryResponse(