337 lines
17 KiB
Plaintext
337 lines
17 KiB
Plaintext
@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>
|
|
@* Task 17: free-text Node filter — exact match against the
|
|
SiteCall.SourceNode column. The Source site dropdown narrows
|
|
to a site; Node narrows further within that site (or across
|
|
sites if Source site is "Any"). *@
|
|
<div class="col-auto">
|
|
<label class="form-label small mb-1" for="sc-node">Node</label>
|
|
<input id="sc-node" type="text" class="form-control form-control-sm"
|
|
style="min-width: 150px;" placeholder="Any"
|
|
data-test="site-calls-filter-node"
|
|
@bind="_nodeFilter" />
|
|
</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>Node</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><span class="small">@(c.SourceNode ?? "—")</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">
|
|
@* No "of N" total: keyset paging has no cheap total-count, so
|
|
the label is intentionally page-number-only. Do not "fix"
|
|
this by adding a total — that would require a COUNT(*). *@
|
|
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">Source node</dt>
|
|
<dd class="col-sm-9">@(string.IsNullOrEmpty(d.SourceNode) ? "—" : d.SourceNode)</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>
|
|
}
|