feat(centralui): Site Calls page with Retry/Discard and Audit drill-in

This commit is contained in:
Joseph Doherty
2026-05-21 04:51:14 -04:00
parent 3cf2b4d47e
commit 7e9d74697b
6 changed files with 1435 additions and 0 deletions

View File

@@ -91,6 +91,19 @@
</Authorized>
</AuthorizeView>
@* Site Calls — Site Call Audit (#22). Deployment-role only,
matching the Notification Report page's gate; the section
header sits inside the policy block so a non-Deployment
user does not see the heading. *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="siteCallsContext">
<div role="presentation" class="nav-section-header">Site Calls</div>
<li class="nav-item">
<NavLink class="nav-link" href="/site-calls/report">Site Calls</NavLink>
</li>
</Authorized>
</AuthorizeView>
@* Monitoring — Health Dashboard is all-roles; Event Logs and
Parked Messages are Deployment-role only (Component-CentralUI). *@
<div role="presentation" class="nav-section-header">Monitoring</div>

View File

@@ -0,0 +1,317 @@
@page "/site-calls/report"
@attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)]
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Messages.Audit
@using ScadaLink.Communication
@inject CommunicationService CommunicationService
@inject ISiteRepository SiteRepository
@inject IDialogService Dialog
@inject ILogger<SiteCallsReport> Logger
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Site Calls</h4>
<button class="btn btn-outline-secondary btn-sm" @onclick="RefreshAll" disabled="@_loading">
@if (_loading) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Refresh
</button>
</div>
@* ── Filters ── *@
<div class="card mb-3">
<div class="card-body py-2">
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label small mb-1" for="sc-status">Status</label>
<select id="sc-status" class="form-select form-select-sm" style="min-width: 130px;"
@bind="_statusFilter">
<option value="">All</option>
<option value="Submitted">Submitted</option>
<option value="Forwarded">Forwarded</option>
<option value="Attempted">Attempted</option>
<option value="Delivered">Delivered</option>
<option value="Parked">Parked</option>
<option value="Failed">Failed</option>
<option value="Discarded">Discarded</option>
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="sc-channel">Channel</label>
<select id="sc-channel" class="form-select form-select-sm" style="min-width: 130px;"
@bind="_channelFilter">
<option value="">All</option>
<option value="ApiOutbound">ApiOutbound</option>
<option value="DbOutbound">DbOutbound</option>
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="sc-site">Source site</label>
<select id="sc-site" class="form-select form-select-sm" style="min-width: 150px;"
@bind="_siteFilter">
<option value="">Any</option>
@foreach (var site in _sites)
{
<option value="@site.SiteIdentifier">@site.Name</option>
}
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="sc-from">From</label>
<input id="sc-from" type="datetime-local" class="form-control form-control-sm"
@bind="_fromFilter" />
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="sc-to">To</label>
<input id="sc-to" type="datetime-local" class="form-control form-control-sm"
@bind="_toFilter" />
</div>
<div class="col">
<label class="form-label small mb-1" for="sc-search">Target keyword</label>
<input id="sc-search" type="search" class="form-control form-control-sm"
placeholder="Exact target…" @bind="_targetFilter" />
</div>
<div class="col-auto">
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="sc-stuck-only"
@bind="_stuckOnly" />
<label class="form-check-label small" for="sc-stuck-only">Stuck only</label>
</div>
</div>
<div class="col-auto">
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters"
disabled="@(!HasActiveFilters)">Clear</button>
</div>
<div class="col-auto">
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@_loading"
data-test="site-calls-query">
@if (_loading) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Query
</button>
</div>
</div>
</div>
</div>
@if (_listError != null)
{
<div class="alert alert-danger">@_listError</div>
}
@* ── Site call list ── *@
@if (_siteCalls == null)
{
@if (_loading)
{
<div class="text-muted small">Loading…</div>
}
}
else if (_siteCalls.Count == 0)
{
<div class="card">
<div class="card-body text-center text-muted py-5">
<div class="fs-5 mb-1">No site calls</div>
<div class="small">No cached calls match the current filters.</div>
</div>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th>Tracked operation</th>
<th>Source site</th>
<th>Channel</th>
<th>Target</th>
<th>Status</th>
<th class="text-end">Retries</th>
<th>Last error</th>
<th>Created</th>
<th>Updated</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var c in _siteCalls)
{
<tr @key="c.TrackedOperationId" class="@(c.IsStuck ? "table-warning" : "")"
style="cursor: pointer;" @ondblclick="() => ShowDetail(c)"
title="Double-click for full detail">
<td><code class="small" title="@c.TrackedOperationId">@ShortId(c.TrackedOperationId)</code></td>
<td><span class="small">@SiteName(c.SourceSite)</span></td>
<td>@c.Channel</td>
<td>@c.Target</td>
<td>
<span class="badge @StatusBadgeClass(c.Status)">@c.Status</span>
@if (c.IsStuck)
{
<span class="badge bg-warning text-dark ms-1">Stuck</span>
}
</td>
<td class="text-end font-monospace">@c.RetryCount</td>
<td>
@if (!string.IsNullOrEmpty(c.LastError))
{
<div class="small text-danger text-truncate" style="max-width: 280px;"
title="@c.LastError">@c.LastError</div>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td><TimestampDisplay Value="@AsOffset(c.CreatedAtUtc)" Format="yyyy-MM-dd HH:mm" /></td>
<td><TimestampDisplay Value="@AsOffset(c.UpdatedAtUtc)" Format="yyyy-MM-dd HH:mm" /></td>
<td class="text-end" @ondblclick:stopPropagation="true">
@* The TrackedOperationId is the audit CorrelationId, so the
link deep-links into the central Audit Log pre-filtered to
this cached call's lifecycle events. *@
<a class="btn btn-outline-secondary btn-sm me-1"
href="/audit/log?correlationId=@c.TrackedOperationId"
data-test="audit-link-@c.TrackedOperationId">
View audit history
</a>
@* Retry/Discard relay only on Parked rows — central relays the
action to the owning site; Failed and other statuses are not
actionable from central. *@
@if (c.Status == "Parked")
{
<button class="btn btn-outline-success btn-sm me-1"
@onclick="() => RetrySiteCall(c)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DiscardSiteCall(c)" disabled="@_actionInProgress">
Discard
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
@* Keyset paging — the Task 4 query response carries a (CreatedAtUtc, Id)
cursor rather than page numbers, so we keep a stack of cursors to step
backwards and the response's NextAfter* cursor to step forwards. *@
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">
Page @(_cursorStack.Count + 1) · @_siteCalls.Count rows
</span>
<div>
<button class="btn btn-outline-secondary btn-sm me-1"
@onclick="PrevPage" disabled="@(_cursorStack.Count == 0 || _loading)"
data-test="site-calls-prev">Previous</button>
<button class="btn btn-outline-secondary btn-sm"
@onclick="NextPage" disabled="@(!HasNextPage || _loading)"
data-test="site-calls-next">Next</button>
</div>
</div>
}
</div>
@* ── Row detail modal ── *@
@if (_detailSiteCall != null)
{
var d = _detailSiteCall;
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);"
@onclick="CloseDetail">
<div class="modal-dialog modal-dialog-scrollable modal-lg" @onclick:stopPropagation="true">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Site Call Detail — @ShortId(d.TrackedOperationId)</h6>
<button type="button" class="btn-close" aria-label="Close"
@onclick="CloseDetail"></button>
</div>
<div class="modal-body">
@if (_detailLoading)
{
<div class="text-muted small">
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
Loading details…
</div>
}
else if (_detailError != null)
{
<div class="text-danger small">@_detailError</div>
}
else if (_detail != null)
{
var det = _detail;
<dl class="row mb-0">
<dt class="col-sm-3">Tracked operation</dt>
<dd class="col-sm-9"><code>@det.TrackedOperationId</code></dd>
<dt class="col-sm-3">Source site</dt>
<dd class="col-sm-9">@SiteName(det.SourceSite)</dd>
<dt class="col-sm-3">Channel</dt>
<dd class="col-sm-9">@det.Channel</dd>
<dt class="col-sm-3">Target</dt>
<dd class="col-sm-9">@det.Target</dd>
<dt class="col-sm-3">Status</dt>
<dd class="col-sm-9">
<span class="badge @StatusBadgeClass(det.Status)">@det.Status</span>
</dd>
<dt class="col-sm-3">Retry count</dt>
<dd class="col-sm-9 font-monospace">@det.RetryCount</dd>
<dt class="col-sm-3">HTTP status</dt>
<dd class="col-sm-9">@(det.HttpStatus?.ToString() ?? "—")</dd>
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9">
<TimestampDisplay Value="@AsOffset(det.CreatedAtUtc)" Format="yyyy-MM-dd HH:mm:ss" />
</dd>
<dt class="col-sm-3">Updated</dt>
<dd class="col-sm-9">
<TimestampDisplay Value="@AsOffset(det.UpdatedAtUtc)" Format="yyyy-MM-dd HH:mm:ss" />
</dd>
<dt class="col-sm-3">Terminal</dt>
<dd class="col-sm-9">
<TimestampDisplay Value="@AsOffset(det.TerminalAtUtc)"
Format="yyyy-MM-dd HH:mm:ss" NullText="—" />
</dd>
<dt class="col-sm-3">Ingested (central)</dt>
<dd class="col-sm-9">
<TimestampDisplay Value="@AsOffset(det.IngestedAtUtc)" Format="yyyy-MM-dd HH:mm:ss" />
</dd>
@if (!string.IsNullOrEmpty(det.LastError))
{
<dt class="col-sm-3">Last error</dt>
@* Plain text — never a MarkupString. *@
<dd class="col-sm-9 text-danger">@det.LastError</dd>
}
</dl>
}
</div>
<div class="modal-footer">
@if (d.Status == "Parked")
{
<button class="btn btn-outline-success btn-sm"
@onclick="() => RetryFromDetail(d)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DiscardFromDetail(d)" disabled="@_actionInProgress">
Discard
</button>
}
<button class="btn btn-outline-secondary btn-sm" @onclick="CloseDetail">Close</button>
</div>
</div>
</div>
</div>
}

View File

@@ -0,0 +1,369 @@
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;
/// <summary>
/// Code-behind for the central Site Calls report page (Site Call Audit #22). A
/// near-mirror of <see cref="ScadaLink.CentralUI.Components.Pages.Notifications.NotificationReport"/>:
/// it queries the central <c>SiteCalls</c> table via
/// <see cref="ScadaLink.Communication.CommunicationService.QuerySiteCallsAsync"/>,
/// shows a filterable/keyset-paged grid and a detail modal, and relays Retry/Discard
/// of <c>Parked</c> cached calls to their owning site.
///
/// <para>
/// Unlike the Notification report, the query response uses a <c>(CreatedAtUtc DESC,
/// TrackedOperationId DESC)</c> 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 <c>NextAfter*</c> cursor (to step forwards).
/// </para>
///
/// <para>
/// Retry/Discard relay to the owning site has a distinct <see cref="SiteCallRelayOutcome.SiteUnreachable"/>
/// 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.
/// </para>
/// </summary>
public partial class SiteCallsReport
{
private const int PageSize = 50;
private ToastNotification _toast = default!;
private List<Site> _sites = new();
// List
private List<SiteCallSummary>? _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();
}
/// <summary>Re-fetch the current page (Refresh button, and after a relay action).</summary>
private async Task RefreshAll()
{
await FetchPage(_currentCursor);
}
/// <summary>Apply the filters and start again from the first page.</summary>
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);
}
/// <summary>
/// Fetch one keyset page starting after <paramref name="cursor"/>.
/// </summary>
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;
}
/// <summary>
/// Surface a relay outcome on the toast. The <see cref="SiteCallRelayOutcome.SiteUnreachable"/>
/// case is deliberately distinct from a generic failure — the action was not
/// applied but the operator can retry once the site is back online.
/// </summary>
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:
default:
_toast.ShowError(errorMessage ?? "The site could not apply the action.");
break;
}
// Defensive: a non-Applied/non-Unreachable outcome that somehow reports an
// unreachable site still gets the unreachable wording.
if (outcome != SiteCallRelayOutcome.SiteUnreachable && !siteReachable
&& outcome != SiteCallRelayOutcome.Applied)
{
_toast.ShowError("Site unreachable — the relay did not reach the owning site.");
}
}
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();
/// <summary>
/// The filter inputs are UTC wall-clock — stamp <see cref="DateTimeKind.Utc"/>
/// on the local-typed value so the query is unambiguous.
/// </summary>
private static DateTime? ToUtc(DateTime? value) =>
value == null ? null : DateTime.SpecifyKind(value.Value, DateTimeKind.Utc);
/// <summary>
/// The <c>SiteCalls</c> timestamps are UTC <see cref="DateTime"/>; wrap them as
/// a <see cref="DateTimeOffset"/> for <c>TimestampDisplay</c>.
/// </summary>
private static DateTimeOffset? AsOffset(DateTime? value) =>
value == null
? null
: new DateTimeOffset(DateTime.SpecifyKind(value.Value, DateTimeKind.Utc));
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"
};
}