From db7833609dddc8b54f5b2c71c7bbd935257798cd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 06:25:46 -0400 Subject: [PATCH] fix(dashboard): debounced off-dispatcher Browse search, bounded disposal, circuit-aware polling - Search: the oninput setter scanned the whole Galaxy synchronously per keystroke. It now debounces 150ms, runs on the thread pool with cancellation (newer keystroke supersedes), and reads a shared singleton DashboardBrowseSearchIndex of pre-lowercased references keyed on the Galaxy cache Sequence. - Disposal: DisposeAsync awaited both loops unbounded and the loops caught only OCE. Loops now handle faults per iteration (poll faults surface in _readError, deploy-notifier faults in a retrying warning), mutate the tree on the dispatcher, drain concurrently with a 5s bound, and the CTSs are disposed in finally. - Circuits: DisconnectedCircuitRetentionPeriod is 30s (was the 3 min default) and a scoped DashboardCircuitConnectionTracker (CircuitHandler) lets the 2s ReadBulk poll skip ticks while the browser is disconnected. - Reads pass a per-page viewer id to the live-data per-viewer share. --- .../Components/Pages/BrowsePage.razor | 324 ++++++++++++++---- .../Dashboard/DashboardBrowseSearchIndex.cs | 117 +++++++ .../DashboardCircuitConnectionTracker.cs | 42 +++ .../DashboardServiceCollectionExtensions.cs | 22 +- .../DashboardBrowseSearchIndexTests.cs | 95 +++++ .../DashboardHubsRegistrationTests.cs | 41 +++ 6 files changed, 573 insertions(+), 68 deletions(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseSearchIndex.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardCircuitConnectionTracker.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardBrowseSearchIndexTests.cs diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor index 6ff5265..33abb90 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor @@ -4,6 +4,8 @@ @inject IDashboardLiveDataService LiveData @inject IDashboardBrowseService BrowseService @inject IGalaxyDeployNotifier DeployNotifier +@inject DashboardBrowseSearchIndex SearchIndex +@inject DashboardCircuitConnectionTracker Circuit @using ZB.MOM.WW.GalaxyRepository @using ZB.MOM.WW.GalaxyRepository.Grpc @@ -36,7 +38,15 @@ } else if (!string.IsNullOrWhiteSpace(Search)) { - @if (_searchMatches.Count == 0) + @if (!string.IsNullOrWhiteSpace(_searchError)) + { +
Search failed: @_searchError
+ } + else if (_searchPending && _searchMatches.Count == 0) + { +
Searching…
+ } + else if (_searchMatches.Count == 0) {
No attributes match “@Search”.
} @@ -115,6 +125,13 @@
Live read failed: @_readError
} + @if (!string.IsNullOrWhiteSpace(_deployError)) + { +
+ Galaxy redeploy notifications failed (retrying): @_deployError +
+ } + @if (_subscribed.Count == 0) {
@@ -194,13 +211,35 @@ @code { private const int SearchResultLimit = 300; + // Keystrokes inside this window coalesce into one search: typing a tag name no + // longer rescans the Galaxy once per character. + private static readonly TimeSpan SearchDebounce = TimeSpan.FromMilliseconds(150); + + // Delay before resubscribing to deploy notifications after the stream ends or faults. + private static readonly TimeSpan DeployResubscribeDelay = TimeSpan.FromSeconds(5); + + // Upper bound on waiting for the page's background loops while disposing, the same + // bound DashboardPageBase and AlarmsPage use: the loops marshal renders through the + // renderer's dispatcher and disposal can run on that same dispatcher, so the wait is + // bounded rather than unconditional. + private static readonly TimeSpan LoopDrainTimeout = TimeSpan.FromSeconds(5); + + // Identifies this page to the shared live-data service, which budgets the advise set + // per viewer so another circuit's reads cannot evict this page's whole panel. + private readonly string _viewerId = "browse-" + Guid.NewGuid().ToString("N"); + private List _roots = []; private ulong _cacheSequence; private string? _staleBanner; - private CancellationTokenSource _deployCts = new(); + private readonly CancellationTokenSource _deployCts = new(); private Task? _deployTask; + private string? _deployError; private string _search = string.Empty; private IReadOnlyList _searchMatches = []; + private bool _searchPending; + private string? _searchError; + private CancellationTokenSource? _searchCts; + private Task? _searchTask; private readonly List _subscribed = []; private readonly Dictionary _values = new(StringComparer.Ordinal); // Per-tag bookkeeping for the Updated column: the signature of the value @@ -225,8 +264,8 @@ BrowseLevelResult roots = BrowseService.GetRoots(new BrowseFilterArgs()); _roots = [.. roots.Nodes]; _cacheSequence = roots.CacheSequence; - _pollTask = PollLoopAsync(); - _deployTask = SubscribeToDeployEventsAsync(); + _pollTask = PollLoopAsync(_cts.Token); + _deployTask = SubscribeToDeployEventsAsync(_deployCts.Token); } private async Task LoadChildrenAsync(DashboardBrowseNode node) @@ -249,30 +288,55 @@ await InvokeAsync(StateHasChanged); } - private async Task SubscribeToDeployEventsAsync() + // Faults are handled inside the loop: a notifier stream that faults or ends is + // surfaced on the page and resubscribed after a delay, so the tree does not silently + // stop tracking redeploys. Only cancellation ends the loop, so DrainAsync never + // observes a faulted task. + private async Task SubscribeToDeployEventsAsync(CancellationToken cancellationToken) { - try + while (!cancellationToken.IsCancellationRequested) { - await foreach (GalaxyDeployEventInfo info in DeployNotifier - .SubscribeAsync(_deployCts.Token) - .ConfigureAwait(false)) + try { - // First Latest replay echoes the sequence we already projected - // from — skip those to avoid a spurious "redeployed" banner. - if (info.Sequence == 0 || (ulong)info.Sequence == _cacheSequence) + await foreach (GalaxyDeployEventInfo info in DeployNotifier + .SubscribeAsync(cancellationToken) + .ConfigureAwait(false)) { - continue; - } + // First Latest replay echoes the sequence we already projected + // from — skip those to avoid a spurious "redeployed" banner. + if (info.Sequence == 0 || (ulong)info.Sequence == _cacheSequence) + { + continue; + } - BrowseLevelResult roots = BrowseService.GetRoots(new BrowseFilterArgs()); - _roots = [.. roots.Nodes]; - _cacheSequence = roots.CacheSequence; - _staleBanner = "Galaxy redeployed — tree refreshed."; - await InvokeAsync(StateHasChanged); + await InvokeAsync(() => + { + BrowseLevelResult roots = BrowseService.GetRoots(new BrowseFilterArgs()); + _roots = [.. roots.Nodes]; + _cacheSequence = roots.CacheSequence; + _staleBanner = "Galaxy redeployed — tree refreshed."; + _deployError = null; + StateHasChanged(); + }).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + await ReportAsync(() => _deployError = ex.Message).ConfigureAwait(false); + } + + try + { + await Task.Delay(DeployResubscribeDelay, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; } - } - catch (OperationCanceledException) - { } } @@ -288,42 +352,101 @@ + $"{entry.AlarmAttributeCount:N0} alarm attributes"; } + // Bound to the filter box with @bind:event="oninput", so the setter runs once per + // keystroke on the render dispatcher. It only schedules the search; the scan itself + // runs after the debounce, off the dispatcher, and a newer keystroke cancels it. private string Search { get => _search; set { _search = value ?? string.Empty; - _searchMatches = ComputeSearch(_search); + ScheduleSearch(_search); } } - private IReadOnlyList ComputeSearch(string rawQuery) + private void ScheduleSearch(string query) { - string query = rawQuery.Trim(); - if (query.Length == 0) + CancelQuietly(_searchCts); + _searchCts = null; + _searchError = null; + + if (query.Trim().Length == 0) { - return []; + _searchMatches = []; + _searchPending = false; + return; } - List matches = []; - foreach (GalaxyObject galaxyObject in GalaxyCache.Current.Objects) + // Linked to the page token, so disposal cancels an in-flight search too. The + // search task owns the source and disposes it when it finishes. + CancellationTokenSource searchCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token); + _searchCts = searchCts; + _searchPending = true; + _searchTask = RunSearchAsync(query, searchCts); + } + + private async Task RunSearchAsync(string query, CancellationTokenSource searchCts) + { + CancellationToken cancellationToken = searchCts.Token; + try { - foreach (GalaxyAttribute attr in galaxyObject.Attributes) + await Task.Delay(SearchDebounce, cancellationToken).ConfigureAwait(false); + GalaxyHierarchyCacheEntry entry = GalaxyCache.Current; + IReadOnlyList matches = await Task.Run( + () => SearchIndex.Search(entry, query, SearchResultLimit, cancellationToken), + cancellationToken) + .ConfigureAwait(false); + + await InvokeAsync(() => { - if (attr.FullTagReference.Contains(query, StringComparison.OrdinalIgnoreCase) - || attr.AttributeName.Contains(query, StringComparison.OrdinalIgnoreCase)) + // Only the latest query may publish: a slower, superseded scan that + // finishes late must not overwrite newer results. + if (ReferenceEquals(_searchCts, searchCts) && !cancellationToken.IsCancellationRequested) { - matches.Add(attr); - if (matches.Count >= SearchResultLimit) - { - return matches; - } + _searchMatches = matches; + _searchPending = false; + StateHasChanged(); } - } + }).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Superseded by a newer keystroke, or the page is going away. + } + catch (Exception ex) + { + await ReportAsync(() => + { + if (ReferenceEquals(_searchCts, searchCts)) + { + _searchPending = false; + _searchError = ex.Message; + } + }).ConfigureAwait(false); + } + finally + { + searchCts.Dispose(); + } + } + + // The search task disposes its own source, so a cancel can race that disposal. + private static void CancelQuietly(CancellationTokenSource? source) + { + if (source is null) + { + return; } - return matches; + try + { + source.Cancel(); + } + catch (ObjectDisposedException) + { + // The search already finished; there is nothing left to cancel. + } } private static string FormatType(GalaxyAttribute attr) @@ -422,17 +545,66 @@ : "When the dashboard first observed this value — MXAccess did not supply a source timestamp for this tag."; } - private async Task PollLoopAsync() + // Fault handling sits inside the loop, matching AlarmsPage: a fault on one tick is + // surfaced in the "Live read failed" banner and retried on the next tick rather than + // ending polling for the life of the page. Cancellation is the only exit, so the loop + // task never faults — which is what DrainAsync in DisposeAsync relies on. + // + // A tick is skipped while the circuit's browser is disconnected: a closed tab keeps + // its circuit for the retention period, and polling ReadBulk on the shared dashboard + // worker for a page nobody can see is pure cost. The first tick after a reconnect + // refreshes as normal. + private async Task PollLoopAsync(CancellationToken cancellationToken) { - try + using PeriodicTimer timer = new(TimeSpan.FromSeconds(2)); + while (true) { - using PeriodicTimer timer = new(TimeSpan.FromSeconds(2)); - while (await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false)) + try + { + if (!await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + { + return; + } + } + catch (OperationCanceledException) + { + return; + } + + if (!Circuit.IsConnected) + { + continue; + } + + try { await InvokeAsync(RefreshValuesAsync).ConfigureAwait(false); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + await ReportAsync(() => _readError = ex.Message).ConfigureAwait(false); + } } - catch (OperationCanceledException) + } + + // Best-effort: the fault being reported may itself be an InvokeAsync against a + // disposed renderer, in which case the dispatch fails the same way and there is no + // page left to show it on. + private async Task ReportAsync(Action update) + { + try + { + await InvokeAsync(() => + { + update(); + StateHasChanged(); + }).ConfigureAwait(false); + } + catch { } } @@ -445,7 +617,7 @@ } string[] tags = [.. _subscribed]; - DashboardLiveReadResult result = await LiveData.ReadAsync(tags, _cts.Token); + DashboardLiveReadResult result = await LiveData.ReadAsync(tags, _viewerId, _cts.Token); _readError = result.Error; _workerPid = result.WorkerProcessId; DateTimeOffset now = DateTimeOffset.UtcNow; @@ -454,7 +626,7 @@ // Stamp the observed-change time only when the value/quality // signature actually changes, so the Updated column does not // tick on every poll for a static tag. - string signature = $"{value.ValueText}{value.Quality}{value.Ok}"; + string signature = $"{value.ValueText}{value.Quality}{value.Ok}"; if (!_valueSignature.TryGetValue(value.TagAddress, out string? previous) || previous != signature) { @@ -471,31 +643,49 @@ /// public async ValueTask DisposeAsync() { - await _cts.CancelAsync(); - await _deployCts.CancelAsync(); - if (_pollTask is not null) + try { - try - { - await _pollTask; - } - catch (OperationCanceledException) - { - } + await _cts.CancelAsync().ConfigureAwait(false); + await _deployCts.CancelAsync().ConfigureAwait(false); + + // Drained together, not in sequence: a wedged dispatcher blocks every loop at + // once, so sequential drains would multiply the bound. DrainAsync tolerates null. + await Task.WhenAll(DrainAsync(_pollTask), DrainAsync(_deployTask), DrainAsync(_searchTask)) + .ConfigureAwait(false); } - if (_deployTask is not null) + finally { - try - { - await _deployTask; - } - catch (OperationCanceledException) - { - } + // Always released, even if cancellation or a drain throws. A search still in + // flight disposes its own linked source. + _cts.Dispose(); + _deployCts.Dispose(); + GC.SuppressFinalize(this); + } + } + + // The accepted cost of the bound is an abandoned loop that keeps its timer or + // notifier subscription until it does unwind; waiting forever on a dispatcher that + // is not draining would wedge the circuit teardown itself. + private static async Task DrainAsync(Task? loop) + { + if (loop is null) + { + return; } - _cts.Dispose(); - _deployCts.Dispose(); - GC.SuppressFinalize(this); + try + { + await loop.WaitAsync(LoopDrainTimeout).ConfigureAwait(false); + } + catch (TimeoutException) + { + } + catch (OperationCanceledException) + { + } + catch + { + // Other disposal-time errors are best-effort. + } } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseSearchIndex.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseSearchIndex.cs new file mode 100644 index 0000000..55bba77 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardBrowseSearchIndex.cs @@ -0,0 +1,117 @@ +using ZB.MOM.WW.GalaxyRepository; +using ZB.MOM.WW.GalaxyRepository.Grpc; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +/// +/// Flat, pre-lowercased attribute index behind the Browse page's filter box, shared by +/// every circuit and rebuilt only when the Galaxy cache sequence changes. +/// +/// +/// The filter used to walk every object and attribute of the cached Galaxy, doing two +/// case-insensitive Contains per attribute, synchronously in the property setter +/// bound to oninput — so a large Galaxy was rescanned on the render dispatcher +/// once per keystroke, per circuit. The page now debounces and runs the search off the +/// dispatcher; this index makes each search a single pass of ordinal substring checks +/// over strings lowercased once per Galaxy deploy rather than once per comparison. +/// Lowercasing is invariant-culture, which matches the previous +/// behaviour for the ASCII tag names +/// Galaxy uses. +/// +public sealed class DashboardBrowseSearchIndex +{ + // How often a scan checks its cancellation token. A superseded keystroke stops + // within this many attributes rather than finishing the whole Galaxy. + private const int CancellationCheckInterval = 1024; + + private IndexSnapshot? _snapshot; + private int _buildCount; + + /// Gets how many times the index has been (re)built (diagnostic, for tests). + internal int BuildCount => Volatile.Read(ref _buildCount); + + /// + /// Returns up to attributes whose full tag reference or + /// attribute name contains , case-insensitively, in + /// Galaxy object order. + /// + /// The current Galaxy cache entry; its sequence keys the index. + /// The filter text as typed; it is trimmed. + /// Maximum number of matches. + /// Cancelled when a newer query supersedes this one. + /// The matches; empty for a blank query. + /// was cancelled. + public IReadOnlyList Search( + GalaxyHierarchyCacheEntry entry, + string rawQuery, + int limit, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(entry); + + string query = (rawQuery ?? string.Empty).Trim().ToLowerInvariant(); + if (query.Length == 0 || limit <= 0) + { + return []; + } + + IndexedAttribute[] attributes = GetOrBuild(entry, cancellationToken); + List matches = []; + for (int i = 0; i < attributes.Length; i++) + { + if (i % CancellationCheckInterval == 0) + { + cancellationToken.ThrowIfCancellationRequested(); + } + + IndexedAttribute candidate = attributes[i]; + if (candidate.FullTagReference.Contains(query, StringComparison.Ordinal) + || candidate.AttributeName.Contains(query, StringComparison.Ordinal)) + { + matches.Add(candidate.Attribute); + if (matches.Count >= limit) + { + break; + } + } + } + + return matches; + } + + private IndexedAttribute[] GetOrBuild(GalaxyHierarchyCacheEntry entry, CancellationToken cancellationToken) + { + IndexSnapshot? current = Volatile.Read(ref _snapshot); + if (current is not null && current.Sequence == entry.Sequence) + { + return current.Attributes; + } + + // A racing build for the same sequence is harmless: the projection is pure, so + // either winner stores equivalent content. + List attributes = []; + foreach (GalaxyObject galaxyObject in entry.Objects) + { + cancellationToken.ThrowIfCancellationRequested(); + foreach (GalaxyAttribute attribute in galaxyObject.Attributes) + { + attributes.Add(new IndexedAttribute( + attribute.FullTagReference.ToLowerInvariant(), + attribute.AttributeName.ToLowerInvariant(), + attribute)); + } + } + + IndexSnapshot built = new(entry.Sequence, [.. attributes]); + Volatile.Write(ref _snapshot, built); + Interlocked.Increment(ref _buildCount); + return built.Attributes; + } + + private sealed record IndexSnapshot(long Sequence, IndexedAttribute[] Attributes); + + private readonly record struct IndexedAttribute( + string FullTagReference, + string AttributeName, + GalaxyAttribute Attribute); +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardCircuitConnectionTracker.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardCircuitConnectionTracker.cs new file mode 100644 index 0000000..1b1ca9a --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardCircuitConnectionTracker.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Components.Server.Circuits; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +/// +/// Per-circuit view of whether the browser is still attached. Registered scoped, so a +/// page injecting it sees its own circuit, and also registered as that circuit's +/// so Blazor drives it. +/// +/// +/// A closed or backgrounded browser tab leaves its circuit alive, disconnected, for +/// +/// before Blazor disposes it, and every timer on the page keeps running in that +/// window. The Browse page's two-second ReadBulk poll is the costly one — it +/// drives the shared dashboard worker — so it skips its tick while +/// is false and resumes on reconnect. +/// +public sealed class DashboardCircuitConnectionTracker : CircuitHandler +{ + private volatile bool _connected = true; + + /// + /// Gets a value indicating whether the circuit's browser connection is up. True + /// until Blazor reports the connection down, and also outside a circuit (prerender), + /// where no handler callbacks ever arrive. + /// + public bool IsConnected => _connected; + + /// + public override Task OnConnectionUpAsync(Circuit circuit, CancellationToken cancellationToken) + { + _connected = true; + return Task.CompletedTask; + } + + /// + public override Task OnConnectionDownAsync(Circuit circuit, CancellationToken cancellationToken) + { + _connected = false; + return Task.CompletedTask; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs index e8632aa..3390a83 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components.Server.Circuits; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -16,6 +17,16 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard; /// public static class DashboardServiceCollectionExtensions { + /// + /// How long a disconnected dashboard circuit is kept for reconnect before Blazor + /// disposes it. The framework default is three minutes, during which a closed Browse + /// tab's page — and its two-second live-read poll against the shared dashboard + /// worker — keeps running. Thirty seconds still rides out a network blip or a + /// laptop lid; a longer outage reloads the page, which costs nothing here because + /// every page re-seeds from in-process state. + /// + public static readonly TimeSpan DisconnectedCircuitRetention = TimeSpan.FromSeconds(30); + /// /// Registers all dashboard services, authentication, and Razor components. /// @@ -54,6 +65,14 @@ public static class DashboardServiceCollectionExtensions // nothing in this process builds a hub connection or needs a token for one. services.AddSingleton(); services.AddScoped(); + // Singleton: the lowercased attribute index is keyed on the Galaxy cache sequence + // and shared by every circuit's Browse filter, so it is built once per deploy. + services.AddSingleton(); + // Scoped (one per circuit) and registered as that circuit's CircuitHandler, so the + // Browse page can pause its live-read poll while its browser is disconnected. + services.AddScoped(); + services.AddScoped( + static provider => provider.GetRequiredService()); // Singleton: EventsHub instances are transient (one per hub invocation), so the // subscriber bookkeeping they share with the broadcaster must outlive them. services.AddSingleton(); @@ -76,7 +95,8 @@ public static class DashboardServiceCollectionExtensions services.AddAntiforgery(); services.AddCascadingAuthenticationState(); services.AddRazorComponents() - .AddInteractiveServerComponents(); + .AddInteractiveServerComponents(circuit => + circuit.DisconnectedCircuitRetentionPeriod = DisconnectedCircuitRetention); services.AddSignalR(); // DEV/TEST ONLY. Read directly from configuration here because authentication scheme diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardBrowseSearchIndexTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardBrowseSearchIndexTests.cs new file mode 100644 index 0000000..98cbb6e --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardBrowseSearchIndexTests.cs @@ -0,0 +1,95 @@ +using ZB.MOM.WW.GalaxyRepository; +using ZB.MOM.WW.GalaxyRepository.Grpc; +using ZB.MOM.WW.MxGateway.Server.Dashboard; + +namespace ZB.MOM.WW.MxGateway.Tests.Dashboard; + +/// +/// Verifies the Browse filter's shared index: case-insensitive matching on reference +/// and attribute name, the result limit, one build per Galaxy cache sequence, and +/// cancellation of a superseded scan. +/// +public sealed class DashboardBrowseSearchIndexTests +{ + /// Matches either the full reference or the attribute name, ignoring case. + [Fact] + public void Search_MatchesReferenceOrAttributeNameCaseInsensitively() + { + DashboardBrowseSearchIndex index = new(); + GalaxyHierarchyCacheEntry entry = CreateEntry(sequence: 1); + + IReadOnlyList byReference = index.Search(entry, " PUMP_001.p ", 10, CancellationToken.None); + IReadOnlyList byName = index.Search(entry, "levels", 10, CancellationToken.None); + + Assert.Equal(["Pump_001.PV"], byReference.Select(attr => attr.FullTagReference)); + Assert.Equal(["Tank_001.Levels[]"], byName.Select(attr => attr.FullTagReference)); + Assert.Empty(index.Search(entry, " ", 10, CancellationToken.None)); + } + + /// Stops at the limit, in Galaxy object order. + [Fact] + public void Search_StopsAtTheLimit() + { + DashboardBrowseSearchIndex index = new(); + + IReadOnlyList matches = index.Search(CreateEntry(sequence: 1), "_001", 2, CancellationToken.None); + + Assert.Equal(["Pump_001.PV", "Pump_001.Alarm"], matches.Select(attr => attr.FullTagReference)); + } + + /// The index is built once per cache sequence and rebuilt when the sequence moves. + [Fact] + public void Search_RebuildsOnlyWhenTheCacheSequenceChanges() + { + DashboardBrowseSearchIndex index = new(); + GalaxyHierarchyCacheEntry first = CreateEntry(sequence: 1); + + index.Search(first, "pump", 10, CancellationToken.None); + index.Search(first, "tank", 10, CancellationToken.None); + Assert.Equal(1, index.BuildCount); + + GalaxyHierarchyCacheEntry redeployed = CreateEntry(sequence: 2, extraTag: "Valve_001.Open"); + IReadOnlyList matches = index.Search(redeployed, "valve", 10, CancellationToken.None); + + Assert.Equal(2, index.BuildCount); + Assert.Equal(["Valve_001.Open"], matches.Select(attr => attr.FullTagReference)); + } + + /// A cancelled (superseded) search throws instead of finishing the scan. + [Fact] + public void Search_WhenCancelled_Throws() + { + DashboardBrowseSearchIndex index = new(); + using CancellationTokenSource cancelled = new(); + cancelled.Cancel(); + + Assert.ThrowsAny( + () => index.Search(CreateEntry(sequence: 1), "pump", 10, cancelled.Token)); + } + + private static GalaxyHierarchyCacheEntry CreateEntry(long sequence, string? extraTag = null) + { + GalaxyObject pump = new() { GobjectId = 1, TagName = "Pump_001" }; + pump.Attributes.Add(new GalaxyAttribute { AttributeName = "PV", FullTagReference = "Pump_001.PV" }); + pump.Attributes.Add(new GalaxyAttribute { AttributeName = "Alarm", FullTagReference = "Pump_001.Alarm" }); + + GalaxyObject tank = new() { GobjectId = 2, TagName = "Tank_001" }; + tank.Attributes.Add(new GalaxyAttribute { AttributeName = "Levels", FullTagReference = "Tank_001.Levels[]" }); + + List objects = [pump, tank]; + if (extraTag is not null) + { + GalaxyObject extra = new() { GobjectId = 3, TagName = extraTag.Split('.')[0] }; + extra.Attributes.Add(new GalaxyAttribute { AttributeName = extraTag.Split('.')[1], FullTagReference = extraTag }); + objects.Add(extra); + } + + return GalaxyHierarchyCacheEntry.Empty with + { + Status = GalaxyCacheStatus.Healthy, + Sequence = sequence, + Objects = objects, + Index = GalaxyHierarchyIndex.Build(objects), + }; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardHubsRegistrationTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardHubsRegistrationTests.cs index a2f9cb2..f7e04ab 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardHubsRegistrationTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardHubsRegistrationTests.cs @@ -1,6 +1,9 @@ using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Components.Server; +using Microsoft.AspNetCore.Components.Server.Circuits; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using ZB.MOM.WW.MxGateway.Server; using ZB.MOM.WW.MxGateway.Server.Dashboard; using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; @@ -61,4 +64,42 @@ public sealed class DashboardHubsRegistrationTests Assert.Same(broadcaster, subscriber); Assert.Same(app.Services.GetRequiredService(), broadcaster); } + + /// + /// A closed tab's circuit (and its Browse poll) is retained for 30 seconds, not the + /// framework's three minutes, and each circuit gets a connection tracker that is also + /// its , so the Browse poll can pause while disconnected. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Build_WhenDashboardEnabled_ShortensCircuitRetentionAndTracksConnection() + { + await using WebApplication app = GatewayApplication.Build([]); + + CircuitOptions circuitOptions = app.Services.GetRequiredService>().Value; + Assert.Equal(TimeSpan.FromSeconds(30), circuitOptions.DisconnectedCircuitRetentionPeriod); + Assert.Equal(DashboardServiceCollectionExtensions.DisconnectedCircuitRetention, circuitOptions.DisconnectedCircuitRetentionPeriod); + + using IServiceScope scope = app.Services.CreateScope(); + DashboardCircuitConnectionTracker tracker = scope.ServiceProvider.GetRequiredService(); + Assert.Contains(scope.ServiceProvider.GetServices(), handler => ReferenceEquals(handler, tracker)); + + Assert.True(tracker.IsConnected); + await tracker.OnConnectionDownAsync(null!, CancellationToken.None); + Assert.False(tracker.IsConnected); + await tracker.OnConnectionUpAsync(null!, CancellationToken.None); + Assert.True(tracker.IsConnected); + } + + /// The Browse filter index is one shared instance, built once per Galaxy deploy. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Build_WhenDashboardEnabled_SharesOneBrowseSearchIndex() + { + await using WebApplication app = GatewayApplication.Build([]); + + Assert.Same( + app.Services.GetRequiredService(), + app.Services.GetRequiredService()); + } }