@page "/monitoring/parked-messages" @attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)] @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 ScadaLink.CentralUI.Auth.SiteScopeService SiteScope @inject CommunicationService CommunicationService @inject IJSRuntime JS @inject IDialogService Dialog @inject ILogger Logger

Parked Messages

@if (_messages != null && _messages.Count > 0) { @_totalCount parked · @DistinctTargets target system@(DistinctTargets == 1 ? "" : "s") @if (OldestMessage != null) { · oldest @Relative(OldestMessage.LastAttemptTimestamp) } @if (FilteredCount != _messages.Count) { (showing @FilteredCount of @_messages.Count) } }
@if (_selectedIds.Count > 0) {
@_selectedIds.Count selected
} @if (_errorMessage != null) {
@_errorMessage
} @if (_messages == null) { @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;
@if (filtered.Count == 0) { } @foreach (var msg in filtered) { var isSelected = _selectedIds.Contains(msg.MessageId); }
Target / Method Origin Error Attempts Last attempt
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 > _pageSize) {
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 = 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? _activeAction; // Drawer private ParkedMessageEntry? _drawerMessage; private ToastNotification _toast = default!; protected override async Task OnInitializedAsync() { // Site scoping (CentralUI-002): a scoped Deployment user may only inspect // and act on parked messages for the sites they are permitted on. _sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync()); } // True only when the currently selected SiteIdentifier is one this user is // permitted on. _sites is already filtered, so membership IS the scope check. private bool SelectedSiteIsPermitted => !string.IsNullOrEmpty(_selectedSiteId) && _sites.Any(s => s.SiteIdentifier == _selectedSiteId); 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; _selectedIds.Clear(); _drawerMessage = null; await FetchPage(); } private async Task PrevPage() { _pageNumber--; _selectedIds.Clear(); await FetchPage(); } private async Task NextPage() { _pageNumber++; _selectedIds.Clear(); await FetchPage(); } private async Task FetchPage() { _searching = true; _errorMessage = null; // Site scoping (CentralUI-002): re-check before querying — the dropdown is // filtered, but the selection must not be trusted on its own. if (!SelectedSiteIsPermitted) { _errorMessage = "You are not permitted to view parked messages for that site."; _messages = null; _searching = false; return; } try { var request = new ParkedMessageQueryRequest( CorrelationId: Guid.NewGuid().ToString("N"), SiteId: _selectedSiteId, PageNumber: _pageNumber, PageSize: _pageSize, Timestamp: DateTimeOffset.UtcNow); var response = await CommunicationService.QueryParkedMessagesAsync(_selectedSiteId, request); if (response.Success) { _messages = response.Messages.ToList(); _totalCount = response.TotalCount; } else { _errorMessage = response.ErrorMessage ?? "Query failed."; } } catch (Exception ex) { _errorMessage = $"Query failed: {ex.Message}"; } _searching = false; } private void ClearFilters() { _categoryFilter = string.Empty; _targetFilter = string.Empty; _originFilter = string.Empty; _ageFilter = "All"; _searchFilter = string.Empty; } 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; if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return; } var confirmed = await Dialog.ConfirmAsync( "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; if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); 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) { if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return; } _actionInProgress = true; _activeAction = "Retry"; try { 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 {ShortId(msg.MessageId)} queued for retry."); await FetchPage(); } else _toast.ShowError(resp.ErrorMessage ?? "Retry failed."); } catch (Exception ex) { _toast.ShowError($"Retry failed: {ex.Message}"); } _actionInProgress = false; _activeAction = null; } private async Task DiscardSingle(ParkedMessageEntry msg) { if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return false; } 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 (JSDisconnectedException) { // Circuit gone — the page is being torn down; nothing to surface. // CentralUI-023: distinguished from a genuine interop failure. } catch (JSException ex) { // A real clipboard failure (e.g. permission denied) — surface it to // the user and log it so it is not invisible in production. Logger.LogWarning(ex, "Clipboard copy failed."); _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() }; }