From e21791adb0e8ef32009890998d7d09d1fbe03a78 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 12 May 2026 03:33:06 -0400 Subject: [PATCH] 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 {first 12}... 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. --- .../Components/Pages/Dashboard.razor | 135 ++++++-- .../Pages/Monitoring/AuditLog.razor | 164 +++++++-- .../Pages/Monitoring/EventLogs.razor | 215 ++++++++---- .../Components/Pages/Monitoring/Health.razor | 310 ++++++++++-------- .../Pages/Monitoring/ParkedMessages.razor | 107 +++++- 5 files changed, 685 insertions(+), 246 deletions(-) diff --git a/src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor b/src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor index 1f2ba8f..ac155b8 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor @@ -1,33 +1,118 @@ @page "/" @attribute [Authorize] +@using ScadaLink.Commons.Interfaces.Repositories +@inject ISiteRepository SiteRepository +@inject ITemplateEngineRepository TemplateEngineRepository +@inject IInboundApiRepository InboundApiRepository -
-

Welcome to ScadaLink

+
+
+

Welcome to ScadaLink

+ + + + Signed in as @context.User.FindFirst("DisplayName")?.Value + + + +

Central management console for the ScadaLink SCADA system.

- - -
-
-
Signed in as
-

@context.User.FindFirst("DisplayName")?.Value

-

@context.User.FindFirst("Username")?.Value

- - @{ - var roles = context.User.FindAll("Role").Select(c => c.Value).ToList(); - } - @if (roles.Count > 0) - { -
Roles
-
- @foreach (var role in roles) - { - @role - } -
- } + @* KPI row *@ +
+
+
+
+
@(_loaded ? _siteCount.ToString() : "—")
+
Sites configured
- - +
+
+
+
+
@(_loaded ? _dataConnectionCount.ToString() : "—")
+
Data connections configured
+
+
+
+
+
+
+
@(_loaded ? _templateCount.ToString() : "—")
+
Templates
+
+
+
+
+
+
+
@(_loaded ? _apiKeyCount.ToString() : "—")
+
API keys
+
+
+
+
+ + @* Quick actions *@ +
Quick actions
+
+ +@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; + } +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/AuditLog.razor b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/AuditLog.razor index a0bf8c2..aae9e75 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/AuditLog.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/AuditLog.razor @@ -4,38 +4,65 @@ @using ScadaLink.Commons.Interfaces.Repositories @attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] @inject ICentralUiRepository CentralUiRepository +@inject IJSRuntime JS

Audit Log

-
+
- - + +
- - + +
- - + +
- - + +
- - + +
-
+
+
@@ -65,20 +92,50 @@ } @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; @entry.User @entry.Action @entry.EntityType - @entry.EntityId + + @if (!string.IsNullOrEmpty(entry.EntityId)) + { + @entityIdShort… + + } + else + { + + } + @entry.EntityName - @if (!string.IsNullOrWhiteSpace(entry.AfterStateJson)) + @if (hasState) { - + if (isLarge) + { + + } + else + { + + } } else { @@ -86,11 +143,11 @@ } - @if (_expandedEntryId == entry.Id && !string.IsNullOrWhiteSpace(entry.AfterStateJson)) + @if (hasState && !isLarge && _expandedEntryId == entry.Id) { -
@FormatJson(entry.AfterStateJson)
+
@FormatJson(entry.AfterStateJson!)
} @@ -99,10 +156,33 @@
- Page @_page of @((_totalCount + _pageSize - 1) / _pageSize) (@_totalCount total) + Page @_page of @TotalPages (@_totalCount total)
- + +
+
+ } + + @if (_modalEntry != null) + { + + } @@ -122,15 +202,30 @@ private bool _searching; private string? _errorMessage; private int? _expandedEntryId; + private AuditLogEntry? _modalEntry; 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() { _page = 1; 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 NextPage() { _page++; await FetchPage(); } @@ -164,6 +259,29 @@ _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 { "Create" => "bg-success", diff --git a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/EventLogs.razor b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/EventLogs.razor index 8efff47..3ea3a2d 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/EventLogs.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/EventLogs.razor @@ -8,55 +8,92 @@ @inject CommunicationService CommunicationService
-

Site Event Logs

+
+

Site Event Logs

+ +
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
@@ -70,6 +107,7 @@ + @@ -81,28 +119,59 @@ @if (_entries.Count == 0) { - + } - @foreach (var entry in _entries) + @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); + + - + - + + @if (expanded) + { + + + + } }
Timestamp Type Severity
No events found.
No events found.
+ + @entry.EventType@entry.Severity + + @SeverityGlyph(entry.Severity) @entry.Severity + + @(entry.InstanceId ?? "—") @entry.Source@entry.Message@entry.Message
+
@entry.Message
+
- @_entries.Count entries loaded - @if (_hasMore) - { - - } + Showing @_entries.Count entries +
+ @if (_hasMore) + { + + } + else if (_entries.Count > 0) + { + End of results + } +
}
@@ -123,6 +192,23 @@ private bool _searching; private string? _errorMessage; private ToastNotification _toast = default!; + private readonly HashSet _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() { @@ -133,11 +219,20 @@ { _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; @@ -185,4 +280,12 @@ "Info" => "bg-info text-dark", _ => "bg-secondary" }; + + private static string SeverityGlyph(string severity) => severity switch + { + "Error" => "⛔", + "Warning" => "⚠", + "Info" => "ℹ", + _ => "•" + }; } diff --git a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor index f57bae8..b655802 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor @@ -24,36 +24,28 @@ else { @* Overview cards *@ -
-
-
+
+
+

@_siteStates.Values.Count(s => s.IsOnline)

Sites Online
-
-
+
+

@_siteStates.Values.Count(s => !s.IsOnline)

Sites Offline
-
-
+
+
-

@_siteStates.Count

- Total Sites -
-
-
-
-
-
-

@_siteStates.Values.Sum(s => s.LatestReport?.ScriptErrorCount ?? 0)

- Total Script Errors +

@_siteStates.Values.Count(SiteHasActiveErrors)

+ Sites with active errors
@@ -63,21 +55,22 @@ @foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key)) { var siteName = GetSiteName(siteId); + var detailsCollapseId = $"site-details-{siteId}";
@if (state.IsOnline) { - Online + @OnlineGlyph Online } else { - Offline + @OfflineGlyph Offline } @siteName (@siteId)
- Last report: @state.LastReportReceivedAt.LocalDateTime.ToString("HH:mm:ss") | Seq: @state.LastSequenceNumber + Last report: | Seq: @state.LastSequenceNumber
@@ -86,7 +79,7 @@ var report = state.LatestReport;
@* Column 1: Nodes *@ -
+
Nodes
@@ -96,8 +89,18 @@ { - - + + } } @@ -105,128 +108,164 @@ { - - + + }
@node.Hostname@(node.IsOnline ? "Online" : "Offline")@node.Role + + @(node.IsOnline ? OnlineGlyph : OfflineGlyph) @(node.IsOnline ? "Online" : "Offline") + + + + @(node.Role == "Primary" ? PrimaryGlyph : StandbyGlyph) @node.Role + +
@(report.NodeHostname != "" ? report.NodeHostname : "Node")@(state.IsOnline ? "Online" : "Offline")@(report.NodeRole == "Active" ? "Primary" : "Standby") + + @(state.IsOnline ? OnlineGlyph : OfflineGlyph) @(state.IsOnline ? "Online" : "Offline") + + + @{ + var roleLabel = report.NodeRole == "Active" ? "Primary" : "Standby"; + } + + @(roleLabel == "Primary" ? PrimaryGlyph : StandbyGlyph) @roleLabel + +
- @* Column 2: Data Connections *@ -
-
Data Connections
- @if (report.DataConnectionStatuses.Count == 0) - { - None - } - else - { - @foreach (var (connName, health) in report.DataConnectionStatuses) + @* Column 2: Data Connections (collapsible) *@ +
+ +
+ @if (report.DataConnectionStatuses.Count == 0) { - var endpoint = report.DataConnectionEndpoints?.GetValueOrDefault(connName); - var quality = report.DataConnectionTagQuality?.GetValueOrDefault(connName); -
-
- @connName - @(endpoint ?? health.ToString()) + None + } + else + { + @foreach (var (connName, health) in report.DataConnectionStatuses) + { + var endpoint = report.DataConnectionEndpoints?.GetValueOrDefault(connName); + var quality = report.DataConnectionTagQuality?.GetValueOrDefault(connName); +
+
+ @connName + @(endpoint ?? health.ToString()) +
+ @if (quality != null) + { + + + + + + + + + + + + + + + +
Tags good@quality.Good.ToString("N0")
Tags bad@quality.Bad.ToString("N0")
Tags uncertain@quality.Uncertain.ToString("N0")
+ }
- @if (quality != null) - { - - - - - - - - - - - - - - - -
Tags good@quality.Good.ToString("N0")
Tags bad@quality.Bad.ToString("N0")
Tags uncertain@quality.Uncertain.ToString("N0")
- } -
+ } } - } +
- @* Column 3: Instances + Store-and-Forward *@ -
-
Instances
- - - - - - - - - - - - - - - -
Deployed@report.DeployedInstanceCount
Enabled@report.EnabledInstanceCount
Disabled@report.DisabledInstanceCount
+ @* Column 3: Instances + Store-and-Forward (collapsible) *@ +
+ +
+
Instances
+ + + + + + + + + + + + + + + +
Deployed@report.DeployedInstanceCount
Enabled@report.EnabledInstanceCount
Disabled@report.DisabledInstanceCount
-
Store-and-Forward Buffers
- @if (report.StoreAndForwardBufferDepths.Count == 0) - { - Empty - } - else - { - @foreach (var (category, depth) in report.StoreAndForwardBufferDepths) +
Store-and-Forward Buffers
+ @if (report.StoreAndForwardBufferDepths.Count == 0) { -
- @category - @depth -
+ Empty } - } + else + { + @foreach (var (category, depth) in report.StoreAndForwardBufferDepths) + { +
+ @category + @depth +
+ } + } +
- @* Column 4: Error Counts + Parked Messages *@ -
-
Error Counts
- - - - - - - - - - - - - - - -
Script Errors - @report.ScriptErrorCount -
Alarm Eval Errors - @report.AlarmEvaluationErrorCount -
Dead Letters - @report.DeadLetterCount -
+ @* Column 4: Error Counts + Parked Messages (collapsible) *@ +
+ +
+
Error Counts
+ + + + + + + + + + + + + + + +
Script Errors + @report.ScriptErrorCount +
Alarm Eval Errors + @report.AlarmEvaluationErrorCount +
Dead Letters + @report.DeadLetterCount +
-
Parked Messages
- @if (report.ParkedMessageCount == 0) - { - Empty - } - else - { - @report.ParkedMessageCount - } +
Parked Messages
+ @if (report.ParkedMessageCount == 0) + { + Empty + } + else + { + @report.ParkedMessageCount + } +
} @@ -241,11 +280,26 @@
@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 _siteStates = new Dictionary(); private Dictionary _siteNames = new(); private Timer? _refreshTimer; 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() { // Load site names for display diff --git a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor index a216ef8..9cf0825 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor @@ -6,17 +6,18 @@ @using ScadaLink.Communication @inject ISiteRepository SiteRepository @inject CommunicationService CommunicationService +@inject IJSRuntime JS

Parked Messages

- + -
+
- - @foreach (var site in _sites) { @@ -24,10 +25,10 @@ }
-
+
@@ -43,6 +44,7 @@ + @@ -56,27 +58,70 @@ @if (_messages.Count == 0) { - + } - @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"; - + + - + + @if (expanded) + { + + + + } }
Message ID Target System Method
No parked messages.
No parked messages.
@msg.MessageId[..Math.Min(12, msg.MessageId.Length)] + + + @idShort… + + @msg.TargetSystem @msg.MethodName@msg.ErrorMessage@msg.ErrorMessage @msg.AttemptCount + @onclick="() => RetryMessage(msg)" + disabled="@_actionInProgress" + title="Retry message (move back to pending)" + aria-label="Retry message @idShort"> + @if (retryActive) + { + + } + Retry + + @onclick="() => DiscardMessage(msg)" + disabled="@_actionInProgress" + title="Permanently discard message" + aria-label="Discard message @idShort"> + @if (discardActive) + { + + } + Discard +
+
@msg.ErrorMessage
+
@@ -105,8 +150,11 @@ private string? _errorMessage; private bool _actionInProgress; + private string? _activeMessageId; + private string? _activeAction; private ToastNotification _toast = default!; private ConfirmDialog _confirmDialog = default!; + private readonly HashSet _expandedRows = new(); protected override async Task OnInitializedAsync() { @@ -116,12 +164,34 @@ private async Task Search() { _pageNumber = 1; + _expandedRows.Clear(); await FetchPage(); } private async Task PrevPage() { _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() { _searching = true; @@ -141,6 +211,7 @@ { _messages = response.Messages.ToList(); _totalCount = response.TotalCount; + _expandedRows.Clear(); } else { @@ -157,6 +228,8 @@ private async Task RetryMessage(ParkedMessageEntry msg) { _actionInProgress = true; + _activeMessageId = msg.MessageId; + _activeAction = "Retry"; try { var request = new ParkedMessageRetryRequest( @@ -179,6 +252,8 @@ { _toast.ShowError($"Retry failed: {ex.Message}"); } + _activeMessageId = null; + _activeAction = null; _actionInProgress = false; } @@ -190,6 +265,8 @@ if (!confirmed) return; _actionInProgress = true; + _activeMessageId = msg.MessageId; + _activeAction = "Discard"; try { var request = new ParkedMessageDiscardRequest( @@ -212,6 +289,8 @@ { _toast.ShowError($"Discard failed: {ex.Message}"); } + _activeMessageId = null; + _activeAction = null; _actionInProgress = false; } }