feat(centralui): Site Calls page with Retry/Discard and Audit drill-in
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace ScadaLink.CentralUI.PlaywrightTests.SiteCalls;
|
||||
|
||||
/// <summary>
|
||||
/// Direct-SQL seeding helper for the Site Calls page Playwright E2E tests
|
||||
/// (Site Call Audit #22, follow-ups Task 6).
|
||||
///
|
||||
/// <para>
|
||||
/// The Site Calls page reads the central <c>SiteCalls</c> table through the
|
||||
/// <c>SiteCallAuditActor</c>, which is a pure read-from-table mirror — so a row
|
||||
/// INSERTed directly into <c>SiteCalls</c> surfaces on the page exactly as a
|
||||
/// telemetry-ingested row would. Mirrors <see cref="Audit.AuditDataSeeder"/>:
|
||||
/// each test inserts its own rows at setup and best-effort deletes them at
|
||||
/// teardown, keeping the suite self-contained without touching
|
||||
/// <c>infra/mssql/seed-config.sql</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Rows are tagged with a unique <c>Target</c> prefix derived from the test name
|
||||
/// + a GUID so the teardown <c>DELETE</c> never touches rows the cluster itself
|
||||
/// produced. <c>CreatedAtUtc</c>/<c>UpdatedAtUtc</c> are pinned to "now" so the
|
||||
/// page's default (unconstrained) query window sees the row.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class SiteCallDataSeeder
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Server=localhost,1433;Database=ScadaLinkConfig;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true;Encrypt=false;Connect Timeout=5";
|
||||
|
||||
private const string EnvVar = "SCADALINK_PLAYWRIGHT_DB";
|
||||
|
||||
/// <summary>
|
||||
/// Connection string for the running cluster's configuration DB. Resolved
|
||||
/// from <c>SCADALINK_PLAYWRIGHT_DB</c> when set, otherwise the local docker
|
||||
/// dev defaults.
|
||||
/// </summary>
|
||||
public static string ConnectionString
|
||||
{
|
||||
get
|
||||
{
|
||||
var fromEnv = Environment.GetEnvironmentVariable(EnvVar);
|
||||
return string.IsNullOrWhiteSpace(fromEnv) ? DefaultConnectionString : fromEnv;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a single row into the central <c>SiteCalls</c> table. Optional
|
||||
/// fields are nullable so a test can shape the row to the status/channel it
|
||||
/// needs for its grid assertions. <c>TrackedOperationId</c> is stored as the
|
||||
/// 36-character GUID string the entity mapping expects.
|
||||
/// </summary>
|
||||
public static async Task InsertSiteCallAsync(
|
||||
Guid trackedOperationId,
|
||||
string channel,
|
||||
string target,
|
||||
string sourceSite,
|
||||
string status,
|
||||
int retryCount,
|
||||
DateTime createdAtUtc,
|
||||
DateTime updatedAtUtc,
|
||||
string? lastError = null,
|
||||
int? httpStatus = null,
|
||||
DateTime? terminalAtUtc = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = @"
|
||||
INSERT INTO [SiteCalls]
|
||||
([TrackedOperationId], [Channel], [Target], [SourceSite], [Status], [RetryCount],
|
||||
[LastError], [HttpStatus], [CreatedAtUtc], [UpdatedAtUtc], [TerminalAtUtc], [IngestedAtUtc])
|
||||
VALUES
|
||||
(@id, @channel, @target, @sourceSite, @status, @retryCount,
|
||||
@lastError, @httpStatus, @createdAtUtc, @updatedAtUtc, @terminalAtUtc, SYSUTCDATETIME());";
|
||||
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
await connection.OpenAsync(ct);
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.Parameters.AddWithValue("@id", trackedOperationId.ToString());
|
||||
cmd.Parameters.AddWithValue("@channel", channel);
|
||||
cmd.Parameters.AddWithValue("@target", target);
|
||||
cmd.Parameters.AddWithValue("@sourceSite", sourceSite);
|
||||
cmd.Parameters.AddWithValue("@status", status);
|
||||
cmd.Parameters.AddWithValue("@retryCount", retryCount);
|
||||
cmd.Parameters.AddWithValue("@lastError", (object?)lastError ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@httpStatus", (object?)httpStatus ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@createdAtUtc", createdAtUtc);
|
||||
cmd.Parameters.AddWithValue("@updatedAtUtc", updatedAtUtc);
|
||||
cmd.Parameters.AddWithValue("@terminalAtUtc", (object?)terminalAtUtc ?? DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort cleanup. Deletes every <c>SiteCalls</c> row whose <c>Target</c>
|
||||
/// starts with <paramref name="targetPrefix"/>. Swallows all errors — the
|
||||
/// prefix carries a per-run GUID so the rows are unique to this test run.
|
||||
/// </summary>
|
||||
public static async Task DeleteByTargetPrefixAsync(string targetPrefix, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
await connection.OpenAsync(ct);
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM [SiteCalls] WHERE [Target] LIKE @prefix";
|
||||
cmd.Parameters.AddWithValue("@prefix", targetPrefix + "%");
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort — the prefix carries a GUID so the rows are unique to
|
||||
// this test run and won't collide on the next pass.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probe whether the configuration DB is reachable. Tests gate their per-test
|
||||
/// setup on this so a downed cluster surfaces a clear message rather than an
|
||||
/// opaque <see cref="SqlException"/>.
|
||||
/// </summary>
|
||||
public static async Task<bool> IsAvailableAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
await connection.OpenAsync(ct);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace ScadaLink.CentralUI.PlaywrightTests.SiteCalls;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage for the central Site Calls page (Site Call Audit #22,
|
||||
/// follow-ups Task 6).
|
||||
///
|
||||
/// <para>
|
||||
/// Each test seeds its own <c>SiteCalls</c> rows directly into the running
|
||||
/// cluster's configuration database via <see cref="SiteCallDataSeeder"/>,
|
||||
/// exercises the UI through Playwright, then best-effort deletes the rows by
|
||||
/// their <c>Target</c> prefix. The Site Calls page reads the <c>SiteCalls</c>
|
||||
/// table through the <c>SiteCallAuditActor</c> (a pure read-from-table mirror),
|
||||
/// so a directly-INSERTed row surfaces exactly as a telemetry-ingested row
|
||||
/// would — the same seeding model the Audit Log E2E tests use. The pattern
|
||||
/// keeps each test self-contained without touching
|
||||
/// <c>infra/mssql/seed-config.sql</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Scenarios covered (per the Task 6 brief):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>PageLoads</c> — the page renders for a Deployment-role user.</item>
|
||||
/// <item><c>FilterNarrowing</c> — a channel filter narrows the results grid.</item>
|
||||
/// <item><c>DrillIn</c> — the "View audit history" link deep-links into the
|
||||
/// Audit Log pre-filtered to the call's TrackedOperationId.</item>
|
||||
/// <item><c>RetryDiscardVisibility</c> — Retry/Discard appear only on Parked
|
||||
/// rows, never on Failed (or other) rows.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class SiteCallsPageTests
|
||||
{
|
||||
private const string SiteCallsUrl = "/site-calls/report";
|
||||
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
|
||||
public SiteCallsPageTests(PlaywrightFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PageLoads_ForDeploymentUser()
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
Assert.Contains(SiteCallsUrl, page.Url);
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Site Calls')")).ToBeVisibleAsync();
|
||||
// The filter card's Query button is the page's primary action.
|
||||
await Assertions.Expect(page.Locator("[data-test='site-calls-query']")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilterNarrowing_ChannelFilterShrinksGrid()
|
||||
{
|
||||
if (!await SiteCallDataSeeder.IsAvailableAsync())
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"SiteCallDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " +
|
||||
"or set SCADALINK_PLAYWRIGHT_DB to a reachable connection string.");
|
||||
}
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/sc-filter/{runId}/";
|
||||
var apiId = Guid.NewGuid();
|
||||
var dbId = Guid.NewGuid();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
// One ApiOutbound row, one DbOutbound row — distinct Targets.
|
||||
await SiteCallDataSeeder.InsertSiteCallAsync(
|
||||
trackedOperationId: apiId, channel: "ApiOutbound", target: targetPrefix + "api",
|
||||
sourceSite: "plant-a", status: "Delivered", retryCount: 0,
|
||||
createdAtUtc: now, updatedAtUtc: now, httpStatus: 200, terminalAtUtc: now);
|
||||
await SiteCallDataSeeder.InsertSiteCallAsync(
|
||||
trackedOperationId: dbId, channel: "DbOutbound", target: targetPrefix + "db",
|
||||
sourceSite: "plant-a", status: "Delivered", retryCount: 0,
|
||||
createdAtUtc: now, updatedAtUtc: now, terminalAtUtc: now);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Unfiltered query: both seeded rows appear (the Target keyword scopes
|
||||
// to this run so unrelated cluster rows do not interfere).
|
||||
await page.Locator("#sc-search").FillAsync(targetPrefix + "api");
|
||||
await page.Locator("[data-test='site-calls-query']").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Only the ApiOutbound row matches the exact target keyword.
|
||||
await Assertions.Expect(page.Locator($"text={targetPrefix}api")).ToBeVisibleAsync();
|
||||
Assert.Equal(0, await page.Locator($"text={targetPrefix}db").CountAsync());
|
||||
|
||||
// Now filter by Channel = DbOutbound with the db target — the row flips.
|
||||
await page.Locator("#sc-search").FillAsync(targetPrefix + "db");
|
||||
await page.Locator("#sc-channel").SelectOptionAsync("DbOutbound");
|
||||
await page.Locator("[data-test='site-calls-query']").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator($"text={targetPrefix}db")).ToBeVisibleAsync();
|
||||
Assert.Equal(0, await page.Locator($"text={targetPrefix}api").CountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DrillIn_ViewAuditHistory_NavigatesToPreFilteredAuditLog()
|
||||
{
|
||||
if (!await SiteCallDataSeeder.IsAvailableAsync())
|
||||
{
|
||||
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
|
||||
}
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/sc-drill-in/{runId}/";
|
||||
var trackedId = Guid.NewGuid();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await SiteCallDataSeeder.InsertSiteCallAsync(
|
||||
trackedOperationId: trackedId, channel: "ApiOutbound", target: targetPrefix + "endpoint",
|
||||
sourceSite: "plant-a", status: "Delivered", retryCount: 0,
|
||||
createdAtUtc: now, updatedAtUtc: now, httpStatus: 200, terminalAtUtc: now);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await page.Locator("#sc-search").FillAsync(targetPrefix + "endpoint");
|
||||
await page.Locator("[data-test='site-calls-query']").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// The row carries a "View audit history" link whose href is the
|
||||
// canonical correlationId deep-link — the TrackedOperationId IS the
|
||||
// audit CorrelationId.
|
||||
var link = page.Locator($"a[data-test='audit-link-{trackedId}']");
|
||||
await Assertions.Expect(link).ToBeVisibleAsync();
|
||||
var href = await link.GetAttributeAsync("href");
|
||||
Assert.Equal($"/audit/log?correlationId={trackedId}", href);
|
||||
|
||||
// Following the link lands on the Audit Log page with the query-string
|
||||
// drill-in context intact.
|
||||
await link.ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
Assert.Contains($"correlationId={trackedId}", page.Url);
|
||||
await Assertions.Expect(page.Locator("h1:has-text('Audit Log')")).ToBeVisibleAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryDiscard_VisibleOnlyOnParkedRows()
|
||||
{
|
||||
if (!await SiteCallDataSeeder.IsAvailableAsync())
|
||||
{
|
||||
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
|
||||
}
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/sc-actions/{runId}/";
|
||||
var parkedId = Guid.NewGuid();
|
||||
var failedId = Guid.NewGuid();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
// One Parked row (actionable) and one Failed row (terminal — not
|
||||
// actionable from central).
|
||||
await SiteCallDataSeeder.InsertSiteCallAsync(
|
||||
trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked",
|
||||
sourceSite: "plant-a", status: "Parked", retryCount: 3,
|
||||
lastError: "HTTP 503 from ERP", httpStatus: 503,
|
||||
createdAtUtc: now, updatedAtUtc: now);
|
||||
await SiteCallDataSeeder.InsertSiteCallAsync(
|
||||
trackedOperationId: failedId, channel: "DbOutbound", target: targetPrefix + "failed",
|
||||
sourceSite: "plant-a", status: "Failed", retryCount: 1,
|
||||
lastError: "constraint violation",
|
||||
createdAtUtc: now, updatedAtUtc: now, terminalAtUtc: now);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Query the parked row first.
|
||||
await page.Locator("#sc-search").FillAsync(targetPrefix + "parked");
|
||||
await page.Locator("[data-test='site-calls-query']").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var parkedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "parked" });
|
||||
await Assertions.Expect(parkedRow).ToBeVisibleAsync();
|
||||
// The Parked row exposes both Retry and Discard.
|
||||
await Assertions.Expect(parkedRow.Locator("button:has-text('Retry')")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(parkedRow.Locator("button:has-text('Discard')")).ToBeVisibleAsync();
|
||||
|
||||
// Now the Failed row — Retry/Discard are absent.
|
||||
await page.Locator("#sc-search").FillAsync(targetPrefix + "failed");
|
||||
await page.Locator("[data-test='site-calls-query']").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var failedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "failed" });
|
||||
await Assertions.Expect(failedRow).ToBeVisibleAsync();
|
||||
Assert.Equal(0, await failedRow.Locator("button:has-text('Retry')").CountAsync());
|
||||
Assert.Equal(0, await failedRow.Locator("button:has-text('Discard')").CountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
using System.Security.Claims;
|
||||
using Akka.Actor;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ScadaLink.CentralUI.Components.Shared;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Messages.Audit;
|
||||
using ScadaLink.Communication;
|
||||
using ScadaLink.Security;
|
||||
using SiteCallsReportPage = ScadaLink.CentralUI.Components.Pages.SiteCalls.SiteCallsReport;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit rendering tests for the Site Calls report page (Site Call Audit #22).
|
||||
///
|
||||
/// Testability note: <see cref="CommunicationService"/> is a concrete class with
|
||||
/// non-virtual methods, so NSubstitute cannot intercept it. The page's calls all
|
||||
/// route through an injected <see cref="IActorRef"/> (the Site Call Audit proxy),
|
||||
/// so the tests wire a real, lightweight <see cref="ActorSystem"/> with a scripted
|
||||
/// <see cref="ReceiveActor"/> that replies with fixed responses — the same seam
|
||||
/// <c>SetSiteCallAudit</c> exists for. Mirrors <see cref="NotificationReportPageTests"/>.
|
||||
/// </summary>
|
||||
public class SiteCallsReportPageTests : BunitContext
|
||||
{
|
||||
private readonly ActorSystem _system = ActorSystem.Create("site-calls-report-tests");
|
||||
private readonly CommunicationService _comms;
|
||||
|
||||
private static readonly Guid ParkedId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
private static readonly Guid FailedId = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
|
||||
// Mutable scripted reply — individual tests can override before rendering.
|
||||
private SiteCallQueryResponse _queryReply = new(
|
||||
"q", true, null,
|
||||
new List<SiteCallSummary>
|
||||
{
|
||||
new(ParkedId, "plant-a", "ApiOutbound", "ERP.GetOrder", "Parked",
|
||||
RetryCount: 3, LastError: "HTTP 503 from ERP", HttpStatus: 503,
|
||||
CreatedAtUtc: DateTime.UtcNow.AddMinutes(-30), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-5),
|
||||
TerminalAtUtc: null, IsStuck: true),
|
||||
new(FailedId, "plant-b", "DbOutbound", "Historian.Write", "Failed",
|
||||
RetryCount: 1, LastError: "constraint violation", HttpStatus: null,
|
||||
CreatedAtUtc: DateTime.UtcNow.AddHours(-2), UpdatedAtUtc: DateTime.UtcNow.AddHours(-2),
|
||||
TerminalAtUtc: DateTime.UtcNow.AddHours(-2), IsStuck: false),
|
||||
},
|
||||
NextAfterCreatedAtUtc: null,
|
||||
NextAfterId: null);
|
||||
|
||||
// Records the most recent retry/discard requests the actor received.
|
||||
private readonly List<SiteCallQueryRequest> _queryRequests = new();
|
||||
private readonly List<RetrySiteCallRequest> _retryRequests = new();
|
||||
private readonly List<DiscardSiteCallRequest> _discardRequests = new();
|
||||
|
||||
// Scripted relay responses — overridable per test.
|
||||
private RetrySiteCallResponse _retryReply =
|
||||
new("q", SiteCallRelayOutcome.Applied, true, true, null);
|
||||
private DiscardSiteCallResponse _discardReply =
|
||||
new("q", SiteCallRelayOutcome.Applied, true, true, null);
|
||||
|
||||
public SiteCallsReportPageTests()
|
||||
{
|
||||
_comms = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
|
||||
var auditProxy = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)));
|
||||
_comms.SetSiteCallAudit(auditProxy);
|
||||
|
||||
Services.AddSingleton(_comms);
|
||||
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
|
||||
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
|
||||
{
|
||||
new("Plant A", "plant-a") { Id = 1 },
|
||||
new("Plant B", "plant-b") { Id = 2 },
|
||||
}));
|
||||
Services.AddSingleton(siteRepo);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("Username", "tester"),
|
||||
new Claim(ClaimTypes.Role, "Deployment"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_RequiresDeploymentPolicy()
|
||||
{
|
||||
var attr = typeof(SiteCallsReportPage)
|
||||
.GetCustomAttributes(typeof(AuthorizeAttribute), true)
|
||||
.Cast<AuthorizeAttribute>()
|
||||
.FirstOrDefault();
|
||||
|
||||
Assert.NotNull(attr);
|
||||
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renders_SiteCallRows()
|
||||
{
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("ERP.GetOrder", cut.Markup);
|
||||
Assert.Contains("Historian.Write", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StuckRow_IsBadged()
|
||||
{
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
var stuckRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("ERP.GetOrder"));
|
||||
Assert.Contains("badge", stuckRow.InnerHtml);
|
||||
Assert.Contains("Stuck", stuckRow.TextContent);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryDiscardButtons_ShownOnlyOnParkedRows()
|
||||
{
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
|
||||
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
|
||||
|
||||
var parkedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("ERP.GetOrder"));
|
||||
var failedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Historian.Write"));
|
||||
|
||||
// The Parked row carries Retry + Discard buttons.
|
||||
Assert.Contains(parkedRow.QuerySelectorAll("button"),
|
||||
b => b.TextContent.Contains("Retry"));
|
||||
Assert.Contains(parkedRow.QuerySelectorAll("button"),
|
||||
b => b.TextContent.Contains("Discard"));
|
||||
|
||||
// The Failed row carries neither — Retry/Discard are Parked-only.
|
||||
Assert.DoesNotContain(failedRow.QuerySelectorAll("button"),
|
||||
b => b.TextContent.Contains("Retry"));
|
||||
Assert.DoesNotContain(failedRow.QuerySelectorAll("button"),
|
||||
b => b.TextContent.Contains("Discard"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickRetry_OnParkedRow_RelaysRetryToOwningSite()
|
||||
{
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
|
||||
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
|
||||
|
||||
var parkedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("ERP.GetOrder"));
|
||||
var retryButton = parkedRow.QuerySelectorAll("button")
|
||||
.First(b => b.TextContent.Contains("Retry"));
|
||||
|
||||
retryButton.Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Single(_retryRequests);
|
||||
Assert.Equal(ParkedId, _retryRequests[0].TrackedOperationId);
|
||||
// The relay carries the owning site so central can route it.
|
||||
Assert.Equal("plant-a", _retryRequests[0].SourceSite);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickDiscard_OnParkedRow_RelaysDiscardToOwningSite()
|
||||
{
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
|
||||
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
|
||||
|
||||
var parkedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("ERP.GetOrder"));
|
||||
var discardButton = parkedRow.QuerySelectorAll("button")
|
||||
.First(b => b.TextContent.Contains("Discard"));
|
||||
|
||||
discardButton.Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Single(_discardRequests);
|
||||
Assert.Equal(ParkedId, _discardRequests[0].TrackedOperationId);
|
||||
Assert.Equal("plant-a", _discardRequests[0].SourceSite);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryRelay_SiteUnreachable_ShowsDistinctMessage()
|
||||
{
|
||||
// The relay never reached the owning site — a transient transport
|
||||
// condition, surfaced distinctly from a generic failure.
|
||||
_retryReply = new RetrySiteCallResponse(
|
||||
"q", SiteCallRelayOutcome.SiteUnreachable, Success: false, SiteReachable: false,
|
||||
ErrorMessage: "Site plant-a is offline — relay not delivered.");
|
||||
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
|
||||
|
||||
var parkedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("ERP.GetOrder"));
|
||||
parkedRow.QuerySelectorAll("button")
|
||||
.First(b => b.TextContent.Contains("Retry"))
|
||||
.Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
Assert.Contains("offline", cut.Markup));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryFailure_ShowsErrorMessage()
|
||||
{
|
||||
_queryReply = new SiteCallQueryResponse(
|
||||
"q", false, "site call query backend unavailable",
|
||||
new List<SiteCallSummary>(), null, null);
|
||||
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
Assert.Contains("site call query backend unavailable", cut.Markup));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Drill-in — every row carries a "View audit history" link to
|
||||
// /audit/log?correlationId={TrackedOperationId}.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SiteCallRow_ViewAuditHistory_Link_HasCorrectHref()
|
||||
{
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// Both rows (Parked + Failed) surface the link — the drill-in is
|
||||
// row-scope, not status-scope.
|
||||
var parkedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("ERP.GetOrder"));
|
||||
var link = parkedRow.QuerySelector("a[data-test^=\"audit-link-\"]");
|
||||
Assert.NotNull(link);
|
||||
Assert.Equal(
|
||||
$"/audit/log?correlationId={ParkedId}",
|
||||
link!.GetAttribute("href"));
|
||||
Assert.Contains("View audit history", link.TextContent);
|
||||
|
||||
var failedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Historian.Write"));
|
||||
var failedLink = failedRow.QuerySelector("a[data-test^=\"audit-link-\"]");
|
||||
Assert.NotNull(failedLink);
|
||||
Assert.Equal(
|
||||
$"/audit/log?correlationId={FailedId}",
|
||||
failedLink!.GetAttribute("href"));
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Keyset paging — Next is driven by the response's NextAfter* cursor, not by
|
||||
// page numbers; the request echoes the cursor back to the actor.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Paging_NextButton_HiddenWhenNoFurtherPage()
|
||||
{
|
||||
// The default reply returns 2 rows and no NextAfter* cursor — there is no
|
||||
// further page, so Next is disabled.
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
|
||||
|
||||
var next = cut.Find("[data-test='site-calls-next']");
|
||||
Assert.True(next.HasAttribute("disabled"));
|
||||
var prev = cut.Find("[data-test='site-calls-prev']");
|
||||
Assert.True(prev.HasAttribute("disabled"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Paging_NextButton_AdvancesUsingKeysetCursor()
|
||||
{
|
||||
// A full page (PageSize=50 rows) plus a NextAfter* cursor: Next is live
|
||||
// and, when clicked, the follow-up query carries that cursor.
|
||||
var firstPage = new List<SiteCallSummary>();
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
firstPage.Add(new SiteCallSummary(
|
||||
Guid.NewGuid(), "plant-a", "ApiOutbound", $"ERP.Op{i}", "Delivered",
|
||||
RetryCount: 0, LastError: null, HttpStatus: 200,
|
||||
CreatedAtUtc: DateTime.UtcNow.AddMinutes(-i), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-i),
|
||||
TerminalAtUtc: DateTime.UtcNow.AddMinutes(-i), IsStuck: false));
|
||||
}
|
||||
|
||||
var cursorCreated = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
|
||||
var cursorId = Guid.Parse("99999999-9999-9999-9999-999999999999");
|
||||
_queryReply = new SiteCallQueryResponse(
|
||||
"q", true, null, firstPage,
|
||||
NextAfterCreatedAtUtc: cursorCreated,
|
||||
NextAfterId: cursorId);
|
||||
|
||||
var cut = Render<SiteCallsReportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("ERP.Op0"));
|
||||
|
||||
var next = cut.Find("[data-test='site-calls-next']");
|
||||
Assert.False(next.HasAttribute("disabled"));
|
||||
|
||||
next.Click();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// Two queries fired: the initial load and the Next click. The second
|
||||
// carries the keyset cursor echoed by the first response.
|
||||
Assert.Equal(2, _queryRequests.Count);
|
||||
Assert.Equal(cursorCreated, _queryRequests[1].AfterCreatedAtUtc);
|
||||
Assert.Equal(cursorId, _queryRequests[1].AfterId);
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stand-in for the Site Call Audit actor. Replies to each message type with
|
||||
/// the test's currently-scripted response.
|
||||
/// </summary>
|
||||
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
|
||||
{
|
||||
public ScriptedSiteCallAuditActor(SiteCallsReportPageTests test)
|
||||
{
|
||||
Receive<SiteCallQueryRequest>(r =>
|
||||
{
|
||||
test._queryRequests.Add(r);
|
||||
Sender.Tell(test._queryReply);
|
||||
});
|
||||
Receive<RetrySiteCallRequest>(r =>
|
||||
{
|
||||
test._retryRequests.Add(r);
|
||||
Sender.Tell(test._retryReply);
|
||||
});
|
||||
Receive<DiscardSiteCallRequest>(r =>
|
||||
{
|
||||
test._discardRequests.Add(r);
|
||||
Sender.Tell(test._discardReply);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
|
||||
private sealed class AlwaysConfirmDialogService : IDialogService
|
||||
{
|
||||
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<string?> PromptAsync(
|
||||
string title, string label, string initialValue = "", string? placeholder = null)
|
||||
=> Task.FromResult<string?>(null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user