@page "/notifications/report" @attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)] @using ScadaLink.Commons.Entities.Sites @using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Commons.Messages.Notification @using ScadaLink.Communication @inject CommunicationService CommunicationService @inject ISiteRepository SiteRepository @inject IDialogService Dialog @inject ILogger Logger

Notification Report

@* ── Filters ── *@
@* Task 16: free-text Node filter — exact match against the notification's SourceNode column. Sites + central nodes both flow through this single input. *@
@if (_listError != null) {
@_listError
} @* ── Notification list ── *@ @if (_notifications == null) { @if (_loading) {
Loading…
} } else if (_notifications.Count == 0) {
No notifications
No notifications match the current filters.
} else {
@foreach (var n in _notifications) { }
ID Type List Subject Status Retries Source site Node Created Delivered Actions
@ShortId(n.NotificationId) @n.Type @n.ListName @n.Subject @if (!string.IsNullOrEmpty(n.LastError)) {
@n.LastError
}
@n.Status @if (n.IsStuck) { Stuck } @n.RetryCount @SiteName(n.SourceSiteId) @(n.SourceNode ?? "—") @* Bundle D (#23 M7-T10) drill-in: NotificationId is the audit CorrelationId, so the link deep-links into the central Audit Log pre-filtered to this notification's lifecycle events. *@ View audit history @if (n.Status == "Parked") { }
@if (_totalCount > _pageSize) {
Page @_pageNumber of @((_totalCount + _pageSize - 1) / _pageSize) · @_totalCount total
} }
@* ── Row detail modal ── *@ @if (_detailNotification != null) { var d = _detailNotification; } @code { private const int _pageSize = 50; private ToastNotification _toast = default!; private List _sites = new(); // List private List? _notifications; private int _totalCount; private int _pageNumber = 1; private bool _loading; private string? _listError; private bool _actionInProgress; // Row detail modal private NotificationSummary? _detailNotification; private NotificationDetail? _detail; private bool _detailLoading; private string? _detailError; // Filters private string _statusFilter = string.Empty; private string _typeFilter = string.Empty; private string _siteFilter = string.Empty; private string _listFilter = string.Empty; private string _subjectFilter = string.Empty; private string _nodeFilter = string.Empty; private bool _stuckOnly; private DateTime? _fromFilter; private DateTime? _toFilter; protected override async Task OnInitializedAsync() { try { _sites = (await SiteRepository.GetAllSitesAsync()).ToList(); } catch (Exception ex) { // Non-fatal — source-site filter just falls back to the raw site IDs. Logger.LogWarning(ex, "Failed to load sites for the report source-site filter."); } await RefreshAll(); } private async Task RefreshAll() { await FetchPage(); } private async Task Search() { _pageNumber = 1; await FetchPage(); } private async Task PrevPage() { _pageNumber--; await FetchPage(); } private async Task NextPage() { _pageNumber++; await FetchPage(); } private async Task FetchPage() { _loading = true; _listError = null; try { var request = new NotificationOutboxQueryRequest( CorrelationId: Guid.NewGuid().ToString("N"), StatusFilter: NullIfEmpty(_statusFilter), TypeFilter: NullIfEmpty(_typeFilter), SourceSiteFilter: NullIfEmpty(_siteFilter), ListNameFilter: NullIfEmpty(_listFilter), StuckOnly: _stuckOnly, SubjectKeyword: NullIfEmpty(_subjectFilter), From: ToUtc(_fromFilter), To: ToUtc(_toFilter), PageNumber: _pageNumber, PageSize: _pageSize, SourceNodeFilter: NullIfEmpty(_nodeFilter)); var response = await CommunicationService.QueryNotificationOutboxAsync(request); if (response.Success) { _notifications = response.Notifications.ToList(); _totalCount = response.TotalCount; } else { _listError = response.ErrorMessage ?? "Query failed."; } } catch (Exception ex) { _listError = $"Query failed: {ex.Message}"; } _loading = false; } private async Task RetryNotification(NotificationSummary n) { var confirmed = await Dialog.ConfirmAsync( "Retry notification", $"Re-queue notification {ShortId(n.NotificationId)} (\"{n.Subject}\") for delivery?"); if (!confirmed) return; _actionInProgress = true; try { var response = await CommunicationService.RetryNotificationAsync( new RetryNotificationRequest(Guid.NewGuid().ToString("N"), n.NotificationId)); if (response.Success) { _toast.ShowSuccess($"Notification {ShortId(n.NotificationId)} re-queued for delivery."); await RefreshAll(); } else { _toast.ShowError(response.ErrorMessage ?? "Retry failed."); } } catch (Exception ex) { _toast.ShowError($"Retry failed: {ex.Message}"); } _actionInProgress = false; } private async Task DiscardNotification(NotificationSummary n) { var confirmed = await Dialog.ConfirmAsync( "Discard notification", $"Permanently discard notification {ShortId(n.NotificationId)} (\"{n.Subject}\")? This cannot be undone.", danger: true); if (!confirmed) return; _actionInProgress = true; try { var response = await CommunicationService.DiscardNotificationAsync( new DiscardNotificationRequest(Guid.NewGuid().ToString("N"), n.NotificationId)); if (response.Success) { _toast.ShowSuccess($"Notification {ShortId(n.NotificationId)} discarded."); await RefreshAll(); } else { _toast.ShowError(response.ErrorMessage ?? "Discard failed."); } } catch (Exception ex) { _toast.ShowError($"Discard failed: {ex.Message}"); } _actionInProgress = false; } private async Task ShowDetail(NotificationSummary n) { // The summary fields render immediately; Body + recipients fill in once the // full-detail fetch completes. _detailNotification = n; _detail = null; _detailError = null; _detailLoading = true; StateHasChanged(); try { var response = await CommunicationService.GetNotificationDetailAsync( new NotificationDetailRequest(Guid.NewGuid().ToString("N"), n.NotificationId)); if (response.Success && response.Detail != null) { _detail = response.Detail; } else { _detailError = response.ErrorMessage ?? "Failed to load notification detail."; } } catch (Exception ex) { _detailError = $"Failed to load notification detail: {ex.Message}"; } _detailLoading = false; } private void CloseDetail() { _detailNotification = null; _detail = null; _detailError = null; _detailLoading = false; } /// /// Best-effort parse of ResolvedTargets into individual recipient addresses. /// The field may be a JSON string array, or a comma/semicolon-separated string. /// Returns an empty list when null/empty. /// private static List ParseRecipients(string? resolvedTargets) { if (string.IsNullOrWhiteSpace(resolvedTargets)) { return new List(); } var trimmed = resolvedTargets.Trim(); if (trimmed.StartsWith('[')) { try { var parsed = System.Text.Json.JsonSerializer.Deserialize>(trimmed); if (parsed != null) { return parsed .Where(r => !string.IsNullOrWhiteSpace(r)) .Select(r => r.Trim()) .ToList(); } } catch (System.Text.Json.JsonException) { // Not valid JSON — fall through to the delimiter-split path. } } return trimmed .Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToList(); } private async Task RetryFromDetail(NotificationSummary n) { await RetryNotification(n); // RefreshAll replaces the row list; close the modal so the user sees the // refreshed grid rather than a now-stale detail snapshot. CloseDetail(); } private async Task DiscardFromDetail(NotificationSummary n) { await DiscardNotification(n); CloseDetail(); } private void ClearFilters() { _statusFilter = string.Empty; _typeFilter = string.Empty; _siteFilter = string.Empty; _listFilter = string.Empty; _subjectFilter = string.Empty; _nodeFilter = string.Empty; _stuckOnly = false; _fromFilter = null; _toFilter = null; } private bool HasActiveFilters => !string.IsNullOrEmpty(_statusFilter) || !string.IsNullOrEmpty(_typeFilter) || !string.IsNullOrEmpty(_siteFilter) || !string.IsNullOrEmpty(_listFilter) || !string.IsNullOrEmpty(_subjectFilter) || !string.IsNullOrEmpty(_nodeFilter) || _stuckOnly || _fromFilter != null || _toFilter != null; private string SiteName(string siteId) => _sites.FirstOrDefault(s => s.SiteIdentifier == siteId)?.Name ?? siteId; private static string? NullIfEmpty(string s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim(); private static DateTimeOffset? ToUtc(DateTime? local) => local == null ? null : new DateTimeOffset(DateTime.SpecifyKind(local.Value, DateTimeKind.Utc)); private static string ShortId(string id) => id[..Math.Min(12, id.Length)]; private static string StatusBadgeClass(string status) => status switch { "Delivered" => "bg-success", "Parked" => "bg-danger", "Retrying" => "bg-warning text-dark", "Pending" => "bg-info text-dark", "Discarded" => "bg-secondary", _ => "bg-light text-dark" }; }