using Microsoft.Extensions.Logging; using ScadaLink.CentralUI.Components.Shared; using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Messages.Audit; namespace ScadaLink.CentralUI.Components.Pages.SiteCalls; /// /// Code-behind for the central Site Calls report page (Site Call Audit #22). A /// near-mirror of : /// it queries the central SiteCalls table via /// , /// shows a filterable/keyset-paged grid and a detail modal, and relays Retry/Discard /// of Parked cached calls to their owning site. /// /// /// Unlike the Notification report, the query response uses a (CreatedAtUtc DESC, /// TrackedOperationId DESC) keyset cursor rather than page numbers, so paging /// keeps a stack of the cursors that opened each page (to step backwards) plus the /// response's NextAfter* cursor (to step forwards). /// /// /// /// Retry/Discard relay to the owning site has a distinct /// outcome — central is an eventually-consistent mirror, not the source of truth, so /// a relay that never reaches the site is a transient transport condition, surfaced /// to the operator differently from a generic failure. /// /// public partial class SiteCallsReport { private const int PageSize = 50; private ToastNotification _toast = default!; private List _sites = new(); // List private List? _siteCalls; private bool _loading; private string? _listError; private bool _actionInProgress; // Keyset paging. The first page is opened with the empty (null, null) cursor. // _cursorStack holds the cursors of the PREVIOUSLY visited pages — it is empty // on page 1, has one entry on page 2, and so on; Previous pops it. _nextCursor // is the cursor for the following page, echoed back by the last query. private readonly Stack<(DateTime? AfterCreatedAtUtc, Guid? AfterId)> _cursorStack = new(); private (DateTime? AfterCreatedAtUtc, Guid? AfterId) _currentCursor = (null, null); private (DateTime? AfterCreatedAtUtc, Guid? AfterId)? _nextCursor; // Row detail modal private SiteCallSummary? _detailSiteCall; private SiteCallDetail? _detail; private bool _detailLoading; private string? _detailError; // Filters private string _statusFilter = string.Empty; private string _channelFilter = string.Empty; private string _siteFilter = string.Empty; private string _targetFilter = string.Empty; private bool _stuckOnly; private DateTime? _fromFilter; private DateTime? _toFilter; private bool HasNextPage => _nextCursor is not null; protected override async Task OnInitializedAsync() { try { _sites = (await SiteRepository.GetAllSitesAsync()).ToList(); } catch (Exception ex) { // Non-fatal — the source-site filter just falls back to raw site IDs. Logger.LogWarning(ex, "Failed to load sites for the Site Calls source-site filter."); } await RefreshAll(); } /// Re-fetch the current page (Refresh button, and after a relay action). private async Task RefreshAll() { await FetchPage(_currentCursor); } /// Apply the filters and start again from the first page. private async Task Search() { _cursorStack.Clear(); await FetchPage((null, null)); } private async Task PrevPage() { if (_cursorStack.Count == 0) { return; } // The top of the stack is the cursor of the page BEFORE the current one. var previousCursor = _cursorStack.Pop(); await FetchPage(previousCursor); } private async Task NextPage() { if (_nextCursor is not { } next) { return; } // Stepping forward: remember the current page's cursor so Previous can // return to it. _cursorStack.Push(_currentCursor); await FetchPage(next); } /// /// Fetch one keyset page starting after . /// private async Task FetchPage( (DateTime? AfterCreatedAtUtc, Guid? AfterId) cursor) { _loading = true; _listError = null; try { var request = new SiteCallQueryRequest( CorrelationId: Guid.NewGuid().ToString("N"), StatusFilter: NullIfEmpty(_statusFilter), SourceSiteFilter: NullIfEmpty(_siteFilter), ChannelFilter: NullIfEmpty(_channelFilter), TargetKeyword: NullIfEmpty(_targetFilter), StuckOnly: _stuckOnly, FromUtc: ToUtc(_fromFilter), ToUtc: ToUtc(_toFilter), AfterCreatedAtUtc: cursor.AfterCreatedAtUtc, AfterId: cursor.AfterId, PageSize: PageSize); var response = await CommunicationService.QuerySiteCallsAsync(request); if (response.Success) { _siteCalls = response.SiteCalls.ToList(); _currentCursor = cursor; // The response echoes the last row's cursor. A short page (fewer // rows than requested) has no further page even if a cursor came // back, so gate Next on a full page too. _nextCursor = response.NextAfterCreatedAtUtc is { } nextCreated && response.NextAfterId is { } nextId && _siteCalls.Count == PageSize ? (nextCreated, nextId) : null; } else { _listError = response.ErrorMessage ?? "Query failed."; } } catch (Exception ex) { _listError = $"Query failed: {ex.Message}"; } _loading = false; } private async Task RetrySiteCall(SiteCallSummary c) { var confirmed = await Dialog.ConfirmAsync( "Retry cached call", $"Relay a retry of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " + $"to site {SiteName(c.SourceSite)}?"); if (!confirmed) return; _actionInProgress = true; try { var response = await CommunicationService.RetrySiteCallAsync( new RetrySiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite)); ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage, appliedMessage: $"Retry of {ShortId(c.TrackedOperationId)} relayed to {SiteName(c.SourceSite)}."); if (response.Success) { await RefreshAll(); } } catch (Exception ex) { _toast.ShowError($"Retry failed: {ex.Message}"); } _actionInProgress = false; } private async Task DiscardSiteCall(SiteCallSummary c) { var confirmed = await Dialog.ConfirmAsync( "Discard cached call", $"Relay a discard of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " + $"to site {SiteName(c.SourceSite)}? This cannot be undone.", danger: true); if (!confirmed) return; _actionInProgress = true; try { var response = await CommunicationService.DiscardSiteCallAsync( new DiscardSiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite)); ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage, appliedMessage: $"Discard of {ShortId(c.TrackedOperationId)} relayed to {SiteName(c.SourceSite)}."); if (response.Success) { await RefreshAll(); } } catch (Exception ex) { _toast.ShowError($"Discard failed: {ex.Message}"); } _actionInProgress = false; } /// /// Surface a relay outcome on the toast — exactly one toast per relay /// response. The case is /// deliberately distinct from a generic failure: the action was not applied /// but the operator can retry once the site is back online. /// /// /// The switch is exhaustive, so it owns /// the single toast. is a redundant /// cross-check on the same signal (the contract sets it false only /// for ); it is folded /// INTO the case rather /// than firing a second toast — an OperationFailed response that also /// reports an unreachable site shows the unreachable wording, once. /// private void ShowRelayOutcome( SiteCallRelayOutcome outcome, bool siteReachable, string? errorMessage, string appliedMessage) { switch (outcome) { case SiteCallRelayOutcome.Applied: _toast.ShowSuccess(appliedMessage); break; case SiteCallRelayOutcome.NotParked: _toast.ShowInfo(errorMessage ?? "The site reported nothing to do — the cached call is no longer parked."); break; case SiteCallRelayOutcome.SiteUnreachable: _toast.ShowError(errorMessage ?? "Site unreachable — the relay did not reach the owning site. " + "Try again once the site is back online."); break; case SiteCallRelayOutcome.OperationFailed when !siteReachable: // An OperationFailed response that nonetheless reports the site // unreachable: trust the reachability signal and show the // unreachable wording instead of the generic failure message. _toast.ShowError(errorMessage ?? "Site unreachable — the relay did not reach the owning site. " + "Try again once the site is back online."); break; case SiteCallRelayOutcome.OperationFailed: default: _toast.ShowError(errorMessage ?? "The site could not apply the action."); break; } } private async Task ShowDetail(SiteCallSummary c) { // The summary fields render immediately from the grid row; the full detail // (HttpStatus, all timestamps, LastError) fills in once the fetch completes. _detailSiteCall = c; _detail = null; _detailError = null; _detailLoading = true; StateHasChanged(); try { var response = await CommunicationService.GetSiteCallDetailAsync( new SiteCallDetailRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId)); if (response.Success && response.Detail != null) { _detail = response.Detail; } else { _detailError = response.ErrorMessage ?? "Failed to load site call detail."; } } catch (Exception ex) { _detailError = $"Failed to load site call detail: {ex.Message}"; } _detailLoading = false; } private void CloseDetail() { _detailSiteCall = null; _detail = null; _detailError = null; _detailLoading = false; } private async Task RetryFromDetail(SiteCallSummary c) { await RetrySiteCall(c); // 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(SiteCallSummary c) { await DiscardSiteCall(c); CloseDetail(); } private void ClearFilters() { _statusFilter = string.Empty; _channelFilter = string.Empty; _siteFilter = string.Empty; _targetFilter = string.Empty; _stuckOnly = false; _fromFilter = null; _toFilter = null; } private bool HasActiveFilters => !string.IsNullOrEmpty(_statusFilter) || !string.IsNullOrEmpty(_channelFilter) || !string.IsNullOrEmpty(_siteFilter) || !string.IsNullOrEmpty(_targetFilter) || _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(); /// /// The filter inputs are UTC wall-clock — stamp /// on the local-typed value so the query is unambiguous. /// private static DateTime? ToUtc(DateTime? value) => value == null ? null : DateTime.SpecifyKind(value.Value, DateTimeKind.Utc); /// /// The SiteCalls timestamps are UTC ; wrap them as /// a for TimestampDisplay. /// private static DateTimeOffset? AsOffset(DateTime? value) => value == null ? null : new DateTimeOffset(DateTime.SpecifyKind(value.Value, DateTimeKind.Utc)); // A Guid's "N" format is always exactly 32 hex chars, so the [..12] slice is // always in range — no length guard needed. private static string ShortId(Guid id) => id.ToString("N")[..12]; private static string StatusBadgeClass(string status) => status switch { "Delivered" => "bg-success", "Parked" => "bg-danger", "Failed" => "bg-danger", "Attempted" => "bg-warning text-dark", "Forwarded" => "bg-info text-dark", "Submitted" => "bg-info text-dark", "Discarded" => "bg-secondary", _ => "bg-light text-dark" }; }