diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/EventLogs.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/EventLogs.razor index fc972d57..e428898d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/EventLogs.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/EventLogs.razor @@ -24,6 +24,9 @@
+
+ Keyword search covers at most the last 7 days unless a narrower From/To is set. +
@@ -67,6 +70,7 @@ type="text" class="form-control form-control-sm" aria-label="Message contains" + aria-describedby="filter-keyword-hint" @bind="_filterKeyword" />
@@ -152,7 +156,13 @@
- Showing @_entries.Count entries + + Showing @_entries.Count entries + @if (_droppedCount > 0) + { + (oldest @_droppedCount dropped — display is capped at @MaxRetainedRows rows) + } +
@if (_hasMore) { @@ -194,6 +204,26 @@ // moved every expansion onto the wrong row. The id is stable by construction. private readonly HashSet _expandedRows = new(); + /// Ceiling on rows retained across "Load more" pages (review 2026-10-21 07/M-3). + internal const int MaxRetainedRows = 1000; + + private int _droppedCount; + + /// + /// Appends a page to the retained list, dropping the oldest (head) rows beyond + /// and un-expanding any dropped row. Returns the number dropped. + /// + internal static int AppendCapped( + List entries, IEnumerable page, HashSet 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 { get @@ -225,6 +255,7 @@ private async Task Search() { _entries = new(); + _droppedCount = 0; _continuationToken = null; _expandedRows.Clear(); await FetchPage(); @@ -276,7 +307,7 @@ if (response.Success) { _entries ??= new(); - _entries.AddRange(response.Entries); + _droppedCount += AppendCapped(_entries, response.Entries, _expandedRows, MaxRetainedRows); _hasMore = response.HasMore; _continuationToken = response.ContinuationToken; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/EventLogsPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/EventLogsPageTests.cs new file mode 100644 index 00000000..2abd7179 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/EventLogsPageTests.cs @@ -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; + +/// Review 2026-10-21 07/M-3 (retained-row cap) and the lane D keyword-window hint. +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 Page(int from, int count) => + Enumerable.Range(from, count).Select(Entry).ToList(); + + /// Rows beyond the cap are dropped from the head and their expansion state cleared. + [Fact] + public void AppendCapped_DropsOldestRowsBeyondCap_AndUnexpandsThem() + { + var entries = Page(0, 8); + var expanded = new HashSet { "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); + } + + /// Under the cap nothing is dropped. + [Fact] + public void AppendCapped_UnderCap_KeepsEverything() + { + var entries = Page(0, 3); + Assert.Equal(0, EventLogs.AppendCapped(entries, Page(3, 3), new HashSet(), cap: 10)); + Assert.Equal(6, entries.Count); + } + + /// The page tells operators the keyword search window is bounded. + [Fact] + public void Page_ShowsKeywordWindowHint() + { + var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "u") }, "T"); + var auth = Substitute.For(); + auth.GetAuthenticationStateAsync().Returns(new AuthenticationState(new ClaimsPrincipal(identity))); + Services.AddSingleton(auth); + Services.AddScoped(_ => new SiteScopeService(auth)); + var siteRepo = Substitute.For(); + siteRepo.GetAllSitesAsync().Returns(new List()); + Services.AddSingleton(siteRepo); + Services.AddSingleton(new CommunicationService( + Options.Create(new CommunicationOptions()), NullLogger.Instance)); + + var cut = Render(); + + Assert.Contains("at most the last 7 days", cut.Markup); + } +}