refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,323 @@
@page "/monitoring/event-logs"
@attribute [Authorize(Policy = ZB.MOM.WW.ScadaBridge.Security.AuthorizationPolicies.RequireDeployment)]
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery
@using ZB.MOM.WW.ScadaBridge.Communication
@inject ISiteRepository SiteRepository
@inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope
@inject CommunicationService CommunicationService
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Site Event Logs</h4>
<button class="btn btn-outline-secondary btn-sm"
type="button"
data-bs-toggle="collapse"
data-bs-target="#event-logs-filters"
aria-expanded="true"
aria-controls="event-logs-filters">
Filter options (@ActiveFilterCount active)
</button>
</div>
<ToastNotification @ref="_toast" />
<div class="collapse show" id="event-logs-filters">
<div class="row mb-3 g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small" for="filter-site">Site</label>
<select id="filter-site" class="form-select form-select-sm" aria-label="Site" @bind="_selectedSiteId">
<option value="">Select site...</option>
@foreach (var site in _sites)
{
<option value="@site.SiteIdentifier">@site.Name</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label small" for="filter-event-type">Event Type</label>
<input id="filter-event-type"
type="text"
class="form-control form-control-sm"
aria-label="Event type"
@bind="_filterEventType"
placeholder="e.g. ScriptError" />
</div>
<div class="col-md-1">
<label class="form-label small" for="filter-severity">Severity</label>
<select id="filter-severity"
class="form-select form-select-sm"
aria-label="Severity"
@bind="_filterSeverity">
<option value="">All</option>
<option>Info</option>
<option>Warning</option>
<option>Error</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label small" for="filter-from">From</label>
<input id="filter-from"
type="datetime-local"
class="form-control form-control-sm"
aria-label="From timestamp"
@bind="_filterFrom" />
</div>
<div class="col-md-2">
<label class="form-label small" for="filter-to">To</label>
<input id="filter-to"
type="datetime-local"
class="form-control form-control-sm"
aria-label="To timestamp"
@bind="_filterTo" />
</div>
<div class="col-md-1">
<label class="form-label small" for="filter-keyword">Message contains</label>
<input id="filter-keyword"
type="text"
class="form-control form-control-sm"
aria-label="Message contains"
@bind="_filterKeyword" />
</div>
<div class="col-md-2">
<label class="form-label small" for="filter-instance">Instance</label>
<input id="filter-instance"
type="text"
class="form-control form-control-sm"
aria-label="Instance name"
@bind="_filterInstanceName"
placeholder="Instance name" />
</div>
<div class="col-md-12 d-flex gap-2">
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@(string.IsNullOrEmpty(_selectedSiteId) || _searching)">
@if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Search
</button>
</div>
</div>
</div>
@if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
@if (_entries != null)
{
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
<th style="width: 1%;"></th>
<th>Timestamp</th>
<th>Type</th>
<th>Severity</th>
<th>Instance</th>
<th>Source</th>
<th>Message</th>
</tr>
</thead>
<tbody>
@if (_entries.Count == 0)
{
<tr><td colspan="7" class="text-muted text-center">No events found.</td></tr>
}
@for (int i = 0; i < _entries.Count; i++)
{
var idx = i;
var entry = _entries[idx];
var rowClass = entry.Severity == "Error" ? "table-danger"
: entry.Severity == "Warning" ? "table-warning"
: "";
var expanded = _expandedRows.Contains(idx);
<tr class="@rowClass">
<td>
<button class="btn btn-link btn-sm p-0"
@onclick="() => ToggleRow(idx)"
aria-label="@(expanded ? "Hide full message" : "View full message")">
@(expanded ? "Hide" : "View")
</button>
</td>
<td class="small"><TimestampDisplay Value="@entry.Timestamp" /></td>
<td class="small">@entry.EventType</td>
<td>
<span class="badge @GetSeverityBadge(entry.Severity)" aria-label="Severity: @entry.Severity">
@SeverityGlyph(entry.Severity) @entry.Severity
</span>
</td>
<td class="small">@(entry.InstanceId ?? "—")</td>
<td class="small">@entry.Source</td>
<td class="small text-truncate" style="max-width: 380px;">@entry.Message</td>
</tr>
@if (expanded)
{
<tr class="@rowClass">
<td colspan="7">
<pre class="small mb-0">@entry.Message</pre>
</td>
</tr>
}
}
</tbody>
</table>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">Showing @_entries.Count entries</span>
<div>
@if (_hasMore)
{
<button class="btn btn-outline-primary btn-sm" @onclick="LoadMore" disabled="@_searching">Load more</button>
}
else if (_entries.Count > 0)
{
<span class="text-muted small">End of results</span>
}
</div>
</div>
}
</div>
@code {
private List<Site> _sites = new();
private string _selectedSiteId = string.Empty;
private string? _filterEventType;
private string _filterSeverity = string.Empty;
private DateTime? _filterFrom;
private DateTime? _filterTo;
private string? _filterKeyword;
private string? _filterInstanceName;
private List<EventLogEntry>? _entries;
private bool _hasMore;
private long? _continuationToken;
private bool _searching;
private string? _errorMessage;
private ToastNotification _toast = default!;
private readonly HashSet<int> _expandedRows = new();
private int ActiveFilterCount
{
get
{
var n = 0;
if (!string.IsNullOrEmpty(_selectedSiteId)) n++;
if (!string.IsNullOrWhiteSpace(_filterEventType)) n++;
if (!string.IsNullOrEmpty(_filterSeverity)) n++;
if (_filterFrom.HasValue) n++;
if (_filterTo.HasValue) n++;
if (!string.IsNullOrWhiteSpace(_filterKeyword)) n++;
if (!string.IsNullOrWhiteSpace(_filterInstanceName)) n++;
return n;
}
}
protected override async Task OnInitializedAsync()
{
// Site scoping (CentralUI-002): a scoped Deployment user may only query
// event logs for the sites they are permitted on.
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
}
// _sites is already filtered, so membership IS the scope check.
private bool SelectedSiteIsPermitted =>
!string.IsNullOrEmpty(_selectedSiteId)
&& _sites.Any(s => s.SiteIdentifier == _selectedSiteId);
private async Task Search()
{
_entries = new();
_continuationToken = null;
_expandedRows.Clear();
await FetchPage();
}
private async Task LoadMore() => await FetchPage();
private void ToggleRow(int idx)
{
if (!_expandedRows.Add(idx))
{
_expandedRows.Remove(idx);
}
}
private async Task FetchPage()
{
_searching = true;
_errorMessage = null;
// Site scoping (CentralUI-002): re-check before querying — the dropdown is
// filtered, but the selection must not be trusted on its own.
if (!SelectedSiteIsPermitted)
{
_errorMessage = "You are not permitted to view event logs for that site.";
_searching = false;
return;
}
try
{
var request = new EventLogQueryRequest(
CorrelationId: Guid.NewGuid().ToString("N"),
SiteId: _selectedSiteId,
// CentralUI-027: <input type="datetime-local"> binds with DateTimeKind.Unspecified
// — the value is the operator's browser-local wall-clock. Tag it Local and
// convert to UTC; the prior code labelled the local value as UTC, silently
// shifting the query window by the operator's UTC offset.
From: LocalInputToUtc(_filterFrom),
To: LocalInputToUtc(_filterTo),
EventType: string.IsNullOrWhiteSpace(_filterEventType) ? null : _filterEventType.Trim(),
Severity: string.IsNullOrWhiteSpace(_filterSeverity) ? null : _filterSeverity,
InstanceId: string.IsNullOrWhiteSpace(_filterInstanceName) ? null : _filterInstanceName.Trim(),
KeywordFilter: string.IsNullOrWhiteSpace(_filterKeyword) ? null : _filterKeyword.Trim(),
ContinuationToken: _continuationToken,
PageSize: 50,
Timestamp: DateTimeOffset.UtcNow);
var response = await CommunicationService.QueryEventLogsAsync(_selectedSiteId, request);
if (response.Success)
{
_entries ??= new();
_entries.AddRange(response.Entries);
_hasMore = response.HasMore;
_continuationToken = response.ContinuationToken;
}
else
{
_errorMessage = response.ErrorMessage ?? "Query failed.";
}
}
catch (Exception ex)
{
_errorMessage = $"Query failed: {ex.Message}";
}
_searching = false;
}
/// <summary>
/// CentralUI-027: convert a value bound from <c>&lt;input type="datetime-local"&gt;</c>
/// (DateTimeKind.Unspecified, operator's browser-local wall-clock) into UTC. Must tag
/// the value Local before <see cref="DateTime.ToUniversalTime"/> can do anything.
/// </summary>
private static DateTimeOffset? LocalInputToUtc(DateTime? value) =>
value.HasValue
? new DateTimeOffset(
DateTime.SpecifyKind(value.Value, DateTimeKind.Local).ToUniversalTime(),
TimeSpan.Zero)
: (DateTimeOffset?)null;
private static string GetSeverityBadge(string severity) => severity switch
{
"Error" => "bg-danger",
"Warning" => "bg-warning text-dark",
"Info" => "bg-info text-dark",
_ => "bg-secondary"
};
private static string SeverityGlyph(string severity) => severity switch
{
"Error" => "⛔",
"Warning" => "⚠",
"Info" => "",
_ => "•"
};
}
@@ -0,0 +1,517 @@
@page "/monitoring/health"
@attribute [Authorize]
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Types
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.HealthMonitoring
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit
@using ZB.MOM.WW.ScadaBridge.Communication
@implements IDisposable
@inject ICentralHealthAggregator HealthAggregator
@inject ISiteRepository SiteRepository
@inject CommunicationService CommunicationService
@inject IAuditLogQueryService AuditLogQueryService
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Health Dashboard</h4>
<div>
<span class="text-muted small me-2">Auto-refresh: @(_autoRefreshSeconds)s</span>
<button class="btn btn-outline-secondary btn-sm" @onclick="RefreshNow">Refresh Now</button>
</div>
</div>
@* Notification Outbox headline KPIs — a central concern, shown regardless of site reports *@
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="text-muted mb-0">Notification Outbox</h6>
<a class="small" href="/notifications/kpis">View details &rarr;</a>
</div>
<div class="row g-3 mb-3">
<div class="col-lg-4 col-md-6 col-12">
<div class="card h-100">
<div class="card-body text-center">
<h3 class="mb-0">@OutboxTileValue(_outboxKpi.QueueDepth)</h3>
<small class="text-muted">Queue Depth</small>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 col-12">
<div class="card h-100 @(_outboxKpiAvailable && _outboxKpi.StuckCount > 0 ? "border-warning" : "")">
<div class="card-body text-center">
<h3 class="mb-0 @(_outboxKpiAvailable && _outboxKpi.StuckCount > 0 ? "text-warning" : "")">@OutboxTileValue(_outboxKpi.StuckCount)</h3>
<small class="text-muted">Stuck</small>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 col-12">
<div class="card h-100 @(_outboxKpiAvailable && _outboxKpi.ParkedCount > 0 ? "border-danger" : "")">
<div class="card-body text-center">
<h3 class="mb-0 @(_outboxKpiAvailable && _outboxKpi.ParkedCount > 0 ? "text-danger" : "")">@OutboxTileValue(_outboxKpi.ParkedCount)</h3>
<small class="text-muted">Parked</small>
</div>
</div>
</div>
</div>
@if (!_outboxKpiAvailable && _outboxKpiError != null)
{
<div class="text-muted small mb-3">Notification Outbox KPIs unavailable: @_outboxKpiError</div>
}
@* Site Call Audit (#22) Task 7 — three KPI tiles for the Site Call channel
(buffered / stuck / parked). Refreshed alongside the site states. *@
<SiteCallKpiTiles Snapshot="@_siteCallKpi"
IsAvailable="@_siteCallKpiAvailable"
ErrorMessage="@_siteCallKpiError" />
@* Audit Log (#23) M7 Bundle E — three KPI tiles for the Audit channel
(volume / error rate / backlog). Refreshed alongside the site states. *@
<AuditKpiTiles Snapshot="@_auditKpi"
IsAvailable="@_auditKpiAvailable"
ErrorMessage="@_auditKpiError" />
@if (_siteStates.Count == 0)
{
<div class="alert alert-info">No site health reports received yet.</div>
}
else
{
@* Overview cards *@
<div class="row g-3 mb-3">
<div class="col-lg-4 col-md-6 col-12">
<div class="card border-success h-100">
<div class="card-body text-center">
<h3 class="mb-0 text-success">@_siteStates.Values.Count(s => s.IsOnline)</h3>
<small class="text-muted">Sites Online</small>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 col-12">
<div class="card border-danger h-100">
<div class="card-body text-center">
<h3 class="mb-0 text-danger">@_siteStates.Values.Count(s => !s.IsOnline)</h3>
<small class="text-muted">Sites Offline</small>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 col-12">
<div class="card border-warning h-100">
<div class="card-body text-center">
<h3 class="mb-0 text-warning">@_siteStates.Values.Count(SiteHasActiveErrors)</h3>
<small class="text-muted">Sites with active errors</small>
</div>
</div>
</div>
</div>
@* Per-site detail cards — central cluster pinned to the top, then sites alphabetically *@
@foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key == CentralHealthReportLoop.CentralSiteId ? 0 : 1).ThenBy(s => s.Key))
{
var isCentral = siteId == CentralHealthReportLoop.CentralSiteId;
var siteName = isCentral ? "Central Cluster" : GetSiteName(siteId);
var detailsCollapseId = $"site-details-{siteId}";
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<div>
@if (state.IsOnline)
{
<span class="badge bg-success me-2" aria-label="State: Online">@OnlineGlyph Online</span>
}
else
{
<span class="badge bg-danger me-2" aria-label="State: Offline">@OfflineGlyph Offline</span>
}
<strong class="fs-5">@siteName@(isCentral ? "" : $" ({siteId})")</strong>
</div>
<small class="text-muted">
Last report: <TimestampDisplay Value="@state.LastReportReceivedAt" Format="HH:mm:ss" NullText="awaiting first report" />
| Last heartbeat: <TimestampDisplay Value="@state.LastHeartbeatAt" Format="HH:mm:ss" />
| Seq: @state.LastSequenceNumber
</small>
</div>
<div class="card-body p-3">
@if (state.LatestReport != null)
{
var report = state.LatestReport;
<div class="row g-3">
@* Column 1: Nodes *@
<div class="col-md-6">
<h6 class="text-muted mb-2 border-bottom pb-1">Nodes</h6>
<table class="table table-sm table-borderless mb-0">
<tbody>
@if (report.ClusterNodes is { Count: > 0 })
{
@foreach (var node in report.ClusterNodes)
{
<tr>
<td class="small">@node.Hostname</td>
<td>
<span class="badge @(node.IsOnline ? "bg-success" : "bg-danger")"
aria-label="State: @(node.IsOnline ? "Online" : "Offline")">
@(node.IsOnline ? OnlineGlyph : OfflineGlyph) @(node.IsOnline ? "Online" : "Offline")
</span>
</td>
<td>
<span class="badge @(node.Role == "Primary" ? "bg-primary" : "bg-secondary")"
aria-label="State: @node.Role">
@(node.Role == "Primary" ? PrimaryGlyph : StandbyGlyph) @node.Role
</span>
</td>
</tr>
}
}
else
{
<tr>
<td class="small">@(report.NodeHostname != "" ? report.NodeHostname : "Node")</td>
<td>
<span class="badge @(state.IsOnline ? "bg-success" : "bg-danger")"
aria-label="State: @(state.IsOnline ? "Online" : "Offline")">
@(state.IsOnline ? OnlineGlyph : OfflineGlyph) @(state.IsOnline ? "Online" : "Offline")
</span>
</td>
<td>
@{
var roleLabel = report.NodeRole == "Active" ? "Primary" : "Standby";
}
<span class="badge @(report.NodeRole == "Active" ? "bg-primary" : "bg-secondary")"
aria-label="State: @roleLabel">
@(roleLabel == "Primary" ? PrimaryGlyph : StandbyGlyph) @roleLabel
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
@* Column 2: Data Connections (collapsible) *@
<div class="col-md-6">
<button class="btn btn-link btn-sm p-0 text-decoration-none mb-2"
data-bs-toggle="collapse"
data-bs-target="@($"#{detailsCollapseId}-conns")"
aria-expanded="false">
Data Connections (@report.DataConnectionStatuses.Count)
</button>
<div class="collapse" id="@($"{detailsCollapseId}-conns")">
@if (report.DataConnectionStatuses.Count == 0)
{
<span class="text-muted small">None</span>
}
else
{
@foreach (var (connName, health) in report.DataConnectionStatuses)
{
var endpoint = report.DataConnectionEndpoints?.GetValueOrDefault(connName);
var quality = report.DataConnectionTagQuality?.GetValueOrDefault(connName);
<div class="mb-2">
<div class="d-flex justify-content-between">
<strong class="small">@connName</strong>
<span class="small">@(endpoint ?? health.ToString())</span>
</div>
@if (quality != null)
{
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="small text-muted py-0">Tags good</td>
<td class="small text-end py-0">@quality.Good.ToString("N0")</td>
</tr>
<tr>
<td class="small text-muted py-0">Tags bad</td>
<td class="small text-end py-0">@quality.Bad.ToString("N0")</td>
</tr>
<tr>
<td class="small text-muted py-0">Tags uncertain</td>
<td class="small text-end py-0">@quality.Uncertain.ToString("N0")</td>
</tr>
</tbody>
</table>
}
</div>
}
}
</div>
</div>
@* Column 3: Instances + Store-and-Forward (collapsible) *@
<div class="col-md-6">
<button class="btn btn-link btn-sm p-0 text-decoration-none mb-2"
data-bs-toggle="collapse"
data-bs-target="@($"#{detailsCollapseId}-queues")"
aria-expanded="false">
Instances &amp; Queues
</button>
<div class="collapse" id="@($"{detailsCollapseId}-queues")">
<h6 class="text-muted mb-2 border-bottom pb-1">Instances</h6>
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="small">Deployed</td>
<td class="text-end">@report.DeployedInstanceCount</td>
</tr>
<tr>
<td class="small">Enabled</td>
<td class="text-end text-success">@report.EnabledInstanceCount</td>
</tr>
<tr>
<td class="small">Disabled</td>
<td class="text-end">@report.DisabledInstanceCount</td>
</tr>
</tbody>
</table>
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">Store-and-Forward Buffers</h6>
@if (report.StoreAndForwardBufferDepths.Count == 0)
{
<span class="text-muted small">Empty</span>
}
else
{
@foreach (var (category, depth) in report.StoreAndForwardBufferDepths)
{
<div class="d-flex justify-content-between mb-1">
<span class="small">@category</span>
<span class="badge @(depth > 0 ? "bg-warning text-dark" : "bg-light text-dark")">@depth</span>
</div>
}
}
</div>
</div>
@* Column 4: Error Counts + Parked Messages (collapsible) *@
<div class="col-md-6">
<button class="btn btn-link btn-sm p-0 text-decoration-none mb-2"
data-bs-toggle="collapse"
data-bs-target="@($"#{detailsCollapseId}-errors")"
aria-expanded="false">
Errors &amp; Parked Messages
</button>
<div class="collapse" id="@($"{detailsCollapseId}-errors")">
<h6 class="text-muted mb-2 border-bottom pb-1">Error Counts</h6>
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="small">Script Errors</td>
<td class="text-end">
<span class="@(report.ScriptErrorCount > 0 ? "text-danger fw-bold" : "")">@report.ScriptErrorCount</span>
</td>
</tr>
<tr>
<td class="small">Alarm Eval Errors</td>
<td class="text-end">
<span class="@(report.AlarmEvaluationErrorCount > 0 ? "text-warning fw-bold" : "")">@report.AlarmEvaluationErrorCount</span>
</td>
</tr>
<tr>
<td class="small">Dead Letters</td>
<td class="text-end">
<span class="@(report.DeadLetterCount > 0 ? "text-danger fw-bold" : "")">@report.DeadLetterCount</span>
</td>
</tr>
</tbody>
</table>
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">Parked Messages</h6>
@if (report.ParkedMessageCount == 0)
{
<span class="text-muted small">Empty</span>
}
else
{
<span class="badge bg-warning text-dark">@report.ParkedMessageCount</span>
}
</div>
</div>
</div>
}
else
{
<span class="text-muted">No report data available.</span>
}
</div>
</div>
}
}
</div>
@code {
// Shape-coded status glyphs to pair with badge colour.
private const string OnlineGlyph = "●"; // ●
private const string OfflineGlyph = "○"; // ○
private const string PrimaryGlyph = "▲"; // ▲
private const string StandbyGlyph = "△"; // △
private IReadOnlyDictionary<string, SiteHealthState> _siteStates = new Dictionary<string, SiteHealthState>();
private Dictionary<string, string> _siteNames = new();
private Timer? _refreshTimer;
private int _autoRefreshSeconds = 10;
// Notification Outbox headline KPIs, refreshed alongside the site states.
private NotificationKpiResponse _outboxKpi =
new(
CorrelationId: string.Empty,
Success: false,
ErrorMessage: null,
QueueDepth: 0,
StuckCount: 0,
ParkedCount: 0,
DeliveredLastInterval: 0,
OldestPendingAge: null);
private bool _outboxKpiAvailable;
private string? _outboxKpiError;
// Audit Log (#23) M7 Bundle E — Audit KPI tiles. Volume + error rate come
// from a 1h aggregate over the central AuditLog table; backlog sums the
// per-site SiteAuditBacklog.PendingCount via the health aggregator.
private AuditLogKpiSnapshot? _auditKpi;
private bool _auditKpiAvailable;
private string? _auditKpiError;
// Site Call Audit (#22) Task 7 — Site Call KPI tiles. Point-in-time counts
// from the central SiteCalls table, fetched alongside the site states. The
// SiteCallKpiResponse message doubles as the snapshot the tile takes.
private SiteCallKpiResponse? _siteCallKpi;
private bool _siteCallKpiAvailable;
private string? _siteCallKpiError;
private static bool SiteHasActiveErrors(SiteHealthState state)
{
var report = state.LatestReport;
if (report == null) return false;
return report.ScriptErrorCount > 0
|| report.AlarmEvaluationErrorCount > 0
|| report.DeadLetterCount > 0;
}
protected override async Task OnInitializedAsync()
{
// Load site names for display
try
{
var sites = await SiteRepository.GetAllSitesAsync();
_siteNames = sites.ToDictionary(s => s.SiteIdentifier, s => s.Name);
}
catch
{
// Non-fatal — fall back to showing siteId only
}
await RefreshNow();
_refreshTimer = new Timer(_ =>
{
InvokeAsync(async () =>
{
await RefreshNow();
StateHasChanged();
});
}, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds));
}
private async Task RefreshNow()
{
_siteStates = HealthAggregator.GetAllSiteStates();
await LoadOutboxKpis();
await LoadSiteCallKpis();
await LoadAuditKpis();
}
private async Task LoadOutboxKpis()
{
try
{
var response = await CommunicationService.GetNotificationKpisAsync(
new NotificationKpiRequest(Guid.NewGuid().ToString("N")));
if (response.Success)
{
_outboxKpi = response;
_outboxKpiAvailable = true;
_outboxKpiError = null;
}
else
{
_outboxKpiAvailable = false;
_outboxKpiError = response.ErrorMessage ?? "KPI query failed.";
}
}
catch (Exception ex)
{
_outboxKpiAvailable = false;
_outboxKpiError = $"KPI query failed: {ex.Message}";
}
}
// Site Call KPI loader: wraps the service call so a transient fault degrades
// the three Site Call tiles to em dashes with an inline error rather than
// killing the dashboard. Mirrors LoadOutboxKpis's error handling shape — a
// response with Success == false (repository fault) and an Ask that threw
// (transport fault) both collapse to "unavailable".
private async Task LoadSiteCallKpis()
{
try
{
var response = await CommunicationService.GetSiteCallKpisAsync(
new SiteCallKpiRequest(Guid.NewGuid().ToString("N")));
if (response.Success)
{
_siteCallKpi = response;
_siteCallKpiAvailable = true;
_siteCallKpiError = null;
}
else
{
_siteCallKpiAvailable = false;
_siteCallKpiError = response.ErrorMessage ?? "KPI query failed.";
}
}
catch (Exception ex)
{
_siteCallKpiAvailable = false;
_siteCallKpiError = $"KPI query failed: {ex.Message}";
}
}
// Tiles show the numeric KPI when available, or an em dash when the outbox
// KPI query failed — matching how the page renders other unavailable data.
private string OutboxTileValue(int value) =>
_outboxKpiAvailable ? value.ToString() : "—";
// Audit KPI loader: wraps the service call so a transient DB outage degrades
// the three tiles to em dashes with an inline error rather than killing the
// dashboard. Mirrors LoadOutboxKpis's error handling shape.
private async Task LoadAuditKpis()
{
try
{
_auditKpi = await AuditLogQueryService.GetKpiSnapshotAsync();
_auditKpiAvailable = true;
_auditKpiError = null;
}
catch (Exception ex)
{
_auditKpiAvailable = false;
_auditKpiError = $"KPI query failed: {ex.Message}";
}
}
private string GetSiteName(string siteId)
{
return _siteNames.GetValueOrDefault(siteId, siteId);
}
private static string GetConnectionHealthBadge(ConnectionHealth health) => health switch
{
ConnectionHealth.Connected => "bg-success",
ConnectionHealth.Connecting => "bg-warning text-dark",
ConnectionHealth.Disconnected => "bg-danger",
_ => "bg-secondary"
};
public void Dispose()
{
_refreshTimer?.Dispose();
}
}
@@ -0,0 +1,755 @@
@page "/monitoring/parked-messages"
@attribute [Authorize(Policy = ZB.MOM.WW.ScadaBridge.Security.AuthorizationPolicies.RequireDeployment)]
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@using ZB.MOM.WW.ScadaBridge.Communication
@inject ISiteRepository SiteRepository
@inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope
@inject CommunicationService CommunicationService
@inject IJSRuntime JS
@inject IDialogService Dialog
@inject ILogger<ParkedMessages> Logger
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
<div class="d-flex align-items-baseline flex-wrap mb-3">
<h4 class="mb-0 me-3">Parked Messages</h4>
@if (_messages != null && _messages.Count > 0)
{
<span class="text-muted small">
@_totalCount parked · @DistinctTargets target system@(DistinctTargets == 1 ? "" : "s")
@if (OldestMessage != null)
{
<span> · oldest @Relative(OldestMessage.LastAttemptTimestamp)</span>
}
@if (FilteredCount != _messages.Count)
{
<span class="ms-2">(showing @FilteredCount of @_messages.Count)</span>
}
</span>
}
</div>
<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="pm-filter-site">Site</label>
<select id="pm-filter-site" class="form-select form-select-sm" style="min-width: 180px;"
value="@_selectedSiteId" @onchange="OnSiteChanged">
<option value="">Select site…</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="pm-filter-cat">Category</label>
<select id="pm-filter-cat" class="form-select form-select-sm" style="min-width: 150px;"
@bind="_categoryFilter">
<option value="">All</option>
<option value="ExternalSystem">External system</option>
<option value="Notification">Notification</option>
<option value="CachedDbWrite">DB write</option>
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="pm-filter-target">Target</label>
<select id="pm-filter-target" class="form-select form-select-sm" style="min-width: 160px;"
@bind="_targetFilter">
<option value="">Any</option>
@foreach (var t in DistinctTargetsList)
{
<option value="@t">@t</option>
}
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="pm-filter-origin">Origin</label>
<select id="pm-filter-origin" class="form-select form-select-sm" style="min-width: 160px;"
@bind="_originFilter">
<option value="">Any</option>
<option value="__none__">(none)</option>
@foreach (var o in DistinctOriginsList)
{
<option value="@o">@o</option>
}
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="pm-filter-age">Age</label>
<select id="pm-filter-age" class="form-select form-select-sm" style="min-width: 130px;"
@bind="_ageFilter">
<option value="All">All</option>
<option value="LastHour">Last hour</option>
<option value="LastDay">Last 24h</option>
<option value="LastWeek">Last 7d</option>
</select>
</div>
<div class="col">
<label class="form-label small mb-1" for="pm-filter-search">Search</label>
<input id="pm-filter-search" type="search" class="form-control form-control-sm"
placeholder="ID, target, method, error…"
@bind="_searchFilter" @bind:event="oninput" />
</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="@(string.IsNullOrEmpty(_selectedSiteId) || _searching)">
@if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Query
</button>
</div>
</div>
</div>
</div>
@if (_selectedIds.Count > 0)
{
<div class="alert alert-secondary py-2 d-flex align-items-center mb-3">
<strong class="me-3">@_selectedIds.Count selected</strong>
<button class="btn btn-outline-success btn-sm me-2"
@onclick="BulkRetry" disabled="@_bulkInProgress">
@if (_bulkInProgress && _bulkAction == "Retry") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Retry selected
</button>
<button class="btn btn-outline-danger btn-sm me-2"
@onclick="BulkDiscard" disabled="@_bulkInProgress">
@if (_bulkInProgress && _bulkAction == "Discard") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Discard selected
</button>
<button type="button" class="btn-close ms-auto"
aria-label="Clear selection" @onclick="ClearSelection"></button>
</div>
}
@if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
@if (_messages == null)
{
@if (!string.IsNullOrEmpty(_selectedSiteId) && _searching)
{
<div class="text-muted small">Loading…</div>
}
}
else if (_messages.Count == 0)
{
<div class="card">
<div class="card-body text-center text-muted py-5">
<div class="fs-5 mb-1">No parked messages</div>
<div class="small">Nothing has failed enough to give up on at this site.</div>
</div>
</div>
}
else
{
var filtered = FilteredMessages;
<div class="table-responsive">
<table class="table table-sm table-hover mb-2 align-middle parked-table">
<thead class="table-light">
<tr>
<th style="width: 36px;">
<input class="form-check-input" type="checkbox"
checked="@AllFilteredSelected"
@onchange="ToggleSelectAll"
aria-label="Select all" />
</th>
<th>Target / Method</th>
<th>Origin</th>
<th>Error</th>
<th style="width: 110px;">Attempts</th>
<th style="width: 160px;">Last attempt</th>
</tr>
</thead>
<tbody>
@if (filtered.Count == 0)
{
<tr><td colspan="6" class="text-muted text-center py-3">No messages match the current filters.</td></tr>
}
@foreach (var msg in filtered)
{
var isSelected = _selectedIds.Contains(msg.MessageId);
<tr @key="msg.MessageId"
class="parked-row @SeverityClass(msg) @(isSelected ? "table-active" : "")"
@onclick="() => OpenDrawer(msg)"
style="cursor: pointer;">
<td @onclick:stopPropagation="true">
<input class="form-check-input" type="checkbox"
checked="@isSelected"
@onchange="e => ToggleSelect(msg.MessageId, (bool)e.Value!)"
aria-label="@($"Select {msg.MessageId[..Math.Min(8, msg.MessageId.Length)]}")" />
</td>
<td>
<div class="fw-semibold">@msg.TargetSystem</div>
<div class="small text-muted">@msg.MethodName</div>
</td>
<td>
@if (!string.IsNullOrEmpty(msg.OriginInstance))
{
<code class="small">@msg.OriginInstance</code>
}
else
{
<span class="text-muted small">—</span>
}
</td>
<td>
<div class="text-danger small parked-error-clamp">@msg.ErrorMessage</div>
</td>
<td>
<div class="small font-monospace">
@msg.AttemptCount<span class="text-muted">/@msg.MaxAttempts</span>
</div>
<div class="progress mt-1" style="height: 3px;">
<div class="progress-bar @AttemptBarClass(msg)"
role="progressbar"
style="width: @AttemptPercent(msg)%;"
aria-valuenow="@msg.AttemptCount"
aria-valuemin="0"
aria-valuemax="@Math.Max(1, msg.MaxAttempts)"></div>
</div>
</td>
<td>
<div class="small" title="@AbsoluteUtc(msg.LastAttemptTimestamp)">
@Relative(msg.LastAttemptTimestamp)
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
@if (_totalCount > _pageSize)
{
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">
Page @_pageNumber of @((_totalCount + _pageSize - 1) / _pageSize) · @_totalCount total
</span>
<div>
<button class="btn btn-outline-secondary btn-sm me-1"
@onclick="PrevPage" disabled="@(_pageNumber <= 1)">Previous</button>
<button class="btn btn-outline-secondary btn-sm"
@onclick="NextPage" disabled="@(_messages.Count < _pageSize)">Next</button>
</div>
</div>
}
}
</div>
@if (_drawerMessage != null)
{
<div class="offcanvas-backdrop fade show" @onclick="CloseDrawer"></div>
<div class="offcanvas offcanvas-end show parked-drawer" tabindex="-1" style="visibility: visible;">
<div class="offcanvas-header border-bottom">
<div>
<div class="text-muted small text-uppercase">Parked message</div>
<h5 class="offcanvas-title mb-0">@_drawerMessage.TargetSystem</h5>
<div class="small text-muted">@_drawerMessage.MethodName</div>
</div>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseDrawer"></button>
</div>
<div class="offcanvas-body small">
<dl class="row mb-3">
<dt class="col-4 text-muted fw-normal">Message ID</dt>
<dd class="col-8 d-flex align-items-center gap-2">
<code class="text-truncate" style="min-width: 0;">@_drawerMessage.MessageId</code>
<button class="btn btn-link btn-sm p-0" title="Copy message ID"
@onclick="() => CopyAsync(_drawerMessage.MessageId)">📋</button>
</dd>
<dt class="col-4 text-muted fw-normal">Category</dt>
<dd class="col-8">@CategoryLabel(_drawerMessage.Category)</dd>
<dt class="col-4 text-muted fw-normal">Origin instance</dt>
<dd class="col-8">
@if (!string.IsNullOrEmpty(_drawerMessage.OriginInstance))
{
<code>@_drawerMessage.OriginInstance</code>
}
else
{
<span class="text-muted">—</span>
}
</dd>
<dt class="col-4 text-muted fw-normal">Attempts</dt>
<dd class="col-8 font-monospace">@_drawerMessage.AttemptCount / @_drawerMessage.MaxAttempts</dd>
<dt class="col-4 text-muted fw-normal">Originally enqueued</dt>
<dd class="col-8">
@Relative(_drawerMessage.OriginalTimestamp)
<span class="text-muted">· @AbsoluteUtc(_drawerMessage.OriginalTimestamp)</span>
</dd>
<dt class="col-4 text-muted fw-normal">Last attempt</dt>
<dd class="col-8">
@Relative(_drawerMessage.LastAttemptTimestamp)
<span class="text-muted">· @AbsoluteUtc(_drawerMessage.LastAttemptTimestamp)</span>
</dd>
</dl>
<div class="text-muted text-uppercase small fw-semibold mb-1">Error</div>
<pre class="bg-light border rounded p-2 small mb-0 parked-error-pre">@_drawerMessage.ErrorMessage</pre>
</div>
<div class="border-top p-3 d-flex gap-2">
<button class="btn btn-outline-success btn-sm flex-grow-1"
@onclick="RetryFromDrawer" disabled="@_actionInProgress">
@if (_actionInProgress && _activeAction == "Retry") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Retry
</button>
<button class="btn btn-outline-danger btn-sm flex-grow-1"
@onclick="DiscardFromDrawer" disabled="@_actionInProgress">
@if (_actionInProgress && _activeAction == "Discard") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Discard
</button>
</div>
</div>
}
<style>
.parked-row { border-left: 3px solid transparent; }
.parked-row.sev-danger { border-left-color: var(--bs-danger); }
.parked-row.sev-warning { border-left-color: var(--bs-warning); }
.parked-row.sev-secondary { border-left-color: var(--bs-secondary-bg-subtle); }
.parked-error-clamp {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
max-width: 520px;
}
.parked-drawer { width: min(560px, 95vw); }
.parked-error-pre { white-space: pre-wrap; word-break: break-word; max-height: 300px; overflow-y: auto; }
.parked-table tbody tr { transition: background-color 0.1s ease; }
</style>
@code {
private List<Site> _sites = new();
private string _selectedSiteId = string.Empty;
private List<ParkedMessageEntry>? _messages;
private int _totalCount;
private int _pageNumber = 1;
private int _pageSize = 50;
private bool _searching;
private string? _errorMessage;
// Filters
private string _categoryFilter = string.Empty;
private string _targetFilter = string.Empty;
private string _originFilter = string.Empty;
private string _ageFilter = "All";
private string _searchFilter = string.Empty;
// Selection
private readonly HashSet<string> _selectedIds = new();
private bool _bulkInProgress;
private string? _bulkAction;
// Per-row action state
private bool _actionInProgress;
private string? _activeAction;
// Drawer
private ParkedMessageEntry? _drawerMessage;
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync()
{
// Site scoping (CentralUI-002): a scoped Deployment user may only inspect
// and act on parked messages for the sites they are permitted on.
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
}
// True only when the currently selected SiteIdentifier is one this user is
// permitted on. _sites is already filtered, so membership IS the scope check.
private bool SelectedSiteIsPermitted =>
!string.IsNullOrEmpty(_selectedSiteId)
&& _sites.Any(s => s.SiteIdentifier == _selectedSiteId);
private async Task OnSiteChanged(ChangeEventArgs e)
{
_selectedSiteId = e.Value?.ToString() ?? string.Empty;
if (!string.IsNullOrEmpty(_selectedSiteId))
{
await Search();
}
else
{
_messages = null;
_selectedIds.Clear();
_drawerMessage = null;
}
}
private async Task Search()
{
_pageNumber = 1;
_selectedIds.Clear();
_drawerMessage = null;
await FetchPage();
}
private async Task PrevPage() { _pageNumber--; _selectedIds.Clear(); await FetchPage(); }
private async Task NextPage() { _pageNumber++; _selectedIds.Clear(); await FetchPage(); }
private async Task FetchPage()
{
_searching = true;
_errorMessage = null;
// Site scoping (CentralUI-002): re-check before querying — the dropdown is
// filtered, but the selection must not be trusted on its own.
if (!SelectedSiteIsPermitted)
{
_errorMessage = "You are not permitted to view parked messages for that site.";
_messages = null;
_searching = false;
return;
}
try
{
var request = new ParkedMessageQueryRequest(
CorrelationId: Guid.NewGuid().ToString("N"),
SiteId: _selectedSiteId,
PageNumber: _pageNumber,
PageSize: _pageSize,
Timestamp: DateTimeOffset.UtcNow);
var response = await CommunicationService.QueryParkedMessagesAsync(_selectedSiteId, request);
if (response.Success)
{
_messages = response.Messages.ToList();
_totalCount = response.TotalCount;
}
else
{
_errorMessage = response.ErrorMessage ?? "Query failed.";
}
}
catch (Exception ex)
{
_errorMessage = $"Query failed: {ex.Message}";
}
_searching = false;
}
private void ClearFilters()
{
_categoryFilter = string.Empty;
_targetFilter = string.Empty;
_originFilter = string.Empty;
_ageFilter = "All";
_searchFilter = string.Empty;
}
private bool HasActiveFilters =>
!string.IsNullOrEmpty(_categoryFilter) ||
!string.IsNullOrEmpty(_targetFilter) ||
!string.IsNullOrEmpty(_originFilter) ||
_ageFilter != "All" ||
!string.IsNullOrEmpty(_searchFilter);
private List<ParkedMessageEntry> FilteredMessages
{
get
{
if (_messages == null) return new();
IEnumerable<ParkedMessageEntry> q = _messages;
if (!string.IsNullOrEmpty(_categoryFilter) &&
Enum.TryParse<StoreAndForwardCategory>(_categoryFilter, out var cat))
q = q.Where(m => m.Category == cat);
if (!string.IsNullOrEmpty(_targetFilter))
q = q.Where(m => m.TargetSystem == _targetFilter);
if (_originFilter == "__none__")
q = q.Where(m => string.IsNullOrEmpty(m.OriginInstance));
else if (!string.IsNullOrEmpty(_originFilter))
q = q.Where(m => m.OriginInstance == _originFilter);
if (_ageFilter != "All")
{
var cutoff = _ageFilter switch
{
"LastHour" => DateTimeOffset.UtcNow.AddHours(-1),
"LastDay" => DateTimeOffset.UtcNow.AddDays(-1),
"LastWeek" => DateTimeOffset.UtcNow.AddDays(-7),
_ => DateTimeOffset.MinValue
};
q = q.Where(m => m.LastAttemptTimestamp >= cutoff);
}
if (!string.IsNullOrEmpty(_searchFilter))
{
var s = _searchFilter.Trim();
q = q.Where(m =>
m.MessageId.Contains(s, StringComparison.OrdinalIgnoreCase) ||
m.TargetSystem.Contains(s, StringComparison.OrdinalIgnoreCase) ||
m.MethodName.Contains(s, StringComparison.OrdinalIgnoreCase) ||
m.ErrorMessage.Contains(s, StringComparison.OrdinalIgnoreCase) ||
(m.OriginInstance ?? string.Empty).Contains(s, StringComparison.OrdinalIgnoreCase));
}
return q.ToList();
}
}
private int FilteredCount => FilteredMessages.Count;
private int DistinctTargets => _messages?.Select(m => m.TargetSystem).Distinct().Count() ?? 0;
private IEnumerable<string> DistinctTargetsList =>
_messages?.Select(m => m.TargetSystem).Distinct().OrderBy(s => s).ToList()
?? (IEnumerable<string>)Array.Empty<string>();
private IEnumerable<string> DistinctOriginsList =>
_messages?.Where(m => !string.IsNullOrEmpty(m.OriginInstance))
.Select(m => m.OriginInstance!).Distinct().OrderBy(s => s).ToList()
?? (IEnumerable<string>)Array.Empty<string>();
private ParkedMessageEntry? OldestMessage =>
_messages?.OrderBy(m => m.LastAttemptTimestamp).FirstOrDefault();
// ── Selection ──
private bool AllFilteredSelected
{
get
{
var filtered = FilteredMessages;
return filtered.Count > 0 && filtered.All(m => _selectedIds.Contains(m.MessageId));
}
}
private void ToggleSelect(string id, bool isChecked)
{
if (isChecked) _selectedIds.Add(id);
else _selectedIds.Remove(id);
}
private void ToggleSelectAll(ChangeEventArgs e)
{
var on = (bool)e.Value!;
var filtered = FilteredMessages;
if (on)
{
foreach (var m in filtered) _selectedIds.Add(m.MessageId);
}
else
{
foreach (var m in filtered) _selectedIds.Remove(m.MessageId);
}
}
private void ClearSelection() => _selectedIds.Clear();
// ── Drawer ──
private void OpenDrawer(ParkedMessageEntry msg) => _drawerMessage = msg;
private void CloseDrawer() => _drawerMessage = null;
private async Task RetryFromDrawer()
{
if (_drawerMessage == null) return;
var msg = _drawerMessage;
await RetrySingle(msg);
CloseDrawer();
}
private async Task DiscardFromDrawer()
{
if (_drawerMessage == null) return;
var msg = _drawerMessage;
var ok = await DiscardSingle(msg);
if (ok) CloseDrawer();
}
// ── Bulk ──
private async Task BulkRetry()
{
var ids = _selectedIds.ToList();
if (ids.Count == 0) return;
if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return; }
var confirmed = await Dialog.ConfirmAsync(
"Retry parked messages",
$"Move {ids.Count} message{(ids.Count == 1 ? "" : "s")} back to the pending queue?");
if (!confirmed) return;
_bulkInProgress = true;
_bulkAction = "Retry";
int success = 0, failed = 0;
foreach (var id in ids)
{
try
{
var req = new ParkedMessageRetryRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, id, DateTimeOffset.UtcNow);
var resp = await CommunicationService.RetryParkedMessageAsync(_selectedSiteId, req);
if (resp.Success) success++; else failed++;
}
catch { failed++; }
}
_toast.ShowSuccess($"{success} queued for retry" + (failed > 0 ? $", {failed} failed" : "."));
_selectedIds.Clear();
_bulkInProgress = false;
_bulkAction = null;
await FetchPage();
}
private async Task BulkDiscard()
{
var ids = _selectedIds.ToList();
if (ids.Count == 0) return;
if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return; }
var confirmed = await Dialog.ConfirmAsync(
"Discard parked messages",
$"Permanently discard {ids.Count} message{(ids.Count == 1 ? "" : "s")}? This cannot be undone.",
danger: true);
if (!confirmed) return;
_bulkInProgress = true;
_bulkAction = "Discard";
int success = 0, failed = 0;
foreach (var id in ids)
{
try
{
var req = new ParkedMessageDiscardRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, id, DateTimeOffset.UtcNow);
var resp = await CommunicationService.DiscardParkedMessageAsync(_selectedSiteId, req);
if (resp.Success) success++; else failed++;
}
catch { failed++; }
}
_toast.ShowSuccess($"{success} discarded" + (failed > 0 ? $", {failed} failed" : "."));
_selectedIds.Clear();
_bulkInProgress = false;
_bulkAction = null;
await FetchPage();
}
// ── Single actions ──
private async Task RetrySingle(ParkedMessageEntry msg)
{
if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return; }
_actionInProgress = true;
_activeAction = "Retry";
try
{
var req = new ParkedMessageRetryRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, msg.MessageId, DateTimeOffset.UtcNow);
var resp = await CommunicationService.RetryParkedMessageAsync(_selectedSiteId, req);
if (resp.Success)
{
_toast.ShowSuccess($"Message {ShortId(msg.MessageId)} queued for retry.");
await FetchPage();
}
else _toast.ShowError(resp.ErrorMessage ?? "Retry failed.");
}
catch (Exception ex) { _toast.ShowError($"Retry failed: {ex.Message}"); }
_actionInProgress = false;
_activeAction = null;
}
private async Task<bool> DiscardSingle(ParkedMessageEntry msg)
{
if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return false; }
var confirmed = await Dialog.ConfirmAsync(
"Discard parked message",
$"Permanently discard message {ShortId(msg.MessageId)}? This cannot be undone.",
danger: true);
if (!confirmed) return false;
_actionInProgress = true;
_activeAction = "Discard";
bool ok = false;
try
{
var req = new ParkedMessageDiscardRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, msg.MessageId, DateTimeOffset.UtcNow);
var resp = await CommunicationService.DiscardParkedMessageAsync(_selectedSiteId, req);
if (resp.Success)
{
_toast.ShowSuccess($"Message {ShortId(msg.MessageId)} discarded.");
ok = true;
await FetchPage();
}
else _toast.ShowError(resp.ErrorMessage ?? "Discard failed.");
}
catch (Exception ex) { _toast.ShowError($"Discard failed: {ex.Message}"); }
_actionInProgress = false;
_activeAction = null;
return ok;
}
private async Task CopyAsync(string text)
{
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
_toast.ShowSuccess("Copied to clipboard.");
}
catch (JSDisconnectedException)
{
// Circuit gone — the page is being torn down; nothing to surface.
// CentralUI-023: distinguished from a genuine interop failure.
}
catch (JSException ex)
{
// A real clipboard failure (e.g. permission denied) — surface it to
// the user and log it so it is not invisible in production.
Logger.LogWarning(ex, "Clipboard copy failed.");
_toast.ShowError("Copy failed.");
}
}
// ── Helpers ──
private static string ShortId(string id) => id[..Math.Min(12, id.Length)];
private static string Relative(DateTimeOffset t)
{
var diff = DateTimeOffset.UtcNow - t;
if (diff.TotalSeconds < 0) return "just now";
if (diff.TotalSeconds < 60) return "just now";
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago";
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago";
if (diff.TotalDays < 30) return $"{(int)diff.TotalDays}d ago";
return t.UtcDateTime.ToString("yyyy-MM-dd");
}
private static string AbsoluteUtc(DateTimeOffset t) =>
$"{t.UtcDateTime:yyyy-MM-dd HH:mm:ss} UTC";
private static string SeverityClass(ParkedMessageEntry msg)
{
var exhausted = msg.MaxAttempts > 0 && msg.AttemptCount >= msg.MaxAttempts;
if (!exhausted) return "sev-secondary";
var age = DateTimeOffset.UtcNow - msg.LastAttemptTimestamp;
return age < TimeSpan.FromHours(1) ? "sev-danger" : "sev-warning";
}
private static int AttemptPercent(ParkedMessageEntry msg)
{
if (msg.MaxAttempts <= 0) return 100;
var pct = (int)Math.Round(msg.AttemptCount * 100.0 / msg.MaxAttempts);
return Math.Clamp(pct, 0, 100);
}
private static string AttemptBarClass(ParkedMessageEntry msg) =>
msg.AttemptCount >= msg.MaxAttempts ? "bg-danger" : "bg-warning";
private static string CategoryLabel(StoreAndForwardCategory c) => c switch
{
StoreAndForwardCategory.ExternalSystem => "External system",
StoreAndForwardCategory.Notification => "Notification",
StoreAndForwardCategory.CachedDbWrite => "Cached DB write",
_ => c.ToString()
};
}