fix(centralui): cap retained event-log rows; hint keyword search 7-day window (review 2026-10-21 07/M-3, lane D follow-up)
This commit is contained in:
@@ -24,6 +24,9 @@
|
|||||||
<ToastNotification @ref="_toast" />
|
<ToastNotification @ref="_toast" />
|
||||||
|
|
||||||
<div class="collapse show" id="event-logs-filters">
|
<div class="collapse show" id="event-logs-filters">
|
||||||
|
<div class="form-text mb-1" id="filter-keyword-hint">
|
||||||
|
Keyword search covers at most the last 7 days unless a narrower From/To is set.
|
||||||
|
</div>
|
||||||
<div class="row mb-3 g-2 align-items-end">
|
<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" for="filter-site">Site</label>
|
<label class="form-label small" for="filter-site">Site</label>
|
||||||
@@ -67,6 +70,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="form-control form-control-sm"
|
class="form-control form-control-sm"
|
||||||
aria-label="Message contains"
|
aria-label="Message contains"
|
||||||
|
aria-describedby="filter-keyword-hint"
|
||||||
@bind="_filterKeyword" />
|
@bind="_filterKeyword" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
@@ -152,7 +156,13 @@
|
|||||||
</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">Showing @_entries.Count entries</span>
|
<span class="text-muted small">
|
||||||
|
Showing @_entries.Count entries
|
||||||
|
@if (_droppedCount > 0)
|
||||||
|
{
|
||||||
|
<text>(oldest @_droppedCount dropped — display is capped at @MaxRetainedRows rows)</text>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
<div>
|
<div>
|
||||||
@if (_hasMore)
|
@if (_hasMore)
|
||||||
{
|
{
|
||||||
@@ -194,6 +204,26 @@
|
|||||||
// moved every expansion onto the wrong row. The id is stable by construction.
|
// moved every expansion onto the wrong row. The id is stable by construction.
|
||||||
private readonly HashSet<string> _expandedRows = new();
|
private readonly HashSet<string> _expandedRows = new();
|
||||||
|
|
||||||
|
/// <summary>Ceiling on rows retained across "Load more" pages (review 2026-10-21 07/M-3).</summary>
|
||||||
|
internal const int MaxRetainedRows = 1000;
|
||||||
|
|
||||||
|
private int _droppedCount;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends a page to the retained list, dropping the oldest (head) rows beyond
|
||||||
|
/// <paramref name="cap"/> and un-expanding any dropped row. Returns the number dropped.
|
||||||
|
/// </summary>
|
||||||
|
internal static int AppendCapped(
|
||||||
|
List<EventLogEntry> entries, IEnumerable<EventLogEntry> page, HashSet<string> expandedRows, int cap)
|
||||||
|
{
|
||||||
|
entries.AddRange(page);
|
||||||
|
var excess = entries.Count - cap;
|
||||||
|
if (excess <= 0) return 0;
|
||||||
|
foreach (var dropped in entries.Take(excess)) expandedRows.Remove(dropped.Id);
|
||||||
|
entries.RemoveRange(0, excess);
|
||||||
|
return excess;
|
||||||
|
}
|
||||||
|
|
||||||
private int ActiveFilterCount
|
private int ActiveFilterCount
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -225,6 +255,7 @@
|
|||||||
private async Task Search()
|
private async Task Search()
|
||||||
{
|
{
|
||||||
_entries = new();
|
_entries = new();
|
||||||
|
_droppedCount = 0;
|
||||||
_continuationToken = null;
|
_continuationToken = null;
|
||||||
_expandedRows.Clear();
|
_expandedRows.Clear();
|
||||||
await FetchPage();
|
await FetchPage();
|
||||||
@@ -276,7 +307,7 @@
|
|||||||
if (response.Success)
|
if (response.Success)
|
||||||
{
|
{
|
||||||
_entries ??= new();
|
_entries ??= new();
|
||||||
_entries.AddRange(response.Entries);
|
_droppedCount += AppendCapped(_entries, response.Entries, _expandedRows, MaxRetainedRows);
|
||||||
_hasMore = response.HasMore;
|
_hasMore = response.HasMore;
|
||||||
_continuationToken = response.ContinuationToken;
|
_continuationToken = response.ContinuationToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using Bunit;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NSubstitute;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring;
|
||||||
|
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;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring;
|
||||||
|
|
||||||
|
/// <summary>Review 2026-10-21 07/M-3 (retained-row cap) and the lane D keyword-window hint.</summary>
|
||||||
|
public class EventLogsPageTests : BunitContext
|
||||||
|
{
|
||||||
|
private static EventLogEntry Entry(int n) =>
|
||||||
|
new($"id-{n}", DateTimeOffset.UnixEpoch.AddSeconds(n), "T", "Info", null, "src", "m", null);
|
||||||
|
|
||||||
|
private static List<EventLogEntry> Page(int from, int count) =>
|
||||||
|
Enumerable.Range(from, count).Select(Entry).ToList();
|
||||||
|
|
||||||
|
/// <summary>Rows beyond the cap are dropped from the head and their expansion state cleared.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void AppendCapped_DropsOldestRowsBeyondCap_AndUnexpandsThem()
|
||||||
|
{
|
||||||
|
var entries = Page(0, 8);
|
||||||
|
var expanded = new HashSet<string> { "id-0", "id-7" };
|
||||||
|
|
||||||
|
var dropped = EventLogs.AppendCapped(entries, Page(8, 4), expanded, cap: 10);
|
||||||
|
|
||||||
|
Assert.Equal(2, dropped);
|
||||||
|
Assert.Equal(10, entries.Count);
|
||||||
|
Assert.Equal("id-2", entries[0].Id);
|
||||||
|
Assert.Equal("id-11", entries[^1].Id);
|
||||||
|
Assert.DoesNotContain("id-0", expanded);
|
||||||
|
Assert.Contains("id-7", expanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Under the cap nothing is dropped.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void AppendCapped_UnderCap_KeepsEverything()
|
||||||
|
{
|
||||||
|
var entries = Page(0, 3);
|
||||||
|
Assert.Equal(0, EventLogs.AppendCapped(entries, Page(3, 3), new HashSet<string>(), cap: 10));
|
||||||
|
Assert.Equal(6, entries.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The page tells operators the keyword search window is bounded.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Page_ShowsKeywordWindowHint()
|
||||||
|
{
|
||||||
|
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "u") }, "T");
|
||||||
|
var auth = Substitute.For<AuthenticationStateProvider>();
|
||||||
|
auth.GetAuthenticationStateAsync().Returns(new AuthenticationState(new ClaimsPrincipal(identity)));
|
||||||
|
Services.AddSingleton(auth);
|
||||||
|
Services.AddScoped(_ => new SiteScopeService(auth));
|
||||||
|
var siteRepo = Substitute.For<ISiteRepository>();
|
||||||
|
siteRepo.GetAllSitesAsync().Returns(new List<Site>());
|
||||||
|
Services.AddSingleton(siteRepo);
|
||||||
|
Services.AddSingleton(new CommunicationService(
|
||||||
|
Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance));
|
||||||
|
|
||||||
|
var cut = Render<EventLogs>();
|
||||||
|
|
||||||
|
Assert.Contains("at most the last 7 days", cut.Markup);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user