using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.SiteCalls;
///
/// Code-behind for the central Site Calls report page. 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!;
[Inject] private SiteScopeService SiteScope { get; set; } = null!;
[Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = null!;
// The Status filter options — the exact strings the dropdown binds and
// the KPI tiles emit (e.g. ?status=Parked). A query-string status only seeds the
// filter when it matches one of these (case-insensitively); anything else is
// dropped so a hand-crafted bad URL still renders the page unfiltered.
private static readonly string[] ValidStatuses =
{
"Submitted", "Forwarded", "Attempted", "Delivered", "Parked", "Failed", "Discarded",
};
private ToastNotification _toast = default!;
private List _sites = new();
// Unfiltered site list so a permitted-site lookup resolves
// correctly for a SourceSite whose Site was filtered out of the dropdown.
private List _allSites = new();
private bool _siteScopeSystemWide;
private HashSet _permittedSiteIds = 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. Only the row's IDENTIFIER is held — never the
// SiteCallSummary record itself — so the modal cannot outlive the row it was
// opened for. DetailRow() re-resolves it from the page currently on screen on
// every render; a row that has left the page resolves to null and the modal
// renders nothing (it self-closes) rather than showing a stale snapshot.
private Guid? _detailSiteCallId;
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 string _nodeFilter = string.Empty;
private bool _stuckOnly;
private DateTime? _fromFilter;
private DateTime? _toFilter;
// ── Trends ──────────────────────────────────────────────────────────────
// Collapsible KPI-history charts for the SiteCallAudit / Global series. The
// section is collapsed on init and the series load lazily on first expand
// (and on each 24h/7d window toggle), so the page's primary job — the call
// list — never waits on the history query. Each metric carries its own
// series + availability + error so one metric's failure leaves the other two
// charts intact; a GetSeriesAsync throw never breaks the grid above.
private bool _trendsExpanded;
private bool _trendsLoading;
private int _windowHours = 24;
private IReadOnlyList? _bufferedSeries;
private bool _bufferedAvailable = true;
private string? _bufferedError;
private IReadOnlyList? _parkedSeries;
private bool _parkedAvailable = true;
private string? _parkedError;
private IReadOnlyList? _failedSeries;
private bool _failedAvailable = true;
private string? _failedError;
private bool HasNextPage => _nextCursor is not null;
///
/// Resolves the row the detail modal is open for from the page currently on
/// screen. Returns null when no row is selected, when the list has not loaded,
/// or when the selected row is no longer in the page — the modal's visibility
/// gate, so a refresh that drops the row closes the surface instead of leaving
/// it stranded on a stale record.
///
private SiteCallSummary? DetailRow() =>
_detailSiteCallId is { } id
? _siteCalls?.FirstOrDefault(c => c.TrackedOperationId == id)
: null;
///
protected override async Task OnInitializedAsync()
{
try
{
_allSites = (await SiteRepository.GetAllSitesAsync()).ToList();
// Restrict the source-site dropdown to the user's
// permitted set. System-wide users see the full list back unchanged.
_sites = await SiteScope.FilterSitesAsync(_allSites);
_siteScopeSystemWide = await SiteScope.IsSystemWideAsync();
_permittedSiteIds = new HashSet(await SiteScope.PermittedSiteIdsAsync());
}
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.");
}
// Seed filters from ?status= / ?stuck= BEFORE the first fetch so the initial
// grid load is already filtered (and the filter card controls reflect it).
ApplyQueryStringFilters();
await RefreshAll();
}
///
/// Pre-apply the Health-dashboard KPI-tile drill-in filters from the URL query
/// string. ?status=<status> seeds when it
/// matches a known status (case-insensitive); ?stuck=true seeds
/// . Lax parsing — an absent, blank, or unrecognised value
/// is silently dropped, leaving the filter empty (the no-param behaviour).
///
private void ApplyQueryStringFilters()
{
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var query = QueryHelpers.ParseQuery(uri.Query);
if (query.Count == 0)
{
return;
}
if (query.TryGetValue("status", out var statusValues))
{
var v = statusValues.ToString();
// Round-trip the dropdown's own option strings (the KPI tile emits the
// canonical casing, e.g. ?status=Parked); normalise to that casing so the
// 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)
{
// Drop any row whose source site is outside the
// user's permitted set, as a row-level safety net behind the
// dropdown restriction.
_siteCalls = await FilterPermittedAsync(response.SiteCalls);
_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)
{
// Server-side re-check before relaying — a Retry relay must
// not fire for a site outside the caller's permitted set, even if the row
// somehow appeared in the grid.
if (!await IsRowSiteAllowedAsync(c.SourceSite))
{
_toast.ShowError("You are not permitted to act on cached calls for this site.");
return;
}
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 requestedBy = await AuthStateProvider.GetCurrentUsernameAsync();
var response = await CommunicationService.RetrySiteCallAsync(
new RetrySiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite, requestedBy));
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)
{
if (!await IsRowSiteAllowedAsync(c.SourceSite))
{
_toast.ShowError("You are not permitted to act on cached calls for this site.");
return;
}
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,
confirmLabel: "Discard");
if (!confirmed) return;
_actionInProgress = true;
try
{
var requestedBy = await AuthStateProvider.GetCurrentUsernameAsync();
var response = await CommunicationService.DiscardSiteCallAsync(
new DiscardSiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite, requestedBy));
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.
_detailSiteCallId = c.TrackedOperationId;
_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()
{
_detailSiteCallId = 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();
///
/// <input type="datetime-local"> binds with
/// and the value is the operator's
/// browser-local wall-clock. Tag it and
/// convert to UTC before the value enters the wire query — otherwise the
/// From/To window is silently shifted by the operator's UTC offset.
///
private static DateTime? ToUtc(DateTime? value) =>
value.HasValue
? DateTime.SpecifyKind(value.Value, DateTimeKind.Local).ToUniversalTime()
: (DateTime?)null;
///
/// 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"
};
///
/// Drops any site-call row whose source site resolves to a Site.Id outside the
/// caller's permitted set. System-wide users get the list back unchanged.
/// Lookup uses _allSites (not _sites ) so a row whose Site was
/// filtered OUT of the dropdown is correctly classified as out-of-scope.
///
private Task> FilterPermittedAsync(
IEnumerable calls)
{
if (_siteScopeSystemWide)
return Task.FromResult(calls.ToList());
var filtered = calls
.Where(c =>
{
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == c.SourceSite);
return resolved is null || _permittedSiteIds.Contains(resolved.Id);
})
.ToList();
return Task.FromResult(filtered);
}
///
/// Server-side re-check for the Retry/Discard relay. True for a system-wide
/// user, or when the row's source site maps to a Site.Id in the permitted set.
///
private Task IsRowSiteAllowedAsync(string sourceSite)
{
if (_siteScopeSystemWide)
return Task.FromResult(true);
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == sourceSite);
if (resolved is null)
return Task.FromResult(true);
return Task.FromResult(_permittedSiteIds.Contains(resolved.Id));
}
// ── Trends ──────────────────────────────────────────────────────────────
///
/// Expand/collapse the Trends section. The series load on first expand (and
/// stay loaded across collapse/expand cycles); collapsing leaves the loaded
/// series in place so re-expanding is instant. A re-fetch only happens on the
/// window toggle.
///
private async Task ToggleTrendsAsync()
{
_trendsExpanded = !_trendsExpanded;
if (_trendsExpanded && _bufferedSeries is null && _parkedSeries is null && _failedSeries is null)
{
await LoadTrendsAsync();
}
}
/// Switch the trend window (24h / 7d) and re-load the series.
private async Task SetWindowAsync(int windowHours)
{
if (_windowHours == windowHours)
{
return;
}
_windowHours = windowHours;
await LoadTrendsAsync();
}
///
/// Load all three SiteCallAudit / Global trend series for the current window.
/// Best-effort: each is
/// wrapped independently so one metric's failure renders only that chart
/// unavailable, and a throw never propagates to break the Site Calls grid.
///
private async Task LoadTrendsAsync()
{
_trendsLoading = true;
try
{
var toUtc = DateTime.UtcNow;
var fromUtc = toUtc - TimeSpan.FromHours(_windowHours);
(_bufferedSeries, _bufferedAvailable, _bufferedError) =
await LoadSeriesAsync(KpiMetrics.SiteCallAudit.Buffered, fromUtc, toUtc);
(_parkedSeries, _parkedAvailable, _parkedError) =
await LoadSeriesAsync(KpiMetrics.SiteCallAudit.Parked, fromUtc, toUtc);
(_failedSeries, _failedAvailable, _failedError) =
await LoadSeriesAsync(KpiMetrics.SiteCallAudit.FailedLastInterval, fromUtc, toUtc);
}
finally
{
// try/finally so an unexpected throw before this line can't lock the
// spinner/disabled state on forever. The per-metric LoadSeriesAsync
// try/catches already swallow GetSeriesAsync failures, so this guards
// only the truly unexpected.
_trendsLoading = false;
}
}
///
/// Fetch one SiteCallAudit / Global series, swallowing any failure into the
/// chart's unavailable state. Returns the points (null on failure), the
/// availability flag, and a short error message for the chart placeholder.
///
private async Task<(IReadOnlyList? Points, bool Available, string? Error)>
LoadSeriesAsync(string metric, DateTime fromUtc, DateTime toUtc)
{
try
{
var points = await KpiHistory.GetSeriesAsync(
KpiSources.SiteCallAudit, metric, KpiScopes.Global, scopeKey: null, fromUtc, toUtc);
return (points, true, null);
}
catch (Exception ex)
{
// A KPI-history hiccup must never break the Site Calls page — the
// chart degrades to its unavailable placeholder instead.
Logger.LogWarning(ex, "Failed to load Site Calls trend series for metric {Metric}.", metric);
return (null, false, "Trend data unavailable.");
}
}
}