@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth @inject Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider MutationAuth @page "/monitoring/parked-messages" @attribute [Authorize(Policy = ZB.MOM.WW.ScadaBridge.Security.AuthorizationPolicies.RequireDeployment)] @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories @using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery @using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums @using ZB.MOM.WW.ScadaBridge.Communication @inject ISiteRepository SiteRepository @inject ZB.MOM.WW.ScadaBridge.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
@* The dismiss control is not a bulk action — it stays outside the group so it keeps its ms-auto right-alignment and its btn-close styling. *@
} @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
} }
@* Re-resolved from the CURRENT page every render (see DrawerMessage), so the drawer can never keep showing a row that no longer exists. The drawer is MOUNTED on _drawerMessageId — user intent — not on the resolve succeeding. Gating the subtree on DrawerMessage would make the drawer's existence a function of list contents, so a render where the row is momentarily absent unmounts it and disposes every event-handler id inside, Close included; a click already in flight against a disposed handler throws GetRequiredEventBindingEntry in the renderer. A row that was retried, discarded or paged away degrades to the notice below instead. *@ @{ var drawer = DrawerMessage; } @if (_drawerMessageId is not null) {
Parked message
@(drawer?.TargetSystem ?? "No longer listed")
@drawer?.MethodName
@if (drawer is null) {
This message is no longer on the current page — it was retried, discarded, or the page changed while the drawer was open.
} else {
Message ID
@drawer.MessageId
Category
@CategoryLabel(drawer.Category)
Origin instance
@if (!string.IsNullOrEmpty(drawer.OriginInstance)) { @drawer.OriginInstance } else { — }
Attempts
@drawer.AttemptCount / @drawer.MaxAttempts
Originally enqueued
@Relative(drawer.OriginalTimestamp) · @AbsoluteUtc(drawer.OriginalTimestamp)
Last attempt
@Relative(drawer.LastAttemptTimestamp) · @AbsoluteUtc(drawer.LastAttemptTimestamp)
Error
@drawer.ErrorMessage
}
@* Actions need the resolved row to act on, so they hide when it is gone. Close stays mounted regardless — see the note above. *@ @if (drawer is not null) {
}
} @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 — holds the row's ID, never the row object. FetchPage() replaces // _messages wholesale after every Retry/Discard and on every page change, so a // captured ParkedMessageEntry would outlive its row and keep rendering values // (attempt count, last-attempt time, error) that no longer exist server-side. // Mirrors ExecutionTreePage's _modalExecutionId. // // ONLY user intent writes this field: OpenDrawer/CloseDrawer, plus the four // navigation actions that invalidate page-scoped state (Search, OnSiteChanged, // PrevPage, NextPage — the same places that clear _selectedIds). FetchPage() // must never touch it: a data refresh that could clear the id would unmount the // drawer mid-render and dispose its event handlers, which is the disposal race // documented on the drawer markup above. private string? _drawerMessageId; // Re-resolves the open drawer's row from the CURRENT page on every render, so // it can never render a stale row. This drives the drawer's CONTENT only — an // unresolvable row degrades to a notice; it does not unmount the drawer. private ParkedMessageEntry? DrawerMessage => _drawerMessageId is null ? null : _messages?.FirstOrDefault(m => m.MessageId == _drawerMessageId); 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(); _drawerMessageId = null; } } private async Task Search() { _pageNumber = 1; _selectedIds.Clear(); _drawerMessageId = null; await FetchPage(); } // The drawer is page-scoped state, exactly like _selectedIds: paging away from // the open message invalidates it, so both are cleared here rather than leaving // the drawer mounted over a page that never contained its row. Clearing on an // explicit navigation action is intent, not a resolve-driven unmount, so it does // not reopen the disposal race. private async Task PrevPage() { _pageNumber--; _selectedIds.Clear(); _drawerMessageId = null; await FetchPage(); } private async Task NextPage() { _pageNumber++; _selectedIds.Clear(); _drawerMessageId = null; 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) => _drawerMessageId = msg.MessageId; private void CloseDrawer() => _drawerMessageId = null; private async Task RetryFromDrawer() { // Snapshot the resolved row before acting — RetrySingle awaits FetchPage(), // after which DrawerMessage no longer resolves. var msg = DrawerMessage; if (msg == null) return; await RetrySingle(msg); CloseDrawer(); } private async Task DiscardFromDrawer() { var msg = DrawerMessage; if (msg == null) return; 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); await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer); var mutationSite = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == _selectedSiteId); if (mutationSite is null || !await SiteScope.IsSiteAllowedAsync(mutationSite.Id)) throw new UnauthorizedAccessException("Site access was revoked."); 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, confirmLabel: "Discard"); 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); await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer); var mutationSite = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == _selectedSiteId); if (mutationSite is null || !await SiteScope.IsSiteAllowedAsync(mutationSite.Id)) throw new UnauthorizedAccessException("Site access was revoked."); 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); await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer); var mutationSite = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == _selectedSiteId); if (mutationSite is null || !await SiteScope.IsSiteAllowedAsync(mutationSite.Id)) throw new UnauthorizedAccessException("Site access was revoked."); 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, confirmLabel: "Discard"); 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); await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer); var mutationSite = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == _selectedSiteId); if (mutationSite is null || !await SiteScope.IsSiteAllowedAsync(mutationSite.Id)) throw new UnauthorizedAccessException("Site access was revoked."); 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() }; }