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:
@@ -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><input type="datetime-local"></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 →</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 & 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 & 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()
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user