using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.WebUtilities; 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. /// /// /// /// Query-string drill-in: the Health-dashboard Site Call KPI tiles deep-link here /// with ?status=Parked (Parked tile) or ?stuck=true (Stuck tile). On /// initialization those params seed / /// BEFORE the first , so the first grid load is already /// filtered and the filter card controls reflect the seeded values. Parsing is lax /// — an absent, blank, or unrecognised value is silently dropped and the page loads /// unfiltered, mirroring AuditLogPage's drill-in convention. /// /// public partial class SiteCallsReport { private const int PageSize = 50; [Inject] private NavigationManager Navigation { get; set; } = null!; // The Status filter binds. An unrecognised value leaves the filter unset. var match = ValidStatuses.FirstOrDefault( s => string.Equals(s, v?.Trim(), StringComparison.OrdinalIgnoreCase)); if (match is not null) { _statusFilter = match; } } if (query.TryGetValue("stuck", out var stuckValues) && bool.TryParse(stuckValues.ToString(), out var stuck)) { _stuckOnly = stuck; } } /// 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, SourceNodeFilter: NullIfEmpty(_nodeFilter)); 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; _nodeFilter = string.Empty; _stuckOnly = false; _fromFilter = null; _toFilter = null; } private bool HasActiveFilters => !string.IsNullOrEmpty(_statusFilter) || !string.IsNullOrEmpty(_channelFilter) || !string.IsNullOrEmpty(_siteFilter) || !string.IsNullOrEmpty(_targetFilter) || !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(); /// /// 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" }; }