refactor(ui/monitoring): KPI dashboard, message expand, copy, pagination fix
Dashboard: user-info card demoted; 4 KPI cards (Sites, Data
connections, Templates, API keys) sourced from existing repositories;
3 Quick-action link cards (Health, Audit Log, Templates). Inline
max-width style replaced with Bootstrap utilities.
Health: KPI row condensed to Online / Offline / Sites with active
errors (Total Sites and Total Script Errors dropped). Per-site cards
re-laid out 2-column with each subsection (Data Connections,
Instances & Queues, Errors & Parked Messages) inside Bootstrap
collapse panels collapsed by default. Online / Offline / Primary /
Standby badges paired with shape glyphs (o / * / triangle) plus
aria-label.
EventLogs: filter row wrapped in a Bootstrap collapse toggled by
"Filter options (n active)"; per-row View toggle reveals the full
message in a collapse row; "Keyword" relabeled "Message contains";
all filter inputs gain id+label-for+aria-label; severity badges paired
with a leading glyph; explicit "End of results" terminator on
Load more.
ParkedMessages: Message ID rendered as <code>{first 12}...</code>
plus a clipboard button; per-row View toggle reveals full error;
action buttons get aria-label="{Retry|Discard} message {id}";
in-flight spinner inside the active button.
AuditLog: pagination Next-disabled now uses
_page * _pageSize >= _totalCount via HasMore helper (fixes the
exactly-page-size edge case). Clear filters button added. Entity ID
rendered as code + clipboard button. View/Hide buttons gain
aria-label referencing the entry id. State JSON larger than 1 KB
renders a "View in modal" button instead of the inline overflow.
This commit is contained in:
@@ -1,33 +1,118 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
|
@inject ISiteRepository SiteRepository
|
||||||
|
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||||
|
@inject IInboundApiRepository InboundApiRepository
|
||||||
|
|
||||||
<div class="container mt-4">
|
<div class="container-fluid mt-3">
|
||||||
<h3>Welcome to ScadaLink</h3>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">Welcome to ScadaLink</h4>
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
<span class="text-muted small">
|
||||||
|
Signed in as <strong>@context.User.FindFirst("DisplayName")?.Value</strong>
|
||||||
|
</span>
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
</div>
|
||||||
<p class="text-muted">Central management console for the ScadaLink SCADA system.</p>
|
<p class="text-muted">Central management console for the ScadaLink SCADA system.</p>
|
||||||
|
|
||||||
<AuthorizeView>
|
@* KPI row *@
|
||||||
<Authorized>
|
<div class="row g-3 mb-4">
|
||||||
<div class="card mt-3" style="max-width: 500px;">
|
<div class="col-lg-3 col-md-6 col-12">
|
||||||
<div class="card-body">
|
<div class="card h-100">
|
||||||
<h6 class="card-subtitle mb-2 text-muted">Signed in as</h6>
|
<div class="card-body text-center">
|
||||||
<p class="card-text mb-1"><strong>@context.User.FindFirst("DisplayName")?.Value</strong></p>
|
<div class="fs-2 fw-bold">@(_loaded ? _siteCount.ToString() : "—")</div>
|
||||||
<p class="card-text small text-muted mb-2">@context.User.FindFirst("Username")?.Value</p>
|
<div class="text-muted small">Sites configured</div>
|
||||||
|
|
||||||
@{
|
|
||||||
var roles = context.User.FindAll("Role").Select(c => c.Value).ToList();
|
|
||||||
}
|
|
||||||
@if (roles.Count > 0)
|
|
||||||
{
|
|
||||||
<h6 class="card-subtitle mb-1 mt-3 text-muted">Roles</h6>
|
|
||||||
<div>
|
|
||||||
@foreach (var role in roles)
|
|
||||||
{
|
|
||||||
<span class="badge bg-secondary me-1">@role</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</div>
|
||||||
</AuthorizeView>
|
<div class="col-lg-3 col-md-6 col-12">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="fs-2 fw-bold">@(_loaded ? _dataConnectionCount.ToString() : "—")</div>
|
||||||
|
<div class="text-muted small">Data connections configured</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-3 col-md-6 col-12">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="fs-2 fw-bold">@(_loaded ? _templateCount.ToString() : "—")</div>
|
||||||
|
<div class="text-muted small">Templates</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-3 col-md-6 col-12">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="fs-2 fw-bold">@(_loaded ? _apiKeyCount.ToString() : "—")</div>
|
||||||
|
<div class="text-muted small">API keys</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Quick actions *@
|
||||||
|
<h6 class="text-muted text-uppercase small mb-2">Quick actions</h6>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-lg-4 col-md-6 col-12">
|
||||||
|
<a class="card h-100 text-decoration-none text-reset" href="/monitoring/health">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<h6 class="mb-1">Health Dashboard</h6>
|
||||||
|
<span class="text-muted">→</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-0">Live cluster, data connection, and queue health per site.</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 col-md-6 col-12">
|
||||||
|
<a class="card h-100 text-decoration-none text-reset" href="/monitoring/audit-log">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<h6 class="mb-1">Recent Audit Log</h6>
|
||||||
|
<span class="text-muted">→</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-0">Browse changes to configuration and deployments.</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4 col-md-6 col-12">
|
||||||
|
<a class="card h-100 text-decoration-none text-reset" href="/design/templates">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<h6 class="mb-1">Templates</h6>
|
||||||
|
<span class="text-muted">→</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-0">Design templates, shared scripts, and external systems.</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool _loaded;
|
||||||
|
private int _siteCount;
|
||||||
|
private int _dataConnectionCount;
|
||||||
|
private int _templateCount;
|
||||||
|
private int _apiKeyCount;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_siteCount = (await SiteRepository.GetAllSitesAsync()).Count;
|
||||||
|
_dataConnectionCount = (await SiteRepository.GetAllDataConnectionsAsync()).Count;
|
||||||
|
_templateCount = (await TemplateEngineRepository.GetAllTemplatesAsync()).Count;
|
||||||
|
_apiKeyCount = (await InboundApiRepository.GetAllApiKeysAsync()).Count;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Non-fatal — leave counts at zero with the placeholder rendering.
|
||||||
|
}
|
||||||
|
_loaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,38 +4,65 @@
|
|||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||||
@inject ICentralUiRepository CentralUiRepository
|
@inject ICentralUiRepository CentralUiRepository
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
<h4 class="mb-3">Audit Log</h4>
|
<h4 class="mb-3">Audit Log</h4>
|
||||||
|
|
||||||
<ToastNotification @ref="_toast" />
|
<ToastNotification @ref="_toast" />
|
||||||
|
|
||||||
<div class="row mb-3 g-2">
|
<div class="row mb-3 g-2 align-items-end">
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label small">User</label>
|
<label class="form-label small" for="audit-filter-user">User</label>
|
||||||
<input type="text" class="form-control form-control-sm" @bind="_filterUser" placeholder="Username" />
|
<input id="audit-filter-user"
|
||||||
|
type="text"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
aria-label="User"
|
||||||
|
@bind="_filterUser"
|
||||||
|
placeholder="Username" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label small">Entity Type</label>
|
<label class="form-label small" for="audit-filter-entity-type">Entity Type</label>
|
||||||
<input type="text" class="form-control form-control-sm" @bind="_filterEntityType" placeholder="e.g. Template" />
|
<input id="audit-filter-entity-type"
|
||||||
|
type="text"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
aria-label="Entity type"
|
||||||
|
@bind="_filterEntityType"
|
||||||
|
placeholder="e.g. Template" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label small">Action</label>
|
<label class="form-label small" for="audit-filter-action">Action</label>
|
||||||
<input type="text" class="form-control form-control-sm" @bind="_filterAction" placeholder="e.g. Create" />
|
<input id="audit-filter-action"
|
||||||
|
type="text"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
aria-label="Action"
|
||||||
|
@bind="_filterAction"
|
||||||
|
placeholder="e.g. Create" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label small">From</label>
|
<label class="form-label small" for="audit-filter-from">From</label>
|
||||||
<input type="datetime-local" class="form-control form-control-sm" @bind="_filterFrom" />
|
<input id="audit-filter-from"
|
||||||
|
type="datetime-local"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
aria-label="From timestamp"
|
||||||
|
@bind="_filterFrom" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label small">To</label>
|
<label class="form-label small" for="audit-filter-to">To</label>
|
||||||
<input type="datetime-local" class="form-control form-control-sm" @bind="_filterTo" />
|
<input id="audit-filter-to"
|
||||||
|
type="datetime-local"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
aria-label="To timestamp"
|
||||||
|
@bind="_filterTo" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 d-flex align-items-end">
|
<div class="col-md-2 d-flex gap-1">
|
||||||
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@_searching">
|
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@_searching">
|
||||||
@if (_searching) { <span class="spinner-border spinner-border-sm"></span> }
|
@if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
|
||||||
Search
|
Search
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters" disabled="@_searching">
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -65,20 +92,50 @@
|
|||||||
}
|
}
|
||||||
@foreach (var entry in _entries)
|
@foreach (var entry in _entries)
|
||||||
{
|
{
|
||||||
|
var entityIdShort = entry.EntityId is { Length: > 0 }
|
||||||
|
? entry.EntityId[..Math.Min(12, entry.EntityId.Length)]
|
||||||
|
: "";
|
||||||
|
var hasState = !string.IsNullOrWhiteSpace(entry.AfterStateJson);
|
||||||
|
var isLarge = hasState && entry.AfterStateJson!.Length > 1024;
|
||||||
<tr>
|
<tr>
|
||||||
<td class="small"><TimestampDisplay Value="@entry.Timestamp" /></td>
|
<td class="small"><TimestampDisplay Value="@entry.Timestamp" /></td>
|
||||||
<td class="small">@entry.User</td>
|
<td class="small">@entry.User</td>
|
||||||
<td><span class="badge @GetActionBadge(entry.Action)">@entry.Action</span></td>
|
<td><span class="badge @GetActionBadge(entry.Action)">@entry.Action</span></td>
|
||||||
<td class="small">@entry.EntityType</td>
|
<td class="small">@entry.EntityType</td>
|
||||||
<td class="small"><code>@entry.EntityId</code></td>
|
<td class="small">
|
||||||
|
@if (!string.IsNullOrEmpty(entry.EntityId))
|
||||||
|
{
|
||||||
|
<code>@entityIdShort…</code>
|
||||||
|
<button class="btn btn-link btn-sm p-0 ms-1"
|
||||||
|
@onclick="() => CopyAsync(entry.EntityId)"
|
||||||
|
title="Copy entity ID"
|
||||||
|
aria-label="Copy entity ID @entry.EntityId">📋</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td class="small">@entry.EntityName</td>
|
<td class="small">@entry.EntityName</td>
|
||||||
<td>
|
<td>
|
||||||
@if (!string.IsNullOrWhiteSpace(entry.AfterStateJson))
|
@if (hasState)
|
||||||
{
|
{
|
||||||
<button class="btn btn-outline-info btn-sm py-0 px-1"
|
if (isLarge)
|
||||||
@onclick="() => ToggleStateView(entry.Id)">
|
{
|
||||||
@(_expandedEntryId == entry.Id ? "Hide" : "View")
|
<button class="btn btn-outline-info btn-sm py-0 px-1"
|
||||||
</button>
|
@onclick="() => ShowStateModal(entry)"
|
||||||
|
aria-label="Open state details in modal for audit entry @entry.Id">
|
||||||
|
View in modal
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-info btn-sm py-0 px-1"
|
||||||
|
@onclick="() => ToggleStateView(entry.Id)"
|
||||||
|
aria-label="Toggle state details for audit entry @entry.Id">
|
||||||
|
@(_expandedEntryId == entry.Id ? "Hide" : "View")
|
||||||
|
</button>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -86,11 +143,11 @@
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@if (_expandedEntryId == entry.Id && !string.IsNullOrWhiteSpace(entry.AfterStateJson))
|
@if (hasState && !isLarge && _expandedEntryId == entry.Id)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7">
|
<td colspan="7">
|
||||||
<pre class="bg-light p-2 rounded small mb-0" style="max-height: 200px; overflow: auto;">@FormatJson(entry.AfterStateJson)</pre>
|
<pre class="bg-light p-2 rounded small mb-0" style="max-height: 200px; overflow: auto;">@FormatJson(entry.AfterStateJson!)</pre>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -99,10 +156,33 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<span class="text-muted small">Page @_page of @((_totalCount + _pageSize - 1) / _pageSize) (@_totalCount total)</span>
|
<span class="text-muted small">Page @_page of @TotalPages (@_totalCount total)</span>
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-outline-secondary btn-sm me-1" @onclick="PrevPage" disabled="@(_page <= 1)">Previous</button>
|
<button class="btn btn-outline-secondary btn-sm me-1" @onclick="PrevPage" disabled="@(_page <= 1)">Previous</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" @onclick="NextPage" disabled="@(_entries.Count < _pageSize)">Next</button>
|
<button class="btn btn-outline-secondary btn-sm" @onclick="NextPage" disabled="@(!HasMore)">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_modalEntry != null)
|
||||||
|
{
|
||||||
|
<div class="modal-backdrop fade show"></div>
|
||||||
|
<div class="modal fade show d-block" tabindex="-1" role="dialog">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
Audit entry @_modalEntry.Id — @_modalEntry.EntityType state
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" @onclick="CloseStateModal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<pre class="bg-light p-2 rounded small mb-0">@FormatJson(_modalEntry.AfterStateJson!)</pre>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseStateModal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -122,15 +202,30 @@
|
|||||||
private bool _searching;
|
private bool _searching;
|
||||||
private string? _errorMessage;
|
private string? _errorMessage;
|
||||||
private int? _expandedEntryId;
|
private int? _expandedEntryId;
|
||||||
|
private AuditLogEntry? _modalEntry;
|
||||||
|
|
||||||
private ToastNotification _toast = default!;
|
private ToastNotification _toast = default!;
|
||||||
|
|
||||||
|
private int TotalPages => _pageSize > 0 ? Math.Max(1, (_totalCount + _pageSize - 1) / _pageSize) : 1;
|
||||||
|
private bool HasMore => _page * _pageSize < _totalCount;
|
||||||
|
|
||||||
private async Task Search()
|
private async Task Search()
|
||||||
{
|
{
|
||||||
_page = 1;
|
_page = 1;
|
||||||
await FetchPage();
|
await FetchPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ClearFilters()
|
||||||
|
{
|
||||||
|
_filterUser = null;
|
||||||
|
_filterEntityType = null;
|
||||||
|
_filterAction = null;
|
||||||
|
_filterFrom = null;
|
||||||
|
_filterTo = null;
|
||||||
|
_page = 1;
|
||||||
|
await FetchPage();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task PrevPage() { _page--; await FetchPage(); }
|
private async Task PrevPage() { _page--; await FetchPage(); }
|
||||||
private async Task NextPage() { _page++; await FetchPage(); }
|
private async Task NextPage() { _page++; await FetchPage(); }
|
||||||
|
|
||||||
@@ -164,6 +259,29 @@
|
|||||||
_expandedEntryId = _expandedEntryId == entryId ? null : entryId;
|
_expandedEntryId = _expandedEntryId == entryId ? null : entryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ShowStateModal(AuditLogEntry entry)
|
||||||
|
{
|
||||||
|
_modalEntry = entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseStateModal()
|
||||||
|
{
|
||||||
|
_modalEntry = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CopyAsync(string text)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
|
||||||
|
_toast.ShowSuccess("Copied to clipboard.");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_toast.ShowError("Copy failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string GetActionBadge(string action) => action switch
|
private static string GetActionBadge(string action) => action switch
|
||||||
{
|
{
|
||||||
"Create" => "bg-success",
|
"Create" => "bg-success",
|
||||||
|
|||||||
@@ -8,55 +8,92 @@
|
|||||||
@inject CommunicationService CommunicationService
|
@inject CommunicationService CommunicationService
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
<h4 class="mb-3">Site Event Logs</h4>
|
<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" />
|
<ToastNotification @ref="_toast" />
|
||||||
|
|
||||||
<div class="row mb-3 g-2">
|
<div class="collapse show" id="event-logs-filters">
|
||||||
<div class="col-md-2">
|
<div class="row mb-3 g-2 align-items-end">
|
||||||
<label class="form-label small">Site</label>
|
<div class="col-md-2">
|
||||||
<select class="form-select form-select-sm" @bind="_selectedSiteId">
|
<label class="form-label small" for="filter-site">Site</label>
|
||||||
<option value="">Select site...</option>
|
<select id="filter-site" class="form-select form-select-sm" aria-label="Site" @bind="_selectedSiteId">
|
||||||
@foreach (var site in _sites)
|
<option value="">Select site...</option>
|
||||||
{
|
@foreach (var site in _sites)
|
||||||
<option value="@site.SiteIdentifier">@site.Name</option>
|
{
|
||||||
}
|
<option value="@site.SiteIdentifier">@site.Name</option>
|
||||||
</select>
|
}
|
||||||
</div>
|
</select>
|
||||||
<div class="col-md-2">
|
</div>
|
||||||
<label class="form-label small">Event Type</label>
|
<div class="col-md-2">
|
||||||
<input type="text" class="form-control form-control-sm" @bind="_filterEventType" placeholder="e.g. ScriptError" />
|
<label class="form-label small" for="filter-event-type">Event Type</label>
|
||||||
</div>
|
<input id="filter-event-type"
|
||||||
<div class="col-md-1">
|
type="text"
|
||||||
<label class="form-label small">Severity</label>
|
class="form-control form-control-sm"
|
||||||
<select class="form-select form-select-sm" @bind="_filterSeverity">
|
aria-label="Event type"
|
||||||
<option value="">All</option>
|
@bind="_filterEventType"
|
||||||
<option>Info</option>
|
placeholder="e.g. ScriptError" />
|
||||||
<option>Warning</option>
|
</div>
|
||||||
<option>Error</option>
|
<div class="col-md-1">
|
||||||
</select>
|
<label class="form-label small" for="filter-severity">Severity</label>
|
||||||
</div>
|
<select id="filter-severity"
|
||||||
<div class="col-md-2">
|
class="form-select form-select-sm"
|
||||||
<label class="form-label small">From</label>
|
aria-label="Severity"
|
||||||
<input type="datetime-local" class="form-control form-control-sm" @bind="_filterFrom" />
|
@bind="_filterSeverity">
|
||||||
</div>
|
<option value="">All</option>
|
||||||
<div class="col-md-2">
|
<option>Info</option>
|
||||||
<label class="form-label small">To</label>
|
<option>Warning</option>
|
||||||
<input type="datetime-local" class="form-control form-control-sm" @bind="_filterTo" />
|
<option>Error</option>
|
||||||
</div>
|
</select>
|
||||||
<div class="col-md-1">
|
</div>
|
||||||
<label class="form-label small">Keyword</label>
|
<div class="col-md-2">
|
||||||
<input type="text" class="form-control form-control-sm" @bind="_filterKeyword" />
|
<label class="form-label small" for="filter-from">From</label>
|
||||||
</div>
|
<input id="filter-from"
|
||||||
<div class="col-md-2">
|
type="datetime-local"
|
||||||
<label class="form-label small">Instance</label>
|
class="form-control form-control-sm"
|
||||||
<input type="text" class="form-control form-control-sm" @bind="_filterInstanceName" placeholder="Instance name" />
|
aria-label="From timestamp"
|
||||||
</div>
|
@bind="_filterFrom" />
|
||||||
<div class="col-md-1 d-flex align-items-end">
|
</div>
|
||||||
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@(string.IsNullOrEmpty(_selectedSiteId) || _searching)">
|
<div class="col-md-2">
|
||||||
@if (_searching) { <span class="spinner-border spinner-border-sm"></span> }
|
<label class="form-label small" for="filter-to">To</label>
|
||||||
Search
|
<input id="filter-to"
|
||||||
</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -70,6 +107,7 @@
|
|||||||
<table class="table table-sm table-striped table-hover">
|
<table class="table table-sm table-striped table-hover">
|
||||||
<thead class="table-dark">
|
<thead class="table-dark">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width: 1%;"></th>
|
||||||
<th>Timestamp</th>
|
<th>Timestamp</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Severity</th>
|
<th>Severity</th>
|
||||||
@@ -81,28 +119,59 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@if (_entries.Count == 0)
|
@if (_entries.Count == 0)
|
||||||
{
|
{
|
||||||
<tr><td colspan="6" class="text-muted text-center">No events found.</td></tr>
|
<tr><td colspan="7" class="text-muted text-center">No events found.</td></tr>
|
||||||
}
|
}
|
||||||
@foreach (var entry in _entries)
|
@for (int i = 0; i < _entries.Count; i++)
|
||||||
{
|
{
|
||||||
<tr class="@(entry.Severity == "Error" ? "table-danger" : entry.Severity == "Warning" ? "table-warning" : "")">
|
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"><TimestampDisplay Value="@entry.Timestamp" /></td>
|
||||||
<td class="small">@entry.EventType</td>
|
<td class="small">@entry.EventType</td>
|
||||||
<td><span class="badge @GetSeverityBadge(entry.Severity)">@entry.Severity</span></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.InstanceId ?? "—")</td>
|
||||||
<td class="small">@entry.Source</td>
|
<td class="small">@entry.Source</td>
|
||||||
<td class="small">@entry.Message</td>
|
<td class="small text-truncate" style="max-width: 380px;">@entry.Message</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@if (expanded)
|
||||||
|
{
|
||||||
|
<tr class="@rowClass">
|
||||||
|
<td colspan="7">
|
||||||
|
<pre class="small mb-0">@entry.Message</pre>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<span class="text-muted small">@_entries.Count entries loaded</span>
|
<span class="text-muted small">Showing @_entries.Count entries</span>
|
||||||
@if (_hasMore)
|
<div>
|
||||||
{
|
@if (_hasMore)
|
||||||
<button class="btn btn-outline-primary btn-sm" @onclick="LoadMore" disabled="@_searching">Load More</button>
|
{
|
||||||
}
|
<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>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -123,6 +192,23 @@
|
|||||||
private bool _searching;
|
private bool _searching;
|
||||||
private string? _errorMessage;
|
private string? _errorMessage;
|
||||||
private ToastNotification _toast = default!;
|
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()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -133,11 +219,20 @@
|
|||||||
{
|
{
|
||||||
_entries = new();
|
_entries = new();
|
||||||
_continuationToken = null;
|
_continuationToken = null;
|
||||||
|
_expandedRows.Clear();
|
||||||
await FetchPage();
|
await FetchPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadMore() => await FetchPage();
|
private async Task LoadMore() => await FetchPage();
|
||||||
|
|
||||||
|
private void ToggleRow(int idx)
|
||||||
|
{
|
||||||
|
if (!_expandedRows.Add(idx))
|
||||||
|
{
|
||||||
|
_expandedRows.Remove(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task FetchPage()
|
private async Task FetchPage()
|
||||||
{
|
{
|
||||||
_searching = true;
|
_searching = true;
|
||||||
@@ -185,4 +280,12 @@
|
|||||||
"Info" => "bg-info text-dark",
|
"Info" => "bg-info text-dark",
|
||||||
_ => "bg-secondary"
|
_ => "bg-secondary"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static string SeverityGlyph(string severity) => severity switch
|
||||||
|
{
|
||||||
|
"Error" => "⛔",
|
||||||
|
"Warning" => "⚠",
|
||||||
|
"Info" => "ℹ",
|
||||||
|
_ => "•"
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,36 +24,28 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
@* Overview cards *@
|
@* Overview cards *@
|
||||||
<div class="row mb-3">
|
<div class="row g-3 mb-3">
|
||||||
<div class="col-md-3">
|
<div class="col-lg-4 col-md-6 col-12">
|
||||||
<div class="card border-success">
|
<div class="card border-success h-100">
|
||||||
<div class="card-body text-center">
|
<div class="card-body text-center">
|
||||||
<h3 class="mb-0 text-success">@_siteStates.Values.Count(s => s.IsOnline)</h3>
|
<h3 class="mb-0 text-success">@_siteStates.Values.Count(s => s.IsOnline)</h3>
|
||||||
<small class="text-muted">Sites Online</small>
|
<small class="text-muted">Sites Online</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-lg-4 col-md-6 col-12">
|
||||||
<div class="card border-danger">
|
<div class="card border-danger h-100">
|
||||||
<div class="card-body text-center">
|
<div class="card-body text-center">
|
||||||
<h3 class="mb-0 text-danger">@_siteStates.Values.Count(s => !s.IsOnline)</h3>
|
<h3 class="mb-0 text-danger">@_siteStates.Values.Count(s => !s.IsOnline)</h3>
|
||||||
<small class="text-muted">Sites Offline</small>
|
<small class="text-muted">Sites Offline</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-lg-4 col-md-6 col-12">
|
||||||
<div class="card">
|
<div class="card border-warning h-100">
|
||||||
<div class="card-body text-center">
|
<div class="card-body text-center">
|
||||||
<h3 class="mb-0">@_siteStates.Count</h3>
|
<h3 class="mb-0 text-warning">@_siteStates.Values.Count(SiteHasActiveErrors)</h3>
|
||||||
<small class="text-muted">Total Sites</small>
|
<small class="text-muted">Sites with active errors</small>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<h3 class="mb-0">@_siteStates.Values.Sum(s => s.LatestReport?.ScriptErrorCount ?? 0)</h3>
|
|
||||||
<small class="text-muted">Total Script Errors</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,21 +55,22 @@
|
|||||||
@foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key))
|
@foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key))
|
||||||
{
|
{
|
||||||
var siteName = GetSiteName(siteId);
|
var siteName = GetSiteName(siteId);
|
||||||
|
var detailsCollapseId = $"site-details-{siteId}";
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center py-2">
|
<div class="card-header d-flex justify-content-between align-items-center py-2">
|
||||||
<div>
|
<div>
|
||||||
@if (state.IsOnline)
|
@if (state.IsOnline)
|
||||||
{
|
{
|
||||||
<span class="badge bg-success me-2">Online</span>
|
<span class="badge bg-success me-2" aria-label="State: Online">@OnlineGlyph Online</span>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span class="badge bg-danger me-2">Offline</span>
|
<span class="badge bg-danger me-2" aria-label="State: Offline">@OfflineGlyph Offline</span>
|
||||||
}
|
}
|
||||||
<strong class="fs-5">@siteName (@siteId)</strong>
|
<strong class="fs-5">@siteName (@siteId)</strong>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
Last report: @state.LastReportReceivedAt.LocalDateTime.ToString("HH:mm:ss") | Seq: @state.LastSequenceNumber
|
Last report: <TimestampDisplay Value="@state.LastReportReceivedAt" Format="HH:mm:ss" /> | Seq: @state.LastSequenceNumber
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-3">
|
<div class="card-body p-3">
|
||||||
@@ -86,7 +79,7 @@
|
|||||||
var report = state.LatestReport;
|
var report = state.LatestReport;
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
@* Column 1: Nodes *@
|
@* Column 1: Nodes *@
|
||||||
<div class="col-md-3">
|
<div class="col-md-6">
|
||||||
<h6 class="text-muted mb-2 border-bottom pb-1">Nodes</h6>
|
<h6 class="text-muted mb-2 border-bottom pb-1">Nodes</h6>
|
||||||
<table class="table table-sm table-borderless mb-0">
|
<table class="table table-sm table-borderless mb-0">
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -96,8 +89,18 @@
|
|||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td class="small">@node.Hostname</td>
|
<td class="small">@node.Hostname</td>
|
||||||
<td><span class="badge @(node.IsOnline ? "bg-success" : "bg-danger")">@(node.IsOnline ? "Online" : "Offline")</span></td>
|
<td>
|
||||||
<td><span class="badge @(node.Role == "Primary" ? "bg-primary" : "bg-secondary")">@node.Role</span></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>
|
</tr>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,128 +108,164 @@
|
|||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td class="small">@(report.NodeHostname != "" ? report.NodeHostname : "Node")</td>
|
<td class="small">@(report.NodeHostname != "" ? report.NodeHostname : "Node")</td>
|
||||||
<td><span class="badge @(state.IsOnline ? "bg-success" : "bg-danger")">@(state.IsOnline ? "Online" : "Offline")</span></td>
|
<td>
|
||||||
<td><span class="badge @(report.NodeRole == "Active" ? "bg-primary" : "bg-secondary")">@(report.NodeRole == "Active" ? "Primary" : "Standby")</span></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>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* Column 2: Data Connections *@
|
@* Column 2: Data Connections (collapsible) *@
|
||||||
<div class="col-md-3">
|
<div class="col-md-6">
|
||||||
<h6 class="text-muted mb-2 border-bottom pb-1">Data Connections</h6>
|
<button class="btn btn-link btn-sm p-0 text-decoration-none mb-2"
|
||||||
@if (report.DataConnectionStatuses.Count == 0)
|
data-bs-toggle="collapse"
|
||||||
{
|
data-bs-target="@($"#{detailsCollapseId}-conns")"
|
||||||
<span class="text-muted small">None</span>
|
aria-expanded="false">
|
||||||
}
|
Data Connections (@report.DataConnectionStatuses.Count)
|
||||||
else
|
</button>
|
||||||
{
|
<div class="collapse" id="@($"{detailsCollapseId}-conns")">
|
||||||
@foreach (var (connName, health) in report.DataConnectionStatuses)
|
@if (report.DataConnectionStatuses.Count == 0)
|
||||||
{
|
{
|
||||||
var endpoint = report.DataConnectionEndpoints?.GetValueOrDefault(connName);
|
<span class="text-muted small">None</span>
|
||||||
var quality = report.DataConnectionTagQuality?.GetValueOrDefault(connName);
|
}
|
||||||
<div class="mb-2">
|
else
|
||||||
<div class="d-flex justify-content-between">
|
{
|
||||||
<strong class="small">@connName</strong>
|
@foreach (var (connName, health) in report.DataConnectionStatuses)
|
||||||
<span class="small">@(endpoint ?? health.ToString())</span>
|
{
|
||||||
|
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>
|
||||||
@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>
|
</div>
|
||||||
|
|
||||||
@* Column 3: Instances + Store-and-Forward *@
|
@* Column 3: Instances + Store-and-Forward (collapsible) *@
|
||||||
<div class="col-md-3">
|
<div class="col-md-6">
|
||||||
<h6 class="text-muted mb-2 border-bottom pb-1">Instances</h6>
|
<button class="btn btn-link btn-sm p-0 text-decoration-none mb-2"
|
||||||
<table class="table table-sm table-borderless mb-0">
|
data-bs-toggle="collapse"
|
||||||
<tbody>
|
data-bs-target="@($"#{detailsCollapseId}-queues")"
|
||||||
<tr>
|
aria-expanded="false">
|
||||||
<td class="small">Deployed</td>
|
Instances & Queues
|
||||||
<td class="text-end">@report.DeployedInstanceCount</td>
|
</button>
|
||||||
</tr>
|
<div class="collapse" id="@($"{detailsCollapseId}-queues")">
|
||||||
<tr>
|
<h6 class="text-muted mb-2 border-bottom pb-1">Instances</h6>
|
||||||
<td class="small">Enabled</td>
|
<table class="table table-sm table-borderless mb-0">
|
||||||
<td class="text-end text-success">@report.EnabledInstanceCount</td>
|
<tbody>
|
||||||
</tr>
|
<tr>
|
||||||
<tr>
|
<td class="small">Deployed</td>
|
||||||
<td class="small">Disabled</td>
|
<td class="text-end">@report.DeployedInstanceCount</td>
|
||||||
<td class="text-end">@report.DisabledInstanceCount</td>
|
</tr>
|
||||||
</tr>
|
<tr>
|
||||||
</tbody>
|
<td class="small">Enabled</td>
|
||||||
</table>
|
<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>
|
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">Store-and-Forward Buffers</h6>
|
||||||
@if (report.StoreAndForwardBufferDepths.Count == 0)
|
@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="text-muted small">Empty</span>
|
||||||
<span class="small">@category</span>
|
|
||||||
<span class="badge @(depth > 0 ? "bg-warning text-dark" : "bg-light text-dark")">@depth</span>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
}
|
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>
|
</div>
|
||||||
|
|
||||||
@* Column 4: Error Counts + Parked Messages *@
|
@* Column 4: Error Counts + Parked Messages (collapsible) *@
|
||||||
<div class="col-md-3">
|
<div class="col-md-6">
|
||||||
<h6 class="text-muted mb-2 border-bottom pb-1">Error Counts</h6>
|
<button class="btn btn-link btn-sm p-0 text-decoration-none mb-2"
|
||||||
<table class="table table-sm table-borderless mb-0">
|
data-bs-toggle="collapse"
|
||||||
<tbody>
|
data-bs-target="@($"#{detailsCollapseId}-errors")"
|
||||||
<tr>
|
aria-expanded="false">
|
||||||
<td class="small">Script Errors</td>
|
Errors & Parked Messages
|
||||||
<td class="text-end">
|
</button>
|
||||||
<span class="@(report.ScriptErrorCount > 0 ? "text-danger fw-bold" : "")">@report.ScriptErrorCount</span>
|
<div class="collapse" id="@($"{detailsCollapseId}-errors")">
|
||||||
</td>
|
<h6 class="text-muted mb-2 border-bottom pb-1">Error Counts</h6>
|
||||||
</tr>
|
<table class="table table-sm table-borderless mb-0">
|
||||||
<tr>
|
<tbody>
|
||||||
<td class="small">Alarm Eval Errors</td>
|
<tr>
|
||||||
<td class="text-end">
|
<td class="small">Script Errors</td>
|
||||||
<span class="@(report.AlarmEvaluationErrorCount > 0 ? "text-warning fw-bold" : "")">@report.AlarmEvaluationErrorCount</span>
|
<td class="text-end">
|
||||||
</td>
|
<span class="@(report.ScriptErrorCount > 0 ? "text-danger fw-bold" : "")">@report.ScriptErrorCount</span>
|
||||||
</tr>
|
</td>
|
||||||
<tr>
|
</tr>
|
||||||
<td class="small">Dead Letters</td>
|
<tr>
|
||||||
<td class="text-end">
|
<td class="small">Alarm Eval Errors</td>
|
||||||
<span class="@(report.DeadLetterCount > 0 ? "text-danger fw-bold" : "")">@report.DeadLetterCount</span>
|
<td class="text-end">
|
||||||
</td>
|
<span class="@(report.AlarmEvaluationErrorCount > 0 ? "text-warning fw-bold" : "")">@report.AlarmEvaluationErrorCount</span>
|
||||||
</tr>
|
</td>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
<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>
|
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">Parked Messages</h6>
|
||||||
@if (report.ParkedMessageCount == 0)
|
@if (report.ParkedMessageCount == 0)
|
||||||
{
|
{
|
||||||
<span class="text-muted small">Empty</span>
|
<span class="text-muted small">Empty</span>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span class="badge bg-warning text-dark">@report.ParkedMessageCount</span>
|
<span class="badge bg-warning text-dark">@report.ParkedMessageCount</span>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -241,11 +280,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@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 IReadOnlyDictionary<string, SiteHealthState> _siteStates = new Dictionary<string, SiteHealthState>();
|
||||||
private Dictionary<string, string> _siteNames = new();
|
private Dictionary<string, string> _siteNames = new();
|
||||||
private Timer? _refreshTimer;
|
private Timer? _refreshTimer;
|
||||||
private int _autoRefreshSeconds = 10;
|
private int _autoRefreshSeconds = 10;
|
||||||
|
|
||||||
|
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()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
// Load site names for display
|
// Load site names for display
|
||||||
|
|||||||
@@ -6,17 +6,18 @@
|
|||||||
@using ScadaLink.Communication
|
@using ScadaLink.Communication
|
||||||
@inject ISiteRepository SiteRepository
|
@inject ISiteRepository SiteRepository
|
||||||
@inject CommunicationService CommunicationService
|
@inject CommunicationService CommunicationService
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
<h4 class="mb-3">Parked Messages</h4>
|
<h4 class="mb-3">Parked Messages</h4>
|
||||||
|
|
||||||
<ToastNotification @ref="_toast" />
|
<ToastNotification @ref="_toast" />
|
||||||
<ConfirmDialog @ref="_confirmDialog" />
|
<ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
|
||||||
|
|
||||||
<div class="row mb-3 g-2">
|
<div class="row mb-3 g-2 align-items-end">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label small">Site</label>
|
<label class="form-label small" for="pm-filter-site">Site</label>
|
||||||
<select class="form-select form-select-sm" @bind="_selectedSiteId">
|
<select id="pm-filter-site" class="form-select form-select-sm" aria-label="Site" @bind="_selectedSiteId">
|
||||||
<option value="">Select site...</option>
|
<option value="">Select site...</option>
|
||||||
@foreach (var site in _sites)
|
@foreach (var site in _sites)
|
||||||
{
|
{
|
||||||
@@ -24,10 +25,10 @@
|
|||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 d-flex align-items-end">
|
<div class="col-md-2">
|
||||||
<button class="btn btn-primary btn-sm" @onclick="Search"
|
<button class="btn btn-primary btn-sm" @onclick="Search"
|
||||||
disabled="@(string.IsNullOrEmpty(_selectedSiteId) || _searching)">
|
disabled="@(string.IsNullOrEmpty(_selectedSiteId) || _searching)">
|
||||||
@if (_searching) { <span class="spinner-border spinner-border-sm"></span> }
|
@if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
|
||||||
Query
|
Query
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
<table class="table table-sm table-striped table-hover">
|
<table class="table table-sm table-striped table-hover">
|
||||||
<thead class="table-dark">
|
<thead class="table-dark">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width: 1%;"></th>
|
||||||
<th>Message ID</th>
|
<th>Message ID</th>
|
||||||
<th>Target System</th>
|
<th>Target System</th>
|
||||||
<th>Method</th>
|
<th>Method</th>
|
||||||
@@ -56,27 +58,70 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@if (_messages.Count == 0)
|
@if (_messages.Count == 0)
|
||||||
{
|
{
|
||||||
<tr><td colspan="8" class="text-muted text-center">No parked messages.</td></tr>
|
<tr><td colspan="9" class="text-muted text-center">No parked messages.</td></tr>
|
||||||
}
|
}
|
||||||
@foreach (var msg in _messages)
|
@for (int i = 0; i < _messages.Count; i++)
|
||||||
{
|
{
|
||||||
|
var idx = i;
|
||||||
|
var msg = _messages[idx];
|
||||||
|
var idShort = msg.MessageId[..Math.Min(12, msg.MessageId.Length)];
|
||||||
|
var expanded = _expandedRows.Contains(idx);
|
||||||
|
var retryActive = _actionInProgress && _activeMessageId == msg.MessageId && _activeAction == "Retry";
|
||||||
|
var discardActive = _actionInProgress && _activeMessageId == msg.MessageId && _activeAction == "Discard";
|
||||||
<tr>
|
<tr>
|
||||||
<td class="small"><code>@msg.MessageId[..Math.Min(12, msg.MessageId.Length)]</code></td>
|
<td>
|
||||||
|
<button class="btn btn-link btn-sm p-0"
|
||||||
|
@onclick="() => ToggleRow(idx)"
|
||||||
|
aria-label="@(expanded ? "Hide error details" : "View error details")">
|
||||||
|
@(expanded ? "Hide" : "View")
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="small">
|
||||||
|
<code class="small">@idShort…</code>
|
||||||
|
<button class="btn btn-link btn-sm p-0 ms-1"
|
||||||
|
@onclick="() => CopyAsync(msg.MessageId)"
|
||||||
|
title="Copy message ID"
|
||||||
|
aria-label="Copy message ID @msg.MessageId">📋</button>
|
||||||
|
</td>
|
||||||
<td class="small">@msg.TargetSystem</td>
|
<td class="small">@msg.TargetSystem</td>
|
||||||
<td class="small">@msg.MethodName</td>
|
<td class="small">@msg.MethodName</td>
|
||||||
<td class="small text-danger">@msg.ErrorMessage</td>
|
<td class="small text-danger text-truncate" style="max-width: 320px;">@msg.ErrorMessage</td>
|
||||||
<td class="small text-center">@msg.AttemptCount</td>
|
<td class="small text-center">@msg.AttemptCount</td>
|
||||||
<td class="small"><TimestampDisplay Value="@msg.OriginalTimestamp" /></td>
|
<td class="small"><TimestampDisplay Value="@msg.OriginalTimestamp" /></td>
|
||||||
<td class="small"><TimestampDisplay Value="@msg.LastAttemptTimestamp" /></td>
|
<td class="small"><TimestampDisplay Value="@msg.LastAttemptTimestamp" /></td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-outline-success btn-sm py-0 px-1 me-1"
|
<button class="btn btn-outline-success btn-sm py-0 px-1 me-1"
|
||||||
@onclick="() => RetryMessage(msg)" disabled="@_actionInProgress"
|
@onclick="() => RetryMessage(msg)"
|
||||||
title="Retry message (move back to pending)">Retry</button>
|
disabled="@_actionInProgress"
|
||||||
|
title="Retry message (move back to pending)"
|
||||||
|
aria-label="Retry message @idShort">
|
||||||
|
@if (retryActive)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||||
|
}
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
||||||
@onclick="() => DiscardMessage(msg)" disabled="@_actionInProgress"
|
@onclick="() => DiscardMessage(msg)"
|
||||||
title="Permanently discard message">Discard</button>
|
disabled="@_actionInProgress"
|
||||||
|
title="Permanently discard message"
|
||||||
|
aria-label="Discard message @idShort">
|
||||||
|
@if (discardActive)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||||
|
}
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@if (expanded)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td colspan="9">
|
||||||
|
<pre class="small mb-0">@msg.ErrorMessage</pre>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -105,8 +150,11 @@
|
|||||||
private string? _errorMessage;
|
private string? _errorMessage;
|
||||||
|
|
||||||
private bool _actionInProgress;
|
private bool _actionInProgress;
|
||||||
|
private string? _activeMessageId;
|
||||||
|
private string? _activeAction;
|
||||||
private ToastNotification _toast = default!;
|
private ToastNotification _toast = default!;
|
||||||
private ConfirmDialog _confirmDialog = default!;
|
private ConfirmDialog _confirmDialog = default!;
|
||||||
|
private readonly HashSet<int> _expandedRows = new();
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -116,12 +164,34 @@
|
|||||||
private async Task Search()
|
private async Task Search()
|
||||||
{
|
{
|
||||||
_pageNumber = 1;
|
_pageNumber = 1;
|
||||||
|
_expandedRows.Clear();
|
||||||
await FetchPage();
|
await FetchPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PrevPage() { _pageNumber--; await FetchPage(); }
|
private async Task PrevPage() { _pageNumber--; await FetchPage(); }
|
||||||
private async Task NextPage() { _pageNumber++; await FetchPage(); }
|
private async Task NextPage() { _pageNumber++; await FetchPage(); }
|
||||||
|
|
||||||
|
private void ToggleRow(int idx)
|
||||||
|
{
|
||||||
|
if (!_expandedRows.Add(idx))
|
||||||
|
{
|
||||||
|
_expandedRows.Remove(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CopyAsync(string text)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
|
||||||
|
_toast.ShowSuccess("Copied to clipboard.");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_toast.ShowError("Copy failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task FetchPage()
|
private async Task FetchPage()
|
||||||
{
|
{
|
||||||
_searching = true;
|
_searching = true;
|
||||||
@@ -141,6 +211,7 @@
|
|||||||
{
|
{
|
||||||
_messages = response.Messages.ToList();
|
_messages = response.Messages.ToList();
|
||||||
_totalCount = response.TotalCount;
|
_totalCount = response.TotalCount;
|
||||||
|
_expandedRows.Clear();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -157,6 +228,8 @@
|
|||||||
private async Task RetryMessage(ParkedMessageEntry msg)
|
private async Task RetryMessage(ParkedMessageEntry msg)
|
||||||
{
|
{
|
||||||
_actionInProgress = true;
|
_actionInProgress = true;
|
||||||
|
_activeMessageId = msg.MessageId;
|
||||||
|
_activeAction = "Retry";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var request = new ParkedMessageRetryRequest(
|
var request = new ParkedMessageRetryRequest(
|
||||||
@@ -179,6 +252,8 @@
|
|||||||
{
|
{
|
||||||
_toast.ShowError($"Retry failed: {ex.Message}");
|
_toast.ShowError($"Retry failed: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
_activeMessageId = null;
|
||||||
|
_activeAction = null;
|
||||||
_actionInProgress = false;
|
_actionInProgress = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +265,8 @@
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
_actionInProgress = true;
|
_actionInProgress = true;
|
||||||
|
_activeMessageId = msg.MessageId;
|
||||||
|
_activeAction = "Discard";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var request = new ParkedMessageDiscardRequest(
|
var request = new ParkedMessageDiscardRequest(
|
||||||
@@ -212,6 +289,8 @@
|
|||||||
{
|
{
|
||||||
_toast.ShowError($"Discard failed: {ex.Message}");
|
_toast.ShowError($"Discard failed: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
_activeMessageId = null;
|
||||||
|
_activeAction = null;
|
||||||
_actionInProgress = false;
|
_actionInProgress = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user