diff --git a/Directory.Packages.props b/Directory.Packages.props index f3926d7da..6e5b9fb9a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ + + diff --git a/docs/Performance.md b/docs/Performance.md index 69eac399a..976e32bb0 100644 --- a/docs/Performance.md +++ b/docs/Performance.md @@ -60,12 +60,11 @@ newest rows stay as a bounded array-of-structs tail that seals into a new chunk target size, giving amortized O(1) append. Repeated strings fold into an interned pool and `EventData` field-name sequences into a shared schema table, and `Append` returns a new snapshot that reference-shares the prior chunks, pool, and schema table, so a published snapshot is safe to -read while the next ingest builds. Each log exposes an `EventColumnView` that sorts its rows into a -display order and reads fields straight through the store's reader with no event objects rehydrated; -`CombinedColumnView` merges several such views with a K-way, column-direct cursor walk that compares -head-to-head off each row's own reader, plus a periodic checkpoint index (stride 64) so a positional -read seeks in log(rows) rather than walking from the start, with per-read offset scratch -stack-allocated up to a capped K. +read while the next ingest builds. The ordered-view engine maintains the display order +incrementally in an `OrderedViewSnapshot` and reads fields straight through the store's reader with no +event objects rehydrated; `OrderedColumnView` presents a single log and `CombinedOrderedColumnView` +presents several, addressing each row to its owning reader over the pre-merged global order rather than +re-running a K-way merge per read. ### P6 - Viewport virtualization and render-buffer reuse @@ -109,7 +108,7 @@ filtering. | Read | Reverse read, newest bookmark, tuned `EvtNext` batch | `EventLogReader` | | Marshalling | Non-boxing packed `EventProperty` | `EventProperty`, `NativeMethods.Evt` | | Resolve | `ProcessorCount - 1` priority-gated parallelism | `OpenLogEffects` | -| Store | Chunked columnar snapshot + K-way combined view | `EventColumnStore`, `EventColumnView`, `CombinedColumnView` | +| Store | Chunked columnar snapshot + incrementally-ordered view engine | `EventColumnStore`, `OrderedColumnView`, `CombinedOrderedColumnView` | | Viewport | `Virtualize` + one-slice-per-window provider | `LogTablePane` | | Render | Per-thread grow-only render buffer, skip-probe | `NativeMethods.Evt` | | Filter memory | Retained structured `EventData` / `UserData` fields | `ResolvedEvent`, `UserDataValueExtractor` | diff --git a/src/EventLogExpert.DatabaseTools/Common/Operations/OperationBase.cs b/src/EventLogExpert.DatabaseTools/Common/Operations/OperationBase.cs index c9c814e26..509f1dfb3 100644 --- a/src/EventLogExpert.DatabaseTools/Common/Operations/OperationBase.cs +++ b/src/EventLogExpert.DatabaseTools/Common/Operations/OperationBase.cs @@ -15,13 +15,13 @@ namespace EventLogExpert.DatabaseTools.Common.Operations; internal abstract class OperationBase { + protected static readonly TimeSpan DefaultFilterRegexTimeout = TimeSpan.FromSeconds(5); private static readonly string[] s_databaseFileSuffixes = ["", "-wal", "-shm"]; private string _providerDetailFormat = "{0, -14} {1, 8} {2, 8} {3, 8} {4, 8} {5, 8} {6, 8}"; public string? FailureSummary { get; private set; } - // Clearing SQLite pools releases Windows file handles before deleting the partial database. protected static async Task CleanupPartialDatabaseAsync( ITraceLogger logger, ProviderDbContext? dbContext, @@ -54,7 +54,8 @@ protected static async Task CleanupPartialDatabaseAsync( } } - // Recompile infinite-timeout regexes so hostile patterns cannot hang the operation. + protected static Regex? EnsureBoundedTimeout(Regex? regex) => EnsureBoundedTimeout(regex, DefaultFilterRegexTimeout); + protected static Regex? EnsureBoundedTimeout(Regex? regex, TimeSpan defaultTimeout) { if (regex is null) { return null; } @@ -77,7 +78,6 @@ protected static async IAsyncEnumerable LoadLocalProvidersAsync IReadOnlySet? excludeProviderNames = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - // Async iterator bridge: synchronous provider reads share an IAsyncEnumerable consumer path. await Task.CompletedTask; foreach (var providerName in GetLocalProviderNames(regex)) diff --git a/src/EventLogExpert.DatabaseTools/CreateDatabase/CreateDatabaseOperation.cs b/src/EventLogExpert.DatabaseTools/CreateDatabase/CreateDatabaseOperation.cs index 97246e27d..90d636aa3 100644 --- a/src/EventLogExpert.DatabaseTools/CreateDatabase/CreateDatabaseOperation.cs +++ b/src/EventLogExpert.DatabaseTools/CreateDatabase/CreateDatabaseOperation.cs @@ -7,7 +7,6 @@ using EventLogExpert.Eventing.OfflineImaging.VirtualDisk; using EventLogExpert.Eventing.OfflineImaging.Wim; using EventLogExpert.Eventing.OfflineImaging.Workspace; -using EventLogExpert.Eventing.ProviderMetadata; using EventLogExpert.Logging.Abstractions; using EventLogExpert.Provider.Database.Context; using EventLogExpert.Provider.Database.Hashing; @@ -21,12 +20,9 @@ internal sealed class CreateDatabaseOperation(CreateDatabaseRequest request) : O { private const int BatchSize = 100; - // SQLite at rest may include main, WAL, and SHM files; overwrite backup/restore must move all three together. private static readonly string[] s_databaseFileSuffixes = ["", "-wal", "-shm"]; - // Set only after every sidecar backup moves; restore must not delete unmoved originals after a torn backup. private bool _overwriteBackupCompleted; - private bool _overwriteBackupTaken; internal enum CreateDatabaseMode { Local, FileSource, OfflineImage } @@ -43,7 +39,6 @@ public async Task ExecuteAsync( return DatabaseToolsOutcome.Failed; } - // Any stale .bak sidecar may be the sole surviving copy after an interrupted overwrite and must block retry. foreach (var suffix in s_databaseFileSuffixes) { var backupPath = request.TargetPath + suffix + ".bak"; @@ -69,7 +64,6 @@ public async Task ExecuteAsync( return DatabaseToolsOutcome.Failed; } - // Fail fast on destination ACL/CFA denial before expensive scan or extraction work begins. string targetDirectory = Path.GetDirectoryName(Path.GetFullPath(request.TargetPath)) ?? request.TargetPath; string? targetBlocked = OfflineScratch.ProbeWritable(targetDirectory); @@ -98,7 +92,7 @@ public async Task ExecuteAsync( logger.Information($"{FormatSkippedProvidersMessage(excludeProviderNames.Count, request.SkipProvidersInFile)}"); } - var filterRegex = EnsureBoundedTimeout(request.FilterRegex, TimeSpan.FromSeconds(5)); + var filterRegex = EnsureBoundedTimeout(request.FilterRegex); var outcome = await CreateCoreAsync(); @@ -120,7 +114,6 @@ async Task CreateCoreAsync() var firstByIdentity = new Dictionary(); #endif - // Create DbContext only after the first provider so failed scans leave no empty database. ProviderDbContext? dbContext = null; OfflineWimImage? wimImage = null; OfflineIsoImage? isoImage = null; @@ -133,7 +126,6 @@ async Task CreateCoreAsync() string? effectiveOfflineImagePath = request.OfflineImagePath; OfflineImageKind? kind = mode == CreateDatabaseMode.OfflineImage ? ResolveImageKind(request) : null; - // WIM apply ignores cooperative cancellation on denied writes, so probe scratch ACLs before native extraction. if (kind is OfflineImageKind.Wim or OfflineImageKind.Iso) { string? scratchBlocked = OfflineScratch.ProbeWritable(OfflineScratch.Root); @@ -191,7 +183,6 @@ async Task CreateCoreAsync() effectiveOfflineImagePath = wimImage!.ExtractedRoot; } - // Offline providers already carry image provenance; only local builds read host provenance. IAsyncEnumerable providersToAdd; SourceOsProvenance? sourceOsProvenance; @@ -335,13 +326,11 @@ async Task CreateCoreAsync() { if (dbContext is not null) { await dbContext.DisposeAsync(); } - // Dispose extracted WIM after SaveChanges because persisted rows may still read from it. wimImage?.Dispose(); isoImage?.Dispose(); vhdxImage?.Dispose(); } - // Never delete target files after a torn overwrite backup; they may be unmoved originals. async Task CleanupPartialUnlessUnmovedOriginalAsync() { if (_overwriteBackupTaken && !_overwriteBackupCompleted) { return; } @@ -350,12 +339,10 @@ async Task CleanupPartialUnlessUnmovedOriginalAsync() } } - // Back up the old database before the writable context opens or creates target files. ProviderDbContext GetOrCreateContext() { if (request.Overwrite && !_overwriteBackupTaken && File.Exists(request.TargetPath)) { - // Set before moving so a torn backup still enters restore. _overwriteBackupTaken = true; TakeOverwriteBackup(); _overwriteBackupCompleted = true; @@ -383,10 +370,8 @@ internal static CreateDatabaseMode SelectMode(CreateDatabaseRequest request) => internal static bool ValidateOfflineImageRequest(CreateDatabaseRequest request, ITraceLogger logger) { - // Kind-ambiguous validation errors are attributed to the Offline root; each resolved kind uses its fine category. ITraceLogger offlineLogger = logger.ForCategory(LogCategories.Offline); - // Reject orphan WIM options so the command cannot silently fall back to local providers. if (string.IsNullOrWhiteSpace(request.OfflineImagePath)) { if (request.ImageKind is not null) @@ -487,7 +472,6 @@ internal static bool ValidateOfflineImageRequest(CreateDatabaseRequest request, return false; } - // Validator cannot mount ISO just to list choices; extraction reports bad indices after mount. if (request.WimIndex is null) { isoLogger.Error( @@ -693,7 +677,6 @@ private async Task FlushHeaderAndBufferAsync( buffer.Clear(); } - // Clear pools and remove aborted-build sidecars before restoring backups. private void RestoreOverwriteBackups(ITraceLogger logger) { var mainBackup = request.TargetPath + ".bak"; @@ -702,14 +685,12 @@ private void RestoreOverwriteBackups(ITraceLogger logger) { SqliteConnection.ClearAllPools(); - // Only delete files after a complete backup; otherwise they may be unmoved originals. if (_overwriteBackupCompleted) { foreach (var suffix in s_databaseFileSuffixes) { var newFile = request.TargetPath + suffix; - // Never delete the main file unless its snapshot exists to take its place. if (suffix.Length == 0 && !File.Exists(mainBackup)) { continue; } if (File.Exists(newFile)) { File.Delete(newFile); } @@ -731,7 +712,6 @@ private void RestoreOverwriteBackups(ITraceLogger logger) } } - // Stale-backup preflight guarantees every .bak move targets a free path. private void TakeOverwriteBackup() { foreach (var suffix in s_databaseFileSuffixes) @@ -815,7 +795,6 @@ private static bool ModelsEquivalent( Func areEquivalent) where TIdentity : notnull { - // Compare distinct identities both ways because the hash drops exact duplicate rows. var firstByIdentity = new Dictionary(first.Count); foreach (TModel model in first) { firstByIdentity[identityOf(model)] = model; } diff --git a/src/EventLogExpert.DatabaseTools/ShowProviders/ShowProvidersOperation.cs b/src/EventLogExpert.DatabaseTools/ShowProviders/ShowProvidersOperation.cs index 1a7517257..5ade4c5fb 100644 --- a/src/EventLogExpert.DatabaseTools/ShowProviders/ShowProvidersOperation.cs +++ b/src/EventLogExpert.DatabaseTools/ShowProviders/ShowProvidersOperation.cs @@ -8,10 +8,6 @@ namespace EventLogExpert.DatabaseTools.ShowProviders; -/// -/// Lists provider details from either local providers ( = null) or -/// a specified source (.db / .evtx / folder). Streams output as each provider is resolved. -/// internal sealed class ShowProvidersOperation(ShowProvidersRequest request) : OperationBase, IDatabaseToolsOperation { private const int HeaderBatchSize = 100; @@ -21,8 +17,7 @@ public async Task ExecuteAsync( IProgress? progress, CancellationToken cancellationToken) { - // Defensive recompile if input has Regex.InfiniteMatchTimeout (otherwise catch below is dead). - var filterRegex = EnsureBoundedTimeout(request.FilterRegex, TimeSpan.FromSeconds(5)); + var filterRegex = EnsureBoundedTimeout(request.FilterRegex); // Buffer first batch to size the column widths (mirrors CreateDatabaseOperation). Lives outside // the try so the cancellation arm can flush partial output the user has been waiting on. diff --git a/src/EventLogExpert.ElevationHelper/ProgramEntry.cs b/src/EventLogExpert.ElevationHelper/ProgramEntry.cs index b20af83a9..577fcce89 100644 --- a/src/EventLogExpert.ElevationHelper/ProgramEntry.cs +++ b/src/EventLogExpert.ElevationHelper/ProgramEntry.cs @@ -16,10 +16,12 @@ namespace EventLogExpert.ElevationHelper; internal static class ProgramEntry { private const int CancelWatchdogExitCode = 12; - // High-IL helper must kill itself if native code ignores cooperative cancellation; the medium-IL host cannot. - private static readonly TimeSpan s_selfTerminateWatchdog = TimeSpan.FromSeconds(8); + private const int PipeConnectTimeoutMilliseconds = 10_000; - // Startup orphan cleanup must not block the control reader from receiving cancellation. + private static readonly TimeSpan s_controlReaderDrainTimeout = TimeSpan.FromSeconds(1); + private static readonly TimeSpan s_requestReadTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan s_selfTerminateWatchdog = TimeSpan.FromSeconds(8); + private static readonly TimeSpan s_selfTerminateWriteTimeout = TimeSpan.FromSeconds(2); private static readonly TimeSpan s_startupReconcileTimeout = TimeSpan.FromSeconds(30); public static async Task MainAsync(string[] args) @@ -55,7 +57,7 @@ private static async Task RunOperationModeAsync(IpcMessageReader reader, Ip try { - using var requestCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var requestCts = new CancellationTokenSource(s_requestReadTimeout); request = await reader.ReadRequestAsync(requestCts.Token); } catch (OperationCanceledException) @@ -80,7 +82,6 @@ private static async Task RunOperationModeAsync(IpcMessageReader reader, Ip using var operationCts = new CancellationTokenSource(); - // Armed on CancelMessage and cancelled on completion so only an ignored cooperative cancel self-terminates. using var watchdogCts = new CancellationTokenSource(); var controlReaderTask = Task.Run(async () => @@ -104,7 +105,6 @@ private static async Task RunOperationModeAsync(IpcMessageReader reader, Ip { operationCts.Cancel(); - // If the operation does not unwind, self-terminate so the host observes pipe EOF promptly. try { await Task.Delay(s_selfTerminateWatchdog, watchdogCts.Token); } catch (OperationCanceledException) { return; } @@ -121,7 +121,6 @@ private static async Task RunOperationModeAsync(IpcMessageReader reader, Ip } }); - // Run orphan cleanup off the main flow so a wedged delete cannot block cancellation handling. var reconcileTask = Task.Run(() => { try { OfflineMaintenance.ReconcileOrphans(logger: null); } @@ -141,7 +140,7 @@ private static async Task RunOperationModeAsync(IpcMessageReader reader, Ip { watchdogCts.Cancel(); operationCts.Cancel(); - try { await controlReaderTask.WaitAsync(TimeSpan.FromSeconds(1)); } catch { /* Best-effort control-reader drain. */ } + try { await controlReaderTask.WaitAsync(s_controlReaderDrainTimeout); } catch { /* Best-effort control-reader drain. */ } await TryWriteTerminalAsync(writer, new FatalMessage(ex.GetType().FullName ?? ex.GetType().Name, ex.Message, ex.StackTrace ?? string.Empty)); @@ -152,7 +151,7 @@ await TryWriteTerminalAsync(writer, watchdogCts.Cancel(); operationCts.Cancel(); - try { await controlReaderTask.WaitAsync(TimeSpan.FromSeconds(1)); } catch { /* Best-effort control-reader drain. */ } + try { await controlReaderTask.WaitAsync(s_controlReaderDrainTimeout); } catch { /* Best-effort control-reader drain. */ } await TryWriteTerminalAsync(writer, new ResultMessage(result.Outcome, result.FailureSummary, (long)result.Duration.TotalMilliseconds)); @@ -166,12 +165,11 @@ private static async Task RunPipeModeAsync(string pipeName, bool probe) ".", pipeName, PipeDirection.InOut, - // Omit client-side CurrentUserOnly: elevated and medium-IL same-user tokens have different SIDs; server DACL plus PID verification authenticates. PipeOptions.Asynchronous); try { - await pipe.ConnectAsync(timeout: 10_000); + await pipe.ConnectAsync(timeout: PipeConnectTimeoutMilliseconds); } catch (TimeoutException) { @@ -208,7 +206,6 @@ await writer.WriteAsync( } } - // Last-resort self-termination leaves orphaned mounts for next elevated-launch reconciliation. private static async Task SelfTerminateAfterUnresponsiveCancelAsync(IpcMessageWriter writer, long elapsedMs) { try @@ -216,7 +213,7 @@ private static async Task SelfTerminateAfterUnresponsiveCancelAsync(IpcMessageWr await TryWriteTerminalAsync(writer, new ResultMessage( DatabaseToolsOutcome.Cancelled, "Cancelled; the elevated helper self-terminated after a native operation ignored cancellation.", - elapsedMs)).WaitAsync(TimeSpan.FromSeconds(2)); + elapsedMs)).WaitAsync(s_selfTerminateWriteTimeout); } catch { /* Pipe EOF on exit is the guaranteed host signal. */ } diff --git a/src/EventLogExpert.EventDbTool/EventLogExpert.EventDbTool.csproj b/src/EventLogExpert.EventDbTool/EventLogExpert.EventDbTool.csproj index 612e18d68..236a95f8b 100644 --- a/src/EventLogExpert.EventDbTool/EventLogExpert.EventDbTool.csproj +++ b/src/EventLogExpert.EventDbTool/EventLogExpert.EventDbTool.csproj @@ -3,7 +3,6 @@ Exe win-x64 - x64 eventdbtool diff --git a/src/EventLogExpert.Filtering/Compilation/FilterService.cs b/src/EventLogExpert.Filtering/Compilation/FilterService.cs index 783576d47..802af9bc4 100644 --- a/src/EventLogExpert.Filtering/Compilation/FilterService.cs +++ b/src/EventLogExpert.Filtering/Compilation/FilterService.cs @@ -13,7 +13,6 @@ namespace EventLogExpert.Filtering.Compilation; public sealed class FilterService : IFilterService { - /// Outer parallelism only kicks in when the combined work justifies the scheduling overhead. private const int OuterParallelTotalEventThreshold = 10_000; public static byte[] ClassifyHighlightWinners( @@ -49,6 +48,22 @@ public static byte[] ClassifyHighlightWinners( return winners; } + public static Func CompileSurvivorPredicate(Filter filter) + { + if (!filter.IsFilteringEnabled) { return static (_, _) => true; } + + var compiledFilters = CompileColumnFilters(filter.Filters); + + var dateFilter = filter.DateFilter; + var dateEnabled = dateFilter?.IsEnabled is true; + var after = dateFilter?.After; + var before = dateFilter?.Before; + + return (reader, locator) => + MatchesCompiledFilters(reader, locator, compiledFilters) && + (!dateEnabled || MatchesDateRange(reader, locator, after, before)); + } + public static IReadOnlyList GetSurvivingOrder(IEventColumnReader reader, Filter filter) { ArgumentNullException.ThrowIfNull(reader); @@ -91,8 +106,6 @@ public IReadOnlyDictionary> FilterActiv IReadOnlyList<(EventLogId Id, IReadOnlyList Events)> logs, Filter filter) { - // Single log, no filters, or trivial total work: sequential per-log (inner PLINQ still - // engages for >=10k events on a single large log). if (logs.Count <= 1 || !filter.IsFilteringEnabled || !ShouldParallelizeAcrossLogs(logs)) @@ -100,8 +113,6 @@ public IReadOnlyDictionary> FilterActiv return BuildSequentialResult(logs, filter); } - // Multi-log heavy work: parallelize across logs, sequential within each log to avoid - // oversubscribing the thread pool. var results = new IReadOnlyList[logs.Count]; try @@ -123,7 +134,6 @@ public IReadOnlyDictionary> FilterActiv for (var index = 0; index < logs.Count; index++) { - // Add() (not the indexer) preserves duplicate-key failure parity with the sequential path. filtered.Add(logs[index].Id, results[index]); } @@ -139,7 +149,6 @@ public IReadOnlyList GetFilteredEvents( return events as IReadOnlyList ?? [.. events]; } - // PLINQ scheduling overhead exceeds the benefit below this threshold. if (events is IReadOnlyCollection { Count: < 10_000 } collection) { return FilterEventsSequential(collection, filter); @@ -159,8 +168,6 @@ public IReadOnlyList GetFilteredEvents( foreach (var savedFilter in filters) { - // A null AoS Compiled is skipped without touching the isEmpty/isFiltered decision. Skipping at compile time - // is equivalent to the row oracle's per-event `continue` before the exclude/include arms. if (savedFilter.Compiled is null) { continue; } if (!FilterCompiler.TryCompileColumn(savedFilter.ComparisonText, out var columnCompiled, out var error)) @@ -225,7 +232,6 @@ private static bool MatchesCompiledFilters( if (isExcluded) { - // Exclude hides only on a decisive Match; Unknown and NoMatch keep the row visible. if (match == FilterMatch.Match) { return false; } continue; @@ -233,7 +239,6 @@ private static bool MatchesCompiledFilters( isEmpty = false; - // Include keeps the row on a Match OR an Unknown; only a decisive NoMatch fails to satisfy it. if (match != FilterMatch.NoMatch) { isFiltered = true; } } @@ -248,7 +253,6 @@ private static bool MatchesDateRange( { reader.GetField(locator, EventFieldId.TimeCreated).TryGetDateTime(out var timeCreated); - // Lifted nullable comparison mirrors the row oracle: a null After or Before makes the arm false. return timeCreated >= after && timeCreated <= before; } @@ -259,7 +263,6 @@ private static bool ShouldParallelizeAcrossLogs( foreach (var data in logs) { - // If we can't cheaply size a log, assume the work is non-trivial and opt in to parallelism. if (data.Events is not IReadOnlyCollection collection) { return true; diff --git a/src/EventLogExpert.Filtering/Evaluation/DateFilter.cs b/src/EventLogExpert.Filtering/Evaluation/DateFilter.cs index c4079a5e7..c597359ae 100644 --- a/src/EventLogExpert.Filtering/Evaluation/DateFilter.cs +++ b/src/EventLogExpert.Filtering/Evaluation/DateFilter.cs @@ -5,9 +5,9 @@ namespace EventLogExpert.Filtering.Evaluation; public sealed record DateFilter { - public DateTime? After { get; set; } + public DateTime? After { get; init; } - public DateTime? Before { get; set; } + public DateTime? Before { get; init; } - public bool IsEnabled { get; set; } = true; + public bool IsEnabled { get; init; } = true; } diff --git a/src/EventLogExpert.Logging/Routing/LogRoutingPolicy.cs b/src/EventLogExpert.Logging/Routing/LogRoutingPolicy.cs index 913103d39..d9cad427c 100644 --- a/src/EventLogExpert.Logging/Routing/LogRoutingPolicy.cs +++ b/src/EventLogExpert.Logging/Routing/LogRoutingPolicy.cs @@ -22,10 +22,6 @@ public LogRoutingPolicy(LoggingOptions options, LogLevel globalBaseline) _globalBaseline = globalBaseline; } - // The file sink writes a category at its configured throttle where one is set (channel-authoritative); every other - // category follows the live global baseline, so raising the global level never un-floors a configured throttle. - // Precedence returns on the first matching tier: runtime overrides (troubleshooting toggles) beat shipped - // throttles, which beat the global baseline. public LogLevel FileMinimumFor(string category) { if (string.IsNullOrEmpty(category)) { return _globalBaseline; } @@ -35,12 +31,6 @@ public LogLevel FileMinimumFor(string category) return TryMatchLongestPrefix(_fileOverrides, category, out LogLevel fileLevel) ? fileLevel : _globalBaseline; } - // Runtime per-category override (e.g. the verbose-resolution troubleshooting toggle): raise or reset a category - // live without touching the shipped throttles or the global baseline. The read-modify-write runs under _writeLock - // so concurrent writers cannot lose updates; readers take a single lock-free volatile snapshot. Because - // FileMinimumFor returns on the first matching tier, a broad runtime prefix (e.g. "Resolution") shadows a narrower - // shipped override ("Resolution.Sub") regardless of specificity - intended for the toggle, and harmless today - // because no shipped "Resolution.*" override exists. public void SetCategoryOverride(string category, LogLevel? level) { ArgumentException.ThrowIfNullOrEmpty(category); @@ -59,7 +49,7 @@ public void SetCategoryOverride(string category, LogLevel? level) } } - public LogLevel UiMinimumFor(bool verbose) => verbose ? LogLevel.Trace : LogLevel.Information; + public LogLevel UIMinimumFor(bool verbose) => verbose ? LogLevel.Trace : LogLevel.Information; public void UpdateGlobalBaseline(LogLevel level) => _globalBaseline = level; @@ -72,8 +62,6 @@ private static IReadOnlyList BuildOverrides(LoggingOptions opt .OrderByDescending(static entry => entry.Prefix.Length)]; } - // Segment-boundary match: "Offline" covers "Offline.Wim" but not "OfflineExtras". Overrides are pre-sorted - // longest-first, so the first match is the most specific. private static bool IsSegmentPrefix(string prefix, string category) { if (!category.StartsWith(prefix, StringComparison.Ordinal)) { return false; } diff --git a/src/EventLogExpert.Logging/Sinks/UiStreamingSink.cs b/src/EventLogExpert.Logging/Sinks/UIStreamingSink.cs similarity index 90% rename from src/EventLogExpert.Logging/Sinks/UiStreamingSink.cs rename to src/EventLogExpert.Logging/Sinks/UIStreamingSink.cs index e9bf93666..714f2f9a4 100644 --- a/src/EventLogExpert.Logging/Sinks/UiStreamingSink.cs +++ b/src/EventLogExpert.Logging/Sinks/UIStreamingSink.cs @@ -6,7 +6,7 @@ namespace EventLogExpert.Logging.Sinks; -public sealed class UiStreamingSink(IProgress progress, LogLevel minimumLevel) : ILogSink +public sealed class UIStreamingSink(IProgress progress, LogLevel minimumLevel) : ILogSink { private readonly IProgress _progress = progress ?? throw new ArgumentNullException(nameof(progress)); diff --git a/src/EventLogExpert.Runtime/Alerts/AlertPresentation.cs b/src/EventLogExpert.Runtime/Alerts/AlertPresentation.cs index 35a8729ee..8b5614052 100644 --- a/src/EventLogExpert.Runtime/Alerts/AlertPresentation.cs +++ b/src/EventLogExpert.Runtime/Alerts/AlertPresentation.cs @@ -5,28 +5,13 @@ namespace EventLogExpert.Runtime.Alerts; -/// -/// Controls how an request -/// is surfaced to the user. -/// public enum AlertPresentation { - /// - /// Default. Use the existing routing: render inline in the active modal host if - /// one is registered, otherwise open a standalone alert popup. - /// Auto, - /// - /// Route to with severity. - /// Only valid for one-button overloads (the banner has no accept/cancel pair); using it on a two-button overload - /// throws. - /// Banner, - /// Require an active inline alert host. Throws if none is registered. InlineOnly, - /// Always open a standalone popup, even if an inline host is registered. PopupOnly, } diff --git a/src/EventLogExpert.Runtime/Alerts/IInlineAlertHost.cs b/src/EventLogExpert.Runtime/Alerts/IInlineAlertHost.cs deleted file mode 100644 index cf3357c21..000000000 --- a/src/EventLogExpert.Runtime/Alerts/IInlineAlertHost.cs +++ /dev/null @@ -1,14 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -namespace EventLogExpert.Runtime.Alerts; - -/// -/// Implemented by an active modal so alerts can be routed as inline banners instead of opening a separate alert -/// modal (which would cancel the active one). -/// -public interface IInlineAlertHost -{ - /// Show inline. Replaces any prior pending inline alert (its task is canceled). - Task ShowInlineAlertAsync(InlineAlertRequest request, CancellationToken cancellationToken); -} diff --git a/src/EventLogExpert.Runtime/Alerts/InlineAlertRequest.cs b/src/EventLogExpert.Runtime/Alerts/InlineAlertRequest.cs deleted file mode 100644 index 97196ac95..000000000 --- a/src/EventLogExpert.Runtime/Alerts/InlineAlertRequest.cs +++ /dev/null @@ -1,22 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -namespace EventLogExpert.Runtime.Alerts; - -public sealed record InlineAlertRequest( - string Title, - string Message, - string? AcceptLabel, - string CancelLabel, - bool IsPrompt, - string? PromptInitialValue) -{ - public Func? Validate { get; init; } - - /// - /// Optional third button rendered between Accept and Cancel. When the user clicks it the result is - /// = true (Accepted stays false). Escape/Cancel remain a plain - /// dismissal (both flags false), so a secondary action is never triggered by dismissing the alert. - /// - public string? SecondaryActionLabel { get; init; } -} diff --git a/src/EventLogExpert.Runtime/Alerts/InlineAlertResult.cs b/src/EventLogExpert.Runtime/Alerts/InlineAlertResult.cs deleted file mode 100644 index f8dcd261f..000000000 --- a/src/EventLogExpert.Runtime/Alerts/InlineAlertResult.cs +++ /dev/null @@ -1,14 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -namespace EventLogExpert.Runtime.Alerts; - -/// Result of an inline alert. is non-null only for prompt requests. -public sealed record InlineAlertResult(bool Accepted, string? PromptValue) -{ - /// - /// True when the user clicked the optional button. - /// Mutually exclusive with ; both false means the alert was cancelled/dismissed. - /// - public bool SecondaryChosen { get; init; } -} diff --git a/src/EventLogExpert.Runtime/Common/Clipboard/EventCopyFormatter.cs b/src/EventLogExpert.Runtime/Common/Clipboard/EventCopyFormatter.cs new file mode 100644 index 000000000..b38730fab --- /dev/null +++ b/src/EventLogExpert.Runtime/Common/Clipboard/EventCopyFormatter.cs @@ -0,0 +1,288 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Eventing.Resolvers; +using EventLogExpert.Runtime.Common.Display; +using EventLogExpert.Runtime.EventLog; +using EventLogExpert.Runtime.LogTable; +using System.Collections.Immutable; +using System.Text; +using System.Xml; +using System.Xml.Linq; + +namespace EventLogExpert.Runtime.Common.Clipboard; + +internal sealed class EventCopyFormatter(IEventDetailResolver detailResolver, IEventXmlResolver xmlResolver) + : IEventCopyFormatter +{ + private readonly IEventDetailResolver _detailResolver = detailResolver; + private readonly IEventXmlResolver _xmlResolver = xmlResolver; + + public async Task FormatAsync(EventCopyRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var events = ResolveSelection(request.Selection); + var selected = ResolveEntry(request.Focus); + + EventCopyFormat format = request.Format; + + if (format == EventCopyFormat.Markdown) + { + IReadOnlyList markdownEvents = + events.Count == 0 ? (selected is null ? [] : [selected]) : events; + + return markdownEvents.Count == 0 ? string.Empty : BuildMarkdownTable(markdownEvents, request); + } + + bool needsXml = format is EventCopyFormat.Xml or EventCopyFormat.Full; + + if (events.Count == 0) + { + if (selected is null) { return string.Empty; } + + string xml = needsXml ? await _xmlResolver.GetXmlAsync(selected, cancellationToken).ConfigureAwait(false) : string.Empty; + + return FormatEventForCopy(format, selected, xml, request); + } + + if (events.Count == 1) + { + string xml = needsXml ? await _xmlResolver.GetXmlAsync(events[0], cancellationToken).ConfigureAwait(false) : string.Empty; + + return FormatEventForCopy(format, events[0], xml, request); + } + + string[] xmlByIndex; + + if (needsXml) + { + int maxConcurrency = Math.Max(2, Math.Min(events.Count, Environment.ProcessorCount)); + using var resolverLock = new SemaphoreSlim(maxConcurrency, maxConcurrency); + + var resolveTasks = new Task[events.Count]; + + for (int i = 0; i < events.Count; i++) + { + var evt = events[i]; + + resolveTasks[i] = ResolveXmlAsync(evt, resolverLock, cancellationToken); + } + + xmlByIndex = await Task.WhenAll(resolveTasks).ConfigureAwait(false); + } + else + { + xmlByIndex = []; + } + + StringBuilder stringToCopy = new(); + + for (int i = 0; i < events.Count; i++) + { + string xml = needsXml ? xmlByIndex[i] : string.Empty; + + AppendFormattedEvent(stringToCopy, format, events[i], xml, request); + stringToCopy.AppendLine(); + } + + return stringToCopy.ToString(); + } + + private static void AppendFormattedEvent( + StringBuilder builder, + EventCopyFormat format, + ResolvedEvent @event, + string xml, + EventCopyRequest request) + { + switch (format) + { + case EventCopyFormat.Default: + foreach ((ColumnName column, _) in request.EnabledColumns.Where(x => x.Value)) + { + switch (column) + { + case ColumnName.RecordId: + builder.Append($"\"{@event.RecordId}\" "); + break; + case ColumnName.Level: + builder.Append($"\"{@event.Level}\" "); + break; + case ColumnName.DateAndTime: + builder.Append($"\"{@event.TimeCreated.ConvertTimeZone(request.TimeZone)}\" "); + break; + case ColumnName.ActivityId: + builder.Append($"\"{@event.ActivityId}\" "); + break; + case ColumnName.Log: + builder.Append($"\"{OwningLogDisplay.ShortName(@event.OwningLog)}\" "); + break; + case ColumnName.ComputerName: + builder.Append($"\"{@event.ComputerName}\" "); + break; + case ColumnName.Source: + builder.Append($"\"{@event.Source}\" "); + break; + case ColumnName.EventId: + builder.Append($"\"{@event.Id}\" "); + break; + case ColumnName.TaskCategory: + builder.Append($"\"{@event.TaskCategory}\" "); + break; + case ColumnName.Keywords: + builder.Append($"\"{@event.KeywordsDisplayName}\" "); + break; + case ColumnName.ProcessId: + builder.Append($"\"{@event.ProcessId}\" "); + break; + case ColumnName.ThreadId: + builder.Append($"\"{@event.ThreadId}\" "); + break; + case ColumnName.User: + builder.Append($"\"{@event.UserId}\" "); + break; + } + } + + builder.Append($"\"{@event.Description}\""); + break; + case EventCopyFormat.Simple: + builder.Append($"\"{@event.Level}\" "); + builder.Append($"\"{@event.TimeCreated.ConvertTimeZone(request.TimeZone)}\" "); + builder.Append($"\"{@event.Source}\" "); + builder.Append($"\"{@event.Id}\" "); + builder.Append($"\"{@event.Description}\""); + break; + case EventCopyFormat.Xml: + if (!string.IsNullOrEmpty(xml)) { builder.Append(FormatXmlForCopy(xml)); } + + break; + case EventCopyFormat.Full: + default: + builder.AppendLine($"Log Name: {@event.LogName}"); + builder.AppendLine($"Source: {@event.Source}"); + builder.AppendLine($"Date: {@event.TimeCreated.ConvertTimeZone(request.TimeZone)}"); + builder.AppendLine($"Event ID: {@event.Id}"); + builder.AppendLine($"Task Category: {@event.TaskCategory}"); + builder.AppendLine($"Level: {@event.Level}"); + builder.AppendLine($"Keywords: {@event.KeywordsDisplayName}"); + builder.AppendLine($"User: {@event.UserId}"); + builder.AppendLine($"Computer: {@event.ComputerName}"); + builder.AppendLine("Description:"); + builder.AppendLine(@event.Description); + builder.AppendLine("Event Xml:"); + + if (!string.IsNullOrEmpty(xml)) + { + builder.AppendLine(FormatXmlForCopy(xml)); + } + + break; + } + } + + private static string BuildMarkdownTable(IReadOnlyList events, EventCopyRequest request) + { + var enabled = request.EnabledColumns; + var order = request.ColumnOrder; + var columns = (order.IsEmpty + ? enabled.Where(column => column.Value).Select(column => column.Key).OrderBy(column => column) + : order.Where(column => enabled.TryGetValue(column, out bool isEnabled) && isEnabled)) + .ToList(); + + StringBuilder builder = new(); + + builder.Append("| "); + foreach (var column in columns) { builder.Append(EscapeMarkdownCell(column.ToFullString())).Append(" | "); } + + builder.AppendLine("Description |"); + + builder.Append('|'); + for (int separator = 0; separator <= columns.Count; separator++) { builder.Append(" --- |"); } + + builder.AppendLine(); + + foreach (var @event in events) + { + builder.Append("| "); + foreach (var column in columns) { builder.Append(EscapeMarkdownCell(GetColumnText(column, @event, request.TimeZone))).Append(" | "); } + + builder.Append(EscapeMarkdownCell(@event.Description)).AppendLine(" |"); + } + + return builder.ToString().TrimEnd(); + } + + private static string EscapeMarkdownCell(string? value) => + (value ?? string.Empty) + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("\r", " ", StringComparison.Ordinal) + .Replace("\n", " ", StringComparison.Ordinal) + .Replace("|", "\\|", StringComparison.Ordinal); + + private static string FormatEventForCopy(EventCopyFormat format, ResolvedEvent @event, string xml, EventCopyRequest request) + { + if (format == EventCopyFormat.Xml) + { + return string.IsNullOrEmpty(xml) ? string.Empty : FormatXmlForCopy(xml); + } + + StringBuilder builder = new(); + + AppendFormattedEvent(builder, format, @event, xml, request); + + return builder.ToString(); + } + + private static string FormatXmlForCopy(string xml) + { + try + { + return XElement.Parse(xml).ToString(); + } + catch (XmlException) + { + return xml; + } + } + + private static string GetColumnText(ColumnName column, ResolvedEvent @event, TimeZoneInfo timeZone) => + EventTableColumnFormatter.GetCellText(@event, column, timeZone); + + private ResolvedEvent? ResolveEntry(SelectionEntry? entry) + { + if (entry?.CurrentHandle is not { } handle) { return null; } + + return _detailResolver.TryResolve(handle, out var detail) ? detail : null; + } + + private IReadOnlyList ResolveSelection(ImmutableList selection) + { + if (selection.Count == 0) { return []; } + + var resolved = new List(selection.Count); + + foreach (var entry in selection) + { + if (ResolveEntry(entry) is { } detail) { resolved.Add(detail); } + } + + return resolved; + } + + private async Task ResolveXmlAsync(ResolvedEvent evt, SemaphoreSlim resolverLock, CancellationToken cancellationToken) + { + await resolverLock.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + return await _xmlResolver.GetXmlAsync(evt, cancellationToken).ConfigureAwait(false); + } + finally + { + resolverLock.Release(); + } + } +} diff --git a/src/EventLogExpert.Runtime/Common/Clipboard/EventCopyRequest.cs b/src/EventLogExpert.Runtime/Common/Clipboard/EventCopyRequest.cs new file mode 100644 index 000000000..7f7c2233b --- /dev/null +++ b/src/EventLogExpert.Runtime/Common/Clipboard/EventCopyRequest.cs @@ -0,0 +1,16 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.EventLog; +using EventLogExpert.Runtime.LogTable; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.Common.Clipboard; + +public sealed record EventCopyRequest( + ImmutableList Selection, + SelectionEntry? Focus, + ImmutableDictionary EnabledColumns, + ImmutableList ColumnOrder, + EventCopyFormat Format, + TimeZoneInfo TimeZone); diff --git a/src/EventLogExpert.Runtime/Common/Clipboard/IEventCopyFormatter.cs b/src/EventLogExpert.Runtime/Common/Clipboard/IEventCopyFormatter.cs new file mode 100644 index 000000000..98f1d7e12 --- /dev/null +++ b/src/EventLogExpert.Runtime/Common/Clipboard/IEventCopyFormatter.cs @@ -0,0 +1,9 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Common.Clipboard; + +public interface IEventCopyFormatter +{ + Task FormatAsync(EventCopyRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/EventLogExpert.Runtime/Common/Sources/IChangeNotifier.cs b/src/EventLogExpert.Runtime/Common/Sources/IChangeNotifier.cs new file mode 100644 index 000000000..c3a37d1ad --- /dev/null +++ b/src/EventLogExpert.Runtime/Common/Sources/IChangeNotifier.cs @@ -0,0 +1,9 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Common.Sources; + +public interface IChangeNotifier +{ + event Action Changed; +} diff --git a/src/EventLogExpert.Runtime/Common/Sources/ObservableStateSourceBase.cs b/src/EventLogExpert.Runtime/Common/Sources/ObservableStateSourceBase.cs new file mode 100644 index 000000000..681980cc5 --- /dev/null +++ b/src/EventLogExpert.Runtime/Common/Sources/ObservableStateSourceBase.cs @@ -0,0 +1,95 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using Fluxor; + +namespace EventLogExpert.Runtime.Common.Sources; + +internal abstract class ObservableStateSourceBase : IChangeNotifier, IDisposable +{ + private static readonly Func s_defaultEquals = + static (next, current) => EqualityComparer.Default.Equals(next, current); + + private readonly Lock _gate = new(); + private readonly ITraceLogger _logger; + private readonly Func _project; + private readonly Func _projectionEquals; + private readonly IState _state; + + private TProjection _current; + private bool _disposed; + + protected ObservableStateSourceBase( + IState state, + ITraceLogger logger, + Func project, + Func? projectionEquals = null) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(project); + + _state = state; + _logger = logger; + _project = project; + _projectionEquals = projectionEquals ?? s_defaultEquals; + + _current = _project(state.Value); + _state.StateChanged += OnStateChanged; + + lock (_gate) { _current = _project(_state.Value); } + } + + public event Action? Changed; + + protected TProjection CurrentProjection + { + get { lock (_gate) { return _current; } } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) { return; } + + _disposed = true; + } + + _state.StateChanged -= OnStateChanged; + } + + private void OnStateChanged(object? sender, EventArgs e) + { + var next = _project(_state.Value); + + lock (_gate) + { + if (_disposed || _projectionEquals(next, _current)) { return; } + + _current = next; + } + + RaiseChanged(); + } + + private void RaiseChanged() + { + var handlers = Changed; + + if (handlers is null) { return; } + + foreach (var handler in handlers.GetInvocationList().Cast()) + { + try + { + handler(); + } + catch (Exception fault) + { + _logger.Trace($"{GetType().Name}: a subscriber threw and was isolated: {fault}"); + } + } + } +} diff --git a/src/EventLogExpert.Runtime/DatabaseTools/Elevation/ElevatedDatabaseToolsRunner.cs b/src/EventLogExpert.Runtime/DatabaseTools/Elevation/ElevatedDatabaseToolsRunner.cs index cc93103cc..787383f83 100644 --- a/src/EventLogExpert.Runtime/DatabaseTools/Elevation/ElevatedDatabaseToolsRunner.cs +++ b/src/EventLogExpert.Runtime/DatabaseTools/Elevation/ElevatedDatabaseToolsRunner.cs @@ -18,11 +18,13 @@ namespace EventLogExpert.Runtime.DatabaseTools.Elevation; -// Duplex named-pipe buffers permit concurrent drain reads and request/cancel writes. internal sealed class ElevatedDatabaseToolsRunner : IElevatedDatabaseToolsRunner { private const int ChannelCapacity = 1024; + private static readonly TimeSpan s_defaultCancellationGrace = TimeSpan.FromSeconds(30); + private static readonly TimeSpan s_defaultExitGrace = TimeSpan.FromSeconds(5); + private static readonly TimeSpan s_defaultHelloTimeout = TimeSpan.FromSeconds(10); private static readonly UTF8Encoding s_utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); private readonly TimeSpan _cancellationGrace; @@ -32,7 +34,7 @@ internal sealed class ElevatedDatabaseToolsRunner : IElevatedDatabaseToolsRunner private readonly ITraceLogger _traceLogger; public ElevatedDatabaseToolsRunner(IElevatedHelperProcessHost host, ITraceLogger traceLogger) - : this(host, traceLogger, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(5)) { } + : this(host, traceLogger, s_defaultHelloTimeout, s_defaultCancellationGrace, s_defaultExitGrace) { } internal ElevatedDatabaseToolsRunner( IElevatedHelperProcessHost host, @@ -518,7 +520,6 @@ private async Task RunAsync( killState.CancelGraceTimer(); - // Join the kill-timer so its disposition write happens-before the TranslateOutcome read. try { await killState.KillTaskOrCompleted.WaitAsync(_exitGrace); } catch (TimeoutException) { @@ -605,7 +606,6 @@ private async Task RunAsync( catch { /* best effort */ } } - // Dispose pipe before force-kill so the helper can exit cooperatively. try { await ((IAsyncDisposable)process.Pipe).DisposeAsync(); } catch { /* best effort */ } diff --git a/src/EventLogExpert.Runtime/DebugLog/OperationLogProgressFactory.cs b/src/EventLogExpert.Runtime/DebugLog/OperationLogProgressFactory.cs index 02bf8b3cc..0c095f816 100644 --- a/src/EventLogExpert.Runtime/DebugLog/OperationLogProgressFactory.cs +++ b/src/EventLogExpert.Runtime/DebugLog/OperationLogProgressFactory.cs @@ -15,7 +15,7 @@ public IProgress Create(IProgress uiProgress, string categ ArgumentNullException.ThrowIfNull(uiProgress); ArgumentException.ThrowIfNullOrEmpty(category); - List sinks = [new UiStreamingSink(uiProgress, routingPolicy.UiMinimumFor(verbose)), fileSink]; + List sinks = [new UIStreamingSink(uiProgress, routingPolicy.UIMinimumFor(verbose)), fileSink]; return new BroadcastLogProgress(sinks, category); } diff --git a/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs b/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs index c9cc52ff2..353a1520d 100644 --- a/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs +++ b/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using EventLogExpert.DatabaseTools.DependencyInjection; using EventLogExpert.Eventing.Readers; +using EventLogExpert.Eventing.Resolvers; using EventLogExpert.Eventing.Writers; using EventLogExpert.Logging.Abstractions; using EventLogExpert.Logging.Configuration; @@ -13,6 +14,7 @@ using EventLogExpert.Runtime.Announcement; using EventLogExpert.Runtime.Banner; using EventLogExpert.Runtime.Common.AppTitle; +using EventLogExpert.Runtime.Common.Clipboard; using EventLogExpert.Runtime.Common.Files; using EventLogExpert.Runtime.Common.Versioning; using EventLogExpert.Runtime.Database; @@ -26,11 +28,11 @@ using EventLogExpert.Runtime.FilterPane; using EventLogExpert.Runtime.Histogram; using EventLogExpert.Runtime.LogTable; -using EventLogExpert.Runtime.Menu; -using EventLogExpert.Runtime.Modal; +using EventLogExpert.Runtime.LogTable.OrderedView; using EventLogExpert.Runtime.Scenarios; using EventLogExpert.Runtime.Scenarios.Favorites; using EventLogExpert.Runtime.Settings; +using EventLogExpert.Runtime.StatusBar; using EventLogExpert.Runtime.Update; using EventLogExpert.Runtime.Update.Deployment; using EventLogExpert.Scenarios.Catalog; @@ -72,8 +74,8 @@ private static void AddDatabaseServices(IServiceCollection services) ActivatorUtilities.CreateInstance(sp, CategoryLogger(sp, LogCategories.Database))); services.AddSingleton(); - services.AddSingleton(static sp => sp.GetRequiredService()); - services.AddSingleton(static sp => sp.GetRequiredService()); + services.Forward(); + services.Forward(); services.AddSingleton(static sp => ActivatorUtilities.CreateInstance(sp, CategoryLogger(sp, LogCategories.Database))); @@ -90,13 +92,6 @@ private static ITraceLogger CategoryLogger(IServiceProvider serviceProvider, str extension(IServiceCollection services) { - /// - /// Helper-friendly subset of . Registers ONLY - /// and its operation factory dependency. Used by the packaged elevation helper - /// which needs to run DatabaseTools operations but must NOT pull in the rest of the runtime (Fluxor, banner services, - /// settings, etc.) - those would require host services (file pickers, modal coordinators) that don't exist in a - /// console helper. - /// public IServiceCollection AddDatabaseToolsRuntime() { ArgumentNullException.ThrowIfNull(services); @@ -107,12 +102,6 @@ public IServiceCollection AddDatabaseToolsRuntime() return services; } - /// - /// Registers backed by the in-Runtime - /// implementation. Callers MUST also register - /// separately (the production implementation lives in the MAUI head's - /// adapter layer; tests substitute scripted fakes). - /// public IServiceCollection AddElevatedDatabaseToolsRunner() { ArgumentNullException.ThrowIfNull(services); @@ -125,39 +114,6 @@ public IServiceCollection AddElevatedDatabaseToolsRunner() return services; } - /// - /// Registers the runtime tier's services. Callers MUST also register: - /// - /// - /// AddFluxor(...) - effect classes and state selectors depend on IDispatcher, - /// IState<T>, etc. - /// - /// AddEventLogFiltering() - effect classes depend on IFilterService. - /// - /// AddEventLogProviderDatabase() - database sub-services depend on - /// IProviderDatabaseMaintenance. - /// - /// - /// IFilePickerService - DatabaseOperationCoordinator depends on it for Import. Host registers - /// a concrete implementation (e.g., MauiFilePickerService). - /// - /// - /// IFilterLibraryStore - FilterLibrary effects depend on it. Register the default - /// SQLite-backed store via services.AddFilterLibrarySqliteStore(dbPath), or supply a custom - /// implementation. - /// - /// - /// IScenarioFavoriteStore - the scenario favorites effects depend on it. Register the default - /// SQLite-backed store via services.AddScenarioFavoriteSqliteStore(dbPath), or supply a custom - /// implementation. - /// - /// - /// IMenuActionService - the scenario launch service depends on it to open a scenario's channels. The - /// host registers the concrete implementation (e.g., MauiMenuActionService). - /// - /// - /// Omitting any of these produces a DI resolution failure when the dependent services are first activated. - /// public IServiceCollection AddEventLogRuntime() { ArgumentNullException.ThrowIfNull(services); @@ -165,7 +121,7 @@ public IServiceCollection AddEventLogRuntime() AddDatabaseServices(services); AddExportServices(services); - // Command facades. + // Commands services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -174,43 +130,75 @@ public IServiceCollection AddEventLogRuntime() services.AddSingleton(); services.AddSingleton(); - // Query facades. + // Queries services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); - // Shared coordination state for EventLog effects classes. + // Coordinators and concurrency services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); - // UI capabilities. + // Ordered-view engine + services.AddSingleton(static _ => new OrderedViewWriter()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Read-model sources + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Change notifiers (concrete raises; interface subscribes; one shared instance) + services.AddSingleton(); + services.Forward(); + services.AddSingleton(); + services.Forward(); + services.AddSingleton(); + services.Forward(); + services.AddSingleton(); + services.Forward(); + + // Indicators, resolvers, formatters, and selectors + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(static sp => sp.GetRequiredService()); + services.AddSingleton(); - // Application services. + // Application shell: title, banners, and announcements services.AddSingleton(); - - // BannerService is the shared backing store for the 5 banner facets. - // Registered once as the concrete type, then each facet interface resolves - // back to the same singleton instance. This preserves cross-facet state - // invariants (single lock, single backing store) while letting consumers - // depend only on the narrow interface they need. services.AddSingleton(); - services.AddSingleton(static sp => sp.GetRequiredService()); - services.AddSingleton(static sp => sp.GetRequiredService()); - services.AddSingleton(static sp => sp.GetRequiredService()); - services.AddSingleton(static sp => sp.GetRequiredService()); - services.AddSingleton(static sp => sp.GetRequiredService()); - - // Export progress is a standalone banner facet with no database coupling, so it is a - // separate singleton from BannerService. The UI BannerCycleStateService consumes it as a - // 7th source; the head menu driver brackets a streaming export with Begin/End. + services.Forward(); + services.Forward(); + services.Forward(); + services.Forward(); + services.Forward(); services.AddSingleton(); - services.AddSingleton(); + // Logging services.Configure(LoggingOptions.ApplyShippedDefaults); services.AddSingleton(static sp => { @@ -219,9 +207,6 @@ public IServiceCollection AddEventLogRuntime() sp.GetRequiredService>().Value, settings.LogLevel); - // Seed the initial verbose-resolution override at construction (mirrors the LogLevel baseline seeding - // above) so a persisted-ON toggle is in effect the moment the singleton is visible - before the eager - // DebugLogHost resolve. DebugLogHost then bridges only subsequent toggles. if (settings.VerboseResolution) { policy.SetCategoryOverride(LogCategories.Resolution, LogLevel.Trace); @@ -236,14 +221,11 @@ public IServiceCollection AddEventLogRuntime() services.AddSingleton(static sp => new DebugLogFileReader( sp.GetRequiredService(), sp.GetRequiredService())); - // Owns the settings->routing-baseline bridge + the unhandled-exception hook. Nothing depends on it, so the - // MAUI head force-resolves it at startup; it depends on FileLogSink so it is disposed (hooks detached) first. services.AddSingleton(); services.AddSingleton(static sp => { List sinks = [sp.GetRequiredService()]; #if DEBUG - // Debug builds also mirror to the console so logs surface in the F12 / debug window; Release is file-only. sinks.Add(new ConsoleSink()); #endif return new LogSourceFactory(sinks); @@ -254,12 +236,9 @@ public IServiceCollection AddEventLogRuntime() sp.GetRequiredService().ForCategory(LogCategories.EventLog)); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); - // Update + deployment services. + // Update and deployment services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -267,12 +246,11 @@ public IServiceCollection AddEventLogRuntime() services.AddSingleton(); services.AddSingleton(); - // DatabaseTools service (CLI-equivalent operations exposed to the UI). + // Database tools services.AddDatabaseToolsServices(); services.TryAddSingleton(); - // Built-in scenarios: the immutable embedded catalog + presence/query/launch services. The registry - // aggregates every registered IScenarioSource (PR1 ships only the built-in source). + // Scenarios and channels services.AddSingleton(); services.AddSingleton(); services.AddSingleton(static sp => @@ -280,8 +258,8 @@ public IServiceCollection AddEventLogRuntime() services.AddSingleton(static sp => new ChannelConfigWriter(CategoryLogger(sp, LogCategories.EventLog))); services.AddSingleton(); - services.AddSingleton(static sp => sp.GetRequiredService()); - services.AddSingleton(static sp => sp.GetRequiredService()); + services.Forward(); + services.Forward(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/EventLogExpert.Runtime/DependencyInjection/ServiceCollectionForwardingExtensions.cs b/src/EventLogExpert.Runtime/DependencyInjection/ServiceCollectionForwardingExtensions.cs new file mode 100644 index 000000000..d942e35df --- /dev/null +++ b/src/EventLogExpert.Runtime/DependencyInjection/ServiceCollectionForwardingExtensions.cs @@ -0,0 +1,23 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace Microsoft.Extensions.DependencyInjection; + +public static class ServiceCollectionForwardingExtensions +{ + /// + /// Registers so it resolves to the single shared + /// singleton instead of constructing a second instance. Register the concrete + /// separately, then forward every additional interface to it. + /// + public static IServiceCollection Forward(this IServiceCollection services) + where TService : class + where TImplementation : class, TService + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(static sp => sp.GetRequiredService()); + + return services; + } +} diff --git a/src/EventLogExpert.Runtime/EventLog/EventFocusSource.cs b/src/EventLogExpert.Runtime/EventLog/EventFocusSource.cs new file mode 100644 index 000000000..aa4b915ad --- /dev/null +++ b/src/EventLogExpert.Runtime/EventLog/EventFocusSource.cs @@ -0,0 +1,18 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.Common.Sources; +using Fluxor; +using Microsoft.Extensions.DependencyInjection; + +namespace EventLogExpert.Runtime.EventLog; + +internal sealed class EventFocusSource( + IState state, + [FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + : ObservableStateSourceBase(state, logger, static state => state.Focus), + IEventFocusSource +{ + public SelectionEntry? Current => CurrentProjection; +} diff --git a/src/EventLogExpert.Runtime/EventLog/EventLogCommands.cs b/src/EventLogExpert.Runtime/EventLog/EventLogCommands.cs index a0df32f58..729ea0e5d 100644 --- a/src/EventLogExpert.Runtime/EventLog/EventLogCommands.cs +++ b/src/EventLogExpert.Runtime/EventLog/EventLogCommands.cs @@ -3,6 +3,7 @@ using EventLogExpert.Eventing.Common.Channels; using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Common.Events; using Fluxor; namespace EventLogExpert.Runtime.EventLog; @@ -13,14 +14,14 @@ internal sealed class EventLogCommands(IDispatcher dispatcher) : IEventLogComman public void CloseAllLogs() => _dispatcher.Dispatch(new CloseAllLogsAction()); - // Emits the user-close discriminator alongside the close so lens lifecycle (and any other user-close-only subscriber) - // can distinguish a genuine tab close from a filter-driven reload, which dispatches CloseLogAction directly. public void CloseLog(EventLogId logId, string logName) { _dispatcher.Dispatch(new CloseLogAction(logId, logName)); _dispatcher.Dispatch(new LogClosedByUserAction(logId, logName)); } + public void ConsumeRevealFocus(EventLocator target) => _dispatcher.Dispatch(new RevealFocusConsumedAction(target)); + public void LoadNewEvents() => _dispatcher.Dispatch(new LoadNewEventsAction()); public void OpenLog(string logName, LogPathType logPathType, CancellationToken token = default) => diff --git a/src/EventLogExpert.Runtime/EventLog/EventLogConcurrencyState.cs b/src/EventLogExpert.Runtime/EventLog/EventLogConcurrencyState.cs index 938e8ab31..8a477cccc 100644 --- a/src/EventLogExpert.Runtime/EventLog/EventLogConcurrencyState.cs +++ b/src/EventLogExpert.Runtime/EventLog/EventLogConcurrencyState.cs @@ -11,18 +11,13 @@ internal sealed class EventLogConcurrencyState private readonly ConcurrentDictionary _logsLoadedWithXml = new(); private long _closeAllToken; - private long _filterToken; public void ClearAllLoadedWithXml() => _logsLoadedWithXml.Clear(); public void ClearLoadedWithXml(EventLogId logId) => _logsLoadedWithXml.TryRemove(logId, out _); - public long GetCurrentFilterToken() => Interlocked.Read(ref _filterToken); - public long GetCurrentReloadToken() => Interlocked.Read(ref _closeAllToken); - public long InvalidateInFlightFilters() => Interlocked.Increment(ref _filterToken); - public void InvalidateInFlightReloads() => Interlocked.Increment(ref _closeAllToken); public bool IsLoadedWithXml(EventLogId logId) => _logsLoadedWithXml.ContainsKey(logId); diff --git a/src/EventLogExpert.Runtime/EventLog/EventLogQueries.cs b/src/EventLogExpert.Runtime/EventLog/EventLogQueries.cs index d38866fd2..52701fced 100644 --- a/src/EventLogExpert.Runtime/EventLog/EventLogQueries.cs +++ b/src/EventLogExpert.Runtime/EventLog/EventLogQueries.cs @@ -65,6 +65,8 @@ public ImmutableArray GetUserDataFieldValues(string fieldName) return EventPropertyValuesCache.GetUserDataFieldValues(byLog, EnumerateAll(byLog), fieldName); } + public bool IsContinuouslyUpdating() => _eventLogState.Value.ContinuouslyUpdate; + private static IEnumerable EnumerateAll( ImmutableDictionary byLog) { diff --git a/src/EventLogExpert.Runtime/EventLog/EventLogState.cs b/src/EventLogExpert.Runtime/EventLog/EventLogState.cs index b6b050448..59d790f5a 100644 --- a/src/EventLogExpert.Runtime/EventLog/EventLogState.cs +++ b/src/EventLogExpert.Runtime/EventLog/EventLogState.cs @@ -11,7 +11,6 @@ namespace EventLogExpert.Runtime.EventLog; [FeatureState] public sealed record EventLogState { - /// The maximum number of new events we will hold in the state before we turn off the watcher. public static int MaxNewEvents => 1000; internal ImmutableDictionary OpenLogs { get; init; } = @@ -38,4 +37,6 @@ public sealed record EventLogState public SelectionEntry? Focus { get; init; } public ImmutableList Selection { get; init; } = []; + + public EventLocator? PendingRevealFocus { get; init; } } diff --git a/src/EventLogExpert.Runtime/EventLog/EventSelectionSource.cs b/src/EventLogExpert.Runtime/EventLog/EventSelectionSource.cs new file mode 100644 index 000000000..896d0c8d3 --- /dev/null +++ b/src/EventLogExpert.Runtime/EventLog/EventSelectionSource.cs @@ -0,0 +1,23 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.Common.Sources; +using Fluxor; +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.EventLog; + +internal sealed class EventSelectionSource( + IState state, + [FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + : ObservableStateSourceBase>( + state, + logger, + static state => state.Selection, + static (next, current) => ReferenceEquals(next, current)), + IEventSelectionSource +{ + public ImmutableList Current => CurrentProjection; +} diff --git a/src/EventLogExpert.Runtime/EventLog/FilterAppliedSource.cs b/src/EventLogExpert.Runtime/EventLog/FilterAppliedSource.cs new file mode 100644 index 000000000..2a459202c --- /dev/null +++ b/src/EventLogExpert.Runtime/EventLog/FilterAppliedSource.cs @@ -0,0 +1,18 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.Common.Sources; +using Fluxor; +using Microsoft.Extensions.DependencyInjection; + +namespace EventLogExpert.Runtime.EventLog; + +internal sealed class FilterAppliedSource( + IState state, + [FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + : ObservableStateSourceBase(state, logger, static state => state.AppliedFilter.IsFilteringEnabled), + IFilterAppliedSource +{ + public bool IsFilteringEnabled => CurrentProjection; +} diff --git a/src/EventLogExpert.Runtime/EventLog/FilteringEffects.cs b/src/EventLogExpert.Runtime/EventLog/FilteringEffects.cs index d86242db3..7a67ba9d7 100644 --- a/src/EventLogExpert.Runtime/EventLog/FilteringEffects.cs +++ b/src/EventLogExpert.Runtime/EventLog/FilteringEffects.cs @@ -1,58 +1,30 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. -using EventLogExpert.Eventing.Common.Channels; -using EventLogExpert.Eventing.Common.EventLogs; -using EventLogExpert.Eventing.Common.Events; -using EventLogExpert.Filtering.Evaluation; -using EventLogExpert.Logging.Abstractions; -using EventLogExpert.Runtime.FilterProgress; -using EventLogExpert.Runtime.Histogram; -using EventLogExpert.Runtime.LogTable; using Fluxor; -using Microsoft.Extensions.DependencyInjection; -using System.Collections.Immutable; using IDispatcher = Fluxor.IDispatcher; namespace EventLogExpert.Runtime.EventLog; internal sealed class FilteringEffects( IState eventLogState, - IState rawEventStore, - IState logTableState, - [FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger, - LogCloseCoordinator closeCoordinator, - EventLogConcurrencyState concurrencyState, - TimeSpan? convergenceDelay = null) + LiveTailIngestCoordinator liveTailCoordinator, + XmlReloadCoordinator xmlReloadCoordinator) { - private readonly LogCloseCoordinator _closeCoordinator = closeCoordinator; - private readonly EventLogConcurrencyState _concurrencyState = concurrencyState; - private readonly TimeSpan _convergenceDelay = convergenceDelay ?? TimeSpan.FromMilliseconds(250); private readonly IState _eventLogState = eventLogState; - private readonly IState _logTableState = logTableState; - private readonly ITraceLogger _logger = logger; - private readonly IState _rawEventStore = rawEventStore; + private readonly LiveTailIngestCoordinator _liveTailCoordinator = liveTailCoordinator; + private readonly XmlReloadCoordinator _xmlReloadCoordinator = xmlReloadCoordinator; [EffectMethod] public Task HandleAddEvent(AddEventAction action, IDispatcher dispatcher) { - // The non-live-tail buffering is handled atomically by ReduceAddEvent; this effect only drives the - // continuously-update live tail (ingest the new event, rebuild its display). if (!_eventLogState.Value.ContinuouslyUpdate || !_eventLogState.Value.OpenLogs.TryGetValue(action.NewEvent.OwningLog, out var owningLog)) { return Task.CompletedTask; } - var newEventsByLog = new Dictionary> - { - [owningLog.Id] = [action.NewEvent] - }; - - // Ingest before the rebuild: the filter-gated build runs in the continuation so it reads the post-ingest store - // (inlining here would lag the tail by one event). Live tail owns no buffer. - dispatcher.Dispatch(new IngestRawEventsAction(newEventsByLog, RawIngestMode.Prepend)); - dispatcher.Dispatch(new RebuildDisplayViewsAction(newEventsByLog, BufferEntriesToConsume: null)); + _liveTailCoordinator.Enqueue(owningLog.Id, action.NewEvent); return Task.CompletedTask; } @@ -60,111 +32,11 @@ public Task HandleAddEvent(AddEventAction action, IDispatcher dispatcher) [EffectMethod] public async Task HandleApplyFilter(ApplyFilterAction action, IDispatcher dispatcher) { - long reloadTokenAtStart = _concurrencyState.GetCurrentReloadToken(); - - bool newRequiresXml = action.Filter.RequiresXml; - - // Reopen only logs missing the XML the new filter needs; UserData filters resolve from stored fields and never force a reload. - var logsNeedingReload = newRequiresXml && !_eventLogState.Value.OpenLogs.IsEmpty - ? _eventLogState.Value.OpenLogs - .Where(kvp => !_concurrencyState.IsLoadedWithXml(kvp.Value.Id)) - .Select(kvp => (kvp.Value.Id, Name: kvp.Key, kvp.Value.Type)) - .ToList() - : []; - - long filterToken = _concurrencyState.InvalidateInFlightFilters(); + PendingXmlReload pendingReload = _xmlReloadCoordinator.Resolve(action.Filter); - if (logsNeedingReload.Count > 0) + if (pendingReload.IsNeeded) { - dispatcher.Dispatch(new SetFilterProgressAction(false)); - - await ReloadLogsWithXmlAsync(logsNeedingReload, reloadTokenAtStart, dispatcher); - - return; - } - - await ApplyFilterAndPublishAsync(action.Filter, filterToken, dispatcher); - } - - [EffectMethod] - public async Task HandleConvergeFilter(ConvergeFilterAction action, IDispatcher dispatcher) - { - long filterToken = action.OriginToken; - - if (_concurrencyState.GetCurrentFilterToken() != filterToken) { return; } - - var filter = _eventLogState.Value.AppliedFilter; - var logTable = _logTableState.Value; - var context = logTable.SortContext; - var version = logTable.DisplayListVersion; - - var targets = ResidualOpenStale(action.StaleIds); - - if (targets.Length == 0) - { - if (_concurrencyState.GetCurrentFilterToken() == filterToken) - { - dispatcher.Dispatch(new SetFilterProgressAction(false)); - } - - return; - } - - bool convergenceScheduled = false; - - try - { - var snapshot = SnapshotEventsForLogs(targets); - var sorted = await Task.Run(() => FilterAndSort(snapshot, filter, context)); - - if (_concurrencyState.GetCurrentFilterToken() != filterToken) { return; } - - var capturedByLog = snapshot.ToDictionary(pair => pair.Id, pair => pair.ContentVersion); - var nowRaw = _rawEventStore.Value.ByLog; - var fresh = new Dictionary(sorted.Count); - var residual = new List(); - - foreach (var logId in targets) - { - // M1 race guard: an unchanged ContentVersion means no rebuild slipped in since the snapshot, so the built view still matches the live store. - if (sorted.TryGetValue(logId, out var view) && - capturedByLog.TryGetValue(logId, out var capturedVersion) && - nowRaw.TryGetValue(logId, out var now) && - now.ContentVersion == capturedVersion) - { - fresh[logId] = view; - } - else - { - residual.Add(logId); - } - } - - if (fresh.Count > 0) - { - dispatcher.Dispatch(new DisplayReadyAction { Views = fresh, Version = version }); - } - - var stillStale = ResidualOpenStale(residual); - - if (stillStale.Length > 0) - { - convergenceScheduled = true; - - await Task.Delay(_convergenceDelay); - - if (_concurrencyState.GetCurrentFilterToken() == filterToken) - { - dispatcher.Dispatch(new ConvergeFilterAction(stillStale, filterToken)); - } - } - } - finally - { - if (!convergenceScheduled && _concurrencyState.GetCurrentFilterToken() == filterToken) - { - dispatcher.Dispatch(new SetFilterProgressAction(false)); - } + await _xmlReloadCoordinator.ReloadAsync(pendingReload, dispatcher); } } @@ -175,317 +47,11 @@ public Task HandleSetContinuouslyUpdate(SetContinuouslyUpdateAction action, IDis { LogReloadEffects.ProcessNewEventBuffer(_eventLogState.Value, dispatcher); } - - return Task.CompletedTask; - } - - [EffectMethod] - public async Task HandleSetGroupBy(SetGroupByAction action, IDispatcher dispatcher) => - await RepublishForSortAsync(dispatcher); - - [EffectMethod] - public async Task HandleSetHistogramVisible(SetHistogramVisibleAction action, IDispatcher dispatcher) - { - // Timeline visibility only changes the default order of a single log with no explicit sort or grouping; every other - // view keeps its order, so skip the rebuild. This predicate matches the reducer's conditional DisplayListVersion bump. - var logTable = _logTableState.Value; - - if (logTable.PerLogEvents.Count != 1 || logTable.RequestedOrderBy is not null || logTable.RequestedGroupBy is not null) - { - return; - } - - await RepublishForSortAsync(dispatcher); - } - - [EffectMethod] - public async Task HandleSetOrderBy(SetOrderByAction action, IDispatcher dispatcher) => - await RepublishForSortAsync(dispatcher); - - [EffectMethod(typeof(ToggleGroupSortingAction))] - public async Task HandleToggleGroupSorting(IDispatcher dispatcher) - { - if (_logTableState.Value.RequestedGroupBy is null) { return; } - - await RepublishForSortAsync(dispatcher); - } - - [EffectMethod(typeof(ToggleSortingAction))] - public async Task HandleToggleSorting(IDispatcher dispatcher) => - await RepublishForSortAsync(dispatcher); - - [EffectMethod(typeof(UpdateTableAction))] - public async Task HandleUpdateTable(IDispatcher dispatcher) - { - // A reopen settles under the live sort and drops the in-flight rebuild; republish if a sort is still pending. - if (!_logTableState.Value.HasPendingSortChange) { return; } - - await RepublishForSortAsync(dispatcher); - } - - private static Dictionary FilterAndSort( - IReadOnlyList<(EventLogId Id, EventColumnStore Store, long ContentVersion)> snapshot, - Filter filter, - SortContext context) - { - var views = new Dictionary(snapshot.Count); - - foreach (var (logId, store, _) in snapshot) + else { - views[logId] = DisplayViewBuilder.Build(store, logId, filter, context); + _liveTailCoordinator.Flush(); } - return views; - } - - private async Task ApplyFilterAndPublishAsync(Filter filter, long filterToken, IDispatcher dispatcher) - { - var snapshot = SnapshotOpenLogEvents(); - var logTable = _logTableState.Value; - var capturedContext = logTable.SortContext; - var version = logTable.DisplayListVersion; - - dispatcher.Dispatch(new SetFilterProgressAction(true)); - - bool convergenceScheduled = false; - - try - { - var sortedActiveLogs = await Task.Run(() => FilterAndSort(snapshot, filter, capturedContext)); - - if (_concurrencyState.GetCurrentFilterToken() != filterToken) { return; } - - var snapshotById = snapshot.ToDictionary(pair => pair.Id, pair => pair.ContentVersion); - var currentRaw = _rawEventStore.Value.ByLog; - var fresh = new Dictionary(sortedActiveLogs.Count); - var staleIds = new List(); - - foreach (var (logId, view) in sortedActiveLogs) - { - if (!snapshotById.TryGetValue(logId, out var snapshotVersion)) { continue; } - - if (currentRaw.TryGetValue(logId, out var current) && - current.ContentVersion == snapshotVersion) - { - fresh[logId] = view; - } - else - { - staleIds.Add(logId); - } - } - - if (staleIds.Count > 0) - { - var pass2Snapshot = SnapshotEventsForLogs(staleIds); - var refilteredSorted = await Task.Run(() => FilterAndSort(pass2Snapshot, filter, capturedContext)); - - if (_concurrencyState.GetCurrentFilterToken() != filterToken) { return; } - - var pass2CapturedById = pass2Snapshot.ToDictionary(pair => pair.Id, pair => pair.ContentVersion); - var nowRaw = _rawEventStore.Value.ByLog; - - foreach (var (logId, view) in refilteredSorted) - { - if (!pass2CapturedById.TryGetValue(logId, out var pass2Version)) { continue; } - - if (nowRaw.TryGetValue(logId, out var now) && - now.ContentVersion == pass2Version) - { - fresh[logId] = view; - } - } - } - - dispatcher.Dispatch(new DisplayReadyAction { Views = fresh, Version = version }); - - var residualStale = ResidualOpenStale(staleIds.Where(id => !fresh.ContainsKey(id))); - - if (residualStale.Length > 0) - { - convergenceScheduled = true; - - await Task.Delay(_convergenceDelay); - - if (_concurrencyState.GetCurrentFilterToken() == filterToken) - { - dispatcher.Dispatch(new ConvergeFilterAction(residualStale, filterToken)); - } - } - } - finally - { - if (!convergenceScheduled && _concurrencyState.GetCurrentFilterToken() == filterToken) - { - dispatcher.Dispatch(new SetFilterProgressAction(false)); - } - } - } - - private async Task ReloadLogsWithXmlAsync( - List<(EventLogId Id, string Name, LogPathType Type)> logsNeedingReload, - long reloadToken, - IDispatcher dispatcher) - { - var reloadNames = logsNeedingReload.Select(t => t.Name).ToHashSet(StringComparer.Ordinal); - - var selectionByLog = _eventLogState.Value.Selection - .Where(entry => entry.ReloadKey is { } key && reloadNames.Contains(key.OwningLog)) - .GroupBy(entry => entry.ReloadKey!.Value.OwningLog) - .ToDictionary( - group => group.Key, - IReadOnlySet (group) => group.Select(entry => entry.ReloadKey!.Value.RecordId).ToHashSet()); - - var focus = _eventLogState.Value.Focus; - long? selectedRecordId = focus?.ReloadKey?.RecordId; - string? selectedLogName = focus?.ReloadKey?.OwningLog; - - if (selectedRecordId.HasValue && - !string.IsNullOrEmpty(selectedLogName) && - reloadNames.Contains(selectedLogName) && - !selectionByLog.ContainsKey(selectedLogName)) - { - selectionByLog[selectedLogName] = new HashSet(); - } - - await _closeCoordinator.AcquireCoordinatorLockAsync(); - - try - { - var closeWaiters = new List<(EventLogId Id, string Name, Task Task)>(logsNeedingReload.Count); - - foreach (var (id, name, _) in logsNeedingReload) - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _closeCoordinator.RegisterCloseCompletion(id, tcs); - closeWaiters.Add((id, name, tcs.Task)); - } - - foreach (var (id, name, _) in logsNeedingReload) - { - dispatcher.Dispatch(new CloseLogAction(id, name)); - } - - var timedOutLogs = new HashSet(StringComparer.Ordinal); - - foreach (var (id, name, task) in closeWaiters) - { - try - { - await task.WaitAsync(LogCloseCoordinator.LogCloseTimeout); - } - catch (TimeoutException) - { - _closeCoordinator.RemoveStrandedCompletion(id); - timedOutLogs.Add(name); - - _logger.Trace( - $"{nameof(HandleApplyFilter)}: close for log '{name}' did not complete within {LogCloseCoordinator.LogCloseTimeout}; selection will not be restored to avoid race with the delayed close wiping the entry."); - } - } - - foreach (var (name, ids) in selectionByLog) - { - if (timedOutLogs.Contains(name)) { continue; } - - long? selectedIdForLog = string.Equals(name, selectedLogName, StringComparison.Ordinal) ? - selectedRecordId : null; - - _closeCoordinator.WritePendingRestore(name, new PendingSelectionRestore(ids, selectedIdForLog)); - } - - if (_concurrencyState.GetCurrentReloadToken() != reloadToken) - { - foreach (var (_, name, _) in logsNeedingReload) - { - _closeCoordinator.ClearPendingRestore(name); - } - - _logger.Trace( - $"{nameof(HandleApplyFilter)}: reload superseded by CloseAll; skipping reopen of {logsNeedingReload.Count} log(s) and clearing pending selection restore."); - - return; - } - - var reopenedSoFar = new List<(EventLogId Id, string Name)>(logsNeedingReload.Count); - - foreach (var (id, name, type) in logsNeedingReload) - { - if (_concurrencyState.GetCurrentReloadToken() != reloadToken) - { - foreach (var (reopenedId, reopenedName) in reopenedSoFar) - { - dispatcher.Dispatch(new CloseLogAction(reopenedId, reopenedName)); - } - - foreach (var (_, restoreName, _) in logsNeedingReload) - { - _closeCoordinator.ClearPendingRestore(restoreName); - } - - _logger.Trace( - $"{nameof(HandleApplyFilter)}: reload superseded by CloseAll mid-reopen; dispatched CloseLog for {reopenedSoFar.Count} just-reopened log(s) and cleared pending selection restore."); - - return; - } - - dispatcher.Dispatch(new OpenLogAction(name, type)); - reopenedSoFar.Add((id, name)); - } - } - finally - { - _closeCoordinator.ReleaseCoordinatorLock(); - } - } - - private Task RepublishForSortAsync(IDispatcher dispatcher) => - ApplyFilterAndPublishAsync( - _eventLogState.Value.AppliedFilter, - _concurrencyState.InvalidateInFlightFilters(), - dispatcher); - - private ImmutableArray ResidualOpenStale(IEnumerable candidateStaleIds) - { - var openIds = new HashSet(); - - foreach (var info in _eventLogState.Value.OpenLogs.Values) { openIds.Add(info.Id); } - - var raw = _rawEventStore.Value.ByLog; - - return [.. candidateStaleIds.Distinct().Where(id => openIds.Contains(id) && raw.ContainsKey(id))]; - } - - private List<(EventLogId Id, EventColumnStore Store, long ContentVersion)> SnapshotEventsForLogs( - IReadOnlyList logIds) - { - var raw = _rawEventStore.Value.ByLog; - var snapshot = new List<(EventLogId Id, EventColumnStore Store, long ContentVersion)>(); - - foreach (var logId in logIds) - { - if (raw.TryGetValue(logId, out var store)) - { - snapshot.Add((logId, store, store.ContentVersion)); - } - } - - return snapshot; - } - - private List<(EventLogId Id, EventColumnStore Store, long ContentVersion)> SnapshotOpenLogEvents() - { - var raw = _rawEventStore.Value.ByLog; - var snapshot = new List<(EventLogId Id, EventColumnStore Store, long ContentVersion)>(); - - foreach (var info in _eventLogState.Value.OpenLogs.Values) - { - if (raw.TryGetValue(info.Id, out var store)) - { - snapshot.Add((info.Id, store, store.ContentVersion)); - } - } - - return snapshot; + return Task.CompletedTask; } } diff --git a/src/EventLogExpert.Runtime/EventLog/IEventFocusSource.cs b/src/EventLogExpert.Runtime/EventLog/IEventFocusSource.cs new file mode 100644 index 000000000..fcc36cdcf --- /dev/null +++ b/src/EventLogExpert.Runtime/EventLog/IEventFocusSource.cs @@ -0,0 +1,11 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Common.Sources; + +namespace EventLogExpert.Runtime.EventLog; + +public interface IEventFocusSource : IChangeNotifier +{ + SelectionEntry? Current { get; } +} diff --git a/src/EventLogExpert.Runtime/EventLog/IEventLogCommands.cs b/src/EventLogExpert.Runtime/EventLog/IEventLogCommands.cs index b258264d7..05f7a1bfc 100644 --- a/src/EventLogExpert.Runtime/EventLog/IEventLogCommands.cs +++ b/src/EventLogExpert.Runtime/EventLog/IEventLogCommands.cs @@ -3,6 +3,7 @@ using EventLogExpert.Eventing.Common.Channels; using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Common.Events; namespace EventLogExpert.Runtime.EventLog; @@ -12,6 +13,8 @@ public interface IEventLogCommands void CloseLog(EventLogId logId, string logName); + void ConsumeRevealFocus(EventLocator target); + void LoadNewEvents(); void OpenLog(string logName, LogPathType logPathType, CancellationToken token = default); diff --git a/src/EventLogExpert.Runtime/EventLog/IEventLogQueries.cs b/src/EventLogExpert.Runtime/EventLog/IEventLogQueries.cs index cb48ad87e..c3dedea32 100644 --- a/src/EventLogExpert.Runtime/EventLog/IEventLogQueries.cs +++ b/src/EventLogExpert.Runtime/EventLog/IEventLogQueries.cs @@ -8,46 +8,19 @@ namespace EventLogExpert.Runtime.EventLog; public interface IEventLogQueries { - /// - /// Returns the distinct names of the currently open Channel logs (excludes File logs). Used by scenario authoring - /// export to record which channels the filter rows were captured against. - /// IReadOnlyList GetChannelNames(); - /// - /// Returns the distinct, sorted <EventData> field names present across all open raw events (used to - /// populate the Basic editor's EventData field-name picker). - /// ImmutableArray GetEventDataFieldNames(); - /// - /// Returns the distinct, sorted values of the named EventData across all open raw - /// events (used to populate the value picker for an EventData filter row). - /// ImmutableArray GetEventDataFieldValues(string fieldName); - /// - /// Returns the UTC date range covering all events across the active logs, with bounds rounded outward to the - /// hour, falling back to when no log has events. - /// (DateTime After, DateTime Before) GetEventDateRange(DateTime fallbackUtcNow); - /// - /// Returns the distinct, sorted set of values present across all open raw events for the given - /// (used to populate filter value pickers). Empty for properties that are not derived - /// from event data. - /// ImmutableArray GetPropertyValues(EventProperty property); - /// - /// Distinct, sorted structured <UserData> field paths (storage keys) across all open raw events, for the - /// Basic editor's UserData field-name picker. - /// ImmutableArray GetUserDataFieldNames(); - /// - /// Distinct, sorted values of the structured UserData field (a storage key) across - /// all open raw events, for the value picker of a UserData filter row. - /// ImmutableArray GetUserDataFieldValues(string fieldName); + + bool IsContinuouslyUpdating(); } diff --git a/src/EventLogExpert.Runtime/EventLog/IEventSelectionSource.cs b/src/EventLogExpert.Runtime/EventLog/IEventSelectionSource.cs new file mode 100644 index 000000000..996bda748 --- /dev/null +++ b/src/EventLogExpert.Runtime/EventLog/IEventSelectionSource.cs @@ -0,0 +1,12 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Common.Sources; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.EventLog; + +public interface IEventSelectionSource : IChangeNotifier +{ + ImmutableList Current { get; } +} diff --git a/src/EventLogExpert.Runtime/EventLog/IFilterAppliedSource.cs b/src/EventLogExpert.Runtime/EventLog/IFilterAppliedSource.cs new file mode 100644 index 000000000..a74c20dce --- /dev/null +++ b/src/EventLogExpert.Runtime/EventLog/IFilterAppliedSource.cs @@ -0,0 +1,11 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Common.Sources; + +namespace EventLogExpert.Runtime.EventLog; + +public interface IFilterAppliedSource : IChangeNotifier +{ + bool IsFilteringEnabled { get; } +} diff --git a/src/EventLogExpert.Runtime/EventLog/ConvergeFilterAction.cs b/src/EventLogExpert.Runtime/EventLog/ILoadedLogNamesSource.cs similarity index 51% rename from src/EventLogExpert.Runtime/EventLog/ConvergeFilterAction.cs rename to src/EventLogExpert.Runtime/EventLog/ILoadedLogNamesSource.cs index 7d8a32024..c118d5eef 100644 --- a/src/EventLogExpert.Runtime/EventLog/ConvergeFilterAction.cs +++ b/src/EventLogExpert.Runtime/EventLog/ILoadedLogNamesSource.cs @@ -1,9 +1,12 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. -using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Runtime.Common.Sources; using System.Collections.Immutable; namespace EventLogExpert.Runtime.EventLog; -internal sealed record ConvergeFilterAction(ImmutableArray StaleIds, long OriginToken); +public interface ILoadedLogNamesSource : IChangeNotifier +{ + ImmutableHashSet Current { get; } +} diff --git a/src/EventLogExpert.Runtime/EventLog/IOpenLogsPresenceSource.cs b/src/EventLogExpert.Runtime/EventLog/IOpenLogsPresenceSource.cs new file mode 100644 index 000000000..6633d0e14 --- /dev/null +++ b/src/EventLogExpert.Runtime/EventLog/IOpenLogsPresenceSource.cs @@ -0,0 +1,11 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Common.Sources; + +namespace EventLogExpert.Runtime.EventLog; + +public interface IOpenLogsPresenceSource : IChangeNotifier +{ + bool HasOpenLogs { get; } +} diff --git a/src/EventLogExpert.Runtime/EventLog/IRevealFocusSource.cs b/src/EventLogExpert.Runtime/EventLog/IRevealFocusSource.cs new file mode 100644 index 000000000..69c716768 --- /dev/null +++ b/src/EventLogExpert.Runtime/EventLog/IRevealFocusSource.cs @@ -0,0 +1,12 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Runtime.Common.Sources; + +namespace EventLogExpert.Runtime.EventLog; + +public interface IRevealFocusSource : IChangeNotifier +{ + EventLocator? Current { get; } +} diff --git a/src/EventLogExpert.Runtime/EventLog/LiveTailIngestCoordinator.cs b/src/EventLogExpert.Runtime/EventLog/LiveTailIngestCoordinator.cs new file mode 100644 index 000000000..97d5a0551 --- /dev/null +++ b/src/EventLogExpert.Runtime/EventLog/LiveTailIngestCoordinator.cs @@ -0,0 +1,115 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Runtime.LogTable; +using System.Diagnostics; +using IDispatcher = Fluxor.IDispatcher; + +namespace EventLogExpert.Runtime.EventLog; + +internal sealed class LiveTailIngestCoordinator : IDisposable +{ + private const int MaxPendingPerLog = 1000; + + private static readonly TimeSpan s_maxBatchAge = TimeSpan.FromMilliseconds(16); + + private readonly IDispatcher _dispatcher; + private readonly Lock _emissionGate = new(); + private readonly Lock _gate = new(); + private readonly TimeSpan _maxBatchAge; + private readonly Dictionary> _pending = []; + private readonly Timer _timer; + + private bool _disposed; + + private long _lastFlushTimestamp; + + public LiveTailIngestCoordinator(IDispatcher dispatcher, TimeSpan? maxBatchAge = null) + { + ArgumentNullException.ThrowIfNull(dispatcher); + + _dispatcher = dispatcher; + _maxBatchAge = maxBatchAge ?? s_maxBatchAge; + + TimeSpan period = _maxBatchAge == Timeout.InfiniteTimeSpan ? Timeout.InfiniteTimeSpan : _maxBatchAge; + + _timer = new Timer(_ => Flush(), null, period, period); + } + + public void Discard(EventLogId logId) + { + lock (_gate) { _pending.Remove(logId); } + } + + public void DiscardAll() + { + lock (_gate) { _pending.Clear(); } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) { return; } + + _disposed = true; + + _pending.Clear(); + } + + _timer.Dispose(); + } + + public void Enqueue(EventLogId logId, ResolvedEvent newEvent) + { + ArgumentNullException.ThrowIfNull(newEvent); + + bool flushNow; + + lock (_gate) + { + if (_disposed) { return; } + + if (!_pending.TryGetValue(logId, out List? batch)) + { + batch = []; + _pending[logId] = batch; + } + + batch.Add(newEvent); + + bool idle = _lastFlushTimestamp == 0 + || Stopwatch.GetElapsedTime(_lastFlushTimestamp) >= _maxBatchAge; + + flushNow = batch.Count >= MaxPendingPerLog || (idle && _pending.Count == 1 && batch.Count == 1); + } + + if (flushNow) { Flush(); } + } + + public void Flush() + { + Dictionary> batches; + + lock (_gate) + { + if (_disposed || _pending.Count == 0) { return; } + + batches = new Dictionary>(_pending.Count); + + foreach ((EventLogId logId, List batch) in _pending) { batches[logId] = batch.AsReadOnly(); } + + _pending.Clear(); + _lastFlushTimestamp = Stopwatch.GetTimestamp(); + } + + lock (_emissionGate) + { + if (Volatile.Read(ref _disposed)) { return; } + + _dispatcher.Dispatch(new IngestRawEventsAction(batches, RawIngestMode.Prepend)); + } + } +} diff --git a/src/EventLogExpert.Runtime/EventLog/LoadedLogNamesSource.cs b/src/EventLogExpert.Runtime/EventLog/LoadedLogNamesSource.cs new file mode 100644 index 000000000..3a049db0b --- /dev/null +++ b/src/EventLogExpert.Runtime/EventLog/LoadedLogNamesSource.cs @@ -0,0 +1,23 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.Common.Sources; +using Fluxor; +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.EventLog; + +internal sealed class LoadedLogNamesSource( + IState state, + [FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + : ObservableStateSourceBase>( + state, + logger, + static state => state.LoadedLogNames, + static (next, current) => ReferenceEquals(next, current)), + ILoadedLogNamesSource +{ + public ImmutableHashSet Current => CurrentProjection; +} diff --git a/src/EventLogExpert.Runtime/EventLog/LogReloadEffects.cs b/src/EventLogExpert.Runtime/EventLog/LogReloadEffects.cs index 9b16a4a1a..ecb3467ef 100644 --- a/src/EventLogExpert.Runtime/EventLog/LogReloadEffects.cs +++ b/src/EventLogExpert.Runtime/EventLog/LogReloadEffects.cs @@ -3,7 +3,6 @@ using EventLogExpert.Eventing.Common.EventLogs; using EventLogExpert.Eventing.Common.Events; -using EventLogExpert.Filtering.Compilation; using EventLogExpert.Runtime.LogTable; using Fluxor; using IDispatcher = Fluxor.IDispatcher; @@ -12,37 +11,21 @@ namespace EventLogExpert.Runtime.EventLog; internal sealed class LogReloadEffects( IState eventLogState, - IState logTableState, IState rawEventStore, - IFilterService filterService, - LogCloseCoordinator closeCoordinator, - PartialLoadCoordinator coordinator) + LogCloseCoordinator closeCoordinator) { private readonly LogCloseCoordinator _closeCoordinator = closeCoordinator; - private readonly PartialLoadCoordinator _coordinator = coordinator; private readonly IState _eventLogState = eventLogState; - private readonly IFilterService _filterService = filterService; - private readonly IState _logTableState = logTableState; private readonly IState _rawEventStore = rawEventStore; [EffectMethod] public Task HandleLoadEvents(LoadEventsAction action, IDispatcher dispatcher) { - var version = _logTableState.Value.DisplayListVersion; - - _coordinator.MarkFinalized(action.LogData.Id); - - // The raw-store reducer runs synchronously before this effect, so the store already holds the finalized build. if (!_rawEventStore.Value.ByLog.TryGetValue(action.LogData.Id, out var store)) { return Task.CompletedTask; } - var view = DisplayViewBuilder.Build( - store, action.LogData.Id, _eventLogState.Value.AppliedFilter, _logTableState.Value.SortContext); - - dispatcher.Dispatch(new UpdateTableAction(action.LogData.Id) { View = view, Version = version }); - if (!_closeCoordinator.TryConsumePendingRestore(action.LogData.Name, out var pending) || pending is null || (pending.SelectedIds.Count <= 0 && !pending.SelectedId.HasValue)) @@ -55,18 +38,6 @@ pending is null || return Task.CompletedTask; } - [EffectMethod] - public Task HandleLoadEventsPartial(LoadEventsPartialAction action, IDispatcher dispatcher) - { - // The raw-store reducer already appended this partial's events, so the coordinator only marks the log dirty and - // rebuilds the view at flush time. - var version = _logTableState.Value.DisplayListVersion; - - _coordinator.Enqueue(action.LogData.Id, version); - - return Task.CompletedTask; - } - [EffectMethod(typeof(LoadNewEventsAction))] public Task HandleLoadNewEvents(IDispatcher dispatcher) { @@ -75,43 +46,6 @@ public Task HandleLoadNewEvents(IDispatcher dispatcher) return Task.CompletedTask; } - [EffectMethod] - public Task HandleRebuildDisplayViews(RebuildDisplayViewsAction action, IDispatcher dispatcher) - { - // Continuation of the IngestRawEventsAction dispatched just before it, so these reads see the post-ingest store. Do - // not inline back into the producer effect: a same-effect read would see the stale pre-ingest store (the fixed bug). - var raw = _rawEventStore.Value.ByLog; - var filter = _eventLogState.Value.AppliedFilter; - var context = _logTableState.Value.SortContext; - var viewsByLog = new Dictionary(action.NewEventsByLog.Count); - - foreach (var (logId, newEvents) in action.NewEventsByLog) - { - // Existence check before filter work: skip a log a concurrent close dropped without filtering it, and rebuild - // only when a new event survives the filter so a fully hidden batch doesn't churn the view. - if (raw.TryGetValue(logId, out var store) && - _filterService.GetFilteredEvents(newEvents, filter).Count > 0) - { - viewsByLog[logId] = DisplayViewBuilder.Build(store, logId, filter, context); - } - } - - if (viewsByLog.Count > 0) - { - dispatcher.Dispatch(new AppendTableEventsBatchAction { ViewsByLog = viewsByLog }); - } - - // Consume only after a successful rebuild (unreachable if it threw above), so a build failure preserves the count. - // Consuming the captured snapshot - not a blanket clear - keeps a mid-flush event; skip the dispatch when nothing - // was captured (an all-filtered rebuild still consumes its non-empty snapshot). - if (action.BufferEntriesToConsume is { Count: > 0 } bufferEntriesToConsume) - { - dispatcher.Dispatch(new NewEventBufferConsumedAction(bufferEntriesToConsume)); - } - - return Task.CompletedTask; - } - internal static void ProcessNewEventBuffer(EventLogState state, IDispatcher dispatcher) { var grouped = new Dictionary>(); @@ -138,9 +72,10 @@ internal static void ProcessNewEventBuffer(EventLogState state, IDispatcher disp dispatcher.Dispatch(new IngestRawEventsAction(rawByLog, RawIngestMode.Prepend)); } - // Continuation runs after the ingest, so it reads the post-ingest store and consumes exactly this snapshot by - // identity. Atomic reducer buffering (ReduceAddEvent) keeps an event buffered concurrently with the flush alive. - dispatcher.Dispatch(new RebuildDisplayViewsAction(rawByLog, BufferEntriesToConsume: state.NewEventBuffer)); + if (state.NewEventBuffer.Count > 0) + { + dispatcher.Dispatch(new NewEventBufferConsumedAction(state.NewEventBuffer)); + } } private static void RestoreSelection( @@ -176,5 +111,7 @@ private static void RestoreSelection( SelectionEntry? focused = focusEntry ?? (restored.Count > 0 ? restored[^1] : null); dispatcher.Dispatch(new SetSelectedEventsAction(restored, focused)); + + dispatcher.Dispatch(new RequestRevealFocusAction(focused!.Value.OriginHandle)); } } diff --git a/src/EventLogExpert.Runtime/EventLog/OpenLogAction.cs b/src/EventLogExpert.Runtime/EventLog/OpenLogAction.cs index de0fd9348..e5f2f6d68 100644 --- a/src/EventLogExpert.Runtime/EventLog/OpenLogAction.cs +++ b/src/EventLogExpert.Runtime/EventLog/OpenLogAction.cs @@ -2,7 +2,8 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.Channels; +using EventLogExpert.Eventing.Common.EventLogs; namespace EventLogExpert.Runtime.EventLog; -internal sealed record OpenLogAction(string LogName, LogPathType LogPathType, CancellationToken Token = default); +internal sealed record OpenLogAction(string LogName, LogPathType LogPathType, CancellationToken Token = default, EventLogId? PreassignedId = null); diff --git a/src/EventLogExpert.Runtime/EventLog/OpenLogEffects.cs b/src/EventLogExpert.Runtime/EventLog/OpenLogEffects.cs index 162996eff..494ae22b7 100644 --- a/src/EventLogExpert.Runtime/EventLog/OpenLogEffects.cs +++ b/src/EventLogExpert.Runtime/EventLog/OpenLogEffects.cs @@ -35,30 +35,29 @@ internal sealed class OpenLogEffects( ICriticalErrorService criticalErrorService, LogCloseCoordinator closeCoordinator, EventLogConcurrencyState concurrencyState, - PartialLoadCoordinator coordinator, + LiveTailIngestCoordinator liveTailCoordinator, IEventLogReaderFactory readerFactory) { - // Eager first paint fires at a screenful so the newest rows render in ~1s instead of waiting for the 3s partial timer. private const int EagerFirstPaintThreshold = 200; - // EvtNext batch: benchmarked Win11 throughput sweet spot; 512 regresses. private const int ReadBatchSize = 256; private static readonly int s_maxGlobalConcurrency = ConcurrencyLimits.MaxBackgroundIoParallelism; + private static readonly TimeSpan s_partialDispatchInterval = TimeSpan.FromSeconds(3); private static readonly PrioritySemaphore s_resolutionGate = new(s_maxGlobalConcurrency); private readonly LogCloseCoordinator _closeCoordinator = closeCoordinator; private readonly EventLogConcurrencyState _concurrencyState = concurrencyState; - private readonly PartialLoadCoordinator _coordinator = coordinator; private readonly ICriticalErrorService _criticalErrorService = criticalErrorService; private readonly IDatabaseService _databaseService = databaseService; private readonly IState _eventLogState = eventLogState; private readonly Lock _globalCtsLock = new(); private readonly ITraceLogger _lifecycleLogger = logger.ForCategory(LogCategories.EventLogLifecycle); + private readonly LiveTailIngestCoordinator _liveTailCoordinator = liveTailCoordinator; private readonly ConcurrentDictionary _logCts = new(); - private readonly ITraceLogger _logger = logger; private readonly ConcurrentDictionary _logLoadCompletions = new(); private readonly ILogWatcherService _logWatcherService = logWatcherService; + private readonly ITraceLogger _logger = logger; private readonly IEventLogReaderFactory _readerFactory = readerFactory; private readonly IEventResolverCache _resolverCache = resolverCache; private readonly IServiceScopeFactory _serviceScopeFactory = serviceScopeFactory; @@ -72,9 +71,8 @@ public async Task HandleCloseAll(IDispatcher dispatcher) { _lifecycleLogger.Debug($"Close-all requested ({_eventLogState.Value.OpenLogs.Count} active logs)."); - _coordinator.DiscardAll(); + _liveTailCoordinator.DiscardAll(); - _concurrencyState.InvalidateInFlightFilters(); _concurrencyState.InvalidateInFlightReloads(); CancelAllLoads(); @@ -92,7 +90,7 @@ public async Task HandleCloseLog(CloseLogAction action, IDispatcher dispatcher) { _lifecycleLogger.Debug($"Close requested for '{action.LogName}' (id: {action.LogId})."); - _coordinator.Discard(action.LogId); + _liveTailCoordinator.Discard(action.LogId); try { @@ -114,12 +112,14 @@ public async Task HandleCloseLog(CloseLogAction action, IDispatcher dispatcher) } } - await _logWatcherService.RemoveLogAsync(action.LogName); + if (!(_eventLogState.Value.OpenLogs.TryGetValue(action.LogName, out var activeLog) && activeLog.Id != action.LogId)) + { + await _logWatcherService.RemoveLogAsync(action.LogName); + _closeCoordinator.ClearPendingRestore(action.LogName); + _xmlResolver.ClearXmlCacheForLog(action.LogName); + } _concurrencyState.ClearLoadedWithXml(action.LogId); - _closeCoordinator.ClearPendingRestore(action.LogName); - - _xmlResolver.ClearXmlCacheForLog(action.LogName); dispatcher.Dispatch(new LogTable.CloseLogAction(action.LogId)); @@ -154,6 +154,13 @@ public async Task HandleOpenLog(OpenLogAction action, IDispatcher dispatcher) var logData = new EventLogData(action.LogName, openInfo.Type) { Id = openInfo.Id }; + if (action.PreassignedId is { } preassignedId && openInfo.Id != preassignedId) + { + _logger.Trace($"Open '{action.LogName}': correlated reopen superseded by a same-name open (expected {preassignedId}, found {openInfo.Id}); skipping load."); + + return; + } + CancellationTokenSource perLoadCts; using (_globalCtsLock.EnterScope()) @@ -330,7 +337,7 @@ private async Task LoadLogAsync( }, null, TimeSpan.Zero, - TimeSpan.FromSeconds(3)); + s_partialDispatchInterval); bool renderXml = _eventLogState.Value.AppliedFilter.RequiresXml; @@ -381,8 +388,6 @@ await Parallel.ForEachAsync( { EventRecord[] batch = item.Batch; - // Classify by ADMITTED (not completed) events so a slow-resolving load still demotes to Bulk after - // its first screenful instead of monopolizing the high-priority lane. var priority = Volatile.Read(ref highAdmitted) < EagerFirstPaintThreshold ? ResolutionPriority.FirstScreenful : ResolutionPriority.Bulk; @@ -435,8 +440,6 @@ await Parallel.ForEachAsync( nextDrainSeq++; } - // Dispatch the first screenful immediately instead of waiting for the 3s timer (the read is - // reversed, so these are the newest events). if (events.Count >= EagerFirstPaintThreshold && Interlocked.Exchange(ref eagerFired, 1) == 0) { dispatchEager = true; @@ -500,12 +503,8 @@ await Parallel.ForEachAsync( return; } - // Stop the timer before dispatching so no stale LoadEventsPartialAction fires after the final LoadEventsAction. await timer.DisposeAsync(); - // Dispatch the finalized list in physical read order (the order partials were appended) so the finalization - // Build keeps every physical Index stable; re-sorting would strand partial-era EventLocators (the selection and - // highlight-cache keys) on a different physical row. token.ThrowIfCancellationRequested(); if (!_eventLogState.Value.OpenLogs.TryGetValue(logData.Name, out var activeLog) diff --git a/src/EventLogExpert.Runtime/EventLog/OpenLogsPresenceSource.cs b/src/EventLogExpert.Runtime/EventLog/OpenLogsPresenceSource.cs new file mode 100644 index 000000000..d93be8e04 --- /dev/null +++ b/src/EventLogExpert.Runtime/EventLog/OpenLogsPresenceSource.cs @@ -0,0 +1,18 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.Common.Sources; +using Fluxor; +using Microsoft.Extensions.DependencyInjection; + +namespace EventLogExpert.Runtime.EventLog; + +internal sealed class OpenLogsPresenceSource( + IState state, + [FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + : ObservableStateSourceBase(state, logger, static state => state.OpenLogCount > 0), + IOpenLogsPresenceSource +{ + public bool HasOpenLogs => CurrentProjection; +} diff --git a/src/EventLogExpert.Runtime/EventLog/PartialLoadCoordinator.cs b/src/EventLogExpert.Runtime/EventLog/PartialLoadCoordinator.cs deleted file mode 100644 index 880fac0c7..000000000 --- a/src/EventLogExpert.Runtime/EventLog/PartialLoadCoordinator.cs +++ /dev/null @@ -1,146 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -using EventLogExpert.Eventing.Common.EventLogs; -using EventLogExpert.Runtime.LogTable; -using Fluxor; -using IDispatcher = Fluxor.IDispatcher; - -namespace EventLogExpert.Runtime.EventLog; - -internal sealed class PartialLoadCoordinator : IDisposable -{ - private static readonly TimeSpan s_flushWindow = TimeSpan.FromMilliseconds(1000); - - private readonly HashSet _dirty = []; - private readonly IDispatcher _dispatcher; - private readonly IState _eventLogState; - private readonly HashSet _finalized = []; - private readonly Lock _gate = new(); - private readonly IState _logTableState; - private readonly IState _rawEventStore; - private readonly HashSet _seen = []; - private readonly Timer _timer; - private readonly Dictionary _versions = []; - - private bool _disposed; - - public PartialLoadCoordinator( - IDispatcher dispatcher, - IState rawEventStore, - IState eventLogState, - IState logTableState) - : this(dispatcher, rawEventStore, eventLogState, logTableState, s_flushWindow) { } - - internal PartialLoadCoordinator( - IDispatcher dispatcher, - IState rawEventStore, - IState eventLogState, - IState logTableState, - TimeSpan flushInterval) - { - _dispatcher = dispatcher; - _rawEventStore = rawEventStore; - _eventLogState = eventLogState; - _logTableState = logTableState; - _timer = new Timer(_ => Flush(), null, flushInterval, flushInterval); - } - - public void Discard(EventLogId logId) - { - lock (_gate) - { - _dirty.Remove(logId); - _versions.Remove(logId); - _finalized.Remove(logId); - _seen.Remove(logId); - } - } - - public void DiscardAll() - { - lock (_gate) - { - _dirty.Clear(); - _versions.Clear(); - _finalized.Clear(); - _seen.Clear(); - } - } - - public void Dispose() - { - lock (_gate) - { - _disposed = true; - _dirty.Clear(); - _versions.Clear(); - _finalized.Clear(); - _seen.Clear(); - } - - _timer.Dispose(); - } - - public void Enqueue(EventLogId logId, int version) - { - lock (_gate) - { - // A straggler partial delta can arrive after the final LoadEvents (effects are fire-and-forget); dropping finalized logs prevents rebuilding a view the finalize already published. - if (_disposed || _finalized.Contains(logId)) { return; } - - _dirty.Add(logId); - - // Use Math.Min so a buffer straddling a filter change adopts the older version, forcing a safe re-sort at finalize. - _versions[logId] = _versions.TryGetValue(logId, out var existingVersion) - ? Math.Min(existingVersion, version) - : version; - - if (_seen.Add(logId)) { FlushLocked(); } - } - } - - public void MarkFinalized(EventLogId logId) - { - lock (_gate) - { - _finalized.Add(logId); - _dirty.Remove(logId); - _versions.Remove(logId); - } - } - - internal void Flush() - { - lock (_gate) { FlushLocked(); } - } - - private void FlushLocked() - { - if (_disposed || _dirty.Count == 0) { return; } - - var raw = _rawEventStore.Value.ByLog; - var filter = _eventLogState.Value.AppliedFilter; - var context = _logTableState.Value.SortContext; - - var viewsByLog = new Dictionary(_dirty.Count); - var versionByLog = new Dictionary(_dirty.Count); - - foreach (var logId in _dirty) - { - if (!raw.TryGetValue(logId, out var store)) { continue; } - - viewsByLog[logId] = DisplayViewBuilder.Build(store, logId, filter, context); - - if (_versions.TryGetValue(logId, out var version)) { versionByLog[logId] = version; } - } - - _dirty.Clear(); - _versions.Clear(); - - if (viewsByLog.Count == 0) { return; } - - // Dispatch under the lock so batches and the final UpdateTable keep FIFO order in Fluxor's queue. - _dispatcher.Dispatch(new AppendTableEventsBatchAction { ViewsByLog = viewsByLog, VersionByLog = versionByLog }); - } -} diff --git a/src/EventLogExpert.Runtime/EventLog/RebuildDisplayViewsAction.cs b/src/EventLogExpert.Runtime/EventLog/RebuildDisplayViewsAction.cs deleted file mode 100644 index 9b929e3ff..000000000 --- a/src/EventLogExpert.Runtime/EventLog/RebuildDisplayViewsAction.cs +++ /dev/null @@ -1,14 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -using EventLogExpert.Eventing.Common.EventLogs; -using EventLogExpert.Eventing.Common.Events; - -namespace EventLogExpert.Runtime.EventLog; - -// Effect-only continuation dispatched after IngestRawEventsAction so the rebuild reads the post-ingest store. -// BufferEntriesToConsume is the flush's captured buffer snapshot to remove on a successful rebuild; null on the -// live-tail path (no buffer to consume). -internal sealed record RebuildDisplayViewsAction( - IReadOnlyDictionary> NewEventsByLog, - IReadOnlyList? BufferEntriesToConsume); diff --git a/src/EventLogExpert.Runtime/EventLog/Reducers.cs b/src/EventLogExpert.Runtime/EventLog/Reducers.cs index 58d1d59c5..16af064bf 100644 --- a/src/EventLogExpert.Runtime/EventLog/Reducers.cs +++ b/src/EventLogExpert.Runtime/EventLog/Reducers.cs @@ -21,9 +21,6 @@ internal sealed class Reducers [ReducerMethod] public static EventLogState ReduceAddEvent(EventLogState state, AddEventAction action) { - // Buffer additively in the reducer so concurrent adds and the flush's consume compose against current state (a - // stale whole-buffer effect write could clobber them). Continuously-update buffers nothing; HandleAddEvent drives - // the live tail. if (state.ContinuouslyUpdate || !state.OpenLogs.ContainsKey(action.NewEvent.OwningLog)) { return state; @@ -59,6 +56,7 @@ state with LoadedLogNames = RecomputeLoadedLogNames(s_emptyNamesByLog, state.LoadedLogNames), Focus = null, Selection = [], + PendingRevealFocus = null, NewEventBuffer = [], NewEventBufferIsFull = false }; @@ -66,21 +64,28 @@ state with [ReducerMethod] public static EventLogState ReduceCloseLog(EventLogState state, CloseLogAction action) { + if (state.OpenLogs.TryGetValue(action.LogName, out var closingLog) && closingLog.Id != action.LogId) + { + return state; + } + var newEventBuffer = state.NewEventBuffer .Where(e => e.OwningLog != action.LogName) .ToList(); - // Drop selections belonging to the closed log; otherwise a reload (close then reopen) leaves stale-generation - // handles that block the highlight refresh when the restored entries arrive. var newSelection = state.Selection .RemoveAll(entry => entry.OriginHandle.LogId == action.LogId); - // Clear focus when it belongs to the closed log; otherwise it would address a defunct generation after the reopen. var newFocus = state.Focus is { } focus && focus.OriginHandle.LogId == action.LogId ? null : state.Focus; + var newPendingRevealFocus = + state.PendingRevealFocus is { } reveal && reveal.LogId == action.LogId + ? null + : state.PendingRevealFocus; + var newNamesByLog = state.NamesByLog.Remove(action.LogName); return state with @@ -91,7 +96,8 @@ public static EventLogState ReduceCloseLog(EventLogState state, CloseLogAction a NewEventBuffer = newEventBuffer, NewEventBufferIsFull = newEventBuffer.Count >= EventLogState.MaxNewEvents, Focus = newFocus, - Selection = newSelection + Selection = newSelection, + PendingRevealFocus = newPendingRevealFocus }; } @@ -154,8 +160,6 @@ public static EventLogState ReduceNewEventBufferConsumed(EventLogState state, Ne { if (action.ConsumedEvents.Count == 0) { return state; } - // Remove only the captured entries by reference identity, so an event a watcher buffered during the flush - // (prepended after the snapshot) survives. Mirrors ReduceCloseLog's filtered removal + IsFull recompute. var consumed = new HashSet(action.ConsumedEvents, ReferenceEqualityComparer.Instance); var remaining = state.NewEventBuffer.Where(bufferedEvent => !consumed.Contains(bufferedEvent)).ToList(); @@ -169,11 +173,9 @@ public static EventLogState ReduceNewEventBufferConsumed(EventLogState state, Ne [ReducerMethod] public static EventLogState ReduceOpenLog(EventLogState state, OpenLogAction action) { - // Idempotent: re-opening an already-active log is a no-op, so callers need not coordinate to avoid - // ImmutableDictionary.Add throwing. if (state.OpenLogs.ContainsKey(action.LogName)) { return state; } - var openLogId = EventLogId.Create(); + var openLogId = action.PreassignedId ?? EventLogId.Create(); var perLogNames = action.LogPathType == LogPathType.Channel ? s_emptyNames.Add(action.LogName) @@ -189,14 +191,23 @@ public static EventLogState ReduceOpenLog(EventLogState state, OpenLogAction act }; } + [ReducerMethod] + public static EventLogState ReduceRequestRevealFocus(EventLogState state, RequestRevealFocusAction action) => + state.PendingRevealFocus == action.Target ? state : state with { PendingRevealFocus = action.Target }; + + [ReducerMethod] + public static EventLogState ReduceRevealFocusConsumed(EventLogState state, RevealFocusConsumedAction action) + { + if (state.PendingRevealFocus != action.Target) { return state; } + + return state with { PendingRevealFocus = null }; + } + [ReducerMethod] public static EventLogState ReduceSelectEvent(EventLogState state, SelectEventAction action) { - // OriginHandle value equality is the selection identity: a stale (prior-generation) handle is distinct from the - // fresh one, matching reference-identity semantics without holding event object references. bool alreadySelected = ContainsByOriginHandle(state.Selection, action.Selection); - // Focus always tracks the affected row (Explorer-style focus), independent of whether the row ends up selected. if (!alreadySelected) { return state with @@ -229,8 +240,6 @@ public static EventLogState ReduceSelectEvent(EventLogState state, SelectEventAc [ReducerMethod] public static EventLogState ReduceSelectEvents(EventLogState state, SelectEventsAction action) { - // OriginHandle-identity dedupe only: blocks the same handle twice but lets distinct-generation handles (a stale - // entry and a freshly restored one) coexist, so a stale selection isn't collapsed with the fresh copy. var existing = new HashSet(); foreach (var entry in state.Selection) { existing.Add(entry.OriginHandle); } @@ -246,8 +255,6 @@ public static EventLogState ReduceSelectEvents(EventLogState state, SelectEvents var newSelection = state.Selection.AddRange(entriesToAdd); - // Preserve focus when it survives the merge (matched by OriginHandle); otherwise focus the last incoming entry - // so the restore path leaves something focused. SelectionEntry newFocus = entriesToAdd[^1]; if (state.Focus is not { } priorFocus) @@ -277,8 +284,6 @@ public static EventLogState ReduceSetContinuouslyUpdate( [ReducerMethod] public static EventLogState ReduceSetSelectedEvents(EventLogState state, SetSelectedEventsAction action) { - // Order-preserving distinct by OriginHandle; the caller orders entries by the current sort, and the reducer - // honors that order. var seen = new HashSet(); var builder = ImmutableList.CreateBuilder(); @@ -292,8 +297,6 @@ public static EventLogState ReduceSetSelectedEvents(EventLogState state, SetSele var newSelection = builder.ToImmutable(); - // Avoid a new state reference when nothing changed; SelectionEntry is a value type, so identity uses OriginHandle - // value equality, not ReferenceEquals. bool selectionUnchanged = SelectionsEqualByOriginHandle(state.Selection, newSelection); bool focusUnchanged = FocusEqualsByOriginHandle(state.Focus, action.Focus); diff --git a/src/EventLogExpert.Runtime/EventLog/RequestRevealFocusAction.cs b/src/EventLogExpert.Runtime/EventLog/RequestRevealFocusAction.cs new file mode 100644 index 000000000..7c830c538 --- /dev/null +++ b/src/EventLogExpert.Runtime/EventLog/RequestRevealFocusAction.cs @@ -0,0 +1,8 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; + +namespace EventLogExpert.Runtime.EventLog; + +internal sealed record RequestRevealFocusAction(EventLocator Target); diff --git a/src/EventLogExpert.Runtime/EventLog/RevealFocusConsumedAction.cs b/src/EventLogExpert.Runtime/EventLog/RevealFocusConsumedAction.cs new file mode 100644 index 000000000..bc2eca839 --- /dev/null +++ b/src/EventLogExpert.Runtime/EventLog/RevealFocusConsumedAction.cs @@ -0,0 +1,8 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; + +namespace EventLogExpert.Runtime.EventLog; + +internal sealed record RevealFocusConsumedAction(EventLocator Target); diff --git a/src/EventLogExpert.Runtime/EventLog/RevealFocusSource.cs b/src/EventLogExpert.Runtime/EventLog/RevealFocusSource.cs new file mode 100644 index 000000000..580b5cc46 --- /dev/null +++ b/src/EventLogExpert.Runtime/EventLog/RevealFocusSource.cs @@ -0,0 +1,19 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.Common.Sources; +using Fluxor; +using Microsoft.Extensions.DependencyInjection; + +namespace EventLogExpert.Runtime.EventLog; + +internal sealed class RevealFocusSource( + IState state, + [FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + : ObservableStateSourceBase(state, logger, static state => state.PendingRevealFocus), + IRevealFocusSource +{ + public EventLocator? Current => CurrentProjection; +} diff --git a/src/EventLogExpert.Runtime/EventLog/XmlReloadCoordinator.cs b/src/EventLogExpert.Runtime/EventLog/XmlReloadCoordinator.cs new file mode 100644 index 000000000..7977b7a6b --- /dev/null +++ b/src/EventLogExpert.Runtime/EventLog/XmlReloadCoordinator.cs @@ -0,0 +1,175 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Channels; +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Filtering.Evaluation; +using EventLogExpert.Logging.Abstractions; +using Fluxor; +using Microsoft.Extensions.DependencyInjection; +using IDispatcher = Fluxor.IDispatcher; + +namespace EventLogExpert.Runtime.EventLog; + +internal sealed class XmlReloadCoordinator( + IState eventLogState, + LogCloseCoordinator closeCoordinator, + EventLogConcurrencyState concurrencyState, + [FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) +{ + private readonly LogCloseCoordinator _closeCoordinator = closeCoordinator; + private readonly EventLogConcurrencyState _concurrencyState = concurrencyState; + private readonly IState _eventLogState = eventLogState; + private readonly ITraceLogger _logger = logger; + + public async Task ReloadAsync(PendingXmlReload pending, IDispatcher dispatcher) + { + ArgumentNullException.ThrowIfNull(pending); + ArgumentNullException.ThrowIfNull(dispatcher); + + var logsNeedingReload = pending.Logs; + long reloadToken = pending.ReloadToken; + var reloadNames = logsNeedingReload.Select(log => log.Name).ToHashSet(StringComparer.Ordinal); + + var selectionByLog = _eventLogState.Value.Selection + .Where(entry => entry.ReloadKey is { } key && reloadNames.Contains(key.OwningLog)) + .GroupBy(entry => entry.ReloadKey!.Value.OwningLog) + .ToDictionary( + group => group.Key, + IReadOnlySet (group) => group.Select(entry => entry.ReloadKey!.Value.RecordId).ToHashSet()); + + var focus = _eventLogState.Value.Focus; + long? selectedRecordId = focus?.ReloadKey?.RecordId; + string? selectedLogName = focus?.ReloadKey?.OwningLog; + + if (selectedRecordId.HasValue && + !string.IsNullOrEmpty(selectedLogName) && + reloadNames.Contains(selectedLogName) && + !selectionByLog.ContainsKey(selectedLogName)) + { + selectionByLog[selectedLogName] = new HashSet(); + } + + await _closeCoordinator.AcquireCoordinatorLockAsync(); + + try + { + var closeWaiters = new List<(EventLogId Id, string Name, Task Task)>(logsNeedingReload.Count); + + foreach (var (id, name, _) in logsNeedingReload) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _closeCoordinator.RegisterCloseCompletion(id, tcs); + closeWaiters.Add((id, name, tcs.Task)); + } + + foreach (var (id, name, _) in logsNeedingReload) + { + dispatcher.Dispatch(new CloseLogAction(id, name)); + } + + var timedOutLogs = new HashSet(StringComparer.Ordinal); + + foreach (var (id, name, task) in closeWaiters) + { + try + { + await task.WaitAsync(LogCloseCoordinator.LogCloseTimeout); + } + catch (TimeoutException) + { + _closeCoordinator.RemoveStrandedCompletion(id); + timedOutLogs.Add(name); + + _logger.Trace( + $"{nameof(ReloadAsync)}: close for log '{name}' did not complete within {LogCloseCoordinator.LogCloseTimeout}; selection will not be restored to avoid race with the delayed close wiping the entry."); + } + } + + foreach (var (name, ids) in selectionByLog) + { + if (timedOutLogs.Contains(name)) { continue; } + + long? selectedIdForLog = string.Equals(name, selectedLogName, StringComparison.Ordinal) ? + selectedRecordId : null; + + _closeCoordinator.WritePendingRestore(name, new PendingSelectionRestore(ids, selectedIdForLog)); + } + + if (_concurrencyState.GetCurrentReloadToken() != reloadToken) + { + foreach (var (_, name, _) in logsNeedingReload) + { + _closeCoordinator.ClearPendingRestore(name); + } + + _logger.Trace( + $"{nameof(ReloadAsync)}: reload superseded by CloseAll; skipping reopen of {logsNeedingReload.Count} log(s) and clearing pending selection restore."); + + return; + } + + var reopenedSoFar = new List<(EventLogId Id, string Name)>(logsNeedingReload.Count); + + void AbortReopenAsSuperseded(string when) + { + foreach (var (reopenedId, reopenedName) in reopenedSoFar) + { + dispatcher.Dispatch(new CloseLogAction(reopenedId, reopenedName)); + } + + foreach (var (_, restoreName, _) in logsNeedingReload) + { + _closeCoordinator.ClearPendingRestore(restoreName); + } + + _logger.Trace( + $"{nameof(ReloadAsync)}: reload superseded by CloseAll {when}; dispatched CloseLog for {reopenedSoFar.Count} reopened log(s) and cleared pending selection restore."); + } + + foreach (var (_, name, type) in logsNeedingReload) + { + if (_concurrencyState.GetCurrentReloadToken() != reloadToken) + { + AbortReopenAsSuperseded("mid-reopen"); + + return; + } + + var reopenedId = EventLogId.Create(); + + dispatcher.Dispatch(new OpenLogAction(name, type, PreassignedId: reopenedId)); + reopenedSoFar.Add((reopenedId, name)); + } + + if (_concurrencyState.GetCurrentReloadToken() != reloadToken) + { + AbortReopenAsSuperseded("after reopen"); + } + } + finally + { + _closeCoordinator.ReleaseCoordinatorLock(); + } + } + + public PendingXmlReload Resolve(Filter filter) + { + long reloadToken = _concurrencyState.GetCurrentReloadToken(); + + var logs = filter.RequiresXml && !_eventLogState.Value.OpenLogs.IsEmpty ? + _eventLogState.Value.OpenLogs + .Where(entry => !_concurrencyState.IsLoadedWithXml(entry.Value.Id)) + .Select(entry => (entry.Value.Id, Name: entry.Key, entry.Value.Type)) + .ToList() : []; + + return new PendingXmlReload(logs, reloadToken); + } +} + +internal sealed record PendingXmlReload( + List<(EventLogId Id, string Name, LogPathType Type)> Logs, + long ReloadToken) +{ + public bool IsNeeded => Logs.Count > 0; +} diff --git a/src/EventLogExpert.Runtime/EventLogExpert.Runtime.csproj b/src/EventLogExpert.Runtime/EventLogExpert.Runtime.csproj index 7cdd3cb49..e339a2047 100644 --- a/src/EventLogExpert.Runtime/EventLogExpert.Runtime.csproj +++ b/src/EventLogExpert.Runtime/EventLogExpert.Runtime.csproj @@ -6,7 +6,7 @@ - + diff --git a/src/EventLogExpert.Runtime/Export/EventExportCoordinator.cs b/src/EventLogExpert.Runtime/Export/EventExportCoordinator.cs new file mode 100644 index 000000000..ffa1b7fd8 --- /dev/null +++ b/src/EventLogExpert.Runtime/Export/EventExportCoordinator.cs @@ -0,0 +1,153 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.Alerts; +using EventLogExpert.Runtime.Banner; +using EventLogExpert.Runtime.Common.Files; +using EventLogExpert.Runtime.LogTable; +using EventLogExpert.Runtime.Settings; +using Fluxor; +using System.Globalization; + +namespace EventLogExpert.Runtime.Export; + +public sealed class EventExportCoordinator( + IState logTableState, + IEventTableExporter eventTableExporter, + IFileSaveService fileSaveService, + IExportProgressBannerService exportProgress, + IAlertDialogService dialogService, + ISettingsService settings, + ILogTableColumnDefaultsProvider columnDefaults, + ITraceLogger traceLogger) +{ + private readonly ILogTableColumnDefaultsProvider _columnDefaults = columnDefaults; + private readonly IAlertDialogService _dialogService = dialogService; + private readonly IEventTableExporter _eventTableExporter = eventTableExporter; + private readonly IExportProgressBannerService _exportProgress = exportProgress; + private readonly IFileSaveService _fileSaveService = fileSaveService; + private readonly IState _logTableState = logTableState; + private readonly ISettingsService _settings = settings; + private readonly ITraceLogger _traceLogger = traceLogger; + + private int _exportInFlight; + + public async Task ExportEventsAsync(ExportFormat format) + { + var state = _logTableState.Value; + var events = state.GetActiveDisplayedEvents(); + + if (events.Count == 0) + { + string reason = state.PresentationState switch + { + PresentationState.Faulted => "These events cannot be exported because the view could not be prepared.", + PresentationState.Updating => + "These events are still being prepared. Please try again once they have finished loading.", + _ => "There are no events to export." + }; + + await _dialogService.ShowAlert("Export events", reason, "Ok", AlertPresentation.Banner); + + return; + } + + var columns = state.GetOrderedEnabledColumns(_columnDefaults); + + if (columns.Count == 0) + { + await _dialogService.ShowAlert( + "Export events", "There are no visible columns to export.", "Ok", AlertPresentation.Banner); + + return; + } + + var timeZone = _settings.TimeZoneInfo; + bool isCsv = format == ExportFormat.Csv; + var fileTypes = isCsv ? FileSaveFileTypes.Csv : FileSaveFileTypes.Json; + string extension = isCsv ? ".csv" : ".json"; + string suggestedFileName = + $"events-{DateTime.Now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture)}{extension}"; + + if (Interlocked.CompareExchange(ref _exportInFlight, 1, 0) != 0) + { + await _dialogService.ShowAlert( + "Export events", "An export is already in progress.", "Ok", AlertPresentation.Banner); + + return; + } + + CancellationTokenSource cancellation = new(); + string? savedPath = null; + Exception? failure = null; + bool canceled = false; + + try + { + savedPath = await _fileSaveService.SaveStreamingAsync( + suggestedFileName, + fileTypes, + async (stream, _) => + { + _exportProgress.Begin( + "Exporting events...", + () => + { + try { cancellation.Cancel(); } + catch (ObjectDisposedException) { /* Teardown disposed the CTS; a late Cancel is a no-op. */ } + }); + + await _eventTableExporter.ExportAsync( + stream, format, events, columns, timeZone, includeDescription: true, cancellation.Token); + }, + CancellationToken.None); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + canceled = true; + } + catch (Exception ex) + { + failure = ex; + } + finally + { + try + { + _exportProgress.End(); + } + finally + { + cancellation.Dispose(); + Interlocked.Exchange(ref _exportInFlight, 0); + } + } + + if (canceled) + { + await _dialogService.ShowAlert( + "Export canceled", "The export was canceled.", "Ok", AlertPresentation.Banner); + + return; + } + + if (failure is not null) + { + _traceLogger.Error($"Failed to export events: {failure}"); + + await _dialogService.ShowAlert("Export failed", failure.Message, "Ok", AlertPresentation.Banner); + + return; + } + + if (savedPath is not null) + { + await _dialogService.ShowAlert( + "Export complete", + $"Exported {events.Count:N0} {(events.Count == 1 ? "event" : "events")} to {savedPath}.", + "Ok", + AlertPresentation.Banner); + } + } +} diff --git a/src/EventLogExpert.Runtime/Export/EventTableExporter.cs b/src/EventLogExpert.Runtime/Export/EventTableExporter.cs index fc913b39e..5062a96df 100644 --- a/src/EventLogExpert.Runtime/Export/EventTableExporter.cs +++ b/src/EventLogExpert.Runtime/Export/EventTableExporter.cs @@ -53,12 +53,8 @@ private static string NeutralizeCsvFormula(string value) { if (value.Length == 0) { return value; } - // A leading TAB or CR is itself a CSV-injection vector; neutralize it directly (it is whitespace, - // so the scan below would otherwise skip past it). if (value[0] is '\t' or '\r') { return "'" + value; } - // Spreadsheet apps trim leading whitespace before evaluating a cell, so a value like " =SUM(A1)" - // is still a formula. Neutralize when the first non-whitespace character is a formula trigger. foreach (char character in value) { if (char.IsWhiteSpace(character)) { continue; } @@ -77,15 +73,12 @@ private static string NeutralizeCsvFormula(string value) bool includeDescription, [EnumeratorCancellation] CancellationToken cancellationToken) { - // This await only satisfies the async-iterator contract; the projection itself is synchronous over the view's - // on-demand rehydrate. await Task.CompletedTask.ConfigureAwait(false); bool neutralizeCsvFormula = format == ExportFormat.Csv; int cellCount = columns.Count + (includeDescription ? 1 : 0); - // Enumerate, never index: EnumerateDetail is an O(n) single-cursor rehydrate, so indexed access would be O(n^2). - foreach (ResolvedEvent @event in events.EnumerateDetail()) + foreach (ResolvedEvent @event in events.EnumerateDetailLean()) { cancellationToken.ThrowIfCancellationRequested(); @@ -99,9 +92,10 @@ private static string NeutralizeCsvFormula(string value) if (includeDescription) { - cells[columns.Count] = neutralizeCsvFormula - ? NeutralizeCsvFormula(@event.Description) - : @event.Description; + cells[columns.Count] = + neutralizeCsvFormula ? + NeutralizeCsvFormula(@event.Description) : + @event.Description; } yield return cells; diff --git a/src/EventLogExpert.Runtime/FilterLenses/FilterLensCommands.cs b/src/EventLogExpert.Runtime/FilterLenses/FilterLensCommands.cs index 0e0568ba2..39e16bfe8 100644 --- a/src/EventLogExpert.Runtime/FilterLenses/FilterLensCommands.cs +++ b/src/EventLogExpert.Runtime/FilterLenses/FilterLensCommands.cs @@ -11,7 +11,7 @@ internal sealed class FilterLensCommands(IDispatcher dispatcher) : IFilterLensCo public void ClearLenses() => _dispatcher.Dispatch(new ClearFilterLensesAction()); - public void RemoveLens(FilterLens lens) => _dispatcher.Dispatch(new RemoveFilterLensAction(lens)); + public void RemoveLens(FilterLensId id) => _dispatcher.Dispatch(new RemoveFilterLensAction(id)); public void ShowEventsNearTime(DateTime timeCreated, TimeSpan radius, TimeZoneInfo displayZone, string? originLog = null) => PushLens(FilterLensFactory.ForTimeWindow(timeCreated, radius, displayZone, originLog)); diff --git a/src/EventLogExpert.Runtime/FilterLenses/FilterLensFactory.cs b/src/EventLogExpert.Runtime/FilterLenses/FilterLensFactory.cs index 94568a897..01fe3051a 100644 --- a/src/EventLogExpert.Runtime/FilterLenses/FilterLensFactory.cs +++ b/src/EventLogExpert.Runtime/FilterLenses/FilterLensFactory.cs @@ -11,18 +11,11 @@ namespace EventLogExpert.Runtime.FilterLenses; internal static class FilterLensFactory { - /// - /// Builds a lens that keeps only rows whose ActivityId equals . An optional - /// overrides the default chip text - used by the parent-activity jump, which is an - /// ActivityId-equality narrowing surfaced to the user under a different name. - /// + private static readonly TimeSpan s_maxTimeWindowRadius = TimeSpan.FromHours(1); + public static FilterLens? ForActivityId(Guid activityId, string? originLog = null, string? label = null) => BuildEqualityLens(EventProperty.ActivityId, activityId, label ?? $"Activity ID = {activityId}", originLog); - /// - /// Builds a lens that keeps only rows whose RelatedActivityId equals , - /// grouping events that share the same parent/correlation activity. - /// public static FilterLens? ForRelatedActivityId(Guid relatedActivityId, string? originLog = null) => BuildEqualityLens( EventProperty.RelatedActivityId, @@ -30,10 +23,6 @@ internal static class FilterLensFactory $"Related Activity ID = {relatedActivityId}", originLog); - /// - /// Builds a lens keeping rows whose TimeCreated is in the inclusive UTC range [startUtc, endUtc]; endpoints are - /// normalized so a right-to-left brush stays a valid non-empty window. - /// public static FilterLens ForTimeRange( DateTime startUtc, DateTime endUtc, @@ -46,7 +35,6 @@ public static FilterLens ForTimeRange( return new FilterLens { - // Include each endpoint's date when the window spans more than one displayed day, so a midnight-straddling or multi-day range isn't a bare, ambiguous "23:55:00 - 00:05:00". Label = afterLocal.Date == beforeLocal.Date ? $"{afterLocal:T} - {beforeLocal:T}" : $"{afterLocal:d} {afterLocal:T} - {beforeLocal:d} {beforeLocal:T}", @@ -56,20 +44,6 @@ public static FilterLens ForTimeRange( }; } - /// - /// Builds a transient time-window lens centered on (the source event's UTC - /// timestamp): the effective view is narrowed to the inclusive range [timeCreatedUtc - radius, timeCreatedUtc + - /// radius], so the source event itself always survives. Bounds are clamped to the range - /// because boundary arithmetic throws on overflow - a degenerate near-min/near-max timestamp must not crash the menu - /// handler. The chip label renders the anchor's time of day in (the grid's display - /// zone) as a compact marker; it is not the grid's full date+time rendering. must be within - /// (0, 1 hour] and a whole number of seconds (validated); the suffix renders it to its largest whole unit (for example - /// 90s stays "90s"). - /// - /// - /// is not within (0, 1 hour], or is not a whole - /// number of seconds. - /// public static FilterLens ForTimeWindow( DateTime timeCreatedUtc, TimeSpan radius, @@ -77,7 +51,7 @@ public static FilterLens ForTimeWindow( string? originLog = null) { ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(radius, TimeSpan.Zero); - ArgumentOutOfRangeException.ThrowIfGreaterThan(radius, TimeSpan.FromHours(1)); + ArgumentOutOfRangeException.ThrowIfGreaterThan(radius, s_maxTimeWindowRadius); if (radius.Ticks % TimeSpan.TicksPerSecond != 0) { @@ -96,14 +70,6 @@ public static FilterLens ForTimeWindow( }; } - /// - /// Encodes a nullable-Guid equality narrowing (keep only rows where the field equals ) - /// as an exclude-of-complement: because the base's include filters are OR-combined an appended include would - /// broaden, so the complement (field != value, ) is excluded to AND-narrow - /// to exactly field == value. NotEqual on a nullable-Guid column is total (a decisive Match for an - /// absent value), so the exclude hides absent-field rows rather than leaking them. Returns - /// only if the criterion fails to format or compile. - /// private static FilterLens? BuildEqualityLens(EventProperty property, Guid value, string label, string? originLog) { if (!TryFormatNotEqual(property, value.ToString(), out var comparisonText)) @@ -128,11 +94,6 @@ public static FilterLens ForTimeWindow( }; } - /// - /// Formats the window radius as a compact chip suffix (for example "30s", "5m", "1h") using the largest whole - /// unit that represents it exactly. Callers are validated to whole-second radii, so every valid radius renders - /// losslessly. - /// private static string FormatRadius(TimeSpan radius) => radius switch { { Minutes: 0, Seconds: 0, Milliseconds: 0 } => $"{radius.TotalHours:0}h", diff --git a/src/EventLogExpert.Runtime/FilterLenses/FilterLensSource.cs b/src/EventLogExpert.Runtime/FilterLenses/FilterLensSource.cs new file mode 100644 index 000000000..6663f18dd --- /dev/null +++ b/src/EventLogExpert.Runtime/FilterLenses/FilterLensSource.cs @@ -0,0 +1,23 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.Common.Sources; +using Fluxor; +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.FilterLenses; + +internal sealed class FilterLensSource( + IState state, + [FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + : ObservableStateSourceBase>( + state, + logger, + static state => [.. state.Lenses.Select(lens => new FilterLensSummary(lens.Id, lens.Label))], + static (next, current) => next.SequenceEqual(current)), + IFilterLensSource +{ + public ImmutableList Lenses => CurrentProjection; +} diff --git a/src/EventLogExpert.Runtime/FilterLenses/FilterLensSummary.cs b/src/EventLogExpert.Runtime/FilterLenses/FilterLensSummary.cs new file mode 100644 index 000000000..64c56417b --- /dev/null +++ b/src/EventLogExpert.Runtime/FilterLenses/FilterLensSummary.cs @@ -0,0 +1,6 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.FilterLenses; + +public sealed record FilterLensSummary(FilterLensId Id, string Label); diff --git a/src/EventLogExpert.Runtime/FilterLenses/IFilterLensCommands.cs b/src/EventLogExpert.Runtime/FilterLenses/IFilterLensCommands.cs index bc3517d90..665098ca9 100644 --- a/src/EventLogExpert.Runtime/FilterLenses/IFilterLensCommands.cs +++ b/src/EventLogExpert.Runtime/FilterLenses/IFilterLensCommands.cs @@ -9,42 +9,15 @@ public interface IFilterLensCommands { void ClearLenses(); - void RemoveLens(FilterLens lens); - - /// - /// Pushes a transient time-window lens centered on (the source event's UTC - /// timestamp), narrowing the view to events no more than before or after it. The window is - /// inclusive, so the source event always stays in view. renders the chip's anchor time - /// in the grid's display zone; is the source event's - /// so the lens auto-clears when that log is closed. - /// + void RemoveLens(FilterLensId id); + void ShowEventsNearTime(DateTime timeCreated, TimeSpan radius, TimeZoneInfo displayZone, string? originLog = null); - /// - /// Pushes a lens narrowing the view to the parent activity's events - those whose ActivityId equals the source - /// event's . A null id is a no-op. is the source - /// event's so the lens auto-clears when that log is closed. - /// void ShowParentActivity(Guid? relatedActivityId, string? originLog = null); - /// - /// Pushes a "Show Related by Activity ID" lens narrowing the view to events sharing - /// . A null id is a no-op, so callers may pass an event's nullable ActivityId directly. - /// is the source event's so the lens auto-clears - /// when that log is closed. - /// void ShowRelatedByActivityId(Guid? activityId, string? originLog = null); - /// - /// Pushes a lens narrowing the view to events that share (siblings of the - /// same parent/correlation activity). A null id is a no-op. is the source event's - /// so the lens auto-clears when that log is closed. - /// void ShowRelatedByRelatedActivityId(Guid? relatedActivityId, string? originLog = null); - /// - /// Pushes a reversible lens narrowing the view to the inclusive UTC range [startUtc, endUtc] (histogram brush); - /// originLog auto-clears the lens when that log closes (null for a combined view). - /// void ShowTimeRange(DateTime startUtc, DateTime endUtc, TimeZoneInfo displayZone, string? originLog = null); } diff --git a/src/EventLogExpert.Runtime/FilterLenses/IFilterLensSource.cs b/src/EventLogExpert.Runtime/FilterLenses/IFilterLensSource.cs new file mode 100644 index 000000000..af5629d1c --- /dev/null +++ b/src/EventLogExpert.Runtime/FilterLenses/IFilterLensSource.cs @@ -0,0 +1,12 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Common.Sources; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.FilterLenses; + +public interface IFilterLensSource : IChangeNotifier +{ + ImmutableList Lenses { get; } +} diff --git a/src/EventLogExpert.Runtime/FilterLenses/Reducers.cs b/src/EventLogExpert.Runtime/FilterLenses/Reducers.cs index 844d86133..84745f2c6 100644 --- a/src/EventLogExpert.Runtime/FilterLenses/Reducers.cs +++ b/src/EventLogExpert.Runtime/FilterLenses/Reducers.cs @@ -18,7 +18,7 @@ public static FilterLensState ReducePush(FilterLensState state, PushFilterLensAc [ReducerMethod] public static FilterLensState ReduceRemove(FilterLensState state, RemoveFilterLensAction action) { - var updated = state.Lenses.RemoveAll(lens => lens.Id == action.Lens.Id); + var updated = state.Lenses.RemoveAll(lens => lens.Id == action.Id); return updated.Count == state.Lenses.Count ? state : state with { Lenses = updated }; } diff --git a/src/EventLogExpert.Runtime/FilterLenses/RemoveFilterLensAction.cs b/src/EventLogExpert.Runtime/FilterLenses/RemoveFilterLensAction.cs index da06ac90a..be2c85a9f 100644 --- a/src/EventLogExpert.Runtime/FilterLenses/RemoveFilterLensAction.cs +++ b/src/EventLogExpert.Runtime/FilterLenses/RemoveFilterLensAction.cs @@ -3,4 +3,4 @@ namespace EventLogExpert.Runtime.FilterLenses; -internal sealed record RemoveFilterLensAction(FilterLens Lens); +internal sealed record RemoveFilterLensAction(FilterLensId Id); diff --git a/src/EventLogExpert.Runtime/FilterLibrary/Effects.cs b/src/EventLogExpert.Runtime/FilterLibrary/Effects.cs index a96d2bcfc..f6354544c 100644 --- a/src/EventLogExpert.Runtime/FilterLibrary/Effects.cs +++ b/src/EventLogExpert.Runtime/FilterLibrary/Effects.cs @@ -17,7 +17,8 @@ internal sealed class Effects( ILegacyFilterMigrator legacyMigrator, IBackslashNameMigrator backslashMigrator, IAnnouncementService announcementService, - ITraceLogger logger) + ITraceLogger logger, + TagBulkUpdateFailedNotifier tagBulkUpdateFailedNotifier) { private const int MaxAutoTrackedRecents = 50; @@ -163,16 +164,8 @@ public async Task HandleLoadLibrary(IDispatcher dispatcher) if (legacyMigrator.ShouldRunMigration()) { - // AddRange-throws → keep returning currently-loaded entries + return without marking complete - // (retries next launch). Post-AddRange LoadAll-throws → in-memory fallback + MarkMigrationCompleted - // as on the happy path. var migrationResult = legacyMigrator.BuildEntriesFromLegacy(); - // Dedup against the already-loaded entries before AddRange - the per-section flag check in - // BuildEntriesFromLegacy short-circuits already-completed sections, but on the bitmask-not-advanced - // path (e.g., MarkMigrationCompleted SetString throws after a successful AddRange) the next launch - // would re-read the still-present legacy keys and produce content-duplicate rows because migration - // entries are Origin=UserSaved and the store's partial UNIQUE INDEX only covers AutoTracked rows. var entriesToAdd = DedupMigrationEntriesAgainstExisting(migrationResult.Entries, entries); if (entriesToAdd.Count > 0) @@ -202,7 +195,6 @@ public async Task HandleLoadLibrary(IDispatcher dispatcher) logger.Information($"Migrated {entriesToAdd.Count} legacy entries to filter library (deduped from {migrationResult.Entries.Count})."); } - // Not wrapped: a SetString failure surfaces via the outer catch (LoadLibraryFailure). On the next // launch ShouldRunMigration returns true again, BuildEntriesFromLegacy re-emits the same entries, // and DedupMigrationEntriesAgainstExisting filters them out against the now-non-empty store - // so the SetString-throws path is idempotent rather than duplicating. @@ -485,8 +477,6 @@ public Task HandleSaveFilterSet(SaveFilterSetAction action, IDispatcher dispatch { Name = action.Name, CreatedUtc = DateTimeOffset.UtcNow, - // Regenerate FilterIds so Razor `@key=filter.Id` diffing stays correct when the - // same pane filters are saved into multiple filter sets. Filters = [.. action.Filters.Select(f => f with { Id = FilterId.Create(), IsEnabled = false })], Origin = LibraryEntryOrigin.UserSaved, }; @@ -545,6 +535,14 @@ public Task HandleSetIsFavorite(SetIsFavoriteAction action, IDispatcher dispatch return PersistAndDispatchAsync(action.EntryId, e => ApplyFavoriteToggle(e, setIsFavorite, unfavoriteTimestamp), dispatcher); } + [EffectMethod(typeof(TagBulkUpdateFailedAction))] + public Task HandleTagBulkUpdateFailed(IDispatcher dispatcher) + { + tagBulkUpdateFailedNotifier.Raise(); + + return Task.CompletedTask; + } + [EffectMethod] public async Task HandleUpdateLibraryEntry(UpdateLibraryEntryAction action, IDispatcher dispatcher) { @@ -580,7 +578,6 @@ private static LibraryEntry ApplyFavoriteToggle(LibraryEntry entry, bool isFavor { if (isFavorite) { - // Favoriting: mutex (LastUsedUtc=null) + promotion (Origin=UserSaved). Symmetric for filter + filter set. return entry switch { LibraryEntrySavedFilter f => f with @@ -599,7 +596,6 @@ private static LibraryEntry ApplyFavoriteToggle(LibraryEntry entry, bool isFavor }; } - // Unfavoriting: filters drop to Recents (matches legacy FilterCache UX); filter sets stay out of Recents. return entry switch { LibraryEntrySavedFilter f => f with @@ -924,7 +920,6 @@ private async Task PruneFromSnapshot(ImmutableList snapshot, IDisp if (autoTrackedRecents.Count <= MaxAutoTrackedRecents) { return; } - // CreatedUtc tie-break keeps prune deterministic when entries share LastUsedUtc. var toDelete = autoTrackedRecents .OrderBy(e => e.LastUsedUtc!.Value) .ThenBy(e => e.CreatedUtc) @@ -933,8 +928,6 @@ private async Task PruneFromSnapshot(ImmutableList snapshot, IDisp foreach (var entry in toDelete) { - // SQL guard no-ops the delete if a concurrent SetIsFavorite/SaveEntry promoted the row - // (Origin=UserSaved or IsFavorite=true) after the snapshot was projected. bool deleted; try { deleted = await store.TryDeleteAutoTrackedIfNotFavoriteAsync(entry.Id).ConfigureAwait(false); } diff --git a/src/EventLogExpert.Runtime/FilterLibrary/ILibraryEntriesSource.cs b/src/EventLogExpert.Runtime/FilterLibrary/ILibraryEntriesSource.cs new file mode 100644 index 000000000..f3a0a4100 --- /dev/null +++ b/src/EventLogExpert.Runtime/FilterLibrary/ILibraryEntriesSource.cs @@ -0,0 +1,12 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Common.Sources; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.FilterLibrary; + +public interface ILibraryEntriesSource : IChangeNotifier +{ + ImmutableList Current { get; } +} diff --git a/src/EventLogExpert.Runtime/FilterLibrary/ILibraryLoadStatusSource.cs b/src/EventLogExpert.Runtime/FilterLibrary/ILibraryLoadStatusSource.cs new file mode 100644 index 000000000..86256e34b --- /dev/null +++ b/src/EventLogExpert.Runtime/FilterLibrary/ILibraryLoadStatusSource.cs @@ -0,0 +1,13 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Common.Sources; + +namespace EventLogExpert.Runtime.FilterLibrary; + +public readonly record struct LibraryLoadStatus(bool IsLoaded, bool LoadError); + +public interface ILibraryLoadStatusSource : IChangeNotifier +{ + LibraryLoadStatus Current { get; } +} diff --git a/src/EventLogExpert.Runtime/FilterLibrary/ITagBulkUpdateFailedNotifier.cs b/src/EventLogExpert.Runtime/FilterLibrary/ITagBulkUpdateFailedNotifier.cs new file mode 100644 index 000000000..5eefd8179 --- /dev/null +++ b/src/EventLogExpert.Runtime/FilterLibrary/ITagBulkUpdateFailedNotifier.cs @@ -0,0 +1,9 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.FilterLibrary; + +public interface ITagBulkUpdateFailedNotifier +{ + event Action Failed; +} diff --git a/src/EventLogExpert.Runtime/FilterLibrary/LibraryEntriesSource.cs b/src/EventLogExpert.Runtime/FilterLibrary/LibraryEntriesSource.cs new file mode 100644 index 000000000..af6957af9 --- /dev/null +++ b/src/EventLogExpert.Runtime/FilterLibrary/LibraryEntriesSource.cs @@ -0,0 +1,23 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.Common.Sources; +using Fluxor; +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.FilterLibrary; + +internal sealed class LibraryEntriesSource( + IState state, + [FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + : ObservableStateSourceBase>( + state, + logger, + static state => state.Entries, + static (next, current) => ReferenceEquals(next, current)), + ILibraryEntriesSource +{ + public ImmutableList Current => CurrentProjection; +} diff --git a/src/EventLogExpert.Runtime/FilterLibrary/LibraryLoadStatusSource.cs b/src/EventLogExpert.Runtime/FilterLibrary/LibraryLoadStatusSource.cs new file mode 100644 index 000000000..417e42c2b --- /dev/null +++ b/src/EventLogExpert.Runtime/FilterLibrary/LibraryLoadStatusSource.cs @@ -0,0 +1,20 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.Common.Sources; +using Fluxor; +using Microsoft.Extensions.DependencyInjection; + +namespace EventLogExpert.Runtime.FilterLibrary; + +internal sealed class LibraryLoadStatusSource( + IState state, + [FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + : ObservableStateSourceBase(state, + logger, + static state => new LibraryLoadStatus(state.IsLoaded, state.LoadError)), + ILibraryLoadStatusSource +{ + public LibraryLoadStatus Current => CurrentProjection; +} diff --git a/src/EventLogExpert.Runtime/FilterLibrary/TagBulkUpdateFailedNotifier.cs b/src/EventLogExpert.Runtime/FilterLibrary/TagBulkUpdateFailedNotifier.cs new file mode 100644 index 000000000..cec53d27a --- /dev/null +++ b/src/EventLogExpert.Runtime/FilterLibrary/TagBulkUpdateFailedNotifier.cs @@ -0,0 +1,40 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace EventLogExpert.Runtime.FilterLibrary; + +internal sealed class TagBulkUpdateFailedNotifier : ITagBulkUpdateFailedNotifier +{ + private readonly ITraceLogger _logger; + + public TagBulkUpdateFailedNotifier([FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + { + ArgumentNullException.ThrowIfNull(logger); + + _logger = logger; + } + + public event Action? Failed; + + public void Raise() + { + var handlers = Failed; + + if (handlers is null) { return; } + + foreach (var handler in handlers.GetInvocationList().Cast()) + { + try + { + handler(); + } + catch (Exception fault) + { + _logger.Trace($"{nameof(TagBulkUpdateFailedNotifier)}: a subscriber threw and was isolated: {fault}"); + } + } + } +} diff --git a/src/EventLogExpert.Runtime/FilterPane/ActiveFiltersSource.cs b/src/EventLogExpert.Runtime/FilterPane/ActiveFiltersSource.cs new file mode 100644 index 000000000..d9013abc1 --- /dev/null +++ b/src/EventLogExpert.Runtime/FilterPane/ActiveFiltersSource.cs @@ -0,0 +1,24 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Filtering.Persistence; +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.Common.Sources; +using Fluxor; +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.FilterPane; + +internal sealed class ActiveFiltersSource( + IState state, + [FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + : ObservableStateSourceBase>( + state, + logger, + static state => state.Filters, + static (next, current) => ReferenceEquals(next, current)), + IActiveFiltersSource +{ + public ImmutableList Current => CurrentProjection; +} diff --git a/src/EventLogExpert.Runtime/FilterPane/ClearAllFiltersNotifier.cs b/src/EventLogExpert.Runtime/FilterPane/ClearAllFiltersNotifier.cs new file mode 100644 index 000000000..53aefaf4f --- /dev/null +++ b/src/EventLogExpert.Runtime/FilterPane/ClearAllFiltersNotifier.cs @@ -0,0 +1,40 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace EventLogExpert.Runtime.FilterPane; + +internal sealed class ClearAllFiltersNotifier : IClearAllFiltersNotifier +{ + private readonly ITraceLogger _logger; + + public ClearAllFiltersNotifier([FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + { + ArgumentNullException.ThrowIfNull(logger); + + _logger = logger; + } + + public event Action? Requested; + + public void Raise() + { + var handlers = Requested; + + if (handlers is null) { return; } + + foreach (var handler in handlers.GetInvocationList().Cast()) + { + try + { + handler(); + } + catch (Exception fault) + { + _logger.Trace($"{nameof(ClearAllFiltersNotifier)}: a subscriber threw and was isolated: {fault}"); + } + } + } +} diff --git a/src/EventLogExpert.Runtime/FilterPane/Effects.cs b/src/EventLogExpert.Runtime/FilterPane/Effects.cs index 051c95b4d..ad43d4267 100644 --- a/src/EventLogExpert.Runtime/FilterPane/Effects.cs +++ b/src/EventLogExpert.Runtime/FilterPane/Effects.cs @@ -14,20 +14,26 @@ namespace EventLogExpert.Runtime.FilterPane; internal sealed class Effects { private readonly IStateSelection _appliedFilter; + private readonly ClearAllFiltersNotifier _clearAllFiltersNotifier; private readonly IState _filterPaneState; private readonly IState _lensState; private readonly IState _rawEventStore; + private readonly SetFilterDateRangeSucceededNotifier _setFilterDateRangeSucceededNotifier; public Effects( IStateSelection appliedFilter, IState rawEventStore, IState filterPaneState, - IState lensState) + IState lensState, + ClearAllFiltersNotifier clearAllFiltersNotifier, + SetFilterDateRangeSucceededNotifier setFilterDateRangeSucceededNotifier) { _appliedFilter = appliedFilter; _rawEventStore = rawEventStore; _filterPaneState = filterPaneState; _lensState = lensState; + _clearAllFiltersNotifier = clearAllFiltersNotifier; + _setFilterDateRangeSucceededNotifier = setFilterDateRangeSucceededNotifier; _appliedFilter.Select(static s => s.AppliedFilter); } @@ -51,6 +57,7 @@ public Task HandleAddFilter(AddFilterAction action, IDispatcher dispatcher) public Task HandleClearAllFilters(IDispatcher dispatcher) { UpdateEventTableFilters(_filterPaneState.Value, dispatcher); + _clearAllFiltersNotifier.Raise(); return Task.CompletedTask; } @@ -131,6 +138,7 @@ public Task HandleSetFilterDateRange(SetFilterDateRangeAction action, IDispatche public Task HandleSetFilterDateRangeSuccess(IDispatcher dispatcher) { UpdateEventTableFilters(_filterPaneState.Value, dispatcher); + _setFilterDateRangeSucceededNotifier.Raise(); return Task.CompletedTask; } @@ -177,9 +185,6 @@ public Task HandleToggleIsEnabled(IDispatcher dispatcher) private void UpdateEventTableFilters(FilterPaneState filterPaneState, IDispatcher dispatcher) { - // Build the effective filter by layering any active transient lenses onto the base through the single shared - // EffectiveFilterBuilder, so the FilterPane apply path and the FilterLens push/pop path can never diverge. - // FilterPaneFilterBuilder handles both the enabled and the excluded-only (pane-disabled) branch, so lenses narrow in both. var candidate = EffectiveFilterBuilder.Build( FilterPaneFilterBuilder.Build(filterPaneState), _lensState.Value.Lenses); diff --git a/src/EventLogExpert.Runtime/FilterPane/FilterPaneQueries.cs b/src/EventLogExpert.Runtime/FilterPane/FilterPaneQueries.cs new file mode 100644 index 000000000..f04a4f349 --- /dev/null +++ b/src/EventLogExpert.Runtime/FilterPane/FilterPaneQueries.cs @@ -0,0 +1,13 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Fluxor; + +namespace EventLogExpert.Runtime.FilterPane; + +internal sealed class FilterPaneQueries(IState filterPaneState) : IFilterPaneQueries +{ + private readonly IState _filterPaneState = filterPaneState; + + public bool IsEnabled() => _filterPaneState.Value.IsEnabled; +} diff --git a/src/EventLogExpert.Runtime/FilterPane/FilteredDateRangeSource.cs b/src/EventLogExpert.Runtime/FilterPane/FilteredDateRangeSource.cs new file mode 100644 index 000000000..a253626a3 --- /dev/null +++ b/src/EventLogExpert.Runtime/FilterPane/FilteredDateRangeSource.cs @@ -0,0 +1,19 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Filtering.Evaluation; +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.Common.Sources; +using Fluxor; +using Microsoft.Extensions.DependencyInjection; + +namespace EventLogExpert.Runtime.FilterPane; + +internal sealed class FilteredDateRangeSource( + IState state, + [FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + : ObservableStateSourceBase(state, logger, static state => state.FilteredDateRange), + IFilteredDateRangeSource +{ + public DateFilter? Current => CurrentProjection; +} diff --git a/src/EventLogExpert.Runtime/FilterPane/IActiveFiltersSource.cs b/src/EventLogExpert.Runtime/FilterPane/IActiveFiltersSource.cs new file mode 100644 index 000000000..06d944184 --- /dev/null +++ b/src/EventLogExpert.Runtime/FilterPane/IActiveFiltersSource.cs @@ -0,0 +1,13 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Filtering.Persistence; +using EventLogExpert.Runtime.Common.Sources; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.FilterPane; + +public interface IActiveFiltersSource : IChangeNotifier +{ + ImmutableList Current { get; } +} diff --git a/src/EventLogExpert.Runtime/FilterPane/IClearAllFiltersNotifier.cs b/src/EventLogExpert.Runtime/FilterPane/IClearAllFiltersNotifier.cs new file mode 100644 index 000000000..266b12386 --- /dev/null +++ b/src/EventLogExpert.Runtime/FilterPane/IClearAllFiltersNotifier.cs @@ -0,0 +1,9 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.FilterPane; + +public interface IClearAllFiltersNotifier +{ + event Action Requested; +} diff --git a/src/EventLogExpert.Runtime/FilterPane/IFilterPaneQueries.cs b/src/EventLogExpert.Runtime/FilterPane/IFilterPaneQueries.cs new file mode 100644 index 000000000..5792ea45b --- /dev/null +++ b/src/EventLogExpert.Runtime/FilterPane/IFilterPaneQueries.cs @@ -0,0 +1,9 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.FilterPane; + +public interface IFilterPaneQueries +{ + bool IsEnabled(); +} diff --git a/src/EventLogExpert.Runtime/FilterPane/IFilteredDateRangeSource.cs b/src/EventLogExpert.Runtime/FilterPane/IFilteredDateRangeSource.cs new file mode 100644 index 000000000..9798f119f --- /dev/null +++ b/src/EventLogExpert.Runtime/FilterPane/IFilteredDateRangeSource.cs @@ -0,0 +1,12 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Filtering.Evaluation; +using EventLogExpert.Runtime.Common.Sources; + +namespace EventLogExpert.Runtime.FilterPane; + +public interface IFilteredDateRangeSource : IChangeNotifier +{ + DateFilter? Current { get; } +} diff --git a/src/EventLogExpert.Runtime/FilterPane/ISetFilterDateRangeSucceededNotifier.cs b/src/EventLogExpert.Runtime/FilterPane/ISetFilterDateRangeSucceededNotifier.cs new file mode 100644 index 000000000..57bcde9ce --- /dev/null +++ b/src/EventLogExpert.Runtime/FilterPane/ISetFilterDateRangeSucceededNotifier.cs @@ -0,0 +1,9 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.FilterPane; + +public interface ISetFilterDateRangeSucceededNotifier +{ + event Action Succeeded; +} diff --git a/src/EventLogExpert.Runtime/FilterPane/SetFilterDateRangeSucceededNotifier.cs b/src/EventLogExpert.Runtime/FilterPane/SetFilterDateRangeSucceededNotifier.cs new file mode 100644 index 000000000..dd0833718 --- /dev/null +++ b/src/EventLogExpert.Runtime/FilterPane/SetFilterDateRangeSucceededNotifier.cs @@ -0,0 +1,40 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace EventLogExpert.Runtime.FilterPane; + +internal sealed class SetFilterDateRangeSucceededNotifier : ISetFilterDateRangeSucceededNotifier +{ + private readonly ITraceLogger _logger; + + public SetFilterDateRangeSucceededNotifier([FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + { + ArgumentNullException.ThrowIfNull(logger); + + _logger = logger; + } + + public event Action? Succeeded; + + public void Raise() + { + var handlers = Succeeded; + + if (handlers is null) { return; } + + foreach (var handler in handlers.GetInvocationList().Cast()) + { + try + { + handler(); + } + catch (Exception fault) + { + _logger.Trace($"{nameof(SetFilterDateRangeSucceededNotifier)}: a subscriber threw and was isolated: {fault}"); + } + } + } +} diff --git a/src/EventLogExpert.Runtime/FilterProgress/FilterProgressState.cs b/src/EventLogExpert.Runtime/FilterProgress/FilterProgressState.cs deleted file mode 100644 index 29364c949..000000000 --- a/src/EventLogExpert.Runtime/FilterProgress/FilterProgressState.cs +++ /dev/null @@ -1,12 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -using Fluxor; - -namespace EventLogExpert.Runtime.FilterProgress; - -[FeatureState] -public sealed record FilterProgressState -{ - public bool IsLoading { get; init; } -} diff --git a/src/EventLogExpert.Runtime/FilterProgress/Reducers.cs b/src/EventLogExpert.Runtime/FilterProgress/Reducers.cs deleted file mode 100644 index 57424182a..000000000 --- a/src/EventLogExpert.Runtime/FilterProgress/Reducers.cs +++ /dev/null @@ -1,23 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -using EventLogExpert.Runtime.EventLog; -using Fluxor; - -namespace EventLogExpert.Runtime.FilterProgress; - -internal sealed class Reducers -{ - [ReducerMethod(typeof(CloseAllLogsAction))] - public static FilterProgressState ReduceCloseAll(FilterProgressState state) => - state.IsLoading ? new FilterProgressState() : state; - - [ReducerMethod] - public static FilterProgressState ReduceSetFilterProgress( - FilterProgressState state, - SetFilterProgressAction action) => - state.IsLoading == action.IsLoading ? state : new FilterProgressState - { - IsLoading = action.IsLoading - }; -} diff --git a/src/EventLogExpert.Runtime/FilterProgress/SetFilterProgressAction.cs b/src/EventLogExpert.Runtime/FilterProgress/SetFilterProgressAction.cs deleted file mode 100644 index d3e32da56..000000000 --- a/src/EventLogExpert.Runtime/FilterProgress/SetFilterProgressAction.cs +++ /dev/null @@ -1,6 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -namespace EventLogExpert.Runtime.FilterProgress; - -internal sealed record SetFilterProgressAction(bool IsLoading); diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramDimensionRequestSource.cs b/src/EventLogExpert.Runtime/Histogram/HistogramDimensionRequestSource.cs new file mode 100644 index 000000000..ca8882193 --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/HistogramDimensionRequestSource.cs @@ -0,0 +1,20 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.Common.Sources; +using Fluxor; +using Microsoft.Extensions.DependencyInjection; + +namespace EventLogExpert.Runtime.Histogram; + +internal sealed class HistogramDimensionRequestSource( + IState state, + [FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + : ObservableStateSourceBase(state, + logger, + static state => state.DimensionRequest), + IHistogramDimensionRequestSource +{ + public HistogramDimensionRequest? Current => CurrentProjection; +} diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramVisibilitySource.cs b/src/EventLogExpert.Runtime/Histogram/HistogramVisibilitySource.cs new file mode 100644 index 000000000..ff520cdcc --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/HistogramVisibilitySource.cs @@ -0,0 +1,18 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.Common.Sources; +using Fluxor; +using Microsoft.Extensions.DependencyInjection; + +namespace EventLogExpert.Runtime.Histogram; + +internal sealed class HistogramVisibilitySource( + IState state, + [FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + : ObservableStateSourceBase(state, logger, static state => state.IsVisible), + IHistogramVisibilitySource +{ + public bool IsVisible => CurrentProjection; +} diff --git a/src/EventLogExpert.Runtime/Histogram/IHistogramDimensionRequestSource.cs b/src/EventLogExpert.Runtime/Histogram/IHistogramDimensionRequestSource.cs new file mode 100644 index 000000000..44f026f5a --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/IHistogramDimensionRequestSource.cs @@ -0,0 +1,11 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Common.Sources; + +namespace EventLogExpert.Runtime.Histogram; + +public interface IHistogramDimensionRequestSource : IChangeNotifier +{ + HistogramDimensionRequest? Current { get; } +} diff --git a/src/EventLogExpert.Runtime/Histogram/IHistogramVisibilitySource.cs b/src/EventLogExpert.Runtime/Histogram/IHistogramVisibilitySource.cs new file mode 100644 index 000000000..7b5b657c7 --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/IHistogramVisibilitySource.cs @@ -0,0 +1,11 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Common.Sources; + +namespace EventLogExpert.Runtime.Histogram; + +public interface IHistogramVisibilitySource : IChangeNotifier +{ + bool IsVisible { get; } +} diff --git a/src/EventLogExpert.Runtime/LogTable/ActiveEventLogSource.cs b/src/EventLogExpert.Runtime/LogTable/ActiveEventLogSource.cs new file mode 100644 index 000000000..d23d0d30b --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/ActiveEventLogSource.cs @@ -0,0 +1,19 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.Common.Sources; +using Fluxor; +using Microsoft.Extensions.DependencyInjection; + +namespace EventLogExpert.Runtime.LogTable; + +internal sealed class ActiveEventLogSource( + IState state, + [FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + : ObservableStateSourceBase(state, logger, static state => state.ActiveEventLogId), + IActiveEventLogSource +{ + public EventLogId? Current => CurrentProjection; +} diff --git a/src/EventLogExpert.Runtime/LogTable/AppendTableEventsBatchAction.cs b/src/EventLogExpert.Runtime/LogTable/AppendTableEventsBatchAction.cs deleted file mode 100644 index 736a44581..000000000 --- a/src/EventLogExpert.Runtime/LogTable/AppendTableEventsBatchAction.cs +++ /dev/null @@ -1,16 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -using EventLogExpert.Eventing.Common.EventLogs; -using System.Collections.Immutable; - -namespace EventLogExpert.Runtime.LogTable; - -public sealed record AppendTableEventsBatchAction -{ - internal IReadOnlyDictionary ViewsByLog { get; init; } = - ImmutableDictionary.Empty; - - internal IReadOnlyDictionary VersionByLog { get; init; } = - ImmutableDictionary.Empty; -} diff --git a/src/EventLogExpert.Runtime/LogTable/ResolvedEventOrdering.ColumnDirectSort.cs b/src/EventLogExpert.Runtime/LogTable/ColumnDirectSort.cs similarity index 73% rename from src/EventLogExpert.Runtime/LogTable/ResolvedEventOrdering.ColumnDirectSort.cs rename to src/EventLogExpert.Runtime/LogTable/ColumnDirectSort.cs index dd09128d9..dcd55d8b6 100644 --- a/src/EventLogExpert.Runtime/LogTable/ResolvedEventOrdering.ColumnDirectSort.cs +++ b/src/EventLogExpert.Runtime/LogTable/ColumnDirectSort.cs @@ -5,7 +5,7 @@ namespace EventLogExpert.Runtime.LogTable; -internal static partial class ResolvedEventOrdering +internal static class ColumnDirectSort { internal static int[] SortColumnDirect( IEventColumnReader reader, @@ -13,28 +13,24 @@ internal static int[] SortColumnDirect( ColumnName? orderBy, bool isDescending, ColumnName? groupBy, - bool isGroupDescending) + bool isGroupDescending, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(reader); - int[] result = survivors.ToArray(); + int[] result = [.. survivors]; if (result.Length < 2) { return result; } - var keys = ColumnDirectKeys.Materialize(reader, orderBy, groupBy); + var keys = ColumnDirectKeys.Materialize(reader, orderBy, groupBy, cancellationToken); Comparison comparison = keys.BuildComparison(orderBy, isDescending, groupBy, isGroupDescending); + cancellationToken.ThrowIfCancellationRequested(); Array.Sort(result, comparison); + cancellationToken.ThrowIfCancellationRequested(); return result; } - /// - /// The flat, physical-row-indexed columns the sort chain reads: numeric columns as value + present flags, the - /// ActivityId column as Guid + present flags, and string columns as a precomputed ordinal rank per row (pooled columns - /// share one ranking scoped to the distinct pool indices those columns actually use; Keywords is dense-ranked from its - /// joined text). OwningLog and RecordId are always materialized because every tie-break chain reads them; DateAndTime - /// is materialized only when the selected chain reads it. - /// private sealed class ColumnDirectKeys { private static readonly int s_columnCount = Enum.GetValues().Length; @@ -65,25 +61,21 @@ private ColumnDirectKeys(int count) private int Count { get; } - internal static ColumnDirectKeys Materialize(IEventColumnReader reader, ColumnName? orderBy, ColumnName? groupBy) + internal static ColumnDirectKeys Materialize( + IEventColumnReader reader, ColumnName? orderBy, ColumnName? groupBy, CancellationToken cancellationToken) { var keys = new ColumnDirectKeys(reader.Count); - // RecordId and OwningLog feed every chain's tie-break, so always materialize them. DateAndTime is read only by - // the ungrouped default chain, an explicit DateAndTime order, or a grouped chain's within-fallback, so skip its - // column copy for an ungrouped sort with an explicit non-DateAndTime order. - keys.MaterializeColumn(reader, ColumnName.RecordId); - keys.MaterializeOwningLog(reader); + keys.MaterializeColumn(reader, ColumnName.RecordId, cancellationToken); + keys.MaterializeOwningLog(reader, cancellationToken); - if (groupBy is not null || orderBy is null) { keys.MaterializeColumn(reader, ColumnName.DateAndTime); } + if (groupBy is not null || orderBy is null) { keys.MaterializeColumn(reader, ColumnName.DateAndTime, cancellationToken); } - if (orderBy is { } orderColumn) { keys.MaterializeColumn(reader, orderColumn); } + if (orderBy is { } orderColumn) { keys.MaterializeColumn(reader, orderColumn, cancellationToken); } - if (groupBy is { } groupColumn) { keys.MaterializeColumn(reader, groupColumn); } + if (groupBy is { } groupColumn) { keys.MaterializeColumn(reader, groupColumn, cancellationToken); } - // Rank OwningLog and any pooled order/group column over ONLY the distinct pool indices they use, so the ordinal - // string sort costs O(used distinct) rather than O(whole pool). - keys.RankPooledColumns(reader); + keys.RankPooledColumns(reader, cancellationToken); return keys; } @@ -104,22 +96,26 @@ internal Comparison BuildComparison( if (orderBy is null) { - return isDescending - ? (a, b) => WithIndexTieBreak(DefaultChain(b, a), a, b) - : (a, b) => WithIndexTieBreak(DefaultChain(a, b), a, b); + return isDescending ? + (a, b) => WithIndexTieBreak(DefaultChain(b, a), a, b) : + (a, b) => WithIndexTieBreak(DefaultChain(a, b), a, b); } ColumnName orderColumn = orderBy.Value; - return isDescending - ? (a, b) => WithIndexTieBreak(OrderedChain(b, a, orderColumn), a, b) - : (a, b) => WithIndexTieBreak(OrderedChain(a, b, orderColumn), a, b); + return isDescending ? + (a, b) => WithIndexTieBreak(OrderedChain(b, a, orderColumn), a, b) : + (a, b) => WithIndexTieBreak(OrderedChain(a, b, orderColumn), a, b); } - private static void CollectUsedPoolIndices(int[] rawPoolIndices, bool[] seen, List used) + private static void CollectUsedPoolIndices(int[] rawPoolIndices, bool[] seen, List used, CancellationToken cancellationToken) { + int scanned = 0; + foreach (int poolIndex in rawPoolIndices) { + if ((scanned++ & 8191) == 0) { cancellationToken.ThrowIfCancellationRequested(); } + if (poolIndex >= 0 && !seen[poolIndex]) { seen[poolIndex] = true; @@ -130,20 +126,29 @@ private static void CollectUsedPoolIndices(int[] rawPoolIndices, bool[] seen, Li private static int CompareRank(int[] rank, int a, int b) => rank[a].CompareTo(rank[b]); - private static int[] DenseRank(string[] values, int[] rankByPosition) + private static int[] DenseRank(string[] values, int[] rankByPosition, CancellationToken cancellationToken) { int length = values.Length; var order = new int[length]; - for (int index = 0; index < length; index++) { order[index] = index; } + for (int index = 0; index < length; index++) + { + if ((index & 8191) == 0) { cancellationToken.ThrowIfCancellationRequested(); } + + order[index] = index; + } + cancellationToken.ThrowIfCancellationRequested(); Array.Sort(order, (x, y) => string.Compare(values[x], values[y], StringComparison.Ordinal)); + cancellationToken.ThrowIfCancellationRequested(); int rank = 0; rankByPosition[order[0]] = 0; for (int position = 1; position < length; position++) { + if ((position & 8191) == 0) { cancellationToken.ThrowIfCancellationRequested(); } + if (!string.Equals(values[order[position]], values[order[position - 1]], StringComparison.Ordinal)) { rank++; @@ -155,8 +160,6 @@ private static int[] DenseRank(string[] values, int[] rankByPosition) return order; } - // The final deterministic tie-break: physical index ascending, applied after the whole chain and never swapped, - // so both a descending chain and its ascending index tie-break agree on a strict total order. private static int WithIndexTieBreak(int chain, int a, int b) => chain != 0 ? chain : a.CompareTo(b); private int CompareColumn(ColumnName column, int a, int b) => column switch @@ -182,7 +185,6 @@ private int CompareNumeric(ColumnName column, int a, int b) { bool[] has = _numericHas[(int)column]!; - // Absent sorts first, reproducing Nullable.Compare's null-low ordering (always-present columns fill true). if (!has[a] || !has[b]) { return has[a] == has[b] ? 0 : (has[a] ? 1 : -1); } long[] values = _numericValues[(int)column]!; @@ -230,8 +232,10 @@ private int GroupedChain( return isDescending ? -Math.Sign(within) : within; } - private void MaterializeColumn(IEventColumnReader reader, ColumnName column) + private void MaterializeColumn(IEventColumnReader reader, ColumnName column, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + switch (column) { case ColumnName.RecordId: @@ -245,7 +249,7 @@ private void MaterializeColumn(IEventColumnReader reader, ColumnName column) MaterializeGuid(reader, column); break; case ColumnName.Keywords: - MaterializeKeywords(reader); + MaterializeKeywords(reader, cancellationToken); break; default: MaterializePooled(reader, column); @@ -264,20 +268,21 @@ private void MaterializeGuid(IEventColumnReader reader, ColumnName column) _guidHas[(int)column] = has; } - private void MaterializeKeywords(IEventColumnReader reader) + private void MaterializeKeywords(IEventColumnReader reader, CancellationToken cancellationToken) { if (_stringRank[(int)ColumnName.Keywords] is not null) { return; } - // Keywords is a joined string, not a single pooled column, so fall back to per-row text then dense-rank it. var values = new string[Count]; for (int index = 0; index < Count; index++) { + if ((index & 8191) == 0) { cancellationToken.ThrowIfCancellationRequested(); } + values[index] = reader.GetField(reader.LocatorAt(index), EventFieldId.KeywordsDisplay).AsString(); } var rankByRow = new int[Count]; - DenseRank(values, rankByRow); + DenseRank(values, rankByRow, cancellationToken); _stringRank[(int)ColumnName.Keywords] = rankByRow; } @@ -292,8 +297,11 @@ private void MaterializeNumeric(IEventColumnReader reader, ColumnName column) _numericHas[(int)column] = has; } - private void MaterializeOwningLog(IEventColumnReader reader) => + private void MaterializeOwningLog(IEventColumnReader reader, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); reader.CopyPoolIndexColumn(EventFieldId.OwningLog, _owningLogRank); + } private void MaterializePooled(IEventColumnReader reader, ColumnName column) { @@ -302,7 +310,6 @@ private void MaterializePooled(IEventColumnReader reader, ColumnName column) var poolIndices = new int[Count]; reader.CopyPoolIndexColumn(ColumnFieldMap.ToFieldId(column), poolIndices); - // Store the raw pool indices; RankPooledColumns converts them to ranks once the used-index set is known. _stringRank[(int)column] = poolIndices; _pooledColumns.Add((int)column); } @@ -310,56 +317,63 @@ private void MaterializePooled(IEventColumnReader reader, ColumnName column) private int OrderedChain(int a, int b, ColumnName orderColumn) => WithTieBreak(CompareColumn(orderColumn, a, b), a, b); - private void RankFromPoolIndices(int[] poolIndices, int[] rankByRow) + private void RankFromPoolIndices(int[] poolIndices, int[] rankByRow, CancellationToken cancellationToken) { for (int index = 0; index < poolIndices.Length; index++) { + if ((index & 8191) == 0) { cancellationToken.ThrowIfCancellationRequested(); } + int poolIndex = poolIndices[index]; rankByRow[index] = poolIndex < 0 ? _nullRank : _rankByPoolIndex[poolIndex]; } } - private void RankPooledColumns(IEventColumnReader reader) + private void RankPooledColumns(IEventColumnReader reader, CancellationToken cancellationToken) { IReadOnlyList pool = reader.Pool; int poolCount = pool.Count; _rankByPoolIndex = poolCount == 0 ? [] : new int[poolCount]; - // Gather only the distinct pool indices the touched pooled columns actually use (OwningLog plus any pooled - // order/group column), so the ordinal sort below runs over that small set instead of the whole pool. var seen = new bool[poolCount]; var used = new List(); - CollectUsedPoolIndices(_owningLogRank, seen, used); + CollectUsedPoolIndices(_owningLogRank, seen, used, cancellationToken); - foreach (int columnIndex in _pooledColumns) { CollectUsedPoolIndices(_stringRank[columnIndex]!, seen, used); } + foreach (int columnIndex in _pooledColumns) { CollectUsedPoolIndices(_stringRank[columnIndex]!, seen, used, cancellationToken); } if (used.Count == 0) { - // Every touched pooled value is absent, so the rows tie on it; a null reads as the empty string. _nullRank = -1; } else { var usedStrings = new string[used.Count]; - for (int index = 0; index < used.Count; index++) { usedStrings[index] = pool[used[index]] ?? string.Empty; } + for (int index = 0; index < used.Count; index++) + { + if ((index & 8191) == 0) { cancellationToken.ThrowIfCancellationRequested(); } + + usedStrings[index] = pool[used[index]] ?? string.Empty; + } var rankByPosition = new int[used.Count]; - int[] order = DenseRank(usedStrings, rankByPosition); + int[] order = DenseRank(usedStrings, rankByPosition, cancellationToken); + + for (int index = 0; index < used.Count; index++) + { + if ((index & 8191) == 0) { cancellationToken.ThrowIfCancellationRequested(); } - for (int index = 0; index < used.Count; index++) { _rankByPoolIndex[used[index]] = rankByPosition[index]; } + _rankByPoolIndex[used[index]] = rankByPosition[index]; + } - // Absent (-1) reads as "". It shares rank 0 when "" is among the used values (the ordinal minimum); - // otherwise it sorts below every present value. _nullRank = usedStrings[order[0]].Length == 0 ? 0 : -1; } - RankFromPoolIndices(_owningLogRank, _owningLogRank); + RankFromPoolIndices(_owningLogRank, _owningLogRank, cancellationToken); foreach (int columnIndex in _pooledColumns) { int[] columnRanks = _stringRank[columnIndex]!; - RankFromPoolIndices(columnRanks, columnRanks); + RankFromPoolIndices(columnRanks, columnRanks, cancellationToken); } } diff --git a/src/EventLogExpert.Runtime/LogTable/DisplayIndicatorGate.cs b/src/EventLogExpert.Runtime/LogTable/DisplayIndicatorGate.cs new file mode 100644 index 000000000..6cf3bfb98 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/DisplayIndicatorGate.cs @@ -0,0 +1,142 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.LogTable; + +public sealed class DisplayIndicatorGate : IDisposable +{ + private const int ActiveIndicatorHistory = 16; + public static readonly TimeSpan OnsetDelay = TimeSpan.FromMilliseconds(200); + private readonly List _activeIndicators = []; + + private readonly Func _delay; + private readonly Lock _gate = new(); + private readonly IOrderedViewSource _source; + + private bool _disposed; + private long _generation; + private CancellationTokenSource? _onset; + + public DisplayIndicatorGate(IOrderedViewSource source, Func? delay = null) + { + _source = source; + _delay = delay ?? Task.Delay; + + _source.Updated += Observe; + + Observe(_source.Current); + } + + public event Action? OnsetElapsed; + + public void Dispose() + { + CancellationTokenSource? onset; + + lock (_gate) + { + if (_disposed) { return; } + + _disposed = true; + onset = _onset; + _onset = null; + } + + _source.Updated -= Observe; + + onset?.Cancel(); + onset?.Dispose(); + } + + public bool IsFiredFor(DisplayIndicatorKind kind, long paintedRevision) + { + if (kind == DisplayIndicatorKind.None) { return false; } + + lock (_gate) + { + foreach (var indicator in _activeIndicators) + { + if (indicator.Kind == kind && indicator.ArmedRevision <= paintedRevision) { return indicator.Fired; } + } + + return false; + } + } + + private void Observe(OrderedViewPresentation presentation) + { + CancellationTokenSource? superseded; + ActiveIndicator armed; + CancellationToken onsetToken; + + lock (_gate) + { + if (_disposed) { return; } + + var kind = presentation.IndicatorKind; + + if (_activeIndicators.Count > 0 && _activeIndicators[0].Kind == kind) { return; } + + superseded = _onset; + _onset = null; + + armed = new ActiveIndicator(kind, ++_generation, presentation.Revision); + + _activeIndicators.Insert(0, armed); + + if (_activeIndicators.Count > ActiveIndicatorHistory) { _activeIndicators.RemoveAt(_activeIndicators.Count - 1); } + + if (kind == DisplayIndicatorKind.None) + { + superseded?.Cancel(); + superseded?.Dispose(); + + return; + } + + _onset = new CancellationTokenSource(); + onsetToken = _onset.Token; + } + + superseded?.Cancel(); + superseded?.Dispose(); + + _ = RunOnsetAsync(armed, onsetToken); + } + + private async Task RunOnsetAsync(ActiveIndicator indicator, CancellationToken token) + { + try + { + await _delay(OnsetDelay, token).ConfigureAwait(false); + } + catch (OperationCanceledException) { return; } + catch (ObjectDisposedException) + { + /* The onset CTS was disposed by a concurrent Dispose()/supersede; treat as cancelled. */ + return; + } + + lock (_gate) + { + if (_disposed) { return; } + + if (_activeIndicators.Count == 0 || _activeIndicators[0].Generation != indicator.Generation) { return; } + + indicator.Fired = true; + } + + OnsetElapsed?.Invoke(); + } + + private sealed class ActiveIndicator(DisplayIndicatorKind kind, long generation, long armedRevision) + { + public long ArmedRevision { get; } = armedRevision; + + public bool Fired { get; set; } + + public long Generation { get; } = generation; + + public DisplayIndicatorKind Kind { get; } = kind; + } +} diff --git a/src/EventLogExpert.Runtime/LogTable/ToggleLoadingAction.cs b/src/EventLogExpert.Runtime/LogTable/DisplayIndicatorKind.cs similarity index 54% rename from src/EventLogExpert.Runtime/LogTable/ToggleLoadingAction.cs rename to src/EventLogExpert.Runtime/LogTable/DisplayIndicatorKind.cs index 68987d47f..f83a5528f 100644 --- a/src/EventLogExpert.Runtime/LogTable/ToggleLoadingAction.cs +++ b/src/EventLogExpert.Runtime/LogTable/DisplayIndicatorKind.cs @@ -1,8 +1,15 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. -using EventLogExpert.Eventing.Common.EventLogs; - namespace EventLogExpert.Runtime.LogTable; -public sealed record ToggleLoadingAction(EventLogId LogId); +public enum DisplayIndicatorKind +{ + None, + + EmptyPending, + + ReorderPending, + + Fault +} diff --git a/src/EventLogExpert.Runtime/LogTable/DisplayIndicatorState.cs b/src/EventLogExpert.Runtime/LogTable/DisplayIndicatorState.cs new file mode 100644 index 000000000..44715a557 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/DisplayIndicatorState.cs @@ -0,0 +1,131 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.LogTable; + +public sealed class DisplayIndicatorState : IDisposable +{ + public static readonly TimeSpan MinimumVisible = TimeSpan.FromMilliseconds(300); + + private readonly Func _delay; + private readonly DisplayIndicatorGate _gate; + private readonly Action _requestRender; + private readonly Lock _sync = new(); + + private bool _disposed; + private CancellationTokenSource? _floor; + private long _floorGeneration; + private bool _floorHolding; + private bool _spinnerOnScreen; + + public DisplayIndicatorState( + DisplayIndicatorGate gate, + Action requestRender, + Func? delay = null) + { + _gate = gate; + _requestRender = requestRender; + _delay = delay ?? Task.Delay; + + _gate.OnsetElapsed += OnGateOnsetElapsed; + } + + public void Dispose() + { + CancellationTokenSource? floor; + + lock (_sync) + { + if (_disposed) { return; } + + _disposed = true; + floor = _floor; + _floor = null; + } + + _gate.OnsetElapsed -= OnGateOnsetElapsed; + + floor?.Cancel(); + floor?.Dispose(); + } + + private void OnGateOnsetElapsed() => _requestRender(); + + public void RecordPaint(DisplayedIndicator painted) + { + CancellationTokenSource? superseded = null; + CancellationToken floorToken = default; + long generation = 0; + bool startFloor = false; + + lock (_sync) + { + if (_disposed) { return; } + + if (painted.Spinner) + { + if (_spinnerOnScreen) { return; } + + _spinnerOnScreen = true; + _floorHolding = true; + superseded = _floor; + _floor = new CancellationTokenSource(); + floorToken = _floor.Token; + generation = ++_floorGeneration; + startFloor = true; + } + else + { + _spinnerOnScreen = false; + _floorHolding = false; + superseded = _floor; + _floor = null; + } + } + + superseded?.Cancel(); + superseded?.Dispose(); + + if (startFloor) { _ = RunFloorAsync(generation, floorToken); } + } + + public DisplayedIndicator Resolve( + DisplayIndicatorKind paintedKind, + long paintedRevision, + bool surfaceStillCatchingUp = false) + { + if (_gate.IsFiredFor(paintedKind, paintedRevision)) + { + return new DisplayedIndicator(paintedKind, true); + } + + bool stillOwed = paintedKind != DisplayIndicatorKind.None; + + lock (_sync) + { + if (!_spinnerOnScreen) { return DisplayedIndicator.Nothing; } + + return stillOwed || surfaceStillCatchingUp || _floorHolding ? + DisplayedIndicator.GenericSpinner : + DisplayedIndicator.Nothing; + } + } + + private async Task RunFloorAsync(long generation, CancellationToken token) + { + try + { + await _delay(MinimumVisible, token).ConfigureAwait(false); + } + catch (OperationCanceledException) { return; } + + lock (_sync) + { + if (_disposed || _floorGeneration != generation || !_floorHolding) { return; } + + _floorHolding = false; + } + + _requestRender(); + } +} diff --git a/src/EventLogExpert.Runtime/LogTable/DisplayOrdering.cs b/src/EventLogExpert.Runtime/LogTable/DisplayOrdering.cs new file mode 100644 index 000000000..439c6e6c5 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/DisplayOrdering.cs @@ -0,0 +1,10 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.LogTable; + +public readonly record struct DisplayOrdering( + ColumnName? OrderBy, + bool IsDescending, + ColumnName? GroupBy, + bool IsGroupDescending); diff --git a/src/EventLogExpert.Runtime/LogTable/DisplayReadyAction.cs b/src/EventLogExpert.Runtime/LogTable/DisplayReadyAction.cs deleted file mode 100644 index 595057dec..000000000 --- a/src/EventLogExpert.Runtime/LogTable/DisplayReadyAction.cs +++ /dev/null @@ -1,15 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -using EventLogExpert.Eventing.Common.EventLogs; -using System.Collections.Immutable; - -namespace EventLogExpert.Runtime.LogTable; - -public sealed record DisplayReadyAction -{ - internal IReadOnlyDictionary Views { get; init; } = - ImmutableDictionary.Empty; - - internal int Version { get; init; } -} diff --git a/src/EventLogExpert.Runtime/LogTable/DisplayViewBuilder.cs b/src/EventLogExpert.Runtime/LogTable/DisplayViewBuilder.cs deleted file mode 100644 index 03079d8c9..000000000 --- a/src/EventLogExpert.Runtime/LogTable/DisplayViewBuilder.cs +++ /dev/null @@ -1,27 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -using EventLogExpert.Eventing.Common.EventLogs; -using EventLogExpert.Eventing.Common.Events; -using EventLogExpert.Filtering.Compilation; -using EventLogExpert.Filtering.Evaluation; - -namespace EventLogExpert.Runtime.LogTable; - -internal static class DisplayViewBuilder -{ - internal static EventColumnView Build( - EventColumnStore store, - EventLogId logId, - Filter filter, - SortContext context) - { - ArgumentNullException.ThrowIfNull(store); - - var reader = store.CreateReader(logId); - var survivors = FilterService.GetSurvivingOrder(reader, filter); - int[] order = survivors as int[] ?? [.. survivors]; - - return EventColumnView.Create(reader, order, context); - } -} diff --git a/src/EventLogExpert.Runtime/LogTable/DisplayedIndicator.cs b/src/EventLogExpert.Runtime/LogTable/DisplayedIndicator.cs new file mode 100644 index 000000000..5ce372d6e --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/DisplayedIndicator.cs @@ -0,0 +1,11 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.LogTable; + +public readonly record struct DisplayedIndicator(DisplayIndicatorKind Sentence, bool Spinner) +{ + public static DisplayedIndicator Nothing { get; } = new(DisplayIndicatorKind.None, false); + + public static DisplayedIndicator GenericSpinner { get; } = new(DisplayIndicatorKind.None, true); +} diff --git a/src/EventLogExpert.Runtime/LogTable/Effects.cs b/src/EventLogExpert.Runtime/LogTable/Effects.cs index c1e62b3a0..b5f542550 100644 --- a/src/EventLogExpert.Runtime/LogTable/Effects.cs +++ b/src/EventLogExpert.Runtime/LogTable/Effects.cs @@ -10,10 +10,12 @@ internal sealed class Effects( ILogTablePreferencesProvider preferencesProvider, IState logTableState, ILogTableColumnDefaultsProvider columnDefaults, - IColumnResetMigrator columnResetMigrator) + IColumnResetMigrator columnResetMigrator, + GroupCollapseNotifier groupCollapseNotifier) { private readonly ILogTableColumnDefaultsProvider _columnDefaults = columnDefaults; private readonly IColumnResetMigrator _columnResetMigrator = columnResetMigrator; + private readonly GroupCollapseNotifier _groupCollapseNotifier = groupCollapseNotifier; private readonly IState _logTableState = logTableState; private readonly ILogTablePreferencesProvider _preferencesProvider = preferencesProvider; @@ -41,7 +43,6 @@ public Task HandleLoadColumns(IDispatcher dispatcher) [EffectMethod] public Task HandleReorderColumn(ReorderColumnAction action, IDispatcher dispatcher) { - // Read from post-reducer state to avoid race conditions with rapid reorder actions _preferencesProvider.ColumnOrderPreference = _logTableState.Value.ColumnOrder; return Task.CompletedTask; @@ -68,10 +69,17 @@ public Task HandleResetColumnDefaults(IDispatcher dispatcher) return Task.CompletedTask; } + [EffectMethod(typeof(SetAllGroupsCollapsedAction))] + public Task HandleSetAllGroupsCollapsed(IDispatcher dispatcher) + { + _groupCollapseNotifier.Raise(); + + return Task.CompletedTask; + } + [EffectMethod] public Task HandleSetColumnWidth(SetColumnWidthAction action, IDispatcher dispatcher) { - // Read from post-reducer state to avoid race conditions _preferencesProvider.ColumnWidthsPreference = new Dictionary(_logTableState.Value.ColumnWidths); return Task.CompletedTask; @@ -110,7 +118,6 @@ private ImmutableList BuildOrder() return _columnDefaults.ColumnOrder; } - // Start with saved order, then append any new columns not in saved order var allColumns = Enum.GetValues().ToHashSet(); var ordered = savedOrder.Where(allColumns.Contains).ToList(); var missing = _columnDefaults.ColumnOrder.Where(c => !ordered.Contains(c)); diff --git a/src/EventLogExpert.Runtime/LogTable/EmptyColumnView.cs b/src/EventLogExpert.Runtime/LogTable/EmptyColumnView.cs new file mode 100644 index 000000000..0e6733569 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/EmptyColumnView.cs @@ -0,0 +1,213 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Filtering.Persistence; +using System.Diagnostics.CodeAnalysis; + +namespace EventLogExpert.Runtime.LogTable; + +internal sealed class EmptyColumnView : IEventColumnView +{ + private static readonly DisplayRow[] s_noRows = []; + + public int Count => 0; + + internal static EmptyColumnView Instance { get; } = new(); + + public void BucketTimeTicksByEventData( + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) { } + + public void BucketTimeTicksByEventDataHResult( + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) { } + + public void BucketTimeTicksByEventDataHResultWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) { } + + public void BucketTimeTicksByEventDataString( + long minTicks, + long bucketSpanTicks, + int bucketCount, + string[] candidateFields, + IReadOnlyDictionary rawValueToSlot, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) { } + + public void BucketTimeTicksByEventDataStringWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string[] candidateFields, + IReadOnlyDictionary rawValueToSlot, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) { } + + public void BucketTimeTicksByEventDataWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) { } + + public void BucketTimeTicksByEventId( + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] targetIds, + int[] slotCounts, + CancellationToken cancellationToken) { } + + public void BucketTimeTicksByEventIdWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] targetIds, + int[] slotCounts, + CancellationToken cancellationToken) { } + + public void BucketTimeTicksByField( + long minTicks, + long bucketSpanTicks, + int bucketCount, + EventFieldId field, + string[] targetValues, + int[] slotCounts, + CancellationToken cancellationToken) { } + + public void BucketTimeTicksByFieldWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + EventFieldId field, + string[] targetValues, + int[] slotCounts, + CancellationToken cancellationToken) { } + + public void BucketTimeTicksBySeverity( + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] slotCounts, + CancellationToken cancellationToken) { } + + public void BucketTimeTicksBySeverityWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] slotCounts, + CancellationToken cancellationToken) { } + + public void CountEventDataHResults( + string fieldName, + IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, + IDictionary counts, + CancellationToken cancellationToken) { } + + public void CountEventDataStringValues( + string[] candidateFields, + IDictionary counts, + CancellationToken cancellationToken) { } + + public void CountEventDataValues( + string fieldName, + IDictionary counts, + CancellationToken cancellationToken) { } + + public void CountEventIds(IDictionary counts, CancellationToken cancellationToken) { } + + public void CountFieldValues(EventFieldId field, IDictionary counts, CancellationToken cancellationToken) { } + + public byte[] EnsureHighlightWinners( + IReadOnlyList orderedColoredFilters, + int planKey, + CancellationToken cancellationToken) => new byte[1]; + + public IEnumerable EnumerateDetail() => []; + + public IEnumerable EnumerateDetailLean() => []; + + public ResolvedEvent GetDetail(EventLocator locator) => throw NotAMember(locator); + + public ResolvedEvent GetDetailLean(EventLocator locator) => throw NotAMember(locator); + + public string GroupKeyAt(EventLocator locator, ColumnName column) => throw NotAMember(locator); + + public EventLocator LocatorAt(int index) => throw new ArgumentOutOfRangeException(nameof(index)); + + public int Rank(EventLocator locator) => -1; + + public EventLocator? ResolveByKey(ValueKey key) => null; + + public IReadOnlyList Slice(int start, int count) + { + if (start < 0) { throw new ArgumentOutOfRangeException(nameof(start)); } + + if (count < 0) { throw new ArgumentOutOfRangeException(nameof(count)); } + + return s_noRows; + } + + public bool TryGetDetail(EventLocator locator, [NotNullWhen(true)] out ResolvedEvent? detail) + { + detail = null; + + return false; + } + + public bool TryGetTimeTicks(EventLocator locator, out long ticks) + { + ticks = 0; + + return false; + } + + public bool TryGetTimeTicksRange(out long minTicks, out long maxTicks, CancellationToken cancellationToken) + { + minTicks = 0; + maxTicks = 0; + + return false; + } + + private static KeyNotFoundException NotAMember(EventLocator locator) => + new($"Locator log id '{locator.LogId}' is not a member of this combined view."); +} diff --git a/src/EventLogExpert.Runtime/LogTable/EventDetailResolver.cs b/src/EventLogExpert.Runtime/LogTable/EventDetailResolver.cs new file mode 100644 index 000000000..fa9084959 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/EventDetailResolver.cs @@ -0,0 +1,30 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; +using Fluxor; +using System.Diagnostics.CodeAnalysis; + +namespace EventLogExpert.Runtime.LogTable; + +internal sealed class EventDetailResolver(IState rawEventStore) : IEventDetailResolver +{ + private readonly IState _rawEventStore = rawEventStore; + + public bool TryResolve(EventLocator locator, [NotNullWhen(true)] out ResolvedEvent? detail) + { + detail = null; + + if (!_rawEventStore.Value.ByLog.TryGetValue(locator.LogId, out var store)) { return false; } + + var reader = store.CreateReader(locator.LogId); + + if (locator.Generation != reader.Generation) { return false; } + + if (locator.Index < 0 || locator.Index >= reader.Count) { return false; } + + detail = reader.GetDetail(locator); + + return true; + } +} diff --git a/src/EventLogExpert.Runtime/LogTable/FilteredLogPresenceCoordinator.cs b/src/EventLogExpert.Runtime/LogTable/FilteredLogPresenceCoordinator.cs new file mode 100644 index 000000000..ed71d9eea --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/FilteredLogPresenceCoordinator.cs @@ -0,0 +1,324 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Filtering.Compilation; +using EventLogExpert.Filtering.Evaluation; +using EventLogExpert.Runtime.EventLog; +using Fluxor; +using System.Collections.Immutable; +using IDispatcher = Fluxor.IDispatcher; + +namespace EventLogExpert.Runtime.LogTable; + +internal sealed class FilteredLogPresenceCoordinator : IDisposable +{ + private readonly EventLogConcurrencyState _concurrencyState; + private readonly HashSet _dirty = []; + private readonly IDispatcher _dispatcher; + private readonly IState _eventLogState; + private readonly Lock _gate = new(); + private readonly IState _presenceState; + private readonly IState _rawEventStore; + + private readonly bool _scanInline; + + internal Action? OnBatchDrainedForTest { get; set; } + + private readonly Dictionary _scanPositions = []; + + private bool _disposed; + private long _filterVersion; + private bool _scanRunning; + + public FilteredLogPresenceCoordinator( + IDispatcher dispatcher, + IState eventLogState, + IState rawEventStore, + IState presenceState, + EventLogConcurrencyState concurrencyState) + : this(dispatcher, eventLogState, rawEventStore, presenceState, concurrencyState, scanInline: false) { } + + internal FilteredLogPresenceCoordinator( + IDispatcher dispatcher, + IState eventLogState, + IState rawEventStore, + IState presenceState, + EventLogConcurrencyState concurrencyState, + bool scanInline) + { + ArgumentNullException.ThrowIfNull(dispatcher); + ArgumentNullException.ThrowIfNull(eventLogState); + ArgumentNullException.ThrowIfNull(rawEventStore); + ArgumentNullException.ThrowIfNull(presenceState); + ArgumentNullException.ThrowIfNull(concurrencyState); + + _dispatcher = dispatcher; + _eventLogState = eventLogState; + _rawEventStore = rawEventStore; + _presenceState = presenceState; + _concurrencyState = concurrencyState; + _scanInline = scanInline; + } + + public void Discard(EventLogId logId) + { + lock (_gate) + { + _dirty.Remove(logId); + _scanPositions.Remove(logId); + } + + ScheduleScan(); + } + + public void DiscardAll() + { + lock (_gate) + { + _dirty.Clear(); + _scanPositions.Clear(); + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) { return; } + + _disposed = true; + _dirty.Clear(); + _scanPositions.Clear(); + } + } + + public void MarkAppended(IEnumerable logIds) + { + ArgumentNullException.ThrowIfNull(logIds); + + lock (_gate) + { + if (_disposed) { return; } + + foreach (var logId in logIds) { _dirty.Add(logId); } + } + + ScheduleScan(); + } + + public void MarkFilterChanged() + { + ImmutableArray openLogs = [.. _eventLogState.Value.OpenLogs.Values.Select(log => log.Id)]; + long filterVersion; + + lock (_gate) + { + if (_disposed) { return; } + + filterVersion = ++_filterVersion; + _scanPositions.Clear(); + _dirty.Clear(); + } + + _dispatcher.Dispatch(new FilteredPresenceInvalidatedAction(filterVersion, openLogs)); + + lock (_gate) + { + if (_disposed) { return; } + + foreach (var logId in openLogs) { _dirty.Add(logId); } + } + + ScheduleScan(); + } + + public void MarkRebuilt(EventLogId logId) + { + lock (_gate) + { + if (_disposed) { return; } + + _scanPositions.Remove(logId); + _dirty.Add(logId); + } + + ScheduleScan(); + } + + private static bool ScanFrom( + EventColumnStore store, + EventLogId logId, + int start, + Func survives) + { + var reader = store.CreateReader(logId); + int count = reader.Count; + + for (int index = start; index < count; index++) + { + if (survives(reader, reader.LocatorAt(index))) { return true; } + } + + return false; + } + + private ImmutableArray> Evaluate(EventLogId[] batch, long filterVersion) + { + var eventLogState = _eventLogState.Value; + Filter filter = eventLogState.AppliedFilter; + + if (XmlDeferralActive(eventLogState)) + { + lock (_gate) + { + if (!_disposed) { foreach (var logId in batch) { _dirty.Add(logId); } } + } + + return []; + } + + var openIds = eventLogState.OpenLogs.Values.Select(log => log.Id).ToHashSet(); + var stores = _rawEventStore.Value.ByLog; + var presenceSnapshot = _presenceState.Value; + var known = presenceSnapshot.ByLog; + bool knownReflectsCurrentFilter = presenceSnapshot.FilterVersion == filterVersion; + var results = ImmutableArray.CreateBuilder>(batch.Length); + + Func? survives = null; + + foreach (var logId in batch) + { + lock (_gate) + { + if (_disposed || filterVersion != _filterVersion) { return []; } + } + + if (!openIds.Contains(logId) || !stores.TryGetValue(logId, out var store)) { continue; } + + if (store.Count <= 0) + { + results.Add(new(logId, FilteredLogPresence.NoSurvivor)); + + continue; + } + + if (!filter.IsFilteringEnabled) + { + results.Add(new(logId, FilteredLogPresence.HasSurvivor)); + + continue; + } + + if (knownReflectsCurrentFilter && known.TryGetValue(logId, out var current) && current == FilteredLogPresence.HasSurvivor) { continue; } + + int start = ResumePoint(logId, store); + + survives ??= FilterService.CompileSurvivorPredicate(filter); + + bool found = ScanFrom(store, logId, start, survives); + + results.Add(new(logId, found ? FilteredLogPresence.HasSurvivor : FilteredLogPresence.NoSurvivor)); + + lock (_gate) + { + if (_disposed || filterVersion != _filterVersion) { return []; } + + if (found) { _scanPositions.Remove(logId); } + else { _scanPositions[logId] = new ScanPosition(store.Count, store.Generation); } + } + } + + return results.ToImmutable(); + } + + private int ResumePoint(EventLogId logId, EventColumnStore store) + { + lock (_gate) + { + if (!_scanPositions.TryGetValue(logId, out var scanPosition)) { return 0; } + + if (scanPosition.Generation != store.Generation || store.Count < scanPosition.ScannedCount) + { + _scanPositions.Remove(logId); + + return 0; + } + + return scanPosition.ScannedCount; + } + } + + private void RunScanLoop() + { + while (true) + { + EventLogId[] batch; + long filterVersion; + + lock (_gate) + { + if (_disposed || _dirty.Count <= 0) + { + _scanRunning = false; + + return; + } + + if (XmlDeferralActive(_eventLogState.Value)) + { + _scanRunning = false; + + return; + } + + batch = [.. _dirty]; + filterVersion = _filterVersion; + _dirty.Clear(); + } + + OnBatchDrainedForTest?.Invoke(); + + try + { + var verdicts = Evaluate(batch, filterVersion); + + if (verdicts.Length > 0) { _dispatcher.Dispatch(new FilteredPresenceUpdatedAction(filterVersion, verdicts)); } + } + catch (Exception) + { + // A faulty survivor predicate or a dispatch failure must not escape this fire-and-forget scan + // loop: escaping would leave _scanRunning stuck true and silently wedge every later presence + // scan. Drop this batch; a later filter change or append re-dirties the logs and retries. + } + } + } + + private bool XmlDeferralActive(EventLogState eventLogState) => + eventLogState.AppliedFilter.RequiresXml && + eventLogState.OpenLogs.Values.Any(log => !_concurrencyState.IsLoadedWithXml(log.Id)); + + private void ScheduleScan() + { + lock (_gate) + { + if (_disposed || _scanRunning || _dirty.Count <= 0) { return; } + + if (XmlDeferralActive(_eventLogState.Value)) { return; } + + _scanRunning = true; + } + + if (_scanInline) + { + RunScanLoop(); + + return; + } + + _ = Task.Run(RunScanLoop); + } + + private readonly record struct ScanPosition(int ScannedCount, int Generation); +} diff --git a/src/EventLogExpert.Runtime/LogTable/FilteredLogPresenceEffects.cs b/src/EventLogExpert.Runtime/LogTable/FilteredLogPresenceEffects.cs new file mode 100644 index 000000000..5f6c4b3b5 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/FilteredLogPresenceEffects.cs @@ -0,0 +1,68 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.EventLog; +using Fluxor; +using CloseAllLogsAction = EventLogExpert.Runtime.EventLog.CloseAllLogsAction; + +namespace EventLogExpert.Runtime.LogTable; + +internal sealed class FilteredLogPresenceEffects(FilteredLogPresenceCoordinator coordinator) +{ + private readonly FilteredLogPresenceCoordinator _coordinator = coordinator; + + [EffectMethod(typeof(ApplyFilterAction))] + public Task HandleApplyFilter(IDispatcher dispatcher) + { + _coordinator.MarkFilterChanged(); + + return Task.CompletedTask; + } + + [EffectMethod(typeof(CloseAllLogsAction))] + public Task HandleCloseAll(IDispatcher dispatcher) + { + _coordinator.DiscardAll(); + + return Task.CompletedTask; + } + + [EffectMethod] + public Task HandleCloseLog(CloseLogAction action, IDispatcher dispatcher) + { + _coordinator.Discard(action.LogId); + + return Task.CompletedTask; + } + + [EffectMethod] + public Task HandleIngestRawEvents(IngestRawEventsAction action, IDispatcher dispatcher) + { + if (action.Mode == RawIngestMode.Replace) + { + foreach (var logId in action.EventsByLog.Keys) { _coordinator.MarkRebuilt(logId); } + + return Task.CompletedTask; + } + + _coordinator.MarkAppended(action.EventsByLog.Keys); + + return Task.CompletedTask; + } + + [EffectMethod] + public Task HandleLoadEvents(LoadEventsAction action, IDispatcher dispatcher) + { + _coordinator.MarkRebuilt(action.LogData.Id); + + return Task.CompletedTask; + } + + [EffectMethod] + public Task HandleLoadEventsPartial(LoadEventsPartialAction action, IDispatcher dispatcher) + { + _coordinator.MarkAppended([action.LogData.Id]); + + return Task.CompletedTask; + } +} diff --git a/src/EventLogExpert.Runtime/LogTable/FilteredLogPresenceReducers.cs b/src/EventLogExpert.Runtime/LogTable/FilteredLogPresenceReducers.cs new file mode 100644 index 000000000..8084d9036 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/FilteredLogPresenceReducers.cs @@ -0,0 +1,68 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using Fluxor; +using System.Collections.Immutable; +using CloseAllLogsAction = EventLogExpert.Runtime.EventLog.CloseAllLogsAction; + +namespace EventLogExpert.Runtime.LogTable; + +internal sealed class FilteredLogPresenceReducers +{ + [ReducerMethod] + public static FilteredLogPresenceState ReduceAddTable(FilteredLogPresenceState state, AddTableAction action) => + state with { ByLog = state.ByLog.SetItem(action.LogData.Id, FilteredLogPresence.Pending) }; + + [ReducerMethod(typeof(CloseAllLogsAction))] + public static FilteredLogPresenceState ReduceCloseAll(FilteredLogPresenceState state) => + state.ByLog.IsEmpty ? + state : + state with { ByLog = ImmutableDictionary.Empty }; + + [ReducerMethod] + public static FilteredLogPresenceState ReduceCloseLog(FilteredLogPresenceState state, CloseLogAction action) + { + var remaining = state.ByLog.Remove(action.LogId); + + return ReferenceEquals(remaining, state.ByLog) ? state : state with { ByLog = remaining }; + } + + [ReducerMethod] + public static FilteredLogPresenceState ReduceInvalidated( + FilteredLogPresenceState state, + FilteredPresenceInvalidatedAction action) + { + if (action.FilterVersion <= state.FilterVersion) { return state; } + + var builder = state.ByLog.ToBuilder(); + + foreach (var logId in action.LogIds) + { + if (builder.ContainsKey(logId)) { builder[logId] = FilteredLogPresence.Pending; } + } + + return state with { ByLog = builder.ToImmutable(), FilterVersion = action.FilterVersion }; + } + + [ReducerMethod] + public static FilteredLogPresenceState ReduceUpdated( + FilteredLogPresenceState state, + FilteredPresenceUpdatedAction action) + { + if (action.FilterVersion != state.FilterVersion) { return state; } + + var builder = state.ByLog.ToBuilder(); + bool changed = false; + + foreach (var (logId, presence) in action.Verdicts) + { + if (!builder.TryGetValue(logId, out var current) || current == presence) { continue; } + + builder[logId] = presence; + changed = true; + } + + return changed ? state with { ByLog = builder.ToImmutable() } : state; + } +} diff --git a/src/EventLogExpert.Runtime/LogTable/FilteredLogPresenceState.cs b/src/EventLogExpert.Runtime/LogTable/FilteredLogPresenceState.cs new file mode 100644 index 000000000..d9cdd3f54 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/FilteredLogPresenceState.cs @@ -0,0 +1,27 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using Fluxor; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.LogTable; + +public enum FilteredLogPresence +{ + Pending, + HasSurvivor, + NoSurvivor +} + +[FeatureState] +public sealed record FilteredLogPresenceState +{ + public ImmutableDictionary ByLog { get; init; } = + ImmutableDictionary.Empty; + + internal long FilterVersion { get; init; } + + public bool IsKnownEmpty(EventLogId logId) => + ByLog.TryGetValue(logId, out var presence) && presence == FilteredLogPresence.NoSurvivor; +} diff --git a/src/EventLogExpert.Runtime/LogTable/UpdateTableAction.cs b/src/EventLogExpert.Runtime/LogTable/FilteredPresenceInvalidatedAction.cs similarity index 53% rename from src/EventLogExpert.Runtime/LogTable/UpdateTableAction.cs rename to src/EventLogExpert.Runtime/LogTable/FilteredPresenceInvalidatedAction.cs index 9353a59b4..4267e294e 100644 --- a/src/EventLogExpert.Runtime/LogTable/UpdateTableAction.cs +++ b/src/EventLogExpert.Runtime/LogTable/FilteredPresenceInvalidatedAction.cs @@ -2,12 +2,8 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.EventLogs; +using System.Collections.Immutable; namespace EventLogExpert.Runtime.LogTable; -public sealed record UpdateTableAction(EventLogId LogId) -{ - internal EventColumnView? View { get; init; } - - internal int Version { get; init; } -} +internal sealed record FilteredPresenceInvalidatedAction(long FilterVersion, ImmutableArray LogIds); diff --git a/src/EventLogExpert.Runtime/LogTable/FilteredPresenceUpdatedAction.cs b/src/EventLogExpert.Runtime/LogTable/FilteredPresenceUpdatedAction.cs new file mode 100644 index 000000000..16bb2ae16 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/FilteredPresenceUpdatedAction.cs @@ -0,0 +1,11 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.LogTable; + +internal sealed record FilteredPresenceUpdatedAction( + long FilterVersion, + ImmutableArray> Verdicts); diff --git a/src/EventLogExpert.Runtime/LogTable/GroupCollapseNotifier.cs b/src/EventLogExpert.Runtime/LogTable/GroupCollapseNotifier.cs new file mode 100644 index 000000000..47af4f428 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/GroupCollapseNotifier.cs @@ -0,0 +1,40 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace EventLogExpert.Runtime.LogTable; + +internal sealed class GroupCollapseNotifier : IGroupCollapseNotifier +{ + private readonly ITraceLogger _logger; + + public GroupCollapseNotifier([FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + { + ArgumentNullException.ThrowIfNull(logger); + + _logger = logger; + } + + public event Action? Requested; + + public void Raise() + { + var handlers = Requested; + + if (handlers is null) { return; } + + foreach (var handler in handlers.GetInvocationList().Cast()) + { + try + { + handler(); + } + catch (Exception fault) + { + _logger.Trace($"{nameof(GroupCollapseNotifier)}: a subscriber threw and was isolated: {fault}"); + } + } + } +} diff --git a/src/EventLogExpert.Runtime/LogTable/AppendTableEventsAction.cs b/src/EventLogExpert.Runtime/LogTable/IActiveEventLogSource.cs similarity index 57% rename from src/EventLogExpert.Runtime/LogTable/AppendTableEventsAction.cs rename to src/EventLogExpert.Runtime/LogTable/IActiveEventLogSource.cs index b26688910..a6b4be767 100644 --- a/src/EventLogExpert.Runtime/LogTable/AppendTableEventsAction.cs +++ b/src/EventLogExpert.Runtime/LogTable/IActiveEventLogSource.cs @@ -2,10 +2,11 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Runtime.Common.Sources; namespace EventLogExpert.Runtime.LogTable; -public sealed record AppendTableEventsAction(EventLogId LogId) +public interface IActiveEventLogSource : IChangeNotifier { - internal EventColumnView? View { get; init; } + EventLogId? Current { get; } } diff --git a/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs b/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs index 8df5566c5..84bc6b7f7 100644 --- a/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs +++ b/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs @@ -7,19 +7,10 @@ namespace EventLogExpert.Runtime.LogTable; -/// -/// The live display facade: a filter-surviving, sorted view over one or more column-backed logs. The viewport -/// reads rows by display position through ; selection, highlight, and scroll resolve by -/// through and . -/// public interface IEventColumnView { int Count { get; } - /// - /// Group-by variant keyed on a named EventData field's whole-number code; (targetCodes length + 1) slots per bin, - /// with the trailing "other" slot also absorbing rows that lack the field. - /// void BucketTimeTicksByEventData( long minTicks, long bucketSpanTicks, @@ -29,13 +20,6 @@ void BucketTimeTicksByEventData( int[] slotCounts, CancellationToken cancellationToken); - /// - /// HRESULT-code variant of for the ErrorCode dimension: only survivors - /// from a provider in whose field - or, when that - /// EventData field is absent, whose UserData carries one of the curated - /// leaves - holds a nonzero 32-bit HRESULT contribute (their target slot, else the trailing "other" slot); every other - /// row is omitted. - /// void BucketTimeTicksByEventDataHResult( long minTicks, long bucketSpanTicks, @@ -60,7 +44,6 @@ void BucketTimeTicksByEventDataHResultWithTie( int[] slotCounts, CancellationToken cancellationToken); - /// Group-by EventData string variant keyed on the first usable named candidate field value. void BucketTimeTicksByEventDataString( long minTicks, long bucketSpanTicks, @@ -94,7 +77,6 @@ void BucketTimeTicksByEventDataWithTie( int[] slotCounts, CancellationToken cancellationToken); - /// Group-by variant keyed on the numeric event id; (targetIds length + 1) slots per bin. void BucketTimeTicksByEventId( long minTicks, long bucketSpanTicks, @@ -113,10 +95,6 @@ void BucketTimeTicksByEventIdWithTie( int[] slotCounts, CancellationToken cancellationToken); - /// - /// Group-by variant of for a pooled string field; (targetValues length + - /// 1) slots per bin. - /// void BucketTimeTicksByField( long minTicks, long bucketSpanTicks, @@ -137,10 +115,6 @@ void BucketTimeTicksByFieldWithTie( int[] slotCounts, CancellationToken cancellationToken); - /// - /// Additively buckets this view's rows by UTC tick and severity slot; bucket-major - /// slotCounts[i*LevelSeverity.SlotCount + slot], out-of-domain ticks clamp to the end buckets. - /// void BucketTimeTicksBySeverity( long minTicks, long bucketSpanTicks, @@ -157,13 +131,6 @@ void BucketTimeTicksBySeverityWithTie( int[] slotCounts, CancellationToken cancellationToken); - /// - /// HRESULT-code variant of for the ErrorCode dimension: tallies this view's - /// survivors from a provider in by the nonzero 32-bit HRESULT in - /// or, when that EventData field is absent, one of the curated - /// UserData leaves (accumulating across a combined view); resolves the - /// top-N failure codes. - /// void CountEventDataHResults( string fieldName, IReadOnlyCollection eligibleProviders, @@ -171,84 +138,40 @@ void CountEventDataHResults( IDictionary counts, CancellationToken cancellationToken); - /// Tallies this view's rows by the first usable string value from the named EventData candidate fields. void CountEventDataStringValues(string[] candidateFields, IDictionary counts, CancellationToken cancellationToken); - /// - /// Tallies this view's rows by the whole-number code of a named EventData field (accumulating across a combined - /// view, since a numeric code is store-independent); resolves the top-N group-by categories for the histogram. - /// void CountEventDataValues(string fieldName, IDictionary counts, CancellationToken cancellationToken); - /// - /// Tallies this view's rows by numeric event id into (accumulating across a combined - /// view). - /// void CountEventIds(IDictionary counts, CancellationToken cancellationToken); - /// - /// Tallies this view's rows by non-empty pooled string value of field (accumulating, so a combined view sums by - /// logical value across logs); resolves the top-N group-by categories. - /// void CountFieldValues(EventFieldId field, IDictionary counts, CancellationToken cancellationToken); - /// - /// Precomputes the highlight "winners" for and returns an OPAQUE, - /// view-specific handle. The result is NOT guaranteed to be a per-row array - a combined view returns a small sentinel - /// handle - so callers MUST NOT index into it or assume its Length equals the row count; it may only be passed - /// back to the *WithTie bucketing APIs on the SAME view instance. lets - /// implementations cache the underlying winner data and skip recomputation while the coloured-filter predicate set is - /// unchanged (the returned handle itself is not guaranteed to be reused). - /// byte[] EnsureHighlightWinners( IReadOnlyList orderedColoredFilters, int planKey, CancellationToken cancellationToken); - /// Full-detail rehydrate of every display row, in display order, for export and clipboard. IEnumerable EnumerateDetail(); + IEnumerable EnumerateDetailLean() => EnumerateDetail(); + ResolvedEvent GetDetail(EventLocator locator); - /// - /// Lean single-row rehydrate (grid scalars plus Description) for the row addressed by . - /// ResolvedEvent GetDetailLean(EventLocator locator); string GroupKeyAt(EventLocator locator, ColumnName column); EventLocator LocatorAt(int index); - /// - /// The display position of in this view, or -1 when the locator is not in the - /// view (filtered out) or does not address this view's log generation. - /// int Rank(EventLocator locator); - /// - /// Re-resolves a stable to the locator that currently carries it, or null when no - /// surviving row matches (a null-RecordId event never produces a key; a filtered-out row is absent). Drives selection - /// restore across a reload. - /// EventLocator? ResolveByKey(ValueKey key); IReadOnlyList Slice(int start, int count); - /// - /// Exception-free resolve of to its full : false - /// when the locator no longer addresses a live physical row (the log closed, the store rebuilt to a newer generation, - /// or the index is out of range). A valid but filtered-out row still resolves, so a focused selection stays - /// inspectable after a filter hides it. - /// bool TryGetDetail(EventLocator locator, [NotNullWhen(true)] out ResolvedEvent? detail); - /// - /// Exception-free read of locator's UTC-tick timestamp from the tick column (no rehydrate); false when it no - /// longer addresses a live row. Like TryGetDetail, a filtered-out row still resolves, so in-view callers must also - /// check Rank. - /// bool TryGetTimeTicks(EventLocator locator, out long ticks); - /// UTC-tick span across this view's rows: true with [minTicks, maxTicks], false when the view is empty. bool TryGetTimeTicksRange(out long minTicks, out long maxTicks, CancellationToken cancellationToken); } diff --git a/src/EventLogExpert.Runtime/LogTable/IEventDetailResolver.cs b/src/EventLogExpert.Runtime/LogTable/IEventDetailResolver.cs new file mode 100644 index 000000000..d19ab4a41 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/IEventDetailResolver.cs @@ -0,0 +1,12 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; +using System.Diagnostics.CodeAnalysis; + +namespace EventLogExpert.Runtime.LogTable; + +public interface IEventDetailResolver +{ + bool TryResolve(EventLocator locator, [NotNullWhen(true)] out ResolvedEvent? detail); +} diff --git a/src/EventLogExpert.Runtime/LogTable/IGroupCollapseNotifier.cs b/src/EventLogExpert.Runtime/LogTable/IGroupCollapseNotifier.cs new file mode 100644 index 000000000..b24b80c20 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/IGroupCollapseNotifier.cs @@ -0,0 +1,9 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.LogTable; + +public interface IGroupCollapseNotifier +{ + event Action Requested; +} diff --git a/src/EventLogExpert.Runtime/LogTable/ILogTabBarSource.cs b/src/EventLogExpert.Runtime/LogTable/ILogTabBarSource.cs new file mode 100644 index 000000000..8c6c56179 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/ILogTabBarSource.cs @@ -0,0 +1,11 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Common.Sources; + +namespace EventLogExpert.Runtime.LogTable; + +public interface ILogTabBarSource : IChangeNotifier +{ + LogTabBarPresentation Current { get; } +} diff --git a/src/EventLogExpert.Runtime/LogTable/ILogTableQueries.cs b/src/EventLogExpert.Runtime/LogTable/ILogTableQueries.cs new file mode 100644 index 000000000..6f6531bc7 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/ILogTableQueries.cs @@ -0,0 +1,27 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; + +namespace EventLogExpert.Runtime.LogTable; + +public interface ILogTableQueries +{ + IReadOnlyList GetTabGroups(); + + bool HasActiveLogs(); + + bool HasMultipleIndividualTabs(); + + bool HasOtherTabsInGroup(LogTabGroupId groupId, EventLogId keepTabId); + + bool HasTabGroup(LogTabGroupId groupId); + + bool IsGroupDescending(); + + bool IsGrouping(); + + bool IsTabOpen(EventLogId tabId); + + bool IsUngroupedTabOpen(EventLogId tabId); +} diff --git a/src/EventLogExpert.Runtime/LogTable/IOrderedViewSource.cs b/src/EventLogExpert.Runtime/LogTable/IOrderedViewSource.cs new file mode 100644 index 000000000..896a5be0e --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/IOrderedViewSource.cs @@ -0,0 +1,11 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.LogTable; + +public interface IOrderedViewSource +{ + event Action Updated; + + OrderedViewPresentation Current { get; } +} diff --git a/src/EventLogExpert.Runtime/LogTable/LogTabBarPresentation.cs b/src/EventLogExpert.Runtime/LogTable/LogTabBarPresentation.cs new file mode 100644 index 000000000..34c4daea0 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/LogTabBarPresentation.cs @@ -0,0 +1,22 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.LogTable; + +public sealed record LogTabBarPresentation +{ + public ImmutableList Tabs { get; init; } = []; + + public ImmutableList Groups { get; init; } = []; + + public EventLogId? ActiveTabId { get; init; } + + public ImmutableHashSet KnownEmptyTabIds { get; init; } = ImmutableHashSet.Empty; + + public bool HasMultipleTabs => Tabs.Count > 1; + + public bool IsKnownEmpty(EventLogId tabId) => KnownEmptyTabIds.Contains(tabId); +} diff --git a/src/EventLogExpert.Runtime/LogTable/LogTabBarSource.cs b/src/EventLogExpert.Runtime/LogTable/LogTabBarSource.cs new file mode 100644 index 000000000..d3179e610 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/LogTabBarSource.cs @@ -0,0 +1,125 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Logging.Abstractions; +using Fluxor; +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.LogTable; + +internal sealed class LogTabBarSource : ILogTabBarSource, IDisposable +{ + private readonly Lock _gate = new(); + private readonly IState _logTableState; + private readonly ITraceLogger _logger; + private readonly IState _presenceState; + + private LogTabBarPresentation _current; + private bool _disposed; + + public LogTabBarSource( + IState logTableState, + IState presenceState, + [FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + { + ArgumentNullException.ThrowIfNull(logTableState); + ArgumentNullException.ThrowIfNull(presenceState); + ArgumentNullException.ThrowIfNull(logger); + + _logTableState = logTableState; + _presenceState = presenceState; + _logger = logger; + + _current = Project(logTableState.Value, presenceState.Value); + _logTableState.StateChanged += OnStateChanged; + _presenceState.StateChanged += OnStateChanged; + + lock (_gate) { _current = Project(_logTableState.Value, _presenceState.Value); } + } + + public event Action? Changed; + + public LogTabBarPresentation Current + { + get { lock (_gate) { return _current; } } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) { return; } + + _disposed = true; + } + + _logTableState.StateChanged -= OnStateChanged; + _presenceState.StateChanged -= OnStateChanged; + } + + private static ImmutableHashSet ComputeKnownEmptyTabIds( + LogTableState logTable, + FilteredLogPresenceState presence) + { + var builder = ImmutableHashSet.CreateBuilder(); + + foreach (var table in logTable.EventTables) + { + if (table.IsCombined || table.IsLoading) { continue; } + + if (presence.IsKnownEmpty(table.Id)) { builder.Add(table.Id); } + } + + return builder.ToImmutable(); + } + + private static bool IsEqual(LogTabBarPresentation next, LogTabBarPresentation current) => + ReferenceEquals(next.Tabs, current.Tabs) && + ReferenceEquals(next.Groups, current.Groups) && + next.ActiveTabId == current.ActiveTabId && + next.KnownEmptyTabIds.SetEquals(current.KnownEmptyTabIds); + + private static LogTabBarPresentation Project(LogTableState logTable, FilteredLogPresenceState presence) => + new() + { + Tabs = logTable.EventTables, + Groups = logTable.Groups, + ActiveTabId = logTable.ActiveEventLogId, + KnownEmptyTabIds = ComputeKnownEmptyTabIds(logTable, presence) + }; + + private void OnStateChanged(object? sender, EventArgs e) + { + var next = Project(_logTableState.Value, _presenceState.Value); + + lock (_gate) + { + if (_disposed || IsEqual(next, _current)) { return; } + + _current = next; + } + + RaiseChanged(); + } + + private void RaiseChanged() + { + var handlers = Changed; + + if (handlers is null) { return; } + + foreach (var handler in handlers.GetInvocationList().Cast()) + { + try + { + handler(); + } + catch (Exception fault) + { + _logger.Trace($"{nameof(LogTabBarSource)}: a subscriber threw and was isolated: {fault}"); + } + } + } +} diff --git a/src/EventLogExpert.Runtime/LogTable/LogTableQueries.cs b/src/EventLogExpert.Runtime/LogTable/LogTableQueries.cs new file mode 100644 index 000000000..6a6cd438c --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/LogTableQueries.cs @@ -0,0 +1,40 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using Fluxor; + +namespace EventLogExpert.Runtime.LogTable; + +internal sealed class LogTableQueries(IState logTableState) : ILogTableQueries +{ + private readonly IState _logTableState = logTableState; + + public IReadOnlyList GetTabGroups() => _logTableState.Value.Groups; + + public bool HasActiveLogs() => _logTableState.Value.EventTables.Any(table => !table.IsCombined); + + public bool HasMultipleIndividualTabs() => + _logTableState.Value.EventTables.Count(table => !table.IsCombined) > 1; + + public bool HasOtherTabsInGroup(LogTabGroupId groupId, EventLogId keepTabId) + { + var state = _logTableState.Value; + + if (state.Groups.FirstOrDefault(group => group.Id == groupId) is not { } group) { return false; } + + return group.MemberIds.Contains(keepTabId) && + state.EventTables.Count(table => table.GroupId is null && group.MemberIds.Contains(table.Id)) > 1; + } + + public bool HasTabGroup(LogTabGroupId groupId) => _logTableState.Value.Groups.Any(group => group.Id == groupId); + + public bool IsGroupDescending() => _logTableState.Value.IsGroupDescending; + + public bool IsGrouping() => _logTableState.Value.GroupBy is not null; + + public bool IsTabOpen(EventLogId tabId) => _logTableState.Value.EventTables.Any(table => table.Id == tabId); + + public bool IsUngroupedTabOpen(EventLogId tabId) => + _logTableState.Value.EventTables.Any(table => table.Id == tabId && table.GroupId is null); +} diff --git a/src/EventLogExpert.Runtime/LogTable/LogTableState.cs b/src/EventLogExpert.Runtime/LogTable/LogTableState.cs index fadfb91a1..509152380 100644 --- a/src/EventLogExpert.Runtime/LogTable/LogTableState.cs +++ b/src/EventLogExpert.Runtime/LogTable/LogTableState.cs @@ -2,9 +2,9 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.EventLogs; -using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Filtering.Evaluation; +using EventLogExpert.Runtime.LogTable.OrderedView; using Fluxor; -using System.Collections.Concurrent; using System.Collections.Immutable; using System.Runtime.CompilerServices; @@ -13,27 +13,28 @@ namespace EventLogExpert.Runtime.LogTable; [FeatureState] public sealed record LogTableState { - internal ImmutableDictionary PerLogEvents { get; init; } = - ImmutableDictionary.Empty; - public ImmutableList EventTables { get; init; } = []; public ImmutableList Groups { get; init; } = []; - public IEventColumnView DisplayedEvents => - PerLogEvents.IsEmpty - ? CombinedColumnView.Empty - : PerLogEvents.Count == 1 - ? SingleLogDisplayList() - : AllLogsView(); + public static IEventColumnView EmptyView => EmptyColumnView.Instance; + + public EventLogId? ActiveEventLogId { get; init; } - /// An empty view sentinel for the UI to bind when no tab is active. - public static IEventColumnView EmptyView => CombinedColumnView.Empty; + internal OrderedViewReady? ActiveOrderedView { get; init; } - public ImmutableDictionary EventCountByLog { get; init; } = - ImmutableDictionary.Empty; + internal ImmutableDictionary RetainedOrderedViews { get; init; } = + ImmutableDictionary.Empty; - public EventLogId? ActiveEventLogId { get; init; } + internal Filter AppliedFilter { get; init; } = new(null, []); + + internal string? FaultCause { get; init; } + + internal bool OrderedViewDisplayEnabled { get; init; } = true; + + internal long HighestInvalidationSequence { get; init; } + + internal long LastPublishedSnapshotVersion { get; init; } = -1; public ImmutableDictionary Columns { get; init; } = ImmutableDictionary.Empty; @@ -50,11 +51,6 @@ public sealed record LogTableState public bool IsGroupDescending { get; init; } - /// - /// Mirrors the timeline pane's visibility (kept in sync through SetHistogramVisibleAction). A single log - /// with no explicit sort defaults to Date/Time order while the timeline is shown, so the table reads in the same order - /// as the time axis, and to Record ID order while it is hidden. Combined views are unaffected (always Date/Time). - /// public bool TimelineVisible { get; init; } internal ColumnName? RequestedOrderBy { get; init; } @@ -65,129 +61,293 @@ public sealed record LogTableState internal bool RequestedIsGroupDescending { get; init; } - internal int DisplayListVersion { get; init; } - public bool GroupsCollapsedByDefault { get; init; } public ImmutableHashSet GroupCollapseOverrides { get; init; } = ImmutableHashSet.Create(StringComparer.Ordinal); + internal int DisplayedLogCount => EventTables.Count(table => !table.IsCombined); + internal SortContext SortContext => - new(ResolvedEventOrdering.ResolveDefaultOrderBy(RequestedOrderBy, RequestedGroupBy, PerLogEvents.Count, TimelineVisible), + new(ResolvedEventOrdering.ResolveDefaultOrderBy(RequestedOrderBy, RequestedGroupBy, DisplayedLogCount, TimelineVisible), RequestedIsDescending, RequestedGroupBy, RequestedIsGroupDescending); + internal ColumnName? CommittedEffectiveOrderBy { get; init; } + + internal SortContext CommittedSortContext => + new(CommittedEffectiveOrderBy, + IsDescending, + GroupBy, + IsGroupDescending); + + private ImmutableArray ActiveScope() + { + LogView? active = null; + + foreach (LogView tab in EventTables) + { + if (tab.Id == ActiveEventLogId) + { + active = tab; + + break; + } + } + + if (active is null) { return []; } + + if (active.GroupId is not { } groupId) { return [active.Id]; } + + if (groupId.IsAll) + { + var openLogs = new List(EventTables.Count); + + foreach (LogView tab in EventTables) + { + if (!tab.IsCombined) { openLogs.Add(tab.Id); } + } + + return Canonical(openLogs); + } + + foreach (LogTabGroup candidate in Groups) + { + if (candidate.Id == groupId) { return Canonical([.. candidate.MemberIds]); } + } + + return []; + } + + private static ImmutableArray Canonical(List logIds) + { + logIds.Sort(static (left, right) => left.Value.CompareTo(right.Value)); + + return [.. logIds]; + } + + private ViewIdentity BuildViewIdentity() => + new(ActiveEventLogId, + ActiveScope(), + RequestedOrderBy, + RequestedIsDescending, + RequestedGroupBy, + RequestedIsGroupDescending, + TimelineVisible, + DisplayedLogCount > 1, + AppliedFilter); + internal bool HasPendingSortChange => RequestedOrderBy != OrderBy || RequestedIsDescending != IsDescending || RequestedGroupBy != GroupBy || RequestedIsGroupDescending != IsGroupDescending; - internal ImmutableDictionary PerLogListVersion { get; init; } = - ImmutableDictionary.Empty; - - private static readonly object s_allLogsKey = new(); + private static readonly ConditionalWeakTable s_ViewIdentitys = []; - private static readonly ConditionalWeakTable< - ImmutableDictionary, - ConcurrentDictionary> s_viewsByGeneration = []; + internal ViewIdentity ViewIdentity => + s_ViewIdentitys.GetValue(this, static state => state.BuildViewIdentity()); - public IEventColumnView DisplayedEventsForTab(LogView tab) + internal bool OrderingIsStale { - if (tab.GroupId is null) { return EventsForLog(tab.Id); } + get + { + var activeTable = EventTables.FirstOrDefault(table => table.Id == ActiveEventLogId); - if (tab.GroupId.Value.IsAll) { return DisplayedEvents; } + if (activeTable is null) { return false; } - var group = Groups.FirstOrDefault(candidate => candidate.Id == tab.GroupId.Value); + if (activeTable.GroupId is null) { return OrderingIsStaleForLog(activeTable.Id); } + + if (IsCombinedOrderedViewCurrent(activeTable) && ActiveOrderedView != null) { return false; } + + if (!activeTable.GroupId.Value.IsAll && + Groups.All(candidate => candidate.Id != activeTable.GroupId.Value)) + { + return false; + } - return group is null ? CombinedColumnView.Empty : GroupView(group); + return IsRetainedViewServable(activeTable.Id) && + RetainedOrderedViews[activeTable.Id].Config != SortContext; + } } - private IEventColumnView AllLogsView() => - InnerCache().GetOrAdd( - s_allLogsKey, - static (_, perLog) => new CombinedColumnView([.. perLog.Values], perLog.Values.First().Context), - PerLogEvents); + private bool OrderingIsStaleForLog(EventLogId logId) + { + if (IsOrderedViewServing(logId) && ActiveOrderedView != null) { return false; } + + return IsRetainedViewServable(logId) && + RetainedOrderedViews[logId].Config != SortContext; + } + + public IEventColumnView DisplayedEventsForTab(LogView tab) => + RoutedReadyForTab(tab)?.View ?? EmptyColumnView.Instance; + + internal ViewContentToken ContentTokenForTab(LogView tab) => + RoutedReadyForTab(tab)?.ContentToken ?? ViewContentToken.Empty; - private IEventColumnView GroupView(LogTabGroup group) + private OrderedViewReady? RoutedReadyForTab(LogView tab) { - EventColumnView? firstPresent = null; - int presentCount = 0; + if (tab.GroupId is null) { return RoutedReadyForLog(tab.Id); } - foreach (var memberId in group.MemberIds) + if (tab.GroupId.Value.IsAll) { - if (PerLogEvents.TryGetValue(memberId, out var view)) - { - firstPresent ??= view; - presentCount++; - } + return IsCombinedOrderedViewCurrent(tab) && ActiveOrderedView != null ? + ActiveOrderedView : + RetainedReadyFor(tab.Id); } - if (presentCount == 0) { return CombinedColumnView.Empty; } + var group = Groups.FirstOrDefault(candidate => candidate.Id == tab.GroupId.Value); - if (presentCount == 1) { return firstPresent!; } + if (group is null) { return null; } - return InnerCache().GetOrAdd( - group.MemberIds, - static (_, args) => BuildGroupView(args.PerLogEvents, args.MemberIds), - (PerLogEvents, group.MemberIds)); + return IsCombinedOrderedViewCurrent(tab) && ActiveOrderedView != null ? + ActiveOrderedView : + RetainedReadyFor(tab.Id); } - private static CombinedColumnView BuildGroupView( - ImmutableDictionary perLog, - ImmutableHashSet memberIds) - { - var views = new List(memberIds.Count); + internal IEventColumnView EventsForLog(EventLogId logId) => + RoutedReadyForLog(logId)?.View ?? EmptyColumnView.Instance; - foreach (var memberId in memberIds) + private OrderedViewReady? RoutedReadyForLog(EventLogId logId) + { + if (IsOrderedViewServing(logId) && ActiveOrderedView != null) { - if (perLog.TryGetValue(memberId, out var view)) { views.Add(view); } + return ActiveOrderedView; } - return new CombinedColumnView(views, views[0].Context); + return RetainedReadyFor(logId); } - private ConcurrentDictionary InnerCache() => - s_viewsByGeneration.GetValue( - PerLogEvents, static _ => new ConcurrentDictionary()); - - // Caller guarantees PerLogEvents.Count == 1. Use the struct enumerator, not LINQ .Values.First(), to avoid boxing - // on this render-path read. - private EventColumnView SingleLogDisplayList() + internal ImmutableDictionary RetainOnly(OrderedViewReady served) { - using var enumerator = PerLogEvents.GetEnumerator(); - enumerator.MoveNext(); + if (served.Identity?.ActiveLogId is not { } servedTabId) { return RetainedOrderedViews; } + + var openTabIds = EventTables.Select(table => table.Id).ToHashSet(); + + var pruned = RetainedOrderedViews; - return enumerator.Current.Value; + foreach (var tabId in RetainedOrderedViews.Keys) + { + if (!openTabIds.Contains(tabId)) { pruned = pruned.Remove(tabId); } + } + + return openTabIds.Contains(servedTabId) ? pruned.SetItem(servedTabId, served) : pruned; } - public IEventColumnView EventsForLog(EventLogId logId) => - PerLogEvents.TryGetValue(logId, out var view) ? view : CombinedColumnView.Empty; + internal LogTableState WithClearedOrderedViewRetention() => + this with + { + ActiveOrderedView = null, + RetainedOrderedViews = ImmutableDictionary.Empty + }; + + private OrderedViewReady? RetainedReadyFor(EventLogId tabId) => + IsRetainedViewServable(tabId) ? RetainedOrderedViews[tabId] : null; + + internal bool IsRetainedViewServable(EventLogId tabId) => + tabId == ActiveEventLogId && + RetainedOrderedViews.TryGetValue(tabId, out var retained) && + retained.Identity is { } identity && + identity.Scope.SequenceEqual(ActiveScope()) && + retained.Config == CommittedSortContext && + !retained.Filter.HasFilteringChangedFrom(AppliedFilter); + + internal bool IsOrderedViewServing(EventLogId logId) => + OrderedViewDisplayEnabled && + ActiveOrderedView != null && + ActiveOrderedView.SingleLogId == logId && + logId == ActiveEventLogId && + !HasPendingSortChange && + ActiveOrderedView.Config == SortContext && + !ActiveOrderedView.Filter.HasFilteringChangedFrom(AppliedFilter); + + // is not serving simply falls back to its retained-or-empty view. The tab must be the active one (the engine holds exactly one + // scope - the active tab's), and the view must have been published for the identity this state is asking for. That + // identity CARRIES the active tab's resolved scope, so identity equality already proves the engine's scope is exactly this + // tab's membership - no separate AllLogs/group set comparison is needed. Grouped display routes under exactly the fence + // ungrouped already uses: HasPendingSortChange covers the group members, and SortContext is built from the requested + // pair, so !HasPendingSortChange with Config == SortContext proves the routed view was ordered under the very GroupBy + // the pane is about to group it by (see LogTablePane.RebuildGroupedRowView). + private bool IsCombinedOrderedViewCurrent(LogView tab) => + OrderedViewDisplayEnabled && + ActiveOrderedView != null && + tab.Id == ActiveEventLogId && + !HasPendingSortChange && + ActiveOrderedView.Identity == ViewIdentity && + ActiveOrderedView.Config == SortContext && + + // SEMANTIC, not `==`: Filter's record equality is reference-based on its collections, while the identity above + // compares filters semantically. A reference check here would reject a view whose identity already matched - + // e.g. re-applying an equivalent filter built from fresh collections - and park the display on the fallback view + // with no further request to repair it. + !ActiveOrderedView.Filter.HasFilteringChangedFrom(AppliedFilter); public IEventColumnView GetActiveDisplayedEvents() { var activeTable = EventTables.FirstOrDefault(table => table.Id == ActiveEventLogId); - return activeTable is null ? CombinedColumnView.Empty : DisplayedEventsForTab(activeTable); + return activeTable is null ? EmptyColumnView.Instance : DisplayedEventsForTab(activeTable); } - public IReadOnlyList GetOrderedEnabledColumns(ILogTableColumnDefaultsProvider columnDefaults) + internal PresentationState PresentationState + { + get + { + var activeTable = EventTables.FirstOrDefault(table => table.Id == ActiveEventLogId); + + if (activeTable is null) { return PresentationState.Current; } + + if (activeTable.GroupId is { IsAll: false } groupId && + Groups.All(candidate => candidate.Id != groupId)) + { + return PresentationState.Current; + } + + if (!OrderedViewDisplayEnabled) { return PresentationState.Faulted; } + + return ServingOrderedView != null ? PresentationState.Current : PresentationState.Updating; + } + } + + internal OrderedViewReady? ServingOrderedView + { + get + { + if (ActiveOrderedView is null) { return null; } + + var activeTable = EventTables.FirstOrDefault(table => table.Id == ActiveEventLogId); + + if (activeTable is null) { return null; } + + bool serving = activeTable.GroupId is null ? + IsOrderedViewServing(activeTable.Id) : + IsCombinedOrderedViewCurrent(activeTable); + + return serving ? ActiveOrderedView : null; + } + } + + public IReadOnlyList GetOrderedEnabledColumns(ILogTableColumnDefaultsProvider columnDefaults) => + ResolveOrderedEnabledColumns(Columns, ColumnOrder, columnDefaults); + + public static IReadOnlyList ResolveOrderedEnabledColumns( + ImmutableDictionary columns, + ImmutableList columnOrder, + ILogTableColumnDefaultsProvider columnDefaults) { ArgumentNullException.ThrowIfNull(columnDefaults); - var enabledColumns = Columns + var enabledColumns = columns .Where(column => column.Value) .Select(column => column.Key) .ToHashSet(); - var order = ColumnOrder.IsEmpty ? columnDefaults.ColumnOrder : ColumnOrder; + var order = columnOrder.IsEmpty ? columnDefaults.ColumnOrder : columnOrder; HashSet present = []; List ordered = []; - // De-duplicate while preserving first occurrence: a persisted ColumnOrder may contain duplicates - // that would otherwise become duplicate export headers (rejected by TabularExportWriter). foreach (var column in order) { if (enabledColumns.Contains(column) && present.Add(column)) @@ -196,8 +356,6 @@ public IReadOnlyList GetOrderedEnabledColumns(ILogTableColumnDefault } } - // Append any enabled column missing from the active order (e.g. enabled but absent from a persisted - // ColumnOrder) so it is never silently dropped from the table or an export. foreach (var column in columnDefaults.ColumnOrder) { if (enabledColumns.Contains(column) && present.Add(column)) @@ -211,52 +369,4 @@ public IReadOnlyList GetOrderedEnabledColumns(ILogTableColumnDefault public bool IsGroupCollapsed(string groupKey) => GroupsCollapsedByDefault ^ GroupCollapseOverrides.Contains(groupKey); - - // Each call must carry a single log's events (one OwningLog). - internal LogTableState WithLogEvents(EventLogId logId, params ResolvedEvent[] events) - { - for (int i = 1; i < events.Length; i++) - { - if (!string.Equals(events[i].OwningLog, events[0].OwningLog, StringComparison.Ordinal)) - { - throw new ArgumentException("All events must share one OwningLog.", nameof(events)); - } - } - - int newCount = PerLogEvents.ContainsKey(logId) ? PerLogEvents.Count : PerLogEvents.Count + 1; - - var context = new SortContext( - ResolvedEventOrdering.ResolveDefaultOrderBy(OrderBy, GroupBy, newCount, TimelineVisible), - IsDescending, - GroupBy, - IsGroupDescending); - - var builder = PerLogEvents.ToBuilder(); - builder[logId] = BuildUnfilteredView(logId, events, context); - - foreach (var (id, view) in PerLogEvents) - { - if (id != logId && !view.HasContext(context)) - { - builder[id] = view.WithContext(context); - } - } - - return this with { PerLogEvents = builder.ToImmutable() }; - } - - // Seeds a display view directly from unfiltered events (test/reconcile helper); with no filter every physical row - // survives. - private static EventColumnView BuildUnfilteredView( - EventLogId logId, - IReadOnlyList events, - SortContext context) - { - var reader = EventColumnStore.Build(events, 0, 0).CreateReader(logId); - int[] survivors = new int[reader.Count]; - - for (int i = 0; i < survivors.Length; i++) { survivors[i] = i; } - - return EventColumnView.Create(reader, survivors, context); - } } diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedView/ChunkedOrderIndex.cs b/src/EventLogExpert.Runtime/LogTable/OrderedView/ChunkedOrderIndex.cs new file mode 100644 index 000000000..2b8264108 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedView/ChunkedOrderIndex.cs @@ -0,0 +1,255 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; + +namespace EventLogExpert.Runtime.LogTable.OrderedView; + +internal sealed class ChunkedOrderIndex +{ + private const int Capacity = 1024; + + private readonly List _buffers = []; + private readonly List _counts = []; + private readonly Dictionary _present = []; + private readonly HashSet _presentClonedThisBatch = []; + private readonly List _sealed = []; + + private IComparer _insertComparer; + + internal ChunkedOrderIndex(IComparer insertComparer) => _insertComparer = insertComparer; + + public int Count + { + get + { + int total = 0; + + foreach (int count in _counts) { total += count; } + + return total; + } + } + + public void Insert(in OrderKey key) + { + SetPresent(key.Locator); + + if (_buffers.Count == 0) + { + var first = new OrderKey[Capacity]; + first[0] = key; + _buffers.Add(first); + _counts.Add(1); + _sealed.Add(null); + return; + } + + int chunk = ChunkForKey(key); + + if (_counts[chunk] == Capacity) + { + SplitChunk(chunk); + chunk = ChunkForKey(key); + } + + InsertIntoChunk(chunk, key); + } + + public OrderedViewSnapshot Publish(IComparer snapshotComparer, long version) + { + int chunkCount = _buffers.Count; + + var present = new Dictionary(_present); + _presentClonedThisBatch.Clear(); + + if (chunkCount == 0) + { + return new OrderedViewSnapshot([], [], [0], present, snapshotComparer, version); + } + + var chunks = new OrderKey[chunkCount][]; + var firstOfChunk = new OrderKey[chunkCount]; + var prefix = new int[chunkCount + 1]; + int accumulated = 0; + + for (int chunk = 0; chunk < chunkCount; chunk++) + { + var frozen = _sealed[chunk]; + + if (frozen is null) + { + frozen = new OrderKey[_counts[chunk]]; + Array.Copy(_buffers[chunk], 0, frozen, 0, _counts[chunk]); + _sealed[chunk] = frozen; + } + + chunks[chunk] = frozen; + firstOfChunk[chunk] = frozen[0]; + prefix[chunk] = accumulated; + accumulated += frozen.Length; + } + + prefix[chunkCount] = accumulated; + + return new OrderedViewSnapshot(chunks, firstOfChunk, prefix, present, snapshotComparer, version); + } + + internal static ChunkedOrderIndex FromSortedRun( + OrderKey[] sortedOrder, + IComparer insertComparer, + CancellationToken cancellationToken = default) + { + var index = new ChunkedOrderIndex(insertComparer); + + index.BulkFill(sortedOrder, + cancellationToken); + + return index; + } + + internal void RebindInsertComparer(IComparer insertComparer) => _insertComparer = insertComparer; + + private void BulkFill(OrderKey[] sortedOrder, CancellationToken cancellationToken) + { + if (sortedOrder.Length == 0) { return; } + + var maxIndexByKey = new Dictionary(); + + for (int position = 0; position < sortedOrder.Length; position++) + { + if ((position & 8191) == 0) { cancellationToken.ThrowIfCancellationRequested(); } + + var generationKey = new LogGeneration(sortedOrder[position].Locator.LogId, sortedOrder[position].Locator.Generation); + int index = sortedOrder[position].Locator.Index; + + if (!maxIndexByKey.TryGetValue(generationKey, out int max) || index > max) { maxIndexByKey[generationKey] = index; } + } + + foreach ((LogGeneration generationKey, int maxIndex) in maxIndexByKey) + { + _present[generationKey] = new ulong[Math.Max((maxIndex >> 6) + 1, 4)]; + _presentClonedThisBatch.Add(generationKey); + } + + for (int position = 0; position < sortedOrder.Length; position++) + { + if ((position & 8191) == 0) { cancellationToken.ThrowIfCancellationRequested(); } + + EventLocator locator = sortedOrder[position].Locator; + var generationKey = new LogGeneration(locator.LogId, locator.Generation); + _present[generationKey][locator.Index >> 6] |= 1UL << (locator.Index & 63); + } + + for (int offset = 0; offset < sortedOrder.Length; offset += Capacity) + { + cancellationToken.ThrowIfCancellationRequested(); + + int take = Math.Min(Capacity, sortedOrder.Length - offset); + var buffer = new OrderKey[Capacity]; + Array.Copy(sortedOrder, offset, buffer, 0, take); + _buffers.Add(buffer); + _counts.Add(take); + _sealed.Add(null); + } + } + + private int ChunkForKey(in OrderKey key) + { + int low = 0, high = _buffers.Count - 1, answer = 0; + + while (low <= high) + { + int mid = (low + high) >> 1; + + if (_insertComparer.Compare(key, _buffers[mid][0]) >= 0) + { + answer = mid; + low = mid + 1; + } + else + { + high = mid - 1; + } + } + + return answer; + } + + private void InsertIntoChunk(int chunk, in OrderKey key) + { + var buffer = _buffers[chunk]; + int count = _counts[chunk]; + int position = LowerBound(buffer, count, key); + + Array.Copy(buffer, position, buffer, position + 1, count - position); + buffer[position] = key; + _counts[chunk] = count + 1; + _sealed[chunk] = null; + } + + private int LowerBound(OrderKey[] buffer, int count, in OrderKey key) + { + int low = 0, high = count; + + while (low < high) + { + int mid = (int)(((uint)low + (uint)high) >> 1); + + if (_insertComparer.Compare(buffer[mid], key) < 0) { low = mid + 1; } + else { high = mid; } + } + + return low; + } + + private void SetPresent(in EventLocator locator) + { + var key = new LogGeneration(locator.LogId, locator.Generation); + int index = locator.Index; + int word = index >> 6; + + if (!_present.TryGetValue(key, out var bits)) + { + bits = new ulong[Math.Max(word + 1, 4)]; + _present[key] = bits; + _presentClonedThisBatch.Add(key); + } + else + { + if (!_presentClonedThisBatch.Contains(key)) + { + bits = (ulong[])bits.Clone(); + _present[key] = bits; + _presentClonedThisBatch.Add(key); + } + + if (word >= bits.Length) + { + Array.Resize(ref bits, Math.Max(word + 1, bits.Length * 2)); + _present[key] = bits; + } + } + + bits[word] |= 1UL << (index & 63); + } + + private void SplitChunk(int chunk) + { + var buffer = _buffers[chunk]; + int half = Capacity / 2; + var left = new OrderKey[Capacity]; + var right = new OrderKey[Capacity]; + + Array.Copy(buffer, 0, left, 0, half); + Array.Copy(buffer, half, right, 0, Capacity - half); + + _buffers[chunk] = left; + _counts[chunk] = half; + _sealed[chunk] = null; + + _buffers.Insert(chunk + 1, right); + _counts.Insert(chunk + 1, Capacity - half); + _sealed.Insert(chunk + 1, null); + } +} diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedView/CombinedOrderedColumnView.cs b/src/EventLogExpert.Runtime/LogTable/OrderedView/CombinedOrderedColumnView.cs new file mode 100644 index 000000000..e57aa61df --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedView/CombinedOrderedColumnView.cs @@ -0,0 +1,737 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Filtering.Compilation; +using EventLogExpert.Filtering.Persistence; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace EventLogExpert.Runtime.LogTable.OrderedView; + +internal sealed class CombinedOrderedColumnView : IEventColumnView +{ + private readonly int _count; + private readonly ConditionalWeakTable _highlightHandles = []; + private readonly IEventColumnReader[] _readers; + private readonly Dictionary _slotByLogGeneration; + private readonly OrderedViewSnapshot _snapshot; + + private Dictionary? _byKey; + private HighlightCache? _highlightCache; + private Partition? _partition; + + internal CombinedOrderedColumnView(OrderedViewSnapshot snapshot, IReadOnlyCollection exactInScope) + { + ArgumentNullException.ThrowIfNull(snapshot); + ArgumentNullException.ThrowIfNull(exactInScope); + + _snapshot = snapshot; + _readers = new IEventColumnReader[exactInScope.Count]; + _slotByLogGeneration = new Dictionary(exactInScope.Count); + + int next = 0; + + foreach (LogGeneration logGeneration in exactInScope) + { + if (!snapshot.TryGetReaderByLog(logGeneration.LogId, + logGeneration.Generation, + out IEventColumnReader? reader)) + { + throw new ArgumentException( + $"The snapshot pins no reader for in-scope member '{logGeneration.LogId}' generation {logGeneration.Generation}.", + nameof(exactInScope)); + } + + if (!_slotByLogGeneration.TryAdd(logGeneration, next)) + { + throw new ArgumentException($"Duplicate in-scope member '{logGeneration}'.", nameof(exactInScope)); + } + + _readers[next] = reader; + next++; + } + + _count = snapshot.Count; + } + + public int Count => _count; + + public void BucketTimeTicksByEventData( + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) + { + Partition partition = GetPartition(); + + for (int slot = 0; slot < _readers.Length; slot++) + { + _readers[slot] + .BucketTimeTicksByEventData( + partition.RankByPhysical[slot], + minTicks, + bucketSpanTicks, + bucketCount, + fieldName, + targetCodes, + slotCounts, + cancellationToken); + } + } + + public void BucketTimeTicksByEventDataHResult( + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) + { + Partition partition = GetPartition(); + + for (int slot = 0; slot < _readers.Length; slot++) + { + _readers[slot] + .BucketTimeTicksByEventDataHResult( + partition.RankByPhysical[slot], + minTicks, + bucketSpanTicks, + bucketCount, + fieldName, + eligibleProviders, + userDataErrorCodePaths, + targetCodes, + slotCounts, + cancellationToken); + } + } + + public void BucketTimeTicksByEventDataHResultWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) + { + Partition partition = GetPartition(); + byte[][] childWinners = ResolveChildWinners(highlightWinners); + + for (int slot = 0; slot < _readers.Length; slot++) + { + _readers[slot] + .BucketTimeTicksByEventDataHResultWithTie( + partition.RankByPhysical[slot], + childWinners[slot], + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + fieldName, + eligibleProviders, + userDataErrorCodePaths, + targetCodes, + slotCounts, + cancellationToken); + } + } + + public void BucketTimeTicksByEventDataString( + long minTicks, + long bucketSpanTicks, + int bucketCount, + string[] candidateFields, + IReadOnlyDictionary rawValueToSlot, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + Partition partition = GetPartition(); + + for (int slot = 0; slot < _readers.Length; slot++) + { + _readers[slot] + .BucketTimeTicksByEventDataString( + partition.RankByPhysical[slot], + minTicks, + bucketSpanTicks, + bucketCount, + candidateFields, + rawValueToSlot, + slotCount, + slotCounts, + cancellationToken); + } + } + + public void BucketTimeTicksByEventDataStringWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string[] candidateFields, + IReadOnlyDictionary rawValueToSlot, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + Partition partition = GetPartition(); + byte[][] childWinners = ResolveChildWinners(highlightWinners); + + for (int slot = 0; slot < _readers.Length; slot++) + { + _readers[slot] + .BucketTimeTicksByEventDataStringWithTie( + partition.RankByPhysical[slot], + childWinners[slot], + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + candidateFields, + rawValueToSlot, + slotCount, + slotCounts, + cancellationToken); + } + } + + public void BucketTimeTicksByEventDataWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) + { + Partition partition = GetPartition(); + byte[][] childWinners = ResolveChildWinners(highlightWinners); + + for (int slot = 0; slot < _readers.Length; slot++) + { + _readers[slot] + .BucketTimeTicksByEventDataWithTie( + partition.RankByPhysical[slot], + childWinners[slot], + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + fieldName, + targetCodes, + slotCounts, + cancellationToken); + } + } + + public void BucketTimeTicksByEventId( + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] targetIds, + int[] slotCounts, + CancellationToken cancellationToken) + { + Partition partition = GetPartition(); + + for (int slot = 0; slot < _readers.Length; slot++) + { + _readers[slot] + .BucketTimeTicksByEventId( + partition.RankByPhysical[slot], + minTicks, + bucketSpanTicks, + bucketCount, + targetIds, + slotCounts, + cancellationToken); + } + } + + public void BucketTimeTicksByEventIdWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] targetIds, + int[] slotCounts, + CancellationToken cancellationToken) + { + Partition partition = GetPartition(); + byte[][] childWinners = ResolveChildWinners(highlightWinners); + + for (int slot = 0; slot < _readers.Length; slot++) + { + _readers[slot] + .BucketTimeTicksByEventIdWithTie( + partition.RankByPhysical[slot], + childWinners[slot], + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + targetIds, + slotCounts, + cancellationToken); + } + } + + public void BucketTimeTicksByField( + long minTicks, + long bucketSpanTicks, + int bucketCount, + EventFieldId field, + string[] targetValues, + int[] slotCounts, + CancellationToken cancellationToken) + { + Partition partition = GetPartition(); + + for (int slot = 0; slot < _readers.Length; slot++) + { + _readers[slot] + .BucketTimeTicksByField( + partition.RankByPhysical[slot], + minTicks, + bucketSpanTicks, + bucketCount, + field, + targetValues, + slotCounts, + cancellationToken); + } + } + + public void BucketTimeTicksByFieldWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + EventFieldId field, + string[] targetValues, + int[] slotCounts, + CancellationToken cancellationToken) + { + Partition partition = GetPartition(); + byte[][] childWinners = ResolveChildWinners(highlightWinners); + + for (int slot = 0; slot < _readers.Length; slot++) + { + _readers[slot] + .BucketTimeTicksByFieldWithTie( + partition.RankByPhysical[slot], + childWinners[slot], + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + field, + targetValues, + slotCounts, + cancellationToken); + } + } + + public void BucketTimeTicksBySeverity( + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + Partition partition = GetPartition(); + + for (int slot = 0; slot < _readers.Length; slot++) + { + _readers[slot] + .BucketTimeTicksBySeverity( + partition.RankByPhysical[slot], + minTicks, + bucketSpanTicks, + bucketCount, + slotCounts, + cancellationToken); + } + } + + public void BucketTimeTicksBySeverityWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + Partition partition = GetPartition(); + byte[][] childWinners = ResolveChildWinners(highlightWinners); + + for (int slot = 0; slot < _readers.Length; slot++) + { + _readers[slot] + .BucketTimeTicksBySeverityWithTie( + partition.RankByPhysical[slot], + childWinners[slot], + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + slotCounts, + cancellationToken); + } + } + + public void CountEventDataHResults( + string fieldName, + IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, + IDictionary counts, + CancellationToken cancellationToken) + { + Partition partition = GetPartition(); + + for (int slot = 0; slot < _readers.Length; slot++) + { + _readers[slot] + .CountEventDataHResults( + partition.RankByPhysical[slot], + fieldName, + eligibleProviders, + userDataErrorCodePaths, + counts, + cancellationToken); + } + } + + public void CountEventDataStringValues( + string[] candidateFields, + IDictionary counts, + CancellationToken cancellationToken) + { + Partition partition = GetPartition(); + + for (int slot = 0; slot < _readers.Length; slot++) + { + _readers[slot] + .CountEventDataStringValues(partition.RankByPhysical[slot], candidateFields, counts, cancellationToken); + } + } + + public void CountEventDataValues( + string fieldName, + IDictionary counts, + CancellationToken cancellationToken) + { + Partition partition = GetPartition(); + + for (int slot = 0; slot < _readers.Length; slot++) + { + _readers[slot].CountEventDataValues(partition.RankByPhysical[slot], fieldName, counts, cancellationToken); + } + } + + public void CountEventIds(IDictionary counts, CancellationToken cancellationToken) + { + Partition partition = GetPartition(); + + for (int slot = 0; slot < _readers.Length; slot++) + { + _readers[slot].CountEventIds(partition.RankByPhysical[slot], counts, cancellationToken); + } + } + + public void CountFieldValues( + EventFieldId field, + IDictionary counts, + CancellationToken cancellationToken) + { + Partition partition = GetPartition(); + + for (int slot = 0; slot < _readers.Length; slot++) + { + _readers[slot].CountFieldValues(partition.RankByPhysical[slot], field, counts, cancellationToken); + } + } + + public byte[] EnsureHighlightWinners( + IReadOnlyList orderedColoredFilters, + int planKey, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(orderedColoredFilters); + + HighlightCache? cache = Volatile.Read(ref _highlightCache); + byte[][] childWinners; + + if (cache is not null && cache.PlanKey == planKey) + { + childWinners = cache.ChildWinners; + } + else + { + Partition partition = GetPartition(); + childWinners = new byte[_readers.Length][]; + + for (int slot = 0; slot < _readers.Length; slot++) + { + childWinners[slot] = FilterService.ClassifyHighlightWinners( + _readers[slot], + partition.SurvivingOrder[slot], + orderedColoredFilters, + cancellationToken); + } + + Volatile.Write(ref _highlightCache, new HighlightCache(planKey, childWinners)); + } + + byte[] handle = new byte[1]; + _highlightHandles.Add(handle, childWinners); + + return handle; + } + + public IEnumerable EnumerateDetail() + { + int count = _snapshot.Count; + + for (int display = 0; display < count; display++) + { + EventLocator locator = _snapshot.At(display).Locator; + + yield return Reader(locator).GetDetail(locator); + } + } + + public IEnumerable EnumerateDetailLean() + { + int count = _snapshot.Count; + + for (int display = 0; display < count; display++) + { + EventLocator locator = _snapshot.At(display).Locator; + + yield return Reader(locator).GetDetailLean(locator); + } + } + + public ResolvedEvent GetDetail(EventLocator locator) => Reader(locator).GetDetail(locator); + + public ResolvedEvent GetDetailLean(EventLocator locator) => Reader(locator).GetDetailLean(locator); + + public string GroupKeyAt(EventLocator locator, ColumnName column) => + ResolvedEventGroupKey.For(Reader(locator), locator, column); + + public EventLocator LocatorAt(int index) => _snapshot.At(index).Locator; + + public int Rank(EventLocator locator) => _snapshot.RankOf(new OrderKey(locator)); + + public EventLocator? ResolveByKey(ValueKey key) + { + Dictionary? byKey = Volatile.Read(ref _byKey); + + if (byKey is null) + { + byKey = BuildByKey(); + + byKey = Interlocked.CompareExchange(ref _byKey, byKey, null) ?? byKey; + } + + return byKey.TryGetValue(key, out EventLocator locator) ? locator : null; + } + + public IReadOnlyList Slice(int start, int count) + { + if (start < 0) { throw new ArgumentOutOfRangeException(nameof(start)); } + + if (count < 0) { throw new ArgumentOutOfRangeException(nameof(count)); } + + int end = (int)Math.Min((long)start + count, _snapshot.Count); + + if (start >= end) { return []; } + + List rows = new(end - start); + + for (int display = start; display < end; display++) + { + EventLocator locator = _snapshot.At(display).Locator; + rows.Add(new DisplayRow(locator, Reader(locator).GetDetailLean(locator))); + } + + return rows; + } + + public bool TryGetDetail(EventLocator locator, [NotNullWhen(true)] out ResolvedEvent? detail) + { + if (TryGetReader(locator, out IEventColumnReader? reader)) + { + detail = reader.GetDetail(locator); + + return true; + } + + detail = null; + + return false; + } + + public bool TryGetTimeTicks(EventLocator locator, out long ticks) + { + if (TryGetReader(locator, out IEventColumnReader? reader)) + { + ticks = reader.GetTimeTicks(locator); + + return true; + } + + ticks = 0; + + return false; + } + + public bool TryGetTimeTicksRange(out long minTicks, out long maxTicks, CancellationToken cancellationToken) + { + Partition partition = GetPartition(); + long min = long.MaxValue; + long max = long.MinValue; + bool any = false; + + for (int slot = 0; slot < _readers.Length; slot++) + { + if (!_readers[slot] + .TryGetTimeTicksRange(partition.RankByPhysical[slot], + out long readerMin, + out long readerMax, + cancellationToken)) + { + continue; + } + + if (readerMin < min) { min = readerMin; } + + if (readerMax > max) { max = readerMax; } + + any = true; + } + + minTicks = any ? min : 0; + maxTicks = any ? max : 0; + + return any; + } + + private Dictionary BuildByKey() + { + int count = _snapshot.Count; + Dictionary map = new(count); + + for (int display = 0; display < count; display++) + { + EventLocator locator = _snapshot.At(display).Locator; + + if (ValueKey.TryCreate(Reader(locator).GetDetailLean(locator), out ValueKey key)) + { + map.TryAdd(key, locator); + } + } + + return map; + } + + private Partition GetPartition() + { + Partition? partition = Volatile.Read(ref _partition); + + if (partition is not null) { return partition; } + + int[][] rankByPhysical = new int[_readers.Length][]; + List[] surviving = new List[_readers.Length]; + + for (int slot = 0; slot < _readers.Length; slot++) + { + rankByPhysical[slot] = new int[_readers[slot].Count]; + Array.Fill(rankByPhysical[slot], -1); + surviving[slot] = []; + } + + int count = _snapshot.Count; + + for (int display = 0; display < count; display++) + { + EventLocator locator = _snapshot.At(display).Locator; + + if (!_slotByLogGeneration.TryGetValue(new LogGeneration(locator.LogId, locator.Generation), out int slot)) + { + throw new InvalidOperationException( + $"The snapshot displays a row for out-of-scope member '{locator.LogId}' generation {locator.Generation}."); + } + + rankByPhysical[slot][locator.Index] = display; + surviving[slot].Add(locator.Index); + } + + int[][] survivingOrder = new int[_readers.Length][]; + + for (int slot = 0; slot < _readers.Length; slot++) { survivingOrder[slot] = [.. surviving[slot]]; } + + partition = new Partition(rankByPhysical, survivingOrder); + + return Interlocked.CompareExchange(ref _partition, partition, null) ?? partition; + } + + private IEventColumnReader Reader(in EventLocator locator) + { + if (TryGetReader(locator, out IEventColumnReader? reader)) { return reader; } + + throw new KeyNotFoundException( + $"Locator '{locator.LogId}' generation {locator.Generation} index {locator.Index} is not in this view's scope."); + } + + private byte[][] ResolveChildWinners(byte[] highlightWinners) + { + ArgumentNullException.ThrowIfNull(highlightWinners); + + return _highlightHandles.TryGetValue(highlightWinners, out byte[][]? childWinners) ? childWinners : + throw new InvalidOperationException("Combined highlight winners must be captured before tie bucketing."); + } + + private bool TryGetReader(in EventLocator locator, [NotNullWhen(true)] out IEventColumnReader? reader) + { + if (_slotByLogGeneration.TryGetValue(new LogGeneration(locator.LogId, locator.Generation), out int slot)) + { + IEventColumnReader candidate = _readers[slot]; + + if (locator.Index >= 0 && locator.Index < candidate.Count) + { + reader = candidate; + + return true; + } + } + + reader = null; + + return false; + } + + private sealed record Partition(int[][] RankByPhysical, int[][] SurvivingOrder); + + private sealed record HighlightCache(int PlanKey, byte[][] ChildWinners); +} diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedView/IReaderResolver.cs b/src/EventLogExpert.Runtime/LogTable/OrderedView/IReaderResolver.cs new file mode 100644 index 000000000..10db633d4 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedView/IReaderResolver.cs @@ -0,0 +1,59 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Common.Events; +using System.Diagnostics.CodeAnalysis; + +namespace EventLogExpert.Runtime.LogTable.OrderedView; + +internal interface IReaderResolver +{ + int Count { get; } + + IEventColumnReader Resolve(in EventLocator locator); + + bool TryResolve(in EventLocator locator, [NotNullWhen(true)] out IEventColumnReader? reader); + + bool TryResolveByLog(EventLogId logId, int generation, [NotNullWhen(true)] out IEventColumnReader? reader); +} + +internal sealed class FrozenReaderResolver : IReaderResolver +{ + private readonly Dictionary _readers; + + internal FrozenReaderResolver(Dictionary readers) => _readers = readers; + + public int Count => _readers.Count; + + public IEventColumnReader Resolve(in EventLocator locator) => + TryResolve(locator, out IEventColumnReader? reader) + ? reader + : throw new KeyNotFoundException($"{nameof(FrozenReaderResolver)}: no frozen reader for the requested log generation."); + + public bool TryResolve(in EventLocator locator, [NotNullWhen(true)] out IEventColumnReader? reader) => + _readers.TryGetValue(new LogGeneration(locator.LogId, locator.Generation), out reader); + + public bool TryResolveByLog(EventLogId logId, int generation, [NotNullWhen(true)] out IEventColumnReader? reader) => + _readers.TryGetValue(new LogGeneration(logId, generation), out reader); +} + +internal sealed class LiveReaderResolver : IReaderResolver +{ + private readonly Dictionary _latestReaders; + + internal LiveReaderResolver(Dictionary latestReaders) => _latestReaders = latestReaders; + + public int Count => _latestReaders.Count; + + public IEventColumnReader Resolve(in EventLocator locator) => + TryResolve(locator, out IEventColumnReader? reader) + ? reader + : throw new KeyNotFoundException($"{nameof(LiveReaderResolver)}: no live reader for the requested log generation."); + + public bool TryResolve(in EventLocator locator, [NotNullWhen(true)] out IEventColumnReader? reader) => + _latestReaders.TryGetValue(new LogGeneration(locator.LogId, locator.Generation), out reader); + + public bool TryResolveByLog(EventLogId logId, int generation, [NotNullWhen(true)] out IEventColumnReader? reader) => + _latestReaders.TryGetValue(new LogGeneration(logId, generation), out reader); +} diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedView/LogGeneration.cs b/src/EventLogExpert.Runtime/LogTable/OrderedView/LogGeneration.cs new file mode 100644 index 000000000..57d98d3e3 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedView/LogGeneration.cs @@ -0,0 +1,8 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; + +namespace EventLogExpert.Runtime.LogTable.OrderedView; + +internal readonly record struct LogGeneration(EventLogId LogId, int Generation); diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderKey.cs b/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderKey.cs new file mode 100644 index 000000000..f28f0f77e --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderKey.cs @@ -0,0 +1,8 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; + +namespace EventLogExpert.Runtime.LogTable.OrderedView; + +internal readonly record struct OrderKey(EventLocator Locator); diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderKeyComparerFactory.cs b/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderKeyComparerFactory.cs new file mode 100644 index 000000000..b1f3cbc7d --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderKeyComparerFactory.cs @@ -0,0 +1,64 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; + +namespace EventLogExpert.Runtime.LogTable.OrderedView; + +internal static class OrderKeyComparerFactory +{ + internal static IComparer Empty { get; } = new EmptyOrderKeyComparer(); + + internal static IComparer Create(SortContext context, IReaderResolver resolver) + { + ResolvedEventOrdering.CrossComparison cross = ResolvedEventOrdering.SelectCrossColumnComparer( + context.OrderBy, context.IsDescending, context.GroupBy, context.IsGroupDescending); + + return new DelegatingOrderKeyComparer(cross, resolver); + } + + private sealed class EmptyOrderKeyComparer : IComparer + { + public int Compare(OrderKey x, OrderKey y) => 0; + } +} + +internal sealed class DelegatingOrderKeyComparer : IComparer +{ + private readonly ResolvedEventOrdering.CrossComparison _cross; + private readonly IReaderResolver _resolver; + + internal DelegatingOrderKeyComparer(ResolvedEventOrdering.CrossComparison cross, IReaderResolver resolver) + { + _cross = cross; + _resolver = resolver; + } + + internal int PinnedReaderCount => _resolver.Count; + + internal IReaderResolver Resolver => _resolver; + + public int Compare(OrderKey x, OrderKey y) + { + EventLocator left = x.Locator; + EventLocator right = y.Locator; + + IEventColumnReader readerLeft = _resolver.Resolve(left); + IEventColumnReader readerRight = _resolver.Resolve(right); + + int byColumn = _cross(readerLeft, left, readerRight, right); + + return byColumn != 0 ? byColumn : CompareIdentity(left, right); + } + + private static int CompareIdentity(in EventLocator left, in EventLocator right) + { + int byLog = left.LogId.Value.CompareTo(right.LogId.Value); + + if (byLog != 0) { return byLog; } + + if (left.Generation != right.Generation) { return left.Generation < right.Generation ? -1 : 1; } + + return left.Index.CompareTo(right.Index); + } +} diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedColumnView.cs b/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedColumnView.cs new file mode 100644 index 000000000..70c533caa --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedColumnView.cs @@ -0,0 +1,454 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Filtering.Compilation; +using EventLogExpert.Filtering.Persistence; +using System.Diagnostics.CodeAnalysis; + +namespace EventLogExpert.Runtime.LogTable.OrderedView; + +internal sealed class OrderedColumnView : IEventColumnView +{ + private readonly IEventColumnReader _reader; + private readonly OrderedViewSnapshot _snapshot; + + private Dictionary? _byKey; + private HighlightWinnerCache? _highlightCache; + private PhysicalProjection? _projection; + + internal OrderedColumnView(OrderedViewSnapshot snapshot, IEventColumnReader reader) + { + ArgumentNullException.ThrowIfNull(snapshot); + ArgumentNullException.ThrowIfNull(reader); + + _snapshot = snapshot; + _reader = reader; + } + + public int Count => _snapshot.Count; + + public void BucketTimeTicksByEventData( + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventData( + RankByPhysical(), + minTicks, + bucketSpanTicks, + bucketCount, + fieldName, + targetCodes, + slotCounts, + cancellationToken); + + public void BucketTimeTicksByEventDataHResult( + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventDataHResult( + RankByPhysical(), + minTicks, + bucketSpanTicks, + bucketCount, + fieldName, + eligibleProviders, + userDataErrorCodePaths, + targetCodes, + slotCounts, + cancellationToken); + + public void BucketTimeTicksByEventDataHResultWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventDataHResultWithTie( + RankByPhysical(), + highlightWinners, + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + fieldName, + eligibleProviders, + userDataErrorCodePaths, + targetCodes, + slotCounts, + cancellationToken); + + public void BucketTimeTicksByEventDataString( + long minTicks, + long bucketSpanTicks, + int bucketCount, + string[] candidateFields, + IReadOnlyDictionary rawValueToSlot, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventDataString( + RankByPhysical(), + minTicks, + bucketSpanTicks, + bucketCount, + candidateFields, + rawValueToSlot, + slotCount, + slotCounts, + cancellationToken); + + public void BucketTimeTicksByEventDataStringWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string[] candidateFields, + IReadOnlyDictionary rawValueToSlot, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventDataStringWithTie( + RankByPhysical(), + highlightWinners, + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + candidateFields, + rawValueToSlot, + slotCount, + slotCounts, + cancellationToken); + + public void BucketTimeTicksByEventDataWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventDataWithTie( + RankByPhysical(), + highlightWinners, + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + fieldName, + targetCodes, + slotCounts, + cancellationToken); + + public void BucketTimeTicksByEventId( + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] targetIds, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventId( + RankByPhysical(), + minTicks, + bucketSpanTicks, + bucketCount, + targetIds, + slotCounts, + cancellationToken); + + public void BucketTimeTicksByEventIdWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] targetIds, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventIdWithTie( + RankByPhysical(), + highlightWinners, + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + targetIds, + slotCounts, + cancellationToken); + + public void BucketTimeTicksByField( + long minTicks, + long bucketSpanTicks, + int bucketCount, + EventFieldId field, + string[] targetValues, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksByField( + RankByPhysical(), + minTicks, + bucketSpanTicks, + bucketCount, + field, + targetValues, + slotCounts, + cancellationToken); + + public void BucketTimeTicksByFieldWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + EventFieldId field, + string[] targetValues, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksByFieldWithTie( + RankByPhysical(), + highlightWinners, + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + field, + targetValues, + slotCounts, + cancellationToken); + + public void BucketTimeTicksBySeverity( + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksBySeverity( + RankByPhysical(), + minTicks, + bucketSpanTicks, + bucketCount, + slotCounts, + cancellationToken); + + public void BucketTimeTicksBySeverityWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksBySeverityWithTie( + RankByPhysical(), + highlightWinners, + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + slotCounts, + cancellationToken); + + public void CountEventDataHResults( + string fieldName, + IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, + IDictionary counts, + CancellationToken cancellationToken) => + _reader.CountEventDataHResults(RankByPhysical(), fieldName, eligibleProviders, userDataErrorCodePaths, counts, cancellationToken); + + public void CountEventDataStringValues(string[] candidateFields, IDictionary counts, CancellationToken cancellationToken) => + _reader.CountEventDataStringValues(RankByPhysical(), candidateFields, counts, cancellationToken); + + public void CountEventDataValues(string fieldName, IDictionary counts, CancellationToken cancellationToken) => + _reader.CountEventDataValues(RankByPhysical(), fieldName, counts, cancellationToken); + + public void CountEventIds(IDictionary counts, CancellationToken cancellationToken) => + _reader.CountEventIds(RankByPhysical(), counts, cancellationToken); + + public void CountFieldValues(EventFieldId field, IDictionary counts, CancellationToken cancellationToken) => + _reader.CountFieldValues(RankByPhysical(), field, counts, cancellationToken); + + public byte[] EnsureHighlightWinners( + IReadOnlyList orderedColoredFilters, + int planKey, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(orderedColoredFilters); + + HighlightWinnerCache? cache = Volatile.Read(ref _highlightCache); + + if (cache is { PlanKey: var key, Winners: var winners } && key == planKey && winners.Length == _reader.Count) + { + return winners; + } + + byte[] fresh = FilterService.ClassifyHighlightWinners(_reader, Projection().Order, orderedColoredFilters, cancellationToken); + Volatile.Write(ref _highlightCache, new HighlightWinnerCache(planKey, fresh)); + + return fresh; + } + + public IEnumerable EnumerateDetail() + { + int count = _snapshot.Count; + + for (int display = 0; display < count; display++) + { + yield return _reader.GetDetail(_snapshot.At(display).Locator); + } + } + + public IEnumerable EnumerateDetailLean() + { + int count = _snapshot.Count; + + for (int display = 0; display < count; display++) + { + yield return _reader.GetDetailLean(_snapshot.At(display).Locator); + } + } + + public ResolvedEvent GetDetail(EventLocator locator) => _reader.GetDetail(locator); + + public ResolvedEvent GetDetailLean(EventLocator locator) => _reader.GetDetailLean(locator); + + public string GroupKeyAt(EventLocator locator, ColumnName column) => ResolvedEventGroupKey.For(_reader, locator, column); + + public EventLocator LocatorAt(int index) => _snapshot.At(index).Locator; + + public int Rank(EventLocator locator) => _snapshot.RankOf(new OrderKey(locator)); + + public EventLocator? ResolveByKey(ValueKey key) + { + var byKey = Volatile.Read(ref _byKey); + + if (byKey is null) + { + byKey = BuildByKey(); + + byKey = Interlocked.CompareExchange(ref _byKey, byKey, null) ?? byKey; + } + + return byKey.TryGetValue(key, out int physical) ? _reader.LocatorAt(physical) : null; + } + + public IReadOnlyList Slice(int start, int count) + { + int total = _snapshot.Count; + int clampedStart = Math.Clamp(start, 0, total); + int clampedCount = Math.Clamp(count, 0, total - clampedStart); + List rows = new(clampedCount); + + for (int offset = 0; offset < clampedCount; offset++) + { + EventLocator locator = _snapshot.At(clampedStart + offset).Locator; + rows.Add(new DisplayRow(locator, _reader.GetDetailLean(locator))); + } + + return rows; + } + + public bool TryGetDetail(EventLocator locator, [NotNullWhen(true)] out ResolvedEvent? detail) + { + if (AddressesReader(locator)) + { + detail = _reader.GetDetail(locator); + + return true; + } + + detail = null; + + return false; + } + + public bool TryGetTimeTicks(EventLocator locator, out long ticks) + { + if (AddressesReader(locator)) + { + ticks = _reader.GetTimeTicks(locator); + + return true; + } + + ticks = 0; + + return false; + } + + public bool TryGetTimeTicksRange(out long minTicks, out long maxTicks, CancellationToken cancellationToken) => + _reader.TryGetTimeTicksRange(RankByPhysical(), out minTicks, out maxTicks, cancellationToken); + + private bool AddressesReader(in EventLocator locator) => + locator.LogId == _reader.LogId + && locator.Generation == _reader.Generation + && locator.Index >= 0 + && locator.Index < _reader.Count; + + private Dictionary BuildByKey() + { + int count = _snapshot.Count; + var map = new Dictionary(count); + + for (int display = 0; display < count; display++) + { + EventLocator locator = _snapshot.At(display).Locator; + + if (ValueKey.TryCreate(_reader.GetDetailLean(locator), out ValueKey key)) + { + map.TryAdd(key, locator.Index); + } + } + + return map; + } + + private PhysicalProjection Projection() + { + var projection = Volatile.Read(ref _projection); + + if (projection is not null) { return projection; } + + int displayCount = _snapshot.Count; + int[] order = new int[displayCount]; + int[] rankByPhysical = new int[_reader.Count]; + Array.Fill(rankByPhysical, -1); + + for (int display = 0; display < displayCount; display++) + { + int physical = _snapshot.At(display).Locator.Index; + order[display] = physical; + rankByPhysical[physical] = display; + } + + projection = new PhysicalProjection(order, rankByPhysical); + + return Interlocked.CompareExchange(ref _projection, projection, null) ?? projection; + } + + private int[] RankByPhysical() => Projection().RankByPhysical; + + private sealed record PhysicalProjection(int[] Order, int[] RankByPhysical); + + private sealed record HighlightWinnerCache(int PlanKey, byte[] Winners); +} diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewDispatchBridge.cs b/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewDispatchBridge.cs new file mode 100644 index 000000000..58786274f --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewDispatchBridge.cs @@ -0,0 +1,34 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Fluxor; + +namespace EventLogExpert.Runtime.LogTable.OrderedView; + +internal sealed class OrderedViewDispatchBridge : IDisposable +{ + private readonly IDispatcher _dispatcher; + private readonly OrderedViewWriter _writer; + + public OrderedViewDispatchBridge(IDispatcher dispatcher, OrderedViewWriter writer) + { + _dispatcher = dispatcher; + _writer = writer; + _writer.Updated += OnUpdated; + _writer.FaultRaised += OnFaultRaised; + } + + public void Dispose() + { + _writer.Updated -= OnUpdated; + _writer.FaultRaised -= OnFaultRaised; + } + + public void NotifyShadowFault(Exception fault) => _dispatcher.Dispatch(new OrderedViewDisplayFaultedAction(fault)); + + private void OnFaultRaised(Exception fault, ViewIdentity? identity) => + _dispatcher.Dispatch(new OrderedViewDisplayFaultedAction(fault, identity)); + + private void OnUpdated(OrderedViewUpdate update) => + _dispatcher.Dispatch(new OrderedViewUpdatedAction(update)); +} diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewScopeState.cs b/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewScopeState.cs new file mode 100644 index 000000000..f9cde2604 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewScopeState.cs @@ -0,0 +1,168 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; + +namespace EventLogExpert.Runtime.LogTable.OrderedView; + +internal sealed class OrderedViewScopeState +{ + private readonly Dictionary _coverage = []; + + private readonly Dictionary _highestGenerationSeen = []; + private readonly HashSet _removedLogs = []; + private readonly HashSet _scopeLogs = []; + + private bool _scoped; + + public IEnumerable Keys => _coverage.Keys; + + public long ScopeVersion { get; private set; } + + public EventLogId? SingleLog + { + get + { + if (!_scoped || _scopeLogs.Count != 1) { return null; } + + foreach (EventLogId logId in _scopeLogs) { return logId; } + + return null; + } + } + + public void AdvanceCoverage(in LogGeneration key, int coveredCount) + { + if (coveredCount > _coverage.GetValueOrDefault(key)) { _coverage[key] = coveredCount; } + } + + public int Coverage(in LogGeneration key) => _coverage.GetValueOrDefault(key); + + public void EvictOutOfScope(in FrozenScope scope, IReadOnlyDictionary activeGeneration) + { + foreach ((EventLogId logId, int active) in activeGeneration) + { + if (!scope.Includes(logId)) { RecordGenerationSeen(logId, active); } + } + + List? evicted = null; + + foreach (LogGeneration key in _coverage.Keys) + { + bool outOfScope = !scope.Includes(key.LogId); + bool closedGeneration = activeGeneration.TryGetValue(key.LogId, out int active) && key.Generation < active; + + if (outOfScope || closedGeneration) { (evicted ??= []).Add(key); } + } + + if (evicted is null) { return; } + + foreach (LogGeneration key in evicted) + { + RecordGenerationSeen(key.LogId, key.Generation); + _coverage.Remove(key); + } + } + + public FrozenScope Freeze() + { + HashSet? scope = _scoped ? [.. _scopeLogs] : null; + + return new FrozenScope(scope, [.. _removedLogs]); + } + + public RowCoverage FreezeCoverage() => new(new Dictionary(_coverage)); + + public bool Includes(EventLogId logId) => + !_removedLogs.Contains(logId) && (!_scoped || _scopeLogs.Contains(logId)); + + public bool IsAtOrAboveGenerationFloor(EventLogId logId, int generation) => + !_highestGenerationSeen.TryGetValue(logId, out int floor) || generation >= floor; + + public void RecordGenerationSeen(EventLogId logId, int generation) + { + if (_removedLogs.Contains(logId)) { return; } + + if (generation > _highestGenerationSeen.GetValueOrDefault(logId)) { _highestGenerationSeen[logId] = generation; } + } + + public void Remove(EventLogId logId) + { + _removedLogs.Add(logId); + _scopeLogs.Remove(logId); + _highestGenerationSeen.Remove(logId); + DropCoverage(logId); + } + + public void Reset() + { + _coverage.Clear(); + _highestGenerationSeen.Clear(); + _removedLogs.Clear(); + _scopeLogs.Clear(); + _scoped = true; + } + + public bool ScopeEquals(IReadOnlyCollection scopeLogs) + { + if (!_scoped) { return false; } + + int matched = 0; + + foreach (EventLogId logId in scopeLogs) + { + if (!_scopeLogs.Contains(logId)) { return false; } + + matched++; + } + + return matched == _scopeLogs.Count; + } + + public bool TrySetScope(IReadOnlyCollection scopeLogs, long scopeVersion) + { + if (scopeVersion < ScopeVersion) { return false; } + + _scoped = true; + ScopeVersion = scopeVersion; + _scopeLogs.Clear(); + + foreach (EventLogId logId in scopeLogs) + { + if (!_removedLogs.Contains(logId)) { _scopeLogs.Add(logId); } + } + + return true; + } + + private void DropCoverage(EventLogId logId) + { + List? drop = null; + + foreach (LogGeneration key in _coverage.Keys) + { + if (key.LogId == logId) { (drop ??= []).Add(key); } + } + + if (drop is null) { return; } + + foreach (LogGeneration key in drop) { _coverage.Remove(key); } + } +} + +internal readonly struct FrozenScope +{ + private readonly HashSet? _scopeLogs; + private readonly HashSet _removedLogs; + + internal FrozenScope(HashSet? scopeLogs, HashSet removedLogs) + { + _scopeLogs = scopeLogs; + _removedLogs = removedLogs; + } + + public int LogCount => _scopeLogs?.Count ?? 0; + + public bool Includes(EventLogId logId) => + !_removedLogs.Contains(logId) && (_scopeLogs is null || _scopeLogs.Contains(logId)); +} diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewShadowEffects.cs b/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewShadowEffects.cs new file mode 100644 index 000000000..64d5a562b --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewShadowEffects.cs @@ -0,0 +1,208 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Filtering.Compilation; +using EventLogExpert.Filtering.Evaluation; +using EventLogExpert.Runtime.EventLog; +using EventLogExpert.Runtime.Histogram; +using Fluxor; +using System.Collections.Immutable; +using IDispatcher = Fluxor.IDispatcher; + +namespace EventLogExpert.Runtime.LogTable.OrderedView; + +internal sealed class OrderedViewShadowEffects( + IState eventLogState, + IState logTableState, + IState rawEventStore, + OrderedViewWriter writer, + ViewRequestIssuer issuer, + OrderedViewDispatchBridge bridge, + IDispatcher dispatcher, + EventLogConcurrencyState concurrencyState) +{ + private readonly OrderedViewDispatchBridge _bridge = bridge; + private readonly EventLogConcurrencyState _concurrencyState = concurrencyState; + private readonly IDispatcher _dispatcher = dispatcher; + private readonly IState _eventLogState = eventLogState; + private readonly ViewRequestIssuer _issuer = issuer; + private readonly IState _logTableState = logTableState; + private readonly IState _rawEventStore = rawEventStore; + private readonly OrderedViewWriter _writer = writer; + + [EffectMethod(typeof(AddTableAction))] + public Task HandleAddTable(IDispatcher dispatcher) => Shadow(Sync); + + [EffectMethod] + public Task HandleApplyFilter(ApplyFilterAction action, IDispatcher dispatcher) => Shadow(Sync); + + [EffectMethod(typeof(CloseAllButThisAction))] + public Task HandleCloseAllButThis(IDispatcher dispatcher) => Shadow(Sync); + + [EffectMethod(typeof(CloseAllLogsAction))] + public Task HandleCloseAllLogs(IDispatcher dispatcher) => + Shadow(() => + { + long sequence = _issuer.ResetForCloseAll(); + + _dispatcher.Dispatch(new ViewRequestInvalidatedAction(sequence)); + _writer.EnqueueClear(_logTableState.Value.ViewIdentity, sequence); + }); + + [EffectMethod(typeof(CloseGroupAction))] + public Task HandleCloseGroup(IDispatcher dispatcher) => Shadow(Sync); + + [EffectMethod] + public Task HandleCloseLog(CloseLogAction action, IDispatcher dispatcher) => + Shadow(() => + { + _writer.EnqueueRemoveLog(action.LogId); + Sync(); + }); + + [EffectMethod(typeof(CloseOthersInGroupAction))] + public Task HandleCloseOthersInGroup(IDispatcher dispatcher) => Shadow(Sync); + + [EffectMethod] + public Task HandleIngestRawEvents(IngestRawEventsAction action, IDispatcher dispatcher) => + Shadow(() => + { + Sync(); + + foreach (EventLogId logId in action.EventsByLog.Keys) { Reconcile(logId); } + }); + + [EffectMethod(typeof(LoadColumnsCompletedAction))] + public Task HandleLoadColumnsCompleted(IDispatcher dispatcher) => Shadow(Sync); + + [EffectMethod] + public Task HandleLoadEvents(LoadEventsAction action, IDispatcher dispatcher) => + Shadow(() => + { + Sync(); + Reconcile(action.LogData.Id); + }); + + [EffectMethod] + public Task HandleLoadEventsPartial(LoadEventsPartialAction action, IDispatcher dispatcher) => + Shadow(() => + { + Sync(); + Reconcile(action.LogData.Id); + }); + + [EffectMethod(typeof(MoveTabToGroupAction))] + public Task HandleMoveTabToGroup(IDispatcher dispatcher) => Shadow(Sync); + + [EffectMethod(typeof(NewGroupFromTabAction))] + public Task HandleNewGroupFromTab(IDispatcher dispatcher) => Shadow(Sync); + + [EffectMethod] + public Task HandleOrderedViewDisplayFaulted(OrderedViewDisplayFaultedAction action, IDispatcher dispatcher) + { + LogTableState state = _logTableState.Value; + + if (action.Identity is not { } faulted || faulted != state.ViewIdentity) { return Task.CompletedTask; } + + if (!_issuer.TryBeginRecovery(faulted, state.LastPublishedSnapshotVersion)) { return Task.CompletedTask; } + + _writer.EnqueueClearFault(); + _issuer.ResetForClear(); + + dispatcher.Dispatch(new OrderedViewDisplayRecoveredAction()); + + return Shadow(Sync); + } + + [EffectMethod(typeof(RemoveTabFromGroupAction))] + public Task HandleRemoveTabFromGroup(IDispatcher dispatcher) => Shadow(Sync); + + [EffectMethod(typeof(SetActiveTableAction))] + public Task HandleSetActiveTable(IDispatcher dispatcher) => Shadow(Sync); + + [EffectMethod] + public Task HandleSetGroupBy(SetGroupByAction action, IDispatcher dispatcher) => Shadow(Sync); + + [EffectMethod] + public Task HandleSetHistogramVisible(SetHistogramVisibleAction action, IDispatcher dispatcher) => Shadow(Sync); + + [EffectMethod] + public Task HandleSetOrderBy(SetOrderByAction action, IDispatcher dispatcher) => Shadow(Sync); + + [EffectMethod(typeof(SetTabGroupCollapsedAction))] + public Task HandleSetTabGroupCollapsed(IDispatcher dispatcher) => Shadow(Sync); + + [EffectMethod(typeof(ToggleGroupSortingAction))] + public Task HandleToggleGroupSorting(IDispatcher dispatcher) => Shadow(Sync); + + [EffectMethod(typeof(ToggleSortingAction))] + public Task HandleToggleSorting(IDispatcher dispatcher) => Shadow(Sync); + + private void Reconcile(EventLogId logId) + { + if (_rawEventStore.Value.ByLog.TryGetValue(logId, out var store)) + { + _writer.EnqueueReconcile(logId, store.CreateReader(logId)); + } + } + + private IReadOnlyDictionary ScopeReaders(ImmutableArray scope) + { + var readers = new Dictionary(scope.Length); + + foreach (EventLogId logId in scope) + { + if (_rawEventStore.Value.ByLog.TryGetValue(logId, out var store)) + { + readers[logId] = store.CreateReader(logId); + } + } + + return readers; + } + + private Task Shadow(Action work) + { + if (!_issuer.Enabled) { return Task.CompletedTask; } + + try { work(); } + catch (Exception fault) + { + _issuer.RecordFault(fault); + _bridge.NotifyShadowFault(fault); + } + + return Task.CompletedTask; + } + + private void Sync() + { + LogTableState state = _logTableState.Value; + ViewIdentity identity = state.ViewIdentity; + Filter filter = identity.Filter; + + if (filter.RequiresXml && + _eventLogState.Value.OpenLogs.Values.Any(log => !_concurrencyState.IsLoadedWithXml(log.Id))) + { + return; + } + + if (_issuer.TryIssue(identity) is not { } sequence) { return; } + + Func survives = FilterService.CompileSurvivorPredicate(filter); + + _dispatcher.Dispatch(new ViewRequestInvalidatedAction(sequence)); + + _writer.EnqueueViewRequest( + new ViewRequest( + identity, + sequence, + identity.Scope, + ScopeReaders(identity.Scope), + state.SortContext, + filter, + (locator, reader) => survives(reader, locator))); + } +} diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewSnapshot.cs b/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewSnapshot.cs new file mode 100644 index 000000000..2166a3518 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewSnapshot.cs @@ -0,0 +1,187 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Common.Events; +using System.Diagnostics.CodeAnalysis; + +namespace EventLogExpert.Runtime.LogTable.OrderedView; + +internal sealed class OrderedViewSnapshot +{ + private readonly OrderKey[][] _chunks; + private readonly IComparer _comparer; + private readonly OrderKey[] _firstOfChunk; + private readonly int[] _prefix; + private readonly Dictionary _presentByLogGeneration; + + internal OrderedViewSnapshot( + OrderKey[][] chunks, + OrderKey[] firstOfChunk, + int[] prefix, + Dictionary presentByLogGeneration, + IComparer comparer, + long version) + { + _chunks = chunks; + _firstOfChunk = firstOfChunk; + _prefix = prefix; + _presentByLogGeneration = presentByLogGeneration; + _comparer = comparer; + Version = version; + } + + public static OrderedViewSnapshot Empty { get; } = + new([], [], [0], [], OrderKeyComparerFactory.Empty, 0); + + public int Count => _prefix.Length == 0 ? 0 : _prefix[^1]; + + public long Version { get; } + + internal int PinnedReaderCount => (_comparer as DelegatingOrderKeyComparer)?.PinnedReaderCount ?? 0; + + public OrderKey At(int displayIndex) + { + int chunk = ChunkForOffset(displayIndex); + + return _chunks[chunk][displayIndex - _prefix[chunk]]; + } + + public bool Contains(EventLogId logId, int generation, int index) + { + if (index < 0 || !_presentByLogGeneration.TryGetValue(new LogGeneration(logId, generation), out var bits)) + { + return false; + } + + int word = index >> 6; + + if ((uint)word >= (uint)bits.Length) { return false; } + + return (bits[word] & (1UL << (index & 63))) != 0; + } + + public int RankOf(in OrderKey key) + { + EventLocator locator = key.Locator; + + if (_chunks.Length == 0 || !Contains(locator.LogId, locator.Generation, locator.Index)) { return -1; } + + int chunk = ChunkForKey(key); + var keys = _chunks[chunk]; + int within = LowerBound(keys, key); + + return within < keys.Length && _comparer.Compare(keys[within], key) == 0 ? _prefix[chunk] + within : -1; + } + + public int SliceInto(int offset, int width, OrderKey[] outBuffer) + { + int total = Count; + + if (offset < 0 || offset >= total || width <= 0) { return 0; } + + int need = Math.Min(width, total - offset); + int written = 0; + int chunk = ChunkForOffset(offset); + + while (written < need && chunk < _chunks.Length) + { + int inChunkStart = (offset + written) - _prefix[chunk]; + int available = _chunks[chunk].Length - inChunkStart; + + if (available > 0) + { + int take = Math.Min(available, need - written); + Array.Copy(_chunks[chunk], inChunkStart, outBuffer, written, take); + written += take; + } + + chunk++; + } + + return written; + } + + internal bool TryGetReader(in EventLocator locator, [NotNullWhen(true)] out IEventColumnReader? reader) + { + if (_comparer is DelegatingOrderKeyComparer delegating) { return delegating.Resolver.TryResolve(locator, out reader); } + + reader = null; + + return false; + } + + internal bool TryGetReaderByLog( + EventLogId logId, + int generation, + [NotNullWhen(true)] out IEventColumnReader? reader) + { + if (_comparer is DelegatingOrderKeyComparer delegating) + { + return delegating.Resolver.TryResolveByLog(logId, generation, out reader); + } + + reader = null; + + return false; + } + + private int ChunkForKey(in OrderKey key) + { + int low = 0, high = _firstOfChunk.Length - 1, answer = 0; + + while (low <= high) + { + int mid = (low + high) >> 1; + + if (_comparer.Compare(key, _firstOfChunk[mid]) >= 0) + { + answer = mid; + low = mid + 1; + } + else + { + high = mid - 1; + } + } + + return answer; + } + + private int ChunkForOffset(int offset) + { + int low = 0, high = _prefix.Length - 2, answer = 0; + + while (low <= high) + { + int mid = (low + high) >> 1; + + if (_prefix[mid] <= offset) + { + answer = mid; + low = mid + 1; + } + else + { + high = mid - 1; + } + } + + return answer; + } + + private int LowerBound(OrderKey[] keys, in OrderKey key) + { + int low = 0, high = keys.Length; + + while (low < high) + { + int mid = (int)(((uint)low + (uint)high) >> 1); + + if (_comparer.Compare(keys[mid], key) < 0) { low = mid + 1; } + else { high = mid; } + } + + return low; + } +} diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewState.cs b/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewState.cs new file mode 100644 index 000000000..8d806f229 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewState.cs @@ -0,0 +1,600 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Common.Events; +using System.Collections.Immutable; +using System.Runtime.InteropServices; + +namespace EventLogExpert.Runtime.LogTable.OrderedView; + +internal sealed record RebuildRequest( + long Generation, + Func Predicate, + SortContext Context, + IReadOnlyDictionary RequestedGeneration, + bool Hold, + RowCoverage Coverage, + IReaderResolver BeginResolver, + FrozenScope Scope, + long ScopeVersion, + EventLogId? SingleLog); + +internal sealed class OrderedViewState +{ + internal const int DefaultBulkBuildThreshold = 50_000; + private readonly Dictionary _activeGeneration = []; + private readonly Dictionary _latestReaders = []; + private readonly LiveReaderResolver _liveResolver; + private readonly Dictionary _requestedGeneration = []; + private readonly OrderedViewScopeState _scopeState = new(); + + private SortContext _activeContext = new(null, false, null, false); + private ImmutableHashSet _adoptedInScope = []; + private FrozenScope _adoptedScope; + private OrderedViewSnapshot _current = OrderedViewSnapshot.Empty; + private long _generation; + private bool _holdIngest; + private ChunkedOrderIndex _index; + private Func _predicate = static (_, _) => true; + private long _publishVersion; + private SortContext _requestedContext = new(null, false, null, false); + private Func _requestedPredicate = static (_, _) => true; + + internal OrderedViewState() + { + _liveResolver = new LiveReaderResolver(_latestReaders); + _index = new ChunkedOrderIndex(OrderKeyComparerFactory.Create(_activeContext, _liveResolver)); + _adoptedScope = _scopeState.Freeze(); + } + + public ImmutableHashSet AdoptedInScope => _adoptedInScope; + + public OrderedViewSnapshot Current => Volatile.Read(ref _current); + + public long Generation => Volatile.Read(ref _generation); + + public int RowCount => _scopeState.FreezeCoverage().RowCount; + + public long ScopeVersion => _scopeState.ScopeVersion; + + internal int TrackedGenerationCount => _requestedGeneration.Count; + + internal int TrackedReaderCount => _latestReaders.Count; + + public static ChunkedOrderIndex BuildIndex(RebuildRequest request) => BuildIndex(request, CancellationToken.None); + + public static ChunkedOrderIndex BuildIndex(RebuildRequest request, CancellationToken cancellationToken) => + BuildIndex(request, cancellationToken, DefaultBulkBuildThreshold); + + public RebuildRequest BeginRebuild(Func newPredicate, SortContext newContext, bool? hold = null) + { + _requestedPredicate = newPredicate; + _requestedContext = newContext; + + if (hold == true) { _holdIngest = true; } + + return CaptureRequest(); + } + + public RebuildRequest BeginReset(EventLogId logId, int newGeneration) + { + if (!_requestedGeneration.TryGetValue(logId, out int current) || newGeneration > current) + { + _requestedGeneration[logId] = newGeneration; + } + + return CaptureRequest(); + } + + public bool CanRestampAdopted( + IReadOnlyCollection scopeLogs, + IReadOnlyDictionary scopeReaders) + { + if (_holdIngest) { return false; } + + if (!_scopeState.ScopeEquals(scopeLogs)) { return false; } + + foreach ((EventLogId logId, IEventColumnReader reader) in scopeReaders) + { + if (!_adoptedScope.Includes(logId)) { return false; } + + if (!_activeGeneration.TryGetValue(logId, out int active) || active != reader.Generation) { return false; } + + if (_scopeState.Coverage(new LogGeneration(logId, reader.Generation)) < reader.Count) { return false; } + } + + return true; + } + + public RebuildRequest CaptureScopeReseed() => CaptureRequest(); + + public OrderedViewSnapshot Clear() + { + Interlocked.Increment(ref _generation); + _latestReaders.Clear(); + _activeGeneration.Clear(); + _requestedGeneration.Clear(); + _scopeState.Reset(); + _adoptedScope = _scopeState.Freeze(); + _predicate = static (_, _) => true; + _requestedPredicate = static (_, _) => true; + _activeContext = new SortContext(null, false, null, false); + _requestedContext = new SortContext(null, false, null, false); + _holdIngest = false; + _index = new ChunkedOrderIndex(OrderKeyComparerFactory.Create(_activeContext, _liveResolver)); + + return PublishWith(FreezeReaders()); + } + + public bool CoversSameGenerations(IReadOnlyDictionary scopeReaders) + { + foreach ((EventLogId logId, IEventColumnReader reader) in scopeReaders) + { + if (_requestedGeneration.TryGetValue(logId, out int requested) && reader.Generation != requested) + { + return false; + } + } + + return true; + } + + public void NotifyRebuildFailed(RebuildRequest request) + { + if (request.Hold && Volatile.Read(ref _generation) == request.Generation) { _holdIngest = false; } + } + + public OrderedViewSnapshot Publish() => PublishWith(FreezeReaders()); + + public bool ReconcileLog(EventLogId logId, IEventColumnReader reader) + { + if (!TryAdmitReader(logId, reader, out LogGeneration readerKey, out bool sameCountReplace)) { return false; } + + int from = _scopeState.Coverage(readerKey); + + _scopeState.AdvanceCoverage(readerKey, reader.Count); + + bool mutated = false; + + if (_adoptedScope.Includes(logId) && !_holdIngest && IsCurrent(readerKey, _activeGeneration)) + { + for (int index = from; index < reader.Count; index++) + { + var locator = new EventLocator(logId, reader.Generation, index); + + if (_predicate(locator, reader)) + { + _index.Insert(new OrderKey(locator)); + mutated = true; + } + } + } + + bool displaysThisGeneration = reader.Count > 0 && + _adoptedScope.Includes(logId) && + _activeGeneration.TryGetValue(logId, out int active) && + active == reader.Generation; + + return mutated || + (displaysThisGeneration && !_adoptedInScope.Contains(readerKey)) || + (displaysThisGeneration && sameCountReplace); + } + + public bool ReconcileScopeReaders(IReadOnlyDictionary scopeReaders) + { + bool advanced = false; + + foreach ((EventLogId logId, IEventColumnReader reader) in scopeReaders) + { + if (SeedScopeReader(logId, reader)) { advanced = true; } + } + + return advanced; + } + + public RebuildRequest RemoveLog(EventLogId logId) + { + _scopeState.Remove(logId); + _requestedGeneration.Remove(logId); + + return CaptureRequest(); + } + + public void RestoreRequestedFromAdopted() + { + _requestedContext = _activeContext; + _requestedPredicate = _predicate; + } + + public bool SeedScopeReader(EventLogId logId, IEventColumnReader reader) + { + bool admitted = TryAdmitReader(logId, reader, out LogGeneration readerKey, out _); + + if ((admitted || _latestReaders.ContainsKey(readerKey)) && + reader.Generation > _requestedGeneration.GetValueOrDefault(logId, int.MinValue)) + { + _requestedGeneration[logId] = reader.Generation; + } + + if (!admitted) { return false; } + + int covered = _scopeState.Coverage(readerKey); + + _scopeState.AdvanceCoverage(readerKey, reader.Count); + + return reader.Count > covered; + } + + public void SupersedeInFlight() => Interlocked.Increment(ref _generation); + + public bool TryAdoptRebuild(RebuildRequest request, ChunkedOrderIndex rebuilt) + { + if (Volatile.Read(ref _generation) != request.Generation) { return false; } + + IReaderResolver commitResolver = FreezeReaders(); + rebuilt.RebindInsertComparer(OrderKeyComparerFactory.Create(request.Context, commitResolver)); + + try + { + foreach (LogGeneration key in _scopeState.Keys) + { + if (!request.Scope.Includes(key.LogId)) { continue; } + + if (!IsCurrent(key, _requestedGeneration)) { continue; } + + int from = request.Coverage.CoverageOf(key); + int to = _scopeState.Coverage(key); + + for (int index = from; index < to; index++) + { + var locator = new EventLocator(key.LogId, key.Generation, index); + + if (request.Predicate(locator, commitResolver.Resolve(locator))) + { + rebuilt.Insert(new OrderKey(locator)); + } + } + } + } + catch + { + // Abort leaves live state intact, but must not leave ingest gated: clear the hold so rows resume (safe under + // pure delegation; the fast-path work owns richer re-key recovery). + _holdIngest = false; + + throw; + } + + _activeGeneration.Clear(); + + foreach (var entry in _requestedGeneration) { _activeGeneration[entry.Key] = entry.Value; } + + _adoptedScope = request.Scope; + _index = rebuilt; + _predicate = request.Predicate; + _activeContext = request.Context; + _holdIngest = false; + + _scopeState.EvictOutOfScope(_adoptedScope, _activeGeneration); + EvictGenerationsOutOfScope(); + + PruneReleasedReaders(); + _index.RebindInsertComparer(OrderKeyComparerFactory.Create(_activeContext, _liveResolver)); + PublishWith(FreezeReaders()); + + return true; + } + + public bool TrySetActiveScope(IReadOnlyCollection scopeLogs, long scopeVersion) + { + if (!_scopeState.TrySetScope(scopeLogs, scopeVersion)) { return false; } + + Interlocked.Increment(ref _generation); + + return true; + } + + internal static ChunkedOrderIndex BuildIndex(RebuildRequest request, CancellationToken cancellationToken, int bulkThreshold) + { + if (TryBuildBulk(request, cancellationToken, bulkThreshold) is { } bulk) { return bulk; } + + var rebuilt = new ChunkedOrderIndex(OrderKeyComparerFactory.Create(request.Context, request.BeginResolver)); + int examined = 0; + + foreach ((LogGeneration key, int covered) in request.Coverage.Entries) + { + if (!request.Scope.Includes(key.LogId)) { continue; } + + if (!IsCurrent(key, request.RequestedGeneration)) { continue; } + + for (int index = 0; index < covered; index++) + { + if ((examined++ & 8191) == 0) { cancellationToken.ThrowIfCancellationRequested(); } + + var locator = new EventLocator(key.LogId, key.Generation, index); + + if (request.Predicate(locator, request.BeginResolver.Resolve(locator))) + { + rebuilt.Insert(new OrderKey(locator)); + } + } + } + + return rebuilt; + } + + private static ChunkedOrderIndex BuildCombinedBulk( + RebuildRequest request, + CancellationToken cancellationToken, + List<(LogGeneration Key, int Covered)> keys, + IComparer comparer) + { + var runKeys = new List(keys.Count); + var runs = new List(keys.Count); + long totalSurvivors = 0; + + foreach ((LogGeneration key, int covered) in keys) + { + IEventColumnReader reader = request.BeginResolver.Resolve(new EventLocator(key.LogId, key.Generation, 0)); + int[] run = SortLogSurvivors(request, cancellationToken, key, covered, reader); + + if (run.Length == 0) { continue; } + + runKeys.Add(key); + runs.Add(run); + totalSurvivors += run.Length; + } + + var merged = new OrderKey[totalSurvivors]; + var cursors = new int[runs.Count]; + var queue = new PriorityQueue(comparer); + + for (int run = 0; run < runs.Count; run++) + { + if ((run & 8191) == 0) { cancellationToken.ThrowIfCancellationRequested(); } + + queue.Enqueue(run, HeadKey(runKeys[run], runs[run], 0)); + } + + int emitted = 0; + + while (queue.TryDequeue(out int run, out OrderKey head)) + { + if ((emitted & 8191) == 0) { cancellationToken.ThrowIfCancellationRequested(); } + + merged[emitted++] = head; + int next = ++cursors[run]; + + if (next < runs[run].Length) { queue.Enqueue(run, HeadKey(runKeys[run], runs[run], next)); } + } + + return ChunkedOrderIndex.FromSortedRun(merged, comparer, cancellationToken); + } + + private static ChunkedOrderIndex BuildSingleLogBulk( + RebuildRequest request, CancellationToken cancellationToken, LogGeneration key, int covered, IComparer comparer) + { + IEventColumnReader reader = request.BeginResolver.Resolve(new EventLocator(key.LogId, key.Generation, 0)); + int[] sortedIndices = SortLogSurvivors(request, cancellationToken, key, covered, reader); + + var sortedOrder = new OrderKey[sortedIndices.Length]; + + for (int display = 0; display < sortedIndices.Length; display++) + { + if ((display & 8191) == 0) { cancellationToken.ThrowIfCancellationRequested(); } + + sortedOrder[display] = new OrderKey(new EventLocator(key.LogId, key.Generation, sortedIndices[display])); + } + + return ChunkedOrderIndex.FromSortedRun(sortedOrder, comparer, cancellationToken); + } + + private static List<(LogGeneration Key, int Covered)> CollectInScopeCurrentKeys( + RebuildRequest request, CancellationToken cancellationToken) + { + var keys = new List<(LogGeneration, int)>(); + int scanned = 0; + + foreach ((LogGeneration key, int covered) in request.Coverage.Entries) + { + if ((scanned++ & 8191) == 0) { cancellationToken.ThrowIfCancellationRequested(); } + + if (!request.Scope.Includes(key.LogId)) { continue; } + + if (!IsCurrent(key, request.RequestedGeneration)) { continue; } + + keys.Add((key, covered)); + } + + return keys; + } + + private static OrderKey HeadKey(LogGeneration key, int[] run, int cursor) => + new(new EventLocator(key.LogId, key.Generation, run[cursor])); + + private static bool IsCurrent(in LogGeneration key, IReadOnlyDictionary generation) => + generation.TryGetValue(key.LogId, out int current) && key.Generation == current; + + private static int[] SortLogSurvivors( + RebuildRequest request, CancellationToken cancellationToken, LogGeneration key, int covered, IEventColumnReader reader) + { + var survivors = new List(covered); + + for (int index = 0; index < covered; index++) + { + if ((index & 8191) == 0) { cancellationToken.ThrowIfCancellationRequested(); } + + var locator = new EventLocator(key.LogId, key.Generation, index); + + if (request.Predicate(locator, reader)) { survivors.Add(index); } + } + + return ColumnDirectSort.SortColumnDirect( + reader, + CollectionsMarshal.AsSpan(survivors), + request.Context.OrderBy, + request.Context.IsDescending, + request.Context.GroupBy, + request.Context.IsGroupDescending, + cancellationToken); + } + + private static ChunkedOrderIndex? TryBuildBulk(RebuildRequest request, CancellationToken cancellationToken, int bulkThreshold) + { + List<(LogGeneration Key, int Covered)> keys = CollectInScopeCurrentKeys(request, cancellationToken); + + long totalCovered = 0; + int summed = 0; + + foreach ((LogGeneration _, int covered) in keys) + { + if ((summed++ & 8191) == 0) { cancellationToken.ThrowIfCancellationRequested(); } + + totalCovered += covered; + } + + if (totalCovered < bulkThreshold) { return null; } + + IComparer comparer = OrderKeyComparerFactory.Create(request.Context, request.BeginResolver); + + return keys.Count switch + { + 1 => BuildSingleLogBulk(request, cancellationToken, keys[0].Key, keys[0].Covered, comparer), + >= 2 => BuildCombinedBulk(request, cancellationToken, keys, comparer), + _ => null + }; + } + + private ImmutableHashSet BuildAdoptedInScope() + { + var builder = ImmutableHashSet.CreateBuilder(); + + foreach ((EventLogId logId, int generation) in _activeGeneration) + { + var key = new LogGeneration(logId, generation); + + if (_adoptedScope.Includes(logId) && + _latestReaders.TryGetValue(key, out IEventColumnReader? reader) && + reader.Count > 0) + { + builder.Add(key); + } + } + + return builder.ToImmutable(); + } + + private RebuildRequest CaptureRequest() + { + long generation = Interlocked.Increment(ref _generation); + var generationSnapshot = new Dictionary(_requestedGeneration); + + return new RebuildRequest( + generation, + _requestedPredicate, + _requestedContext, + generationSnapshot, + _holdIngest, + _scopeState.FreezeCoverage(), + FreezeReaders(), + _scopeState.Freeze(), + _scopeState.ScopeVersion, + _scopeState.SingleLog); + } + + private void EvictGenerationsOutOfScope() + { + HashSet? evicted = null; + + foreach (EventLogId logId in _activeGeneration.Keys) + { + if (!_adoptedScope.Includes(logId)) { (evicted ??= []).Add(logId); } + } + + foreach (EventLogId logId in _requestedGeneration.Keys) + { + if (!_adoptedScope.Includes(logId)) { (evicted ??= []).Add(logId); } + } + + if (evicted is null) { return; } + + foreach (EventLogId logId in evicted) + { + _activeGeneration.Remove(logId); + _requestedGeneration.Remove(logId); + } + } + + private FrozenReaderResolver FreezeReaders() => + new(new Dictionary(_latestReaders)); + + private void PruneReleasedReaders() + { + List? released = null; + + foreach (LogGeneration key in _latestReaders.Keys) + { + if (!_adoptedScope.Includes(key.LogId) || + !_activeGeneration.TryGetValue(key.LogId, out int active) || + key.Generation < active) + { + (released ??= []).Add(key); + } + } + + if (released is null) { return; } + + foreach (LogGeneration key in released) + { + _scopeState.RecordGenerationSeen(key.LogId, key.Generation); + _latestReaders.Remove(key); + } + } + + private OrderedViewSnapshot PublishWith(IReaderResolver frozenResolver) + { + _adoptedInScope = BuildAdoptedInScope(); + + OrderedViewSnapshot snapshot = _index.Publish(OrderKeyComparerFactory.Create(_activeContext, frozenResolver), ++_publishVersion); + Volatile.Write(ref _current, snapshot); + + return snapshot; + } + + private bool TryAdmitReader( + EventLogId logId, IEventColumnReader reader, out LogGeneration readerKey, out bool sameCountReplace) + { + readerKey = new LogGeneration(logId, reader.Generation); + sameCountReplace = false; + + if (!_scopeState.Includes(logId)) { return false; } + + bool reestablishing = !_requestedGeneration.ContainsKey(logId) && !_activeGeneration.ContainsKey(logId); + + if (reestablishing && !_scopeState.IsAtOrAboveGenerationFloor(logId, reader.Generation)) { return false; } + + if (_latestReaders.TryGetValue(readerKey, out var existing)) + { + bool strictlyNewer = reader.Count > existing.Count || + (reader.Count == existing.Count && reader.ContentVersion > existing.ContentVersion); + + if (!strictlyNewer) { return false; } + + sameCountReplace = reader.Count == existing.Count; + } + + _latestReaders[readerKey] = reader; + + if (!_requestedGeneration.ContainsKey(logId)) { _requestedGeneration[logId] = reader.Generation; } + + if (reader.Count > 0 && + !_activeGeneration.ContainsKey(logId) && + _requestedGeneration.GetValueOrDefault(logId, reader.Generation) == reader.Generation) + { + _activeGeneration[logId] = reader.Generation; + } + + return true; + } +} diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewUpdate.cs b/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewUpdate.cs new file mode 100644 index 000000000..d359f131a --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewUpdate.cs @@ -0,0 +1,26 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Filtering.Evaluation; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.LogTable.OrderedView; + +internal abstract record OrderedViewUpdate(long SnapshotVersion, ViewIdentity? Identity, long Sequence); + +internal sealed record OrderedViewReady( + long SnapshotVersion, + ViewIdentity? Identity, + long Sequence, + EventLogId? SingleLogId, + ImmutableHashSet InScope, + IEventColumnView View, + SortContext Config, + Filter Filter) : OrderedViewUpdate(SnapshotVersion, Identity, Sequence) +{ + public ViewContentToken ContentToken { get; init; } = ViewContentToken.Empty; +} + +internal sealed record OrderedViewCleared(long SnapshotVersion, ViewIdentity? Identity, long Sequence) + : OrderedViewUpdate(SnapshotVersion, Identity, Sequence); diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewWriter.cs b/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewWriter.cs new file mode 100644 index 000000000..94a9174cc --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedView/OrderedViewWriter.cs @@ -0,0 +1,689 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Filtering.Evaluation; +using System.Collections.Immutable; +using System.Threading.Channels; + +namespace EventLogExpert.Runtime.LogTable.OrderedView; + +internal sealed class OrderedViewWriter : IAsyncDisposable +{ + private readonly Task? _cadence; + private readonly Channel _commandChannel; + private readonly Task _owner; + private readonly List> _pendingDrain = []; + private readonly int _publishEvery; + private readonly CancellationTokenSource _shutdown = new(); + private readonly OrderedViewState _state = new(); + + private SortContext _adoptedConfig; + private Filter _adoptedFilter; + private int _adoptedGeneration; + private ViewIdentity? _adoptedIdentity; + private EventLogId? _adoptedLog; + + private int _adoptedScopeLogCount; + + private long _adoptedSequence; + + private int _buildsStarted; + + private (Task Task, CancellationTokenSource Cts)? _currentBuild; + private RebuildRequest? _desiredBuild; + private bool _dirty; + + private bool _faultAnnounced; + + private volatile Exception? _faulted; + + private long _highestSequence; + private long _lastUpdateVersion; + + private PendingBuild? _pending; + private Filter _pendingFilter; + private int _pendingRebuilds; + + private bool _rebuildRequired; + + private bool _seededRowsAwaitingBuild; + + private int _sincePublish; + + private ImmutableHashSet? _singleLogInScope; + private LogGeneration? _singleLogInScopeKey; + + public OrderedViewWriter(int publishEvery = 256, int publishIntervalMs = 16) + { + _publishEvery = Math.Max(1, publishEvery); + _commandChannel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); + _owner = Task.Run(RunAsync); + + if (publishIntervalMs > 0) { _cadence = Task.Run(() => CadenceLoopAsync(publishIntervalMs)); } + } + + public event Action? FaultRaised; + + public event Action? Updated; + + private enum CommandKind + { + ViewRequest, + Reset, + Adopt, + RebuildFailed, + Flush, + Drain, + Reconcile, + RemoveLog, + Clear, + ClearFault + } + + public OrderedViewSnapshot Current => _state.Current; + + public Exception? Faulted => _faulted; + + public long Generation => _state.Generation; + + public long ScopeVersion => _state.ScopeVersion; + + internal int BuildsStarted => _buildsStarted; + + internal Task? CurrentBuildTask => _currentBuild?.Task; + + public async ValueTask DisposeAsync() + { + _shutdown.Cancel(); + _commandChannel.Writer.TryComplete(); + + if (_cadence is not null) + { + try { await _cadence; } + catch (OperationCanceledException) { } + } + + await _owner; + + _desiredBuild = null; + + if (_currentBuild is { } build) + { + build.Cts.Cancel(); + + try { await build.Task; } + catch { /* cancelled/faulted build - ignore on dispose */ } + + build.Cts.Dispose(); + _currentBuild = null; + } + + _shutdown.Dispose(); + } + + public async Task DrainAsync() + { + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + if (!_commandChannel.Writer.TryWrite(Command.ForDrain(done))) { done.TrySetResult(_state.Current); } + + return await done.Task; + } + + public void EnqueueClear(ViewIdentity identity, long sequence) => + _commandChannel.Writer.TryWrite(Command.ForClear(identity, sequence)); + + public void EnqueueClearFault() => _commandChannel.Writer.TryWrite(Command.ForClearFault()); + + public void EnqueueFlush() => _commandChannel.Writer.TryWrite(Command.ForFlush()); + + public void EnqueueReconcile(EventLogId logId, IEventColumnReader reader) => + _commandChannel.Writer.TryWrite(Command.ForReconcile(logId, reader)); + + public void EnqueueRemoveLog(EventLogId logId) => + _commandChannel.Writer.TryWrite(Command.ForRemoveLog(logId)); + + public void EnqueueResetLog(EventLogId logId, int newGeneration) => + _commandChannel.Writer.TryWrite(Command.ForReset(logId, newGeneration)); + + public void EnqueueViewRequest(ViewRequest request) => + _commandChannel.Writer.TryWrite(Command.ForViewRequest(request)); + + private OrderedViewUpdate BuildUpdate() + { + OrderedViewSnapshot snapshot = _state.Current; + ImmutableHashSet inScope = _state.AdoptedInScope; + + if (_adoptedLog is { } log && + inScope.Contains(new LogGeneration(log, _adoptedGeneration)) && + snapshot.TryGetReaderByLog(log, _adoptedGeneration, out IEventColumnReader? reader)) + { + var singleKey = new LogGeneration(log, _adoptedGeneration); + + if (_singleLogInScope is null || _singleLogInScopeKey != singleKey) + { + _singleLogInScope = [singleKey]; + _singleLogInScopeKey = singleKey; + } + + return new OrderedViewReady(snapshot.Version, + _adoptedIdentity, + _adoptedSequence, + log, + _singleLogInScope, + new OrderedColumnView(snapshot, reader), + _adoptedConfig, + _adoptedFilter) + { + ContentToken = ViewContentToken.From(_adoptedFilter, _singleLogInScope, snapshot) + }; + } + + if (_adoptedLog is null && inScope.Count > 0) + { + return new OrderedViewReady(snapshot.Version, + _adoptedIdentity, + _adoptedSequence, + null, + inScope, + new CombinedOrderedColumnView(snapshot, inScope), + _adoptedConfig, + _adoptedFilter) + { + ContentToken = ViewContentToken.From(_adoptedFilter, inScope, snapshot) + }; + } + + if (_adoptedScopeLogCount > 0) + { + return new OrderedViewReady(snapshot.Version, + _adoptedIdentity, + _adoptedSequence, + _adoptedLog, + [], + EmptyColumnView.Instance, + _adoptedConfig, + _adoptedFilter); + } + + return new OrderedViewCleared(snapshot.Version, _adoptedIdentity, _adoptedSequence); + } + + private async Task CadenceLoopAsync(int intervalMs) + { + try + { + while (!_shutdown.IsCancellationRequested) + { + await Task.Delay(intervalMs, _shutdown.Token); + + _commandChannel.Writer.TryWrite(Command.ForFlush()); + } + } + catch (OperationCanceledException) { } + } + + private void CancelRunningBuild() + { + _currentBuild?.Cts.Cancel(); + } + + private void CompleteRebuild() + { + _pendingRebuilds--; + DisposeCompletedBuild(); + + if (_desiredBuild is { } desired) + { + _desiredBuild = null; + StartRebuild(desired); + } + + if (_pendingRebuilds != 0 || _pendingDrain.Count <= 0) + { + return; + } + + PublishNow(); + + foreach (var signal in _pendingDrain) { signal.TrySetResult(_state.Current); } + + _pendingDrain.Clear(); + } + + private void Dispatch(in Command command) + { + switch (command.Kind) + { + case CommandKind.ViewRequest: + if (command.ViewRequest is { } viewRequest) { DispatchViewRequest(viewRequest); } + + break; + case CommandKind.Reset: + RebindAndStart(_state.BeginReset(command.LogId, command.Generation)); + + break; + case CommandKind.Reconcile: + if (command.Reader is { } reconcileReader) + { + bool reconciled; + + try + { + reconciled = _state.ReconcileLog(command.LogId, reconcileReader); + } + catch (Exception reconcileFault) + { + RecordFault(reconcileFault); + RequireRebuild(); + + break; + } + + if (reconciled) + { + _dirty = true; + + if (_state.Current.Count == 0) { PublishNow(); } + else if (++_sincePublish >= _publishEvery) { PublishNow(); } + } + } + + break; + case CommandKind.RemoveLog: + RebindAndStart(_state.RemoveLog(command.LogId)); + + break; + case CommandKind.Clear: + if (command.Sequence <= _highestSequence) { break; } + + _highestSequence = command.Sequence; + _state.Clear(); + + CancelRunningBuild(); + _desiredBuild = null; + _dirty = false; + _sincePublish = 0; + _adoptedLog = null; + _adoptedScopeLogCount = 0; + _adoptedIdentity = command.Identity; + _adoptedSequence = command.Sequence; + _pending = null; + + _rebuildRequired = false; + _faultAnnounced = false; + _seededRowsAwaitingBuild = false; + + break; + case CommandKind.Adopt: + try + { + if (command.Request is { } request && command.Rebuilt is { } rebuilt && _state.TryAdoptRebuild(request, rebuilt)) + { + _dirty = false; + _sincePublish = 0; + + _adoptedLog = request.SingleLog; + _adoptedScopeLogCount = request.Scope.LogCount; + + _adoptedGeneration = _adoptedLog is { } adoptedLog ? + request.RequestedGeneration.GetValueOrDefault(adoptedLog) : 0; + + _adoptedConfig = request.Context; + _adoptedFilter = _pendingFilter; + + if (_pending is { } adopting && adopting.EngineGeneration == request.Generation) + { + _adoptedIdentity = adopting.Identity; + _adoptedSequence = adopting.Sequence; + _pending = null; + } + + _rebuildRequired = false; + _faultAnnounced = false; + _seededRowsAwaitingBuild = false; + } + else if (_seededRowsAwaitingBuild) + { + // rows a retag seeded onto it, exactly as a throwing one would. + RequireRebuild(); + } + } + catch + { + // A replay that throws abandons this build just as surely as an off-thread failure does, so its + // token has to go the same way. Left in place it would still name a build that no longer exists, + // and the next request for this same view would attach to it rather than start one - so nothing + // would ever be rebuilt and that identity would never be published. Generation-matched for the + // same reason the off-thread path is: a newer build may already have claimed the slot. + // A build that abandons here takes any rows a retag seeded onto it with it: the replay that was + // going to place them never runs, and no replacement was captured. Repairing is what keeps + // coverage honest about what the index holds. + if (_seededRowsAwaitingBuild) { RequireRebuild(); } + + if (command.Request is { } abandoned && + _pending is { } dead && + dead.EngineGeneration == abandoned.Generation) + { + _pending = null; + } + + throw; + } + finally + { + CompleteRebuild(); + } + + break; + case CommandKind.RebuildFailed: + if (command.Error is { } error) + { + var failed = command.Request; + + bool owned = failed is null || failed.Generation == _state.Generation; + + ViewIdentity? faultedIdentity = owned && _pending is { } carrying && failed is not null && + carrying.EngineGeneration == failed.Generation ? + carrying.Identity : + null; + + RecordFault(error, owned, faultedIdentity); + + if (failed is not null) + { + _state.NotifyRebuildFailed(failed); + + if (_pending is { } dead && dead.EngineGeneration == failed.Generation) { _pending = null; } + } + } + + if (_seededRowsAwaitingBuild) { RequireRebuild(); } + + CompleteRebuild(); + + break; + case CommandKind.Flush: + if (_dirty) { PublishNow(); } + + break; + case CommandKind.ClearFault: + _faulted = null; + _faultAnnounced = false; + + break; + case CommandKind.Drain: + if (command.Signal is { } drainSignal) + { + if (_pendingRebuilds == 0) + { + PublishNow(); + drainSignal.TrySetResult(_state.Current); + } + else + { + _pendingDrain.Add(drainSignal); + } + } + + break; + } + } + + private void DispatchViewRequest(ViewRequest request) + { + if (request.Sequence <= _highestSequence) { return; } + + _highestSequence = request.Sequence; + + if (_pending is { } pending && + pending.EngineGeneration == _state.Generation && + request.Identity.CoversSameViewAs(pending.Identity) && + _state.CoversSameGenerations(request.ScopeReaders)) + { + if (_state.ReconcileScopeReaders(request.ScopeReaders)) { _seededRowsAwaitingBuild = true; } + + _pending = pending with { Identity = request.Identity, Sequence = request.Sequence }; + + return; + } + + if (!_rebuildRequired && + !_seededRowsAwaitingBuild && + _adoptedIdentity is { } adoptedIdentity && + request.Identity.CoversSameViewAs(adoptedIdentity) && + _state.CanRestampAdopted(request.ScopeLogs, request.ScopeReaders)) + { + _state.SupersedeInFlight(); + CancelRunningBuild(); + _desiredBuild = null; + _pending = null; + + _state.RestoreRequestedFromAdopted(); + _pendingFilter = _adoptedFilter; + _adoptedIdentity = request.Identity; + _adoptedSequence = request.Sequence; + PublishNow(); + + return; + } + + _pendingFilter = request.Filter; + _state.TrySetActiveScope(request.ScopeLogs, request.Sequence); + _state.ReconcileScopeReaders(request.ScopeReaders); + + RebuildRequest rebuild = _state.BeginRebuild(request.Predicate, request.Context, request.Hold); + + _pending = new PendingBuild(request.Identity, request.Sequence, rebuild.Generation); + StartRebuild(rebuild); + } + + private void DisposeCompletedBuild() + { + _currentBuild?.Cts.Dispose(); + _currentBuild = null; + } + + private void FailPendingDrain() + { + foreach (var signal in _pendingDrain) { signal.TrySetResult(_state.Current); } + + _pendingDrain.Clear(); + } + + private void PublishNow() + { + _state.Publish(); + _dirty = false; + _sincePublish = 0; + } + + private void RaiseUpdateIfAdvanced() + { + if (_rebuildRequired) { return; } + + long version = _state.Current.Version; + + if (version <= _lastUpdateVersion) { return; } + + Action? handler = Updated; + + if (handler is null) { return; } + + OrderedViewUpdate update; + + try + { + update = BuildUpdate(); + } + catch (Exception buildFault) + { + _lastUpdateVersion = version; + RecordFault(buildFault); + + return; + } + + _lastUpdateVersion = version; + + try { handler(update); } + catch (Exception subscriberFault) { RecordFault(subscriberFault, announce: false); } + } + + private void RebindAndStart(RebuildRequest request) + { + if (_pending is { } pending) { _pending = pending with { EngineGeneration = request.Generation }; } + + StartRebuild(request); + } + + private void RecordFault(Exception fault, bool announce = true, ViewIdentity? identity = null) + { + _faulted ??= fault; + + if (!announce || _faultAnnounced) { return; } + + _faultAnnounced = true; + + try { FaultRaised?.Invoke(fault, identity); } + catch (Exception) { /* a broken fault subscriber must not mask the original fault or kill the owner loop */ } + } + + private void RequireRebuild() + { + if (_rebuildRequired) { return; } + + _rebuildRequired = true; + + RebindAndStart(_state.CaptureScopeReseed()); + } + + private async Task RunAsync() + { + var reader = _commandChannel.Reader; + + while (await reader.WaitToReadAsync()) + { + while (reader.TryRead(out var command)) + { + try + { + Dispatch(command); + } + catch (Exception ex) + { + // One bad command must not kill the pipeline; record and drain. + RecordFault(ex); + + if (command is { Kind: CommandKind.Drain, Signal: { } drainSignal }) { drainSignal.TrySetException(ex); } + } + + RaiseUpdateIfAdvanced(); + } + } + + FailPendingDrain(); + } + + private void StartBuild(RebuildRequest request) + { + _pendingRebuilds++; + _buildsStarted++; + + var cts = new CancellationTokenSource(); + var token = cts.Token; + + var build = Task.Run(() => + { + try + { + ChunkedOrderIndex rebuilt = OrderedViewState.BuildIndex(request, token); + _commandChannel.Writer.TryWrite(Command.ForAdopt(request, rebuilt)); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + _commandChannel.Writer.TryWrite(Command.ForRebuildFailed(null)); // superseded/shutdown - expected, not a fault + } + catch (Exception ex) + { + // Includes an OperationCanceledException the token did NOT ask for: a predicate that throws one is a + // faulty predicate, not a supersede, and misreporting it would swallow the fault and leave a held gate set. + _commandChannel.Writer.TryWrite(Command.ForRebuildFailed(ex, request)); // a faulty predicate - a real fault + } + }); + + _currentBuild = (build, cts); + } + + private void StartRebuild(RebuildRequest request) + { + if (_shutdown.IsCancellationRequested) { return; } + + if (_pendingRebuilds > 0) + { + _desiredBuild = request; + CancelRunningBuild(); + + return; + } + + StartBuild(request); + } + + private readonly struct Command + { + public CommandKind Kind { get; private init; } + + public IEventColumnReader? Reader { get; private init; } + + public RebuildRequest? Request { get; private init; } + + public ChunkedOrderIndex? Rebuilt { get; private init; } + + public TaskCompletionSource? Signal { get; private init; } + + public Exception? Error { get; private init; } + + public EventLogId LogId { get; private init; } + + public int Generation { get; private init; } + + public ViewIdentity? Identity { get; private init; } + + public long Sequence { get; private init; } + + public ViewRequest? ViewRequest { get; private init; } + + public static Command ForViewRequest(ViewRequest request) => + new() { Kind = CommandKind.ViewRequest, ViewRequest = request }; + + public static Command ForReset(EventLogId logId, int generation) => + new() { Kind = CommandKind.Reset, LogId = logId, Generation = generation }; + + public static Command ForReconcile(EventLogId logId, IEventColumnReader reader) => + new() { Kind = CommandKind.Reconcile, LogId = logId, Reader = reader }; + + public static Command ForRemoveLog(EventLogId logId) => + new() { Kind = CommandKind.RemoveLog, LogId = logId }; + + public static Command ForClear(ViewIdentity identity, long sequence) => + new() { Kind = CommandKind.Clear, Identity = identity, Sequence = sequence }; + + public static Command ForAdopt(RebuildRequest request, ChunkedOrderIndex rebuilt) => + new() { Kind = CommandKind.Adopt, Request = request, Rebuilt = rebuilt }; + + public static Command ForRebuildFailed(Exception? error, RebuildRequest? request = null) => + new() { Kind = CommandKind.RebuildFailed, Error = error, Request = request }; + + public static Command ForFlush() => new() { Kind = CommandKind.Flush }; + + public static Command ForClearFault() => new() { Kind = CommandKind.ClearFault }; + + public static Command ForDrain(TaskCompletionSource signal) => + new() { Kind = CommandKind.Drain, Signal = signal }; + } + + private sealed record PendingBuild(ViewIdentity Identity, long Sequence, long EngineGeneration); +} diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedView/RowCoverage.cs b/src/EventLogExpert.Runtime/LogTable/OrderedView/RowCoverage.cs new file mode 100644 index 000000000..d2ba593a3 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedView/RowCoverage.cs @@ -0,0 +1,27 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.LogTable.OrderedView; + +internal sealed class RowCoverage +{ + private readonly Dictionary _byKey; + + internal RowCoverage(Dictionary byKey) => _byKey = byKey; + + public IEnumerable> Entries => _byKey; + + public int RowCount + { + get + { + int total = 0; + + foreach (int covered in _byKey.Values) { total += covered; } + + return total; + } + } + + public int CoverageOf(in LogGeneration key) => _byKey.GetValueOrDefault(key); +} diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedView/ViewContentToken.cs b/src/EventLogExpert.Runtime/LogTable/OrderedView/ViewContentToken.cs new file mode 100644 index 000000000..d7740e178 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedView/ViewContentToken.cs @@ -0,0 +1,111 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Filtering.Evaluation; +using System.Collections.Immutable; +using System.Diagnostics; + +namespace EventLogExpert.Runtime.LogTable.OrderedView; + +internal readonly record struct ViewContentTokenReaderStamp(EventLogId LogId, int Generation, long ContentVersion, int Count); + +public readonly struct ViewContentToken : IEquatable +{ + private readonly Filter _filter; + private readonly ImmutableArray _readers; + private readonly int _survivorCount; + + private ViewContentToken(Filter filter, ImmutableArray readers, int survivorCount) + { + _filter = filter; + _readers = readers; + _survivorCount = survivorCount; + } + + public static ViewContentToken Empty { get; } = new(default, ImmutableArray.Empty, 0); + + internal static ViewContentToken From( + Filter filter, + ImmutableHashSet inScope, + OrderedViewSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + + if (inScope.IsEmpty) { return Empty; } + + ImmutableArray.Builder builder = + ImmutableArray.CreateBuilder(inScope.Count); + + foreach (LogGeneration member in inScope) + { + bool resolved = snapshot.TryGetReaderByLog(member.LogId, member.Generation, out IEventColumnReader? reader); + + Debug.Assert(resolved, "An in-scope member did not resolve a reader; the view constructor should have thrown."); + + if (resolved) + { + builder.Add( + new ViewContentTokenReaderStamp(member.LogId, member.Generation, reader!.ContentVersion, reader.Count)); + } + } + + return FromStamps(filter, builder.ToImmutable(), snapshot.Count); + } + + internal static ViewContentToken FromStamps( + Filter filter, ImmutableArray readers, int survivorCount) + { + if (readers.IsDefaultOrEmpty) { return Empty; } + + ImmutableArray sorted = readers.Sort(static (left, right) => + { + int byLog = left.LogId.Value.CompareTo(right.LogId.Value); + + return byLog != 0 ? byLog : left.Generation.CompareTo(right.Generation); + }); + + return new ViewContentToken(filter, sorted, survivorCount); + } + + public static bool operator ==(ViewContentToken left, ViewContentToken right) => left.Equals(right); + + public static bool operator !=(ViewContentToken left, ViewContentToken right) => !left.Equals(right); + + public bool Equals(ViewContentToken other) + { + ImmutableArray readers = + _readers.IsDefault ? ImmutableArray.Empty : _readers; + ImmutableArray otherReaders = + other._readers.IsDefault ? ImmutableArray.Empty : other._readers; + + if (readers.Length != otherReaders.Length || _survivorCount != other._survivorCount) { return false; } + + if (readers.Length > 0 && !_filter.Equals(other._filter)) { return false; } + + for (int index = 0; index < readers.Length; index++) + { + if (!readers[index].Equals(otherReaders[index])) { return false; } + } + + return true; + } + + public override bool Equals(object? obj) => obj is ViewContentToken other && Equals(other); + + public override int GetHashCode() + { + ImmutableArray readers = + _readers.IsDefault ? ImmutableArray.Empty : _readers; + + var hash = new HashCode(); + hash.Add(_survivorCount); + + if (readers.Length > 0) { hash.Add(_filter); } + + foreach (ViewContentTokenReaderStamp reader in readers) { hash.Add(reader); } + + return hash.ToHashCode(); + } +} diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedView/ViewRequest.cs b/src/EventLogExpert.Runtime/LogTable/OrderedView/ViewRequest.cs new file mode 100644 index 000000000..48a9736c4 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedView/ViewRequest.cs @@ -0,0 +1,18 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Filtering.Evaluation; + +namespace EventLogExpert.Runtime.LogTable.OrderedView; + +internal sealed record ViewRequest( + ViewIdentity Identity, + long Sequence, + IReadOnlyCollection ScopeLogs, + IReadOnlyDictionary ScopeReaders, + SortContext Context, + Filter Filter, + Func Predicate, + bool? Hold = null); diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedView/ViewRequestIssuer.cs b/src/EventLogExpert.Runtime/LogTable/OrderedView/ViewRequestIssuer.cs new file mode 100644 index 000000000..6764979c6 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedView/ViewRequestIssuer.cs @@ -0,0 +1,75 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.LogTable.OrderedView; + +internal sealed class ViewRequestIssuer +{ + private readonly Lock _gate = new(); + + private volatile bool _enabled = true; + private Exception? _lastFault; + private ViewIdentity? _lastIssuedIdentity; + + private ViewIdentity? _recoveringIdentity; + private long _recoveringWatermark; + + private long _sequence; + + public bool Enabled + { + get => _enabled; + set => _enabled = value; + } + + public Exception? LastFault => Volatile.Read(ref _lastFault); + + public void RecordFault(Exception fault) => Volatile.Write(ref _lastFault, fault); + + public long ResetForClear() + { + lock (_gate) + { + _lastIssuedIdentity = null; + + return ++_sequence; + } + } + + public long ResetForCloseAll() + { + lock (_gate) + { + _lastIssuedIdentity = null; + _recoveringIdentity = null; + _recoveringWatermark = 0; + + return ++_sequence; + } + } + + public bool TryBeginRecovery(ViewIdentity identity, long servedWatermark) + { + lock (_gate) + { + if (_recoveringIdentity == identity && servedWatermark <= _recoveringWatermark) { return false; } + + _recoveringIdentity = identity; + _recoveringWatermark = servedWatermark; + + return true; + } + } + + public long? TryIssue(ViewIdentity identity) + { + lock (_gate) + { + if (_lastIssuedIdentity == identity) { return null; } + + _lastIssuedIdentity = identity; + + return ++_sequence; + } + } +} diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedViewDisplayFaultedAction.cs b/src/EventLogExpert.Runtime/LogTable/OrderedViewDisplayFaultedAction.cs new file mode 100644 index 000000000..00a449cb7 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedViewDisplayFaultedAction.cs @@ -0,0 +1,6 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.LogTable; + +internal sealed record OrderedViewDisplayFaultedAction(Exception Fault, ViewIdentity? Identity = null); diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedViewDisplayRecoveredAction.cs b/src/EventLogExpert.Runtime/LogTable/OrderedViewDisplayRecoveredAction.cs new file mode 100644 index 000000000..628acde55 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedViewDisplayRecoveredAction.cs @@ -0,0 +1,6 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.LogTable; + +internal sealed record OrderedViewDisplayRecoveredAction; diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedViewPresentation.cs b/src/EventLogExpert.Runtime/LogTable/OrderedViewPresentation.cs new file mode 100644 index 000000000..9f1f40353 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedViewPresentation.cs @@ -0,0 +1,45 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Runtime.LogTable.OrderedView; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.LogTable; + +public sealed record OrderedViewPresentation( + IEventColumnView View, + EventLogId? ActiveTabId, + DisplayOrdering Ordering, + PresentationState State, + long Revision, + string? FaultCause = null, + bool OrderingIsStale = false) +{ + public DisplayIndicatorKind IndicatorKind => + State switch + { + PresentationState.Faulted => DisplayIndicatorKind.Fault, + PresentationState.Updating when View.Count == 0 => DisplayIndicatorKind.EmptyPending, + PresentationState.Updating when OrderingIsStale => DisplayIndicatorKind.ReorderPending, + _ => DisplayIndicatorKind.None + }; + + public bool GroupsCollapsedByDefault { get; init; } + + public ViewContentToken ContentToken { get; init; } = ViewContentToken.Empty; + + public ImmutableHashSet GroupCollapseOverrides { get; init; } = + ImmutableHashSet.Create(StringComparer.Ordinal); + + public string? ActiveLogName { get; init; } + + public ImmutableDictionary Columns { get; init; } = ImmutableDictionary.Empty; + + public ImmutableList ColumnOrder { get; init; } = []; + + public ImmutableDictionary ColumnWidths { get; init; } = ImmutableDictionary.Empty; + + public bool IsGroupCollapsed(string groupKey) => + GroupsCollapsedByDefault ^ GroupCollapseOverrides.Contains(groupKey); +} diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedViewSource.cs b/src/EventLogExpert.Runtime/LogTable/OrderedViewSource.cs new file mode 100644 index 000000000..59474f59e --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedViewSource.cs @@ -0,0 +1,144 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.LogTable.OrderedView; +using Fluxor; +using Microsoft.Extensions.DependencyInjection; + +namespace EventLogExpert.Runtime.LogTable; + +internal sealed class OrderedViewSource : IOrderedViewSource, IDisposable +{ + private readonly Lock _gate = new(); + private readonly IState _logTableState; + private readonly ITraceLogger _logger; + + private OrderedViewPresentation _current; + private bool _disposed; + + public OrderedViewSource( + IState logTableState, + [FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + { + ArgumentNullException.ThrowIfNull(logTableState); + ArgumentNullException.ThrowIfNull(logger); + + _logTableState = logTableState; + _logger = logger; + + _current = Project(logTableState.Value, revision: 0); + _logTableState.StateChanged += OnStateChanged; + + lock (_gate) + { + var reconciled = Project(_logTableState.Value, _current.Revision + 1); + + if (!IsEqual(_current, reconciled)) { _current = reconciled; } + } + } + + public event Action? Updated; + + public OrderedViewPresentation Current + { + get { lock (_gate) { return _current; } } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) { return; } + + _disposed = true; + } + + _logTableState.StateChanged -= OnStateChanged; + } + + private static bool IsEqual(OrderedViewPresentation current, OrderedViewPresentation next) => + ReferenceEquals(current.View, next.View) && + current.ContentToken == next.ContentToken && + current.ActiveTabId == next.ActiveTabId && + current.Ordering == next.Ordering && + current.State == next.State && + current.FaultCause == next.FaultCause && + current.OrderingIsStale == next.OrderingIsStale && + current.GroupsCollapsedByDefault == next.GroupsCollapsedByDefault && + current.ActiveLogName == next.ActiveLogName && + (ReferenceEquals(current.GroupCollapseOverrides, next.GroupCollapseOverrides) || + current.GroupCollapseOverrides.SetEquals(next.GroupCollapseOverrides)) && + ReferenceEquals(current.Columns, next.Columns) && + ReferenceEquals(current.ColumnOrder, next.ColumnOrder) && + ReferenceEquals(current.ColumnWidths, next.ColumnWidths); + + private static OrderedViewPresentation Project(LogTableState state, long revision) + { + var activeTable = state.EventTables.FirstOrDefault(table => table.Id == state.ActiveEventLogId); + + var ordering = new DisplayOrdering(state.OrderBy, state.IsDescending, state.GroupBy, state.IsGroupDescending); + + IEventColumnView view = activeTable is null ? + LogTableState.EmptyView : + state.DisplayedEventsForTab(activeTable); + + var presentationState = state.PresentationState; + + return new OrderedViewPresentation( + view, + activeTable?.Id, + ordering, + presentationState, + revision, + presentationState == PresentationState.Faulted ? state.FaultCause : null, + state.OrderingIsStale) + { + ActiveLogName = activeTable is { IsCombined: false } ? activeTable.LogName : null, + GroupsCollapsedByDefault = state.GroupsCollapsedByDefault, + ContentToken = activeTable is null ? ViewContentToken.Empty : state.ContentTokenForTab(activeTable), + GroupCollapseOverrides = state.GroupCollapseOverrides, + Columns = state.Columns, + ColumnOrder = state.ColumnOrder, + ColumnWidths = state.ColumnWidths + }; + } + + private void OnStateChanged(object? sender, EventArgs args) + { + OrderedViewPresentation published; + + lock (_gate) + { + if (_disposed) { return; } + + OrderedViewPresentation next = Project(_logTableState.Value, _current.Revision + 1); + + if (IsEqual(_current, next)) { return; } + + _current = next; + published = next; + } + + Publish(published); + } + + private void Publish(OrderedViewPresentation presentation) + { + Action? handlers = Updated; + + if (handlers is null) { return; } + + foreach (Delegate handler in handlers.GetInvocationList()) + { + try + { + ((Action)handler)(presentation); + } + catch (Exception fault) + { + _logger.Trace($"{nameof(OrderedViewSource)}: a subscriber threw and was isolated: {fault}"); + } + } + } +} diff --git a/src/EventLogExpert.Runtime/LogTable/OrderedViewUpdatedAction.cs b/src/EventLogExpert.Runtime/LogTable/OrderedViewUpdatedAction.cs new file mode 100644 index 000000000..b93f90b65 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OrderedViewUpdatedAction.cs @@ -0,0 +1,8 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.LogTable.OrderedView; + +namespace EventLogExpert.Runtime.LogTable; + +internal sealed record OrderedViewUpdatedAction(OrderedViewUpdate Update); diff --git a/src/EventLogExpert.Runtime/LogTable/PresentationState.cs b/src/EventLogExpert.Runtime/LogTable/PresentationState.cs new file mode 100644 index 000000000..0dee40e43 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/PresentationState.cs @@ -0,0 +1,13 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.LogTable; + +public enum PresentationState +{ + Current, + + Updating, + + Faulted +} diff --git a/src/EventLogExpert.Runtime/LogTable/Reducers.cs b/src/EventLogExpert.Runtime/LogTable/Reducers.cs index e03ae8f60..d676e5ed8 100644 --- a/src/EventLogExpert.Runtime/LogTable/Reducers.cs +++ b/src/EventLogExpert.Runtime/LogTable/Reducers.cs @@ -3,8 +3,11 @@ using EventLogExpert.Eventing.Common.Channels; using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Filtering.Evaluation; using EventLogExpert.Runtime.EventLog; using EventLogExpert.Runtime.Histogram; +using EventLogExpert.Runtime.LogTable.OrderedView; using Fluxor; using System.Collections.Immutable; @@ -23,15 +26,12 @@ public static LogTableState ReduceAddTable(LogTableState state, AddTableAction a IsLoading = true }; - var counts = state.EventCountByLog.SetItem(newTable.Id, 0); - if (state.EventTables.IsEmpty) { return ResetGroupCollapseIfActiveChanged( state with { EventTables = state.EventTables.Add(newTable), - EventCountByLog = counts, ActiveEventLogId = newTable.Id }, state.ActiveEventLogId); @@ -41,144 +41,43 @@ state with if (combinedTable is not null) { - return state with + // later, by which time the served view it needed to record is already gone. + return RetainServedView(state, state with { EventTables = state.EventTables.Add(newTable), - EventCountByLog = counts - }; + }); } combinedTable = new LogView(EventLogId.Create()) { GroupId = LogTabGroupId.AllLogs }; - return ResetGroupCollapseIfActiveChanged( + return RetainServedView(state, ResetGroupCollapseIfActiveChanged( state with { EventTables = state.EventTables .Add(combinedTable) .Add(newTable), - EventCountByLog = counts, ActiveEventLogId = combinedTable.Id }, - state.ActiveEventLogId); - } - - [ReducerMethod] - public static LogTableState ReduceAppendTableEvents(LogTableState state, AppendTableEventsAction action) - { - var table = state.EventTables.FirstOrDefault(t => action.LogId == t.Id); - - if (table is null || table.IsCombined || action.View is null) { return state; } - - var view = action.View; - - int postCount = state.PerLogEvents.ContainsKey(table.Id) ? - state.PerLogEvents.Count : - state.PerLogEvents.Count + 1; - - var context = EffectiveSortContext( - state.OrderBy, state.IsDescending, state.GroupBy, state.IsGroupDescending, postCount, state.TimelineVisible); - - var perLog = SetLog(state.PerLogEvents, table.Id, view, context); - perLog = ReconcileToLogCount(perLog, state); - var updatedTable = SetComputerNameIfFirstEvent(table, view); - var counts = state.EventCountByLog.SetItem(table.Id, view.Count); - - return state with - { - PerLogEvents = perLog, - EventTables = ReferenceEquals(updatedTable, table) ? - state.EventTables : - state.EventTables.Replace(table, updatedTable), - EventCountByLog = counts - }; - } - - [ReducerMethod] - public static LogTableState ReduceAppendTableEventsBatch( - LogTableState state, - AppendTableEventsBatchAction action) - { - if (action.ViewsByLog.Count == 0) { return state; } - - // Skip batches for closed logs: avoid resurrecting events and stale counts. - bool changed = false; - var perLog = state.PerLogEvents; - var perLogVersion = state.PerLogListVersion; - var counts = state.EventCountByLog; - var updatedTables = state.EventTables; - - // Count new logs first so appends use the post-batch sort context (no boundary re-sort). - int newLogs = 0; - - foreach (var (logId, _) in action.ViewsByLog) - { - if (perLog.ContainsKey(logId)) { continue; } - - var table = state.EventTables.FirstOrDefault(t => t.Id == logId); - - if (table is not null && !table.IsCombined) { newLogs++; } - } - - var context = EffectiveSortContext( - state.OrderBy, state.IsDescending, state.GroupBy, state.IsGroupDescending, perLog.Count + newLogs, state.TimelineVisible); - - foreach (var (logId, view) in action.ViewsByLog) - { - var table = updatedTables.FirstOrDefault(t => t.Id == logId); - - if (table is null || table.IsCombined) { continue; } - - if (action.VersionByLog.TryGetValue(logId, out var version)) - { - perLogVersion = perLogVersion.SetItem( - logId, - perLogVersion.TryGetValue(logId, out var existingVersion) ? Math.Min(existingVersion, version) : version); - } - else - { - perLogVersion = perLogVersion.Remove(logId); - } - - perLog = SetLog(perLog, logId, view, context); - counts = counts.SetItem(logId, view.Count); - changed = true; - - var updatedTable = SetComputerNameIfFirstEvent(table, view); - - if (!ReferenceEquals(updatedTable, table)) - { - updatedTables = updatedTables.Replace(table, updatedTable); - } - } - - if (!changed) { return state; } - - perLog = ReconcileToLogCount(perLog, state); - - return state with - { - PerLogEvents = perLog, - PerLogListVersion = perLogVersion, - EventTables = updatedTables, - EventCountByLog = counts - }; + state.ActiveEventLogId)); } [ReducerMethod] public static LogTableState ReduceApplyFilter(LogTableState state, ApplyFilterAction action) => - state with { DisplayListVersion = state.DisplayListVersion + 1 }; + RetainServedView(state, state with + { + AppliedFilter = action.Filter.HasFilteringChangedFrom(state.AppliedFilter) ? + action.Filter : + state.AppliedFilter + }); [ReducerMethod(typeof(CloseAllLogsAction))] public static LogTableState ReduceCloseAll(LogTableState state) => - ResetGroupCollapse(state with + ResetGroupCollapse((state with { EventTables = [], Groups = [], - PerLogEvents = ImmutableDictionary.Empty, - PerLogListVersion = ImmutableDictionary.Empty, - EventCountByLog = ImmutableDictionary.Empty, ActiveEventLogId = null - }); + }).WithClearedOrderedViewRetention()); [ReducerMethod] public static LogTableState ReduceCloseLog(LogTableState state, CloseLogAction action) @@ -190,24 +89,17 @@ public static LogTableState ReduceCloseLog(LogTableState state, CloseLogAction a var (groups, healedTables) = RemoveLogFromGroups(state.Groups, state.EventTables, action.LogId); var remainingTables = healedTables.RemoveAll(table => table.Id == action.LogId); - var counts = state.EventCountByLog.Remove(action.LogId); - var perLog = ReconcileToLogCount(state.PerLogEvents.Remove(action.LogId), state); - var perLogVersion = state.PerLogListVersion.Remove(action.LogId); - int perLogTabsRemaining = remainingTables.Count(table => !table.IsCombined); if (perLogTabsRemaining == 0) { return ResetGroupCollapseIfActiveChanged( - state with + (state with { EventTables = [], Groups = [], - PerLogEvents = ImmutableDictionary.Empty, - PerLogListVersion = ImmutableDictionary.Empty, - EventCountByLog = ImmutableDictionary.Empty, ActiveEventLogId = null - }, + }).WithClearedOrderedViewRetention(), state.ActiveEventLogId); } @@ -215,82 +107,32 @@ state with ? remainingTables.RemoveAll(table => table.GroupId?.IsAll == true) : remainingTables; + var finalTableIds = finalTables.Select(table => table.Id).ToHashSet(); + var updated = state with { EventTables = finalTables, Groups = groups, - PerLogEvents = perLog, - PerLogListVersion = perLogVersion, - EventCountByLog = counts + RetainedOrderedViews = state.RetainedOrderedViews.RemoveRange( + state.RetainedOrderedViews.Keys.Where(id => !finalTableIds.Contains(id))), }; - return ResetGroupCollapseIfActiveChanged(RepairActiveTab(updated, null), state.ActiveEventLogId); + return RetainServedView(state, ResetGroupCollapseIfActiveChanged(RepairActiveTab(updated, null), state.ActiveEventLogId)); } [ReducerMethod] - public static LogTableState ReduceDisplayReady( - LogTableState state, - DisplayReadyAction action) + public static LogTableState ReduceIngestRawEvents(LogTableState state, IngestRawEventsAction action) { - if (action.Version != state.DisplayListVersion) { return state; } - - var flipped = state with - { - OrderBy = state.RequestedOrderBy, - IsDescending = state.RequestedIsDescending, - GroupBy = state.RequestedGroupBy, - IsGroupDescending = state.RequestedIsGroupDescending - }; - - // Skip log ids absent from EventTables: log closed while filter ran. - var tablesById = state.EventTables - .Where(table => !table.IsCombined) - .ToDictionary(table => table.Id); - - // The views were built under the requested context; heal any that pre-date it. - int postCount = 0; - - foreach (var (logId, _) in tablesById) - { - if (action.Views.ContainsKey(logId) || state.PerLogEvents.ContainsKey(logId)) { postCount++; } - } - - var context = EffectiveSortContext( - flipped.OrderBy, flipped.IsDescending, flipped.GroupBy, flipped.IsGroupDescending, postCount, flipped.TimelineVisible); - var perLogBuilder = ImmutableDictionary.CreateBuilder(); - var perLogVersion = state.PerLogListVersion; - var counts = state.EventCountByLog; var tables = state.EventTables; - foreach (var (logId, table) in tablesById) + foreach (var (logId, events) in action.EventsByLog) { - if (action.Views.TryGetValue(logId, out var view)) - { - perLogBuilder[logId] = view.HasContext(context) ? view : view.WithContext(context); - perLogVersion = perLogVersion.SetItem(logId, action.Version); - counts = counts.SetItem(logId, view.Count); - - var updatedTable = SetComputerNameIfFirstEvent(table, view); + if (events.Count <= 0) { continue; } - if (!ReferenceEquals(updatedTable, table)) { tables = tables.Replace(table, updatedTable); } - } - else if (state.PerLogEvents.TryGetValue(logId, out var existingView)) - { - perLogBuilder[logId] = existingView.HasContext(context) ? - existingView : - existingView.WithContext(context); - } + tables = LatchComputerNameForLog(tables, logId, events); } - var result = flipped with - { - PerLogEvents = ReconcileToLogCount(perLogBuilder.ToImmutable(), flipped), - PerLogListVersion = perLogVersion, - EventTables = tables, - EventCountByLog = counts - }; - - return state.RequestedGroupBy != state.GroupBy ? ResetGroupCollapse(result) : result; + return ReferenceEquals(tables, state.EventTables) ? state : state with { EventTables = tables }; } [ReducerMethod] @@ -305,38 +147,44 @@ public static LogTableState ReduceLoadColumnsCompleted( ColumnOrder = action.ColumnOrder }; - bool liveGroupHidden = updated.GroupBy is { } liveGroup && IsHidden(liveGroup); bool requestedGroupHidden = updated.RequestedGroupBy is { } requestedGroup && IsHidden(requestedGroup); - if (!liveGroupHidden && !requestedGroupHidden) { return updated; } - - var result = updated; + if (!requestedGroupHidden) { return updated; } - if (requestedGroupHidden) - { - result = result with { RequestedGroupBy = null, RequestedIsGroupDescending = false }; - } + var result = updated with { RequestedGroupBy = null, RequestedIsGroupDescending = false }; - if (liveGroupHidden) - { - result = result with - { - GroupBy = null, - IsGroupDescending = false, - GroupsCollapsedByDefault = false, - GroupCollapseOverrides = ImmutableHashSet.Create(StringComparer.Ordinal), - PerLogEvents = ResortAllLogs( - updated.PerLogEvents, - EffectiveSortContext(updated.OrderBy, updated.IsDescending, null, false, updated.PerLogEvents.Count, updated.TimelineVisible)) - }; - } - - return result; + return RetainServedView(state, result); bool IsHidden(ColumnName column) => !action.LoadedColumns.TryGetValue(column, out bool isVisible) || !isVisible; } + [ReducerMethod] + public static LogTableState ReduceLoadEvents(LogTableState state, LoadEventsAction action) + { + var table = state.EventTables.FirstOrDefault(candidate => action.LogData.Id == candidate.Id); + + if (table is null || table.IsCombined) { return state; } + + var finalized = SetComputerNameFromRawEvents(table, action.Events); + + if (finalized.IsLoading) { finalized = finalized with { IsLoading = false }; } + + return ReferenceEquals(finalized, table) ? + state : + state with { EventTables = state.EventTables.Replace(table, finalized) }; + } + + [ReducerMethod] + public static LogTableState ReduceLoadEventsPartial(LogTableState state, LoadEventsPartialAction action) + { + if (action.Events.Count == 0) { return state; } + + var tables = LatchComputerNameForLog(state.EventTables, action.LogData.Id, action.Events); + + return ReferenceEquals(tables, state.EventTables) ? state : state with { EventTables = tables }; + } + [ReducerMethod] public static LogTableState ReduceMoveTabToGroup(LogTableState state, MoveTabToGroupAction action) { @@ -352,7 +200,7 @@ public static LogTableState ReduceMoveTabToGroup(LogTableState state, MoveTabToG RemoveLogFromGroups(state.Groups, state.EventTables, action.TabId); var ungrouped = state with { Groups = ungroupedGroups, EventTables = ungroupedTables }; - return ResetGroupCollapseIfActiveChanged(RepairActiveTab(ungrouped, null), state.ActiveEventLogId); + return RetainServedView(state, ResetGroupCollapseIfActiveChanged(RepairActiveTab(ungrouped, null), state.ActiveEventLogId)); } var target = state.Groups.FirstOrDefault(group => group.Id == action.TargetGroupId); @@ -364,8 +212,8 @@ public static LogTableState ReduceMoveTabToGroup(LogTableState state, MoveTabToG var headerId = tables.FirstOrDefault(table => table.GroupId == action.TargetGroupId)?.Id; var updated = state with { Groups = updatedGroups, EventTables = tables }; - return ResetGroupCollapseIfActiveChanged( - RedirectActiveToGroupIfHidden(RepairActiveTab(updated, headerId)), state.ActiveEventLogId); + return RetainServedView(state, ResetGroupCollapseIfActiveChanged( + RedirectActiveToGroupIfHidden(RepairActiveTab(updated, headerId)), state.ActiveEventLogId)); } [ReducerMethod] @@ -385,7 +233,67 @@ public static LogTableState ReduceNewGroupFromTab(LogTableState state, NewGroupF var tables = prunedTables.Insert(childIndex, header); var updated = state with { Groups = prunedGroups.Add(group), EventTables = tables }; - return ResetGroupCollapseIfActiveChanged(RepairActiveTab(updated, header.Id), state.ActiveEventLogId); + return RetainServedView(state, ResetGroupCollapseIfActiveChanged(RepairActiveTab(updated, header.Id), state.ActiveEventLogId)); + } + + [ReducerMethod] + public static LogTableState ReduceOrderedViewDisplayFaulted(LogTableState state, OrderedViewDisplayFaultedAction action) + { + if (action.Identity is { } faulted && faulted != state.ViewIdentity) { return state; } + + return RetainServedView(state, state with + { + OrderedViewDisplayEnabled = false, + ActiveOrderedView = null, + FaultCause = Describe(action.Fault) + }); + } + + [ReducerMethod(typeof(OrderedViewDisplayRecoveredAction))] + public static LogTableState ReduceOrderedViewDisplayRecovered( + LogTableState state) => + state.OrderedViewDisplayEnabled ? + state : + state with + { + OrderedViewDisplayEnabled = true, + FaultCause = null + }; + + [ReducerMethod] + public static LogTableState ReduceOrderedViewUpdated(LogTableState state, OrderedViewUpdatedAction action) + { + LogTableState next = action.Update switch + { + OrderedViewReady view + when view.SnapshotVersion > state.LastPublishedSnapshotVersion + && view.Sequence >= state.HighestInvalidationSequence + && view.Identity == state.ViewIdentity => + AdoptEngineOrdering(state, + state with + { + ActiveOrderedView = view, + LastPublishedSnapshotVersion = view.SnapshotVersion, + + OrderedViewDisplayEnabled = true, + FaultCause = null + }), + OrderedViewCleared invalidation + when invalidation.SnapshotVersion > state.LastPublishedSnapshotVersion + && invalidation.Sequence >= state.HighestInvalidationSequence + && invalidation.Identity == state.ViewIdentity => + RetainServedView(state, state with + { + ActiveOrderedView = null, + LastPublishedSnapshotVersion = invalidation.SnapshotVersion, + + OrderedViewDisplayEnabled = true, + FaultCause = null + }), + _ => state + }; + + return next; } [ReducerMethod] @@ -396,7 +304,7 @@ public static LogTableState ReduceRemoveTabFromGroup(LogTableState state, Remove var (groups, tables) = RemoveLogFromGroups(state.Groups, state.EventTables, action.TabId); var updated = state with { Groups = groups, EventTables = tables }; - return ResetGroupCollapseIfActiveChanged(RepairActiveTab(updated, null), state.ActiveEventLogId); + return RetainServedView(state, ResetGroupCollapseIfActiveChanged(RepairActiveTab(updated, null), state.ActiveEventLogId)); } [ReducerMethod] @@ -443,9 +351,9 @@ public static LogTableState ReduceSetActiveTable(LogTableState state, SetActiveT if (activeTable is null) { return state; } - return ResetGroupCollapseIfActiveChanged( + return RetainServedView(state, ResetGroupCollapseIfActiveChanged( state with { ActiveEventLogId = activeTable.Id }, - state.ActiveEventLogId); + state.ActiveEventLogId)); } [ReducerMethod] @@ -473,12 +381,11 @@ public static LogTableState ReduceSetGroupBy(LogTableState state, SetGroupByActi { if (state.RequestedGroupBy == action.GroupBy) { return state; } - return state with + return RetainServedView(state, state with { RequestedGroupBy = action.GroupBy, - RequestedIsGroupDescending = false, - DisplayListVersion = state.DisplayListVersion + 1 - }; + RequestedIsGroupDescending = false + }); } [ReducerMethod] @@ -486,34 +393,24 @@ public static LogTableState ReduceSetHistogramVisible(LogTableState state, SetHi { if (state.TimelineVisible == action.IsVisible) { return state; } - // A single log with no explicit sort takes its default order from timeline visibility, so bump the display version - // only when that republish will actually follow (see FilteringEffects.HandleSetHistogramVisible). Bumping on a - // combined or explicitly sorted toggle would reject an in-flight republish carrying the pre-bump version with no replacement. - bool willResort = state.PerLogEvents.Count == 1 && - state.RequestedOrderBy is null && - state.RequestedGroupBy is null; - - return state with + return RetainServedView(state, state with { - TimelineVisible = action.IsVisible, - DisplayListVersion = willResort ? state.DisplayListVersion + 1 : state.DisplayListVersion - }; + TimelineVisible = action.IsVisible + }); } [ReducerMethod] public static LogTableState ReduceSetOrderBy(LogTableState state, SetOrderByAction action) => - state.RequestedOrderBy.Equals(action.OrderBy) ? + RetainServedView(state, state.RequestedOrderBy.Equals(action.OrderBy) ? state with { RequestedOrderBy = null, - RequestedIsDescending = true, - DisplayListVersion = state.DisplayListVersion + 1 + RequestedIsDescending = true } : state with { - RequestedOrderBy = action.OrderBy, - DisplayListVersion = state.DisplayListVersion + 1 - }; + RequestedOrderBy = action.OrderBy + }); [ReducerMethod] public static LogTableState ReduceSetTabGroupCollapsed(LogTableState state, SetTabGroupCollapsedAction action) @@ -524,9 +421,9 @@ public static LogTableState ReduceSetTabGroupCollapsed(LogTableState state, SetT var updated = state with { Groups = state.Groups.Replace(group, group with { IsCollapsed = action.Collapsed }) }; - return action.Collapsed - ? ResetGroupCollapseIfActiveChanged(RedirectActiveToGroupIfHidden(updated), state.ActiveEventLogId) - : updated; + return action.Collapsed ? + RetainServedView(state, ResetGroupCollapseIfActiveChanged(RedirectActiveToGroupIfHidden(updated), state.ActiveEventLogId)) : + updated; } [ReducerMethod] @@ -549,93 +446,105 @@ public static LogTableState ReduceToggleGroupSorting(LogTableState state) { if (state.RequestedGroupBy is null) { return state; } - return state with + return RetainServedView(state, state with { - RequestedIsGroupDescending = !state.RequestedIsGroupDescending, - DisplayListVersion = state.DisplayListVersion + 1 - }; + RequestedIsGroupDescending = !state.RequestedIsGroupDescending + }); } + [ReducerMethod(typeof(ToggleSortingAction))] + public static LogTableState ReduceToggleSorting(LogTableState state) => + RetainServedView(state, state with + { + RequestedIsDescending = !state.RequestedIsDescending + }); + [ReducerMethod] - public static LogTableState ReduceToggleLoading(LogTableState state, ToggleLoadingAction action) - { - var table = state.EventTables.FirstOrDefault(table => table.Id == action.LogId); + public static LogTableState ReduceViewRequestInvalidated(LogTableState state, ViewRequestInvalidatedAction action) => + action.Sequence <= state.HighestInvalidationSequence ? + state : + state with + { + HighestInvalidationSequence = action.Sequence, - if (table is null) { return state; } + RetainedOrderedViews = state.ServingOrderedView is { } served ? + state.RetainOnly(served) : + state.RetainedOrderedViews, + ActiveOrderedView = null + }; - return state with - { - EventTables = state.EventTables - .Remove(table) - .Add(table with { IsLoading = !table.IsLoading }) - }; - } + private static LogTableState AdoptEngineOrdering(LogTableState prior, LogTableState adopted) + { + if (!prior.HasPendingSortChange && prior.SortContext == prior.CommittedSortContext) { return adopted; } - [ReducerMethod(typeof(ToggleSortingAction))] - public static LogTableState ReduceToggleSorting(LogTableState state) => - state with + var flipped = adopted with { - RequestedIsDescending = !state.RequestedIsDescending, - DisplayListVersion = state.DisplayListVersion + 1 + OrderBy = adopted.RequestedOrderBy, + IsDescending = adopted.RequestedIsDescending, + GroupBy = adopted.RequestedGroupBy, + IsGroupDescending = adopted.RequestedIsGroupDescending, + CommittedEffectiveOrderBy = ResolvedEventOrdering.ResolveDefaultOrderBy( + adopted.RequestedOrderBy, + adopted.RequestedGroupBy, + adopted.DisplayedLogCount, + adopted.TimelineVisible) }; - [ReducerMethod] - public static LogTableState ReduceUpdateTable(LogTableState state, UpdateTableAction action) - { - var table = state.EventTables.FirstOrDefault(t => action.LogId == t.Id); + return prior.RequestedGroupBy != prior.GroupBy ? ResetGroupCollapse(flipped) : flipped; + } - if (table is null || table.IsCombined || action.View is null) { return state; } + private static string Describe(Exception fault) + { + const int MessageLimit = 200; - var view = action.View; + string message = fault.Message ?? string.Empty; - int postCount = state.PerLogEvents.ContainsKey(table.Id) ? - state.PerLogEvents.Count : - state.PerLogEvents.Count + 1; + if (message.Length <= MessageLimit) { return $"{fault.GetType().Name}: {message}"; } - var context = EffectiveSortContext( - state.OrderBy, state.IsDescending, state.GroupBy, state.IsGroupDescending, postCount, state.TimelineVisible); + int cut = MessageLimit; - // Always store the finalize view: built over the just-rebuilt raw store, its reader (and every locator it hands - // out) addresses the current generation, so a pre-finalize view would strand selection. - var perLog = SetLog(state.PerLogEvents, table.Id, view, context); - var perLogVersion = state.PerLogListVersion.SetItem(table.Id, action.Version); + if (char.IsHighSurrogate(message[cut - 1])) { cut--; } - perLog = ReconcileToLogCount(perLog, state); - var updatedTable = SetComputerNameIfFirstEvent(table, view) with { IsLoading = false }; - var counts = state.EventCountByLog.SetItem(table.Id, view.Count); + return $"{fault.GetType().Name}: {message[..cut]}..."; + } - return state with + private static string? FirstNonEmptyComputerName(IReadOnlyList events) + { + for (int index = 0; index < events.Count; index++) { - PerLogEvents = perLog, - PerLogListVersion = perLogVersion, - EventTables = state.EventTables.Replace(table, updatedTable), - EventCountByLog = counts - }; + string candidate = events[index].ComputerName; + + if (!string.IsNullOrEmpty(candidate)) { return candidate; } + } + + return null; } - private static SortContext EffectiveSortContext( - ColumnName? orderBy, - bool isDescending, - ColumnName? groupBy, - bool isGroupDescending, - int logCount, - bool timelineVisible) => - new(ResolvedEventOrdering.ResolveDefaultOrderBy(orderBy, groupBy, logCount, timelineVisible), - isDescending, - groupBy, - isGroupDescending); - - private static ImmutableDictionary ReconcileToLogCount( - ImmutableDictionary perLog, - LogTableState state) => - ResortAllLogs(perLog, - EffectiveSortContext( - state.OrderBy, - state.IsDescending, - state.GroupBy, - state.IsGroupDescending, - perLog.Count, - state.TimelineVisible)); + private static ImmutableList LatchComputerNameForLog( + ImmutableList tables, + EventLogId logId, + IReadOnlyList events) + { + int index = 0; + + foreach (var table in tables) + { + if (table.Id != logId) + { + index++; + + continue; + } + + if (table.IsCombined || !string.IsNullOrEmpty(table.ComputerName)) { return tables; } + + return FirstNonEmptyComputerName(events) is { } resolved ? + tables.SetItem(index, table with { ComputerName = resolved }) : + tables; + } + + return tables; + } private static LogTableState RedirectActiveToGroupIfHidden(LogTableState state) { @@ -716,45 +625,15 @@ private static LogTableState ResetGroupCollapseIfActiveChanged( EventLogId? previousActiveId) => updated.ActiveEventLogId == previousActiveId ? updated : ResetGroupCollapse(updated); - private static ImmutableDictionary ResortAllLogs( - ImmutableDictionary perLog, - SortContext context) - { - if (perLog.IsEmpty) { return perLog; } - - var builder = perLog.ToBuilder(); - - foreach (var (logId, view) in perLog) - { - if (!view.HasContext(context)) { builder[logId] = view.WithContext(context); } - } - - return builder.ToImmutable(); - } + private static LogTableState RetainServedView(LogTableState prior, LogTableState next) => + prior.ServingOrderedView is { } served && next.ServingOrderedView is null ? + next with { RetainedOrderedViews = next.RetainOnly(served) } : + next; - private static LogView SetComputerNameIfFirstEvent(LogView table, EventColumnView view) + private static LogView SetComputerNameFromRawEvents(LogView table, IReadOnlyList events) { - if (!string.IsNullOrEmpty(table.ComputerName) || view.Count == 0) { return table; } + if (!string.IsNullOrEmpty(table.ComputerName)) { return table; } - // The first displayed event's ComputerName may be empty (resolver miss); scan display order for the first - // non-empty one, matching the AoS reducer. - for (int i = 0; i < view.Count; i++) - { - var candidate = view.GetDetailLean(view.LocatorAt(i)); - - if (!string.IsNullOrEmpty(candidate.ComputerName)) - { - return table with { ComputerName = candidate.ComputerName }; - } - } - - return table; + return FirstNonEmptyComputerName(events) is { } resolved ? table with { ComputerName = resolved } : table; } - - private static ImmutableDictionary SetLog( - ImmutableDictionary perLog, - EventLogId logId, - EventColumnView view, - SortContext context) => - perLog.SetItem(logId, view.HasContext(context) ? view : view.WithContext(context)); } diff --git a/src/EventLogExpert.Runtime/LogTable/SetAllGroupsCollapsedAction.cs b/src/EventLogExpert.Runtime/LogTable/SetAllGroupsCollapsedAction.cs index e274ec4ad..9e373d578 100644 --- a/src/EventLogExpert.Runtime/LogTable/SetAllGroupsCollapsedAction.cs +++ b/src/EventLogExpert.Runtime/LogTable/SetAllGroupsCollapsedAction.cs @@ -3,5 +3,4 @@ namespace EventLogExpert.Runtime.LogTable; -// Public so LogTablePane can subscribe via SubscribeToAction, like the other LogTable actions. public sealed record SetAllGroupsCollapsedAction(bool Collapsed); diff --git a/src/EventLogExpert.Runtime/LogTable/ViewIdentity.cs b/src/EventLogExpert.Runtime/LogTable/ViewIdentity.cs new file mode 100644 index 000000000..202d49e34 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/ViewIdentity.cs @@ -0,0 +1,137 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Filtering.Evaluation; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.LogTable; + +internal sealed class ViewIdentity : IEquatable +{ + private readonly EventLogId? _activeLogId; + private readonly Filter _filter; + private readonly ColumnName? _groupBy; + private readonly int _hash; + private readonly bool _isDescending; + private readonly bool _isGroupDescending; + + private readonly bool _isMultiLogDisplay; + private readonly ColumnName? _orderBy; + + private readonly ImmutableArray _scope; + private readonly bool _timelineVisible; + + internal ViewIdentity( + EventLogId? activeLogId, + ImmutableArray scope, + ColumnName? orderBy, + bool isDescending, + ColumnName? groupBy, + bool isGroupDescending, + bool timelineVisible, + bool isMultiLogDisplay, + Filter filter) + { + _activeLogId = activeLogId; + _scope = scope; + _orderBy = orderBy; + _isDescending = isDescending; + _groupBy = groupBy; + _isGroupDescending = isGroupDescending; + _timelineVisible = timelineVisible; + _isMultiLogDisplay = isMultiLogDisplay; + _filter = filter; + _hash = ComputeHash(); + } + + internal EventLogId? ActiveLogId => _activeLogId; + + internal Filter Filter => _filter; + + internal bool IsMultiLogDisplay => _isMultiLogDisplay; + + internal ColumnName? RequestedGroupBy => _groupBy; + + internal bool RequestedIsDescending => _isDescending; + + internal bool RequestedIsGroupDescending => _isGroupDescending; + + internal ColumnName? RequestedOrderBy => _orderBy; + + internal ImmutableArray Scope => _scope; + + internal bool TimelineVisible => _timelineVisible; + + public static bool operator ==(ViewIdentity? left, ViewIdentity? right) => + left?.Equals(right) ?? right is null; + + public static bool operator !=(ViewIdentity? left, ViewIdentity? right) => !(left == right); + + public bool Equals(ViewIdentity? other) + { + if (ReferenceEquals(this, other)) { return true; } + + if (other is null || _hash != other._hash) { return false; } + + return _activeLogId == other._activeLogId && CoversSameViewAs(other); + } + + public override bool Equals(object? obj) => Equals(obj as ViewIdentity); + + public override int GetHashCode() => _hash; + + internal bool CoversSameViewAs(ViewIdentity other) + { + if (_orderBy != other._orderBy || + _isDescending != other._isDescending || + _groupBy != other._groupBy || + _isGroupDescending != other._isGroupDescending || + _timelineVisible != other._timelineVisible || + _isMultiLogDisplay != other._isMultiLogDisplay) + { + return false; + } + + if (_scope.Length != other._scope.Length) { return false; } + + for (int index = 0; index < _scope.Length; index++) + { + if (_scope[index] != other._scope[index]) { return false; } + } + + return !_filter.HasFilteringChangedFrom(other._filter); + } + + private int ComputeHash() + { + var hash = new HashCode(); + + hash.Add(_activeLogId); + hash.Add(_orderBy); + hash.Add(_isDescending); + hash.Add(_groupBy); + hash.Add(_isGroupDescending); + hash.Add(_timelineVisible); + hash.Add(_isMultiLogDisplay); + + foreach (EventLogId logId in _scope) { hash.Add(logId); } + + hash.Add(_filter.DateFilter); + + ImmutableArray snapshots = _filter.Snapshots; + + if (snapshots.IsDefault) + { + hash.Add(-1); + } + else + { + hash.Add(snapshots.Length); + + foreach (FilterSnapshot snapshot in snapshots) { hash.Add(snapshot); } + } + + return hash.ToHashCode(); + } +} diff --git a/src/EventLogExpert.Runtime/LogTable/ViewRequestInvalidatedAction.cs b/src/EventLogExpert.Runtime/LogTable/ViewRequestInvalidatedAction.cs new file mode 100644 index 000000000..3ca5ac860 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/ViewRequestInvalidatedAction.cs @@ -0,0 +1,6 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.LogTable; + +internal sealed record ViewRequestInvalidatedAction(long Sequence); diff --git a/src/EventLogExpert.Runtime/Menu/IMenuService.cs b/src/EventLogExpert.Runtime/Menu/IMenuService.cs deleted file mode 100644 index 55df70826..000000000 --- a/src/EventLogExpert.Runtime/Menu/IMenuService.cs +++ /dev/null @@ -1,46 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -namespace EventLogExpert.Runtime.Menu; - -/// -/// Coordinates a single active popup menu at a time. Hosts subscribe to and render -/// at (, ). -/// -public interface IMenuService -{ - /// Raised when a top-level menu reports the menubar should switch to the previous (-1) or next (+1) entry. - event Action? NavigateBarRequested; - - event Action? StateChanged; - - /// - /// False when an existing menu is being replaced and the original opener should be preserved (e.g., menubar - /// arrow-key navigation). - /// - bool ActiveCaptureOpener { get; } - - /// True to focus the first item, false for the last (e.g., ArrowUp open). - bool ActiveFocusFirst { get; } - - IReadOnlyList? ActiveItems { get; } - - /// Per-open id; use as a @key so re-opening produces a fresh component instance. - long ActiveMenuId { get; } - - double PositionX { get; } - - double PositionY { get; } - - void Close(); - - void NavigateBar(int direction); - - /// - /// Opens a menu at the given client-coordinate position, replacing any active menu. Pass - /// false for ArrowUp opens (focus lands on the last enabled item per WAI-ARIA menubar - /// pattern). Pass false when replacing an open menu so closing restores focus to the - /// original opener. - /// - void OpenAt(double x, double y, IReadOnlyList items, bool focusFirst = true, bool captureOpener = true); -} diff --git a/src/EventLogExpert.Runtime/Menu/MenuAnchorRect.cs b/src/EventLogExpert.Runtime/Menu/MenuAnchorRect.cs deleted file mode 100644 index bbfe5e7fc..000000000 --- a/src/EventLogExpert.Runtime/Menu/MenuAnchorRect.cs +++ /dev/null @@ -1,21 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -namespace EventLogExpert.Runtime.Menu; - -/// -/// Bounding rectangle returned by the getMenuElementRect JS interop helper. Used by menu trigger anchors -/// (top-level menu bar items, FilterPane chevron, and any future split-button or dropdown opener) to position the -/// popup at the bottom-left of the trigger element. -/// -/// -/// Promoted from per-component duplicates so JS interop deserialization stays consistent across all named -/// consumers (≥2 callers per the shared-types policy). -/// -public sealed record MenuAnchorRect( - double Left, - double Top, - double Right, - double Bottom, - double Width, - double Height); diff --git a/src/EventLogExpert.Runtime/Modal/IModalCoordinator.cs b/src/EventLogExpert.Runtime/Modal/IModalCoordinator.cs deleted file mode 100644 index 0c4941248..000000000 --- a/src/EventLogExpert.Runtime/Modal/IModalCoordinator.cs +++ /dev/null @@ -1,46 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -using EventLogExpert.Runtime.Alerts; -using Microsoft.AspNetCore.Components; -using System.Diagnostics.CodeAnalysis; - -namespace EventLogExpert.Runtime.Modal; - -/// Coordinates modal lifecycle and owns the inline-alert host registry that callers route through. -public interface IModalCoordinator -{ - event Action? StateChanged; - - ModalSession? ActiveSession { get; } - - void Complete(ModalId modalId, TResult? result); - - void ForceCloseActive(); - - /// Returns the active modal's scope, or if no modal is active. - ModalScope? GetActiveModalScope(); - - /// - /// Opens . If an active modal exists, asks it to close via the veto pipeline first; - /// if vetoed, returns a result with set to . - /// - Task> PushAsync(IDictionary? parameters = null) - where TModal : IComponent; - - /// Register a modal's close handler, scope, and optional inline-alert host. Stale ids are ignored. - void RegisterModal(ModalRegistration registration); - - /// - /// Asks the active modal to close. Coalesces concurrent calls; the first verdict wins. Critical-scoped modals - /// reject immediately, regardless of in-flight state. - /// If a close handler throws (e.g., from a force-close race), the - /// close is treated as accepted so coalesced awaiters resolve successfully. - /// - Task RequestCloseActiveAsync(ModalCloseReason reason); - - bool TryGetInlineAlertHost([NotNullWhen(true)] out IInlineAlertHost? host); - - void UnregisterModal(ModalId modalId); -} - diff --git a/src/EventLogExpert.Runtime/Modal/IModalService.cs b/src/EventLogExpert.Runtime/Modal/IModalService.cs deleted file mode 100644 index 466ed138d..000000000 --- a/src/EventLogExpert.Runtime/Modal/IModalService.cs +++ /dev/null @@ -1,31 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -using Microsoft.AspNetCore.Components; - -namespace EventLogExpert.Runtime.Modal; - -/// -/// Coordinates a single active modal at a time. Hosts subscribe to and render -/// with . -/// -public interface IModalService -{ - event Action? StateChanged; - - /// Per-show id; use as a @key so reopening produces a fresh component instance. - ModalId ActiveModalId { get; } - - IDictionary? ActiveModalParameters { get; } - - Type? ActiveModalType { get; } - - void CancelActive(); - - /// Complete the active modal's task. Stale ids (from replaced modals) are ignored. - void Complete(ModalId modalId, TResult? result); - - /// Open a modal. Any prior active modal is canceled (its task completes with default). - Task Show(IDictionary? parameters = null) - where TModal : IComponent; -} diff --git a/src/EventLogExpert.Runtime/Modal/ModalOpenResult.cs b/src/EventLogExpert.Runtime/Modal/ModalOpenResult.cs deleted file mode 100644 index 2a21378c1..000000000 --- a/src/EventLogExpert.Runtime/Modal/ModalOpenResult.cs +++ /dev/null @@ -1,10 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -namespace EventLogExpert.Runtime.Modal; - -/// -/// Result of . WasOpened distinguishes -/// user-completion-with-default from preempt-veto. -/// -public sealed record ModalOpenResult(TResult? Result, bool WasOpened); diff --git a/src/EventLogExpert.Runtime/Scenarios/Favorites/IScenarioFavoritesSource.cs b/src/EventLogExpert.Runtime/Scenarios/Favorites/IScenarioFavoritesSource.cs new file mode 100644 index 000000000..997d6de26 --- /dev/null +++ b/src/EventLogExpert.Runtime/Scenarios/Favorites/IScenarioFavoritesSource.cs @@ -0,0 +1,12 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Common.Sources; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.Scenarios.Favorites; + +public interface IScenarioFavoritesSource : IChangeNotifier +{ + ImmutableHashSet FavoriteScenarioIds { get; } +} diff --git a/src/EventLogExpert.Runtime/Scenarios/Favorites/ScenarioFavoritesSource.cs b/src/EventLogExpert.Runtime/Scenarios/Favorites/ScenarioFavoritesSource.cs new file mode 100644 index 000000000..bcc730011 --- /dev/null +++ b/src/EventLogExpert.Runtime/Scenarios/Favorites/ScenarioFavoritesSource.cs @@ -0,0 +1,20 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.Common.Sources; +using Fluxor; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.Scenarios.Favorites; + +internal sealed class ScenarioFavoritesSource(IState state, ITraceLogger logger) + : ObservableStateSourceBase>( + state, + logger, + static state => state.FavoriteScenarioIds, + static (next, current) => ReferenceEquals(next, current) || next.SetEquals(current)), + IScenarioFavoritesSource +{ + public ImmutableHashSet FavoriteScenarioIds => CurrentProjection; +} diff --git a/src/EventLogExpert.Runtime/StatusBar/IStatusBarSource.cs b/src/EventLogExpert.Runtime/StatusBar/IStatusBarSource.cs new file mode 100644 index 000000000..9f0dd0832 --- /dev/null +++ b/src/EventLogExpert.Runtime/StatusBar/IStatusBarSource.cs @@ -0,0 +1,11 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Common.Sources; + +namespace EventLogExpert.Runtime.StatusBar; + +public interface IStatusBarSource : IChangeNotifier +{ + StatusBarPresentation Current { get; } +} diff --git a/src/EventLogExpert.Runtime/StatusBar/StatusBarFormatter.cs b/src/EventLogExpert.Runtime/StatusBar/StatusBarFormatter.cs index a055142bf..2450915fb 100644 --- a/src/EventLogExpert.Runtime/StatusBar/StatusBarFormatter.cs +++ b/src/EventLogExpert.Runtime/StatusBar/StatusBarFormatter.cs @@ -5,19 +5,8 @@ namespace EventLogExpert.Runtime.StatusBar; -/// -/// Pure presentation helpers for the status bar: the active-source label, the total/shown/selected count phrase, -/// the filter-lens indicator tooltip, and the coarse screen-reader activity announcement. Kept free of Fluxor so the -/// UI component stays thin and the logic is unit-testable in isolation, mirroring EventTableColumnFormatter. -/// Count formatting uses the current culture (thousands separators); tests pin the culture. -/// public static class StatusBarFormatter { - /// - /// Tooltip for the filter-lens indicator that names the narrowing mechanism the breadcrumb/pane owns the detail - /// of: "Filter active", "N lenses", or "Filter + N lenses". Returns when nothing narrows (the - /// indicator is then hidden). - /// public static string? FilterIndicatorTooltip(bool persistentActive, int lensCount) { var lensText = lensCount switch @@ -36,32 +25,29 @@ public static class StatusBarFormatter }; } - /// - /// The coarse activity label announced to screen readers (via the single polite status region). It changes only - /// on state transitions, so per-tick loading/buffer counts - which render as silent visual siblings - never announce. - /// Priority puts a load error first: is only ever an error message or empty, so it - /// must surface even while another log is still loading; then buffer-full, then loading, then continuous updating. - /// public static string FormatActivityAnnouncement( bool isLoading, bool bufferFull, bool continuouslyUpdating, - string resolverStatus) + string resolverStatus, + DisplayIndicatorKind displayIndicator = DisplayIndicatorKind.None) { if (!string.IsNullOrEmpty(resolverStatus)) { return resolverStatus; } + if (displayIndicator == DisplayIndicatorKind.Fault) { return "These events could not be prepared"; } + if (bufferFull) { return "Buffer full"; } if (isLoading) { return "Loading"; } - return continuouslyUpdating ? "Continuously updating" : string.Empty; + return displayIndicator switch + { + DisplayIndicatorKind.EmptyPending => "Loading events", + DisplayIndicatorKind.ReorderPending => "Reordering events", + _ => continuouslyUpdating ? "Continuously updating" : string.Empty + }; } - /// - /// "1,234 events" normally, "200 of 1,234 shown" when the effective (base intersect lenses) filter narrows the - /// view, plus " {middot} 3 selected" only for a multi-select ( >= 2 - a single - /// click already selects one row, so surfacing "1 selected" would be near-omnipresent noise). - /// public static string FormatCounts(int total, int shown, bool isFiltered, int selectedCount) { var head = isFiltered ? $"{shown:N0} of {total:N0} shown" : $"{total:N0} events"; @@ -69,12 +55,6 @@ public static string FormatCounts(int total, int shown, bool isFiltered, int sel return selectedCount >= 2 ? $"{head} \u00b7 {selectedCount:N0} selected" : head; } - /// - /// The active tab's source label: a channel's , an opened file's base name, a named - /// group's name, "All logs (N)" for the implicit all-logs view (N = open per-log tabs), or "Combined (N logs)" for an - /// unnamed group. Remote/computer origin is deliberately omitted ( is the event's - /// origin machine, not the connection source). Returns "No log open" when there is no active view. - /// public static string FormatSource( LogView? active, IReadOnlyList eventTables, diff --git a/src/EventLogExpert.Runtime/StatusBar/StatusBarPresentation.cs b/src/EventLogExpert.Runtime/StatusBar/StatusBarPresentation.cs new file mode 100644 index 000000000..a70d711a3 --- /dev/null +++ b/src/EventLogExpert.Runtime/StatusBar/StatusBarPresentation.cs @@ -0,0 +1,39 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Runtime.LogTable; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.StatusBar; + +public readonly record struct LoadingProgress(int Loaded, int Failed); + +public sealed record StatusBarPresentation +{ + public bool ContinuouslyUpdate { get; init; } + + public int NewEventBufferCount { get; init; } + + public bool NewEventBufferIsFull { get; init; } + + public int SelectionCount { get; init; } + + public bool IsPersistentFilterActive { get; init; } + + public int RawEventTotal { get; init; } + + public ImmutableDictionary RawEventCountsByLog { get; init; } = + ImmutableDictionary.Empty; + + public ImmutableDictionary LoadingActivities { get; init; } = + ImmutableDictionary.Empty; + + public string ResolverStatus { get; init; } = string.Empty; + + public ImmutableList Tabs { get; init; } = []; + + public ImmutableList Groups { get; init; } = []; + + public EventLogId? ActiveTabId { get; init; } +} diff --git a/src/EventLogExpert.Runtime/StatusBar/StatusBarSource.cs b/src/EventLogExpert.Runtime/StatusBar/StatusBarSource.cs new file mode 100644 index 000000000..f272f6ab2 --- /dev/null +++ b/src/EventLogExpert.Runtime/StatusBar/StatusBarSource.cs @@ -0,0 +1,252 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.EventLog; +using EventLogExpert.Runtime.FilterPane; +using EventLogExpert.Runtime.LogTable; +using Fluxor; +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.StatusBar; + +internal sealed class StatusBarSource : IStatusBarSource, IDisposable +{ + private readonly IState _eventLogState; + private readonly IState _filterPaneState; + private readonly Lock _gate = new(); + private readonly IState _logTableState; + private readonly ITraceLogger _logger; + private readonly IState _rawCountState; + private readonly IState _statusBarState; + + private bool _disposed; + private (bool ContinuouslyUpdate, int BufferCount, bool BufferFull, int SelectionCount) _eventLogFacets; + private (ImmutableList Tabs, ImmutableList Groups, EventLogId? ActiveTabId) _logTableFacets; + private bool _persistentFilterActive; + private (int Total, ImmutableDictionary ByLog) _rawCountFacets; + private (ImmutableDictionary Loading, string Resolver) _statusFacets; + + public StatusBarSource( + IState eventLogState, + IState filterPaneState, + IState rawCountState, + IState statusBarState, + IState logTableState, + [FromKeyedServices(LogCategories.EventLog)] ITraceLogger logger) + { + ArgumentNullException.ThrowIfNull(eventLogState); + ArgumentNullException.ThrowIfNull(filterPaneState); + ArgumentNullException.ThrowIfNull(rawCountState); + ArgumentNullException.ThrowIfNull(statusBarState); + ArgumentNullException.ThrowIfNull(logTableState); + ArgumentNullException.ThrowIfNull(logger); + + _eventLogState = eventLogState; + _filterPaneState = filterPaneState; + _rawCountState = rawCountState; + _statusBarState = statusBarState; + _logTableState = logTableState; + _logger = logger; + + SeedFacets(); + + _eventLogState.StateChanged += OnEventLogChanged; + _filterPaneState.StateChanged += OnFilterPaneChanged; + _rawCountState.StateChanged += OnRawCountChanged; + _statusBarState.StateChanged += OnStatusBarChanged; + _logTableState.StateChanged += OnLogTableChanged; + + lock (_gate) { SeedFacets(); } + } + + public event Action? Changed; + + public StatusBarPresentation Current => Project( + _eventLogState.Value, + _filterPaneState.Value, + _rawCountState.Value, + _statusBarState.Value, + _logTableState.Value); + + public void Dispose() + { + lock (_gate) + { + if (_disposed) { return; } + + _disposed = true; + } + + _eventLogState.StateChanged -= OnEventLogChanged; + _filterPaneState.StateChanged -= OnFilterPaneChanged; + _rawCountState.StateChanged -= OnRawCountChanged; + _statusBarState.StateChanged -= OnStatusBarChanged; + _logTableState.StateChanged -= OnLogTableChanged; + } + + private static bool DictEquals( + ImmutableDictionary left, + ImmutableDictionary right) + where TKey : notnull + { + if (ReferenceEquals(left, right)) { return true; } + + if (left.Count != right.Count) { return false; } + + foreach (var pair in left) + { + if (!right.TryGetValue(pair.Key, out var value) || + !EqualityComparer.Default.Equals(value, pair.Value)) + { + return false; + } + } + + return true; + } + + private static StatusBarPresentation Project( + EventLogState eventLog, + FilterPaneState filterPane, + RawEventCountState rawCount, + StatusBarState statusBar, + LogTableState logTable) => + new() + { + ContinuouslyUpdate = eventLog.ContinuouslyUpdate, + NewEventBufferCount = eventLog.NewEventBuffer.Count, + NewEventBufferIsFull = eventLog.NewEventBufferIsFull, + SelectionCount = eventLog.Selection.Count, + IsPersistentFilterActive = filterPane.IsFilteringEnabled, + RawEventTotal = rawCount.Total, + RawEventCountsByLog = rawCount.ByLog, + LoadingActivities = statusBar.EventsLoading.ToImmutableDictionary( + pair => pair.Key, + pair => new LoadingProgress(pair.Value.Item1, pair.Value.Item2)), + ResolverStatus = statusBar.ResolverStatus, + Tabs = logTable.EventTables, + Groups = logTable.Groups, + ActiveTabId = logTable.ActiveEventLogId + }; + + private static (bool, int, bool, int) ProjectEventLog(EventLogState state) => + (state.ContinuouslyUpdate, state.NewEventBuffer.Count, state.NewEventBufferIsFull, state.Selection.Count); + + private static (ImmutableList, ImmutableList, EventLogId?) ProjectLogTable( + LogTableState state) => + (state.EventTables, state.Groups, state.ActiveEventLogId); + + private static (int, ImmutableDictionary) ProjectRawCount(RawEventCountState state) => + (state.Total, state.ByLog); + + private static (ImmutableDictionary, string) ProjectStatus(StatusBarState state) => + (state.EventsLoading, state.ResolverStatus); + + private void OnEventLogChanged(object? sender, EventArgs e) + { + var next = ProjectEventLog(_eventLogState.Value); + + lock (_gate) + { + if (_disposed || next == _eventLogFacets) { return; } + + _eventLogFacets = next; + } + + RaiseChanged(); + } + + private void OnFilterPaneChanged(object? sender, EventArgs e) + { + var next = _filterPaneState.Value.IsFilteringEnabled; + + lock (_gate) + { + if (_disposed || next == _persistentFilterActive) { return; } + + _persistentFilterActive = next; + } + + RaiseChanged(); + } + + private void OnLogTableChanged(object? sender, EventArgs e) + { + var next = ProjectLogTable(_logTableState.Value); + + lock (_gate) + { + if (_disposed || + (ReferenceEquals(next.Item1, _logTableFacets.Tabs) && + ReferenceEquals(next.Item2, _logTableFacets.Groups) && + next.Item3 == _logTableFacets.ActiveTabId)) + { + return; + } + + _logTableFacets = next; + } + + RaiseChanged(); + } + + private void OnRawCountChanged(object? sender, EventArgs e) + { + var next = ProjectRawCount(_rawCountState.Value); + + lock (_gate) + { + if (_disposed || (next.Item1 == _rawCountFacets.Total && DictEquals(next.Item2, _rawCountFacets.ByLog))) + { + return; + } + + _rawCountFacets = next; + } + + RaiseChanged(); + } + + private void OnStatusBarChanged(object? sender, EventArgs e) + { + var next = ProjectStatus(_statusBarState.Value); + + lock (_gate) + { + if (_disposed || + (next.Item2 == _statusFacets.Resolver && DictEquals(next.Item1, _statusFacets.Loading))) + { + return; + } + + _statusFacets = next; + } + + RaiseChanged(); + } + + private void RaiseChanged() + { + var handlers = Changed; + + if (handlers is null) { return; } + + foreach (var handler in handlers.GetInvocationList().Cast()) + { + try { handler(); } + catch (Exception fault) { _logger.Trace($"{nameof(StatusBarSource)}: a subscriber threw and was isolated: {fault}"); } + } + } + + private void SeedFacets() + { + _eventLogFacets = ProjectEventLog(_eventLogState.Value); + _persistentFilterActive = _filterPaneState.Value.IsFilteringEnabled; + _rawCountFacets = ProjectRawCount(_rawCountState.Value); + _statusFacets = ProjectStatus(_statusBarState.Value); + _logTableFacets = ProjectLogTable(_logTableState.Value); + } +} diff --git a/src/EventLogExpert.Runtime/Alerts/AlertDialogService.cs b/src/EventLogExpert.UI/Alerts/AlertDialogService.cs similarity index 98% rename from src/EventLogExpert.Runtime/Alerts/AlertDialogService.cs rename to src/EventLogExpert.UI/Alerts/AlertDialogService.cs index d6903ac86..a8fed248b 100644 --- a/src/EventLogExpert.Runtime/Alerts/AlertDialogService.cs +++ b/src/EventLogExpert.UI/Alerts/AlertDialogService.cs @@ -1,11 +1,12 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. +using EventLogExpert.Runtime.Alerts; using EventLogExpert.Runtime.Banner; using EventLogExpert.Runtime.Common.Threading; -using EventLogExpert.Runtime.Modal; +using EventLogExpert.UI.Modal; -namespace EventLogExpert.Runtime.Alerts; +namespace EventLogExpert.UI.Alerts; public sealed class AlertDialogService( IModalCoordinator modalCoordinator, diff --git a/src/EventLogExpert.UI/Alerts/IInlineAlertHost.cs b/src/EventLogExpert.UI/Alerts/IInlineAlertHost.cs new file mode 100644 index 000000000..e4ddff5ab --- /dev/null +++ b/src/EventLogExpert.UI/Alerts/IInlineAlertHost.cs @@ -0,0 +1,9 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.UI.Alerts; + +public interface IInlineAlertHost +{ + Task ShowInlineAlertAsync(InlineAlertRequest request, CancellationToken cancellationToken); +} diff --git a/src/EventLogExpert.UI/Alerts/InlineAlertRequest.cs b/src/EventLogExpert.UI/Alerts/InlineAlertRequest.cs new file mode 100644 index 000000000..3246128e4 --- /dev/null +++ b/src/EventLogExpert.UI/Alerts/InlineAlertRequest.cs @@ -0,0 +1,17 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.UI.Alerts; + +public sealed record InlineAlertRequest( + string Title, + string Message, + string? AcceptLabel, + string CancelLabel, + bool IsPrompt, + string? PromptInitialValue) +{ + public Func? Validate { get; init; } + + public string? SecondaryActionLabel { get; init; } +} diff --git a/src/EventLogExpert.UI/Alerts/InlineAlertResult.cs b/src/EventLogExpert.UI/Alerts/InlineAlertResult.cs new file mode 100644 index 000000000..862ead1b4 --- /dev/null +++ b/src/EventLogExpert.UI/Alerts/InlineAlertResult.cs @@ -0,0 +1,9 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.UI.Alerts; + +public sealed record InlineAlertResult(bool Accepted, string? PromptValue) +{ + public bool SecondaryChosen { get; init; } +} diff --git a/src/EventLogExpert.UI/Banner/BannerCycleStateService.cs b/src/EventLogExpert.UI/Banner/BannerCycleStateService.cs index 4e6b5d77d..1a2765feb 100644 --- a/src/EventLogExpert.UI/Banner/BannerCycleStateService.cs +++ b/src/EventLogExpert.UI/Banner/BannerCycleStateService.cs @@ -3,8 +3,8 @@ using EventLogExpert.Runtime.Banner; using EventLogExpert.Runtime.Database; -using EventLogExpert.Runtime.Modal; using EventLogExpert.UI.DatabaseTools; +using EventLogExpert.UI.Modal; namespace EventLogExpert.UI.Banner; @@ -24,18 +24,9 @@ public sealed class BannerCycleStateService : IBannerCycleStateService, IDisposa private IReadOnlyList _items = []; private bool _modalContentDisplayed; private BannerCycleItem? _pendingOverrideItem; - // _priorityStolenSelection holds the most recent item that won selection via priorityOverride - // (i.e., a newly-arrived higher-priority item). Cleared (a) when its source identity is no longer - // in the current source fingerprint, or (b) when the user explicitly navigates via MoveNext/MovePrev - // (user-acknowledgment). While non-null, the _userPreferredItem restore path is gated off so a - // stale low-priority preference cannot bounce back over the priority-stolen selection on the next - // unrelated rebuild. - private BannerCycleItem? _priorityStolenSelection; private HashSet<(BannerView View, BannerId? EntryId)> _priorSourceFingerprint = []; + private BannerCycleItem? _priorityStolenSelection; private BannerCycleItem? _selectedItem; - // _userPreferredItem captures the last item the user explicitly selected via MoveNext/MovePrev. - // It survives temporary source-filter events (e.g., the DatabaseToolsModal suppressing the - // Attention banner) so the user's preferred banner is restored when the filter lifts. private BannerCycleItem? _userPreferredItem; public BannerCycleStateService( @@ -217,7 +208,6 @@ private static bool ItemMatches(BannerCycleItem selected, BannerCycleItem candid return selected.EntryId == candidate.EntryId; } - // BannerView enum is ordered for display, not priority - use this rank for comparisons. private static int PriorityRank(BannerView view) => view switch { BannerView.Critical => 6, @@ -257,7 +247,6 @@ private void RebuildAndReselectLocked() bool attentionSuppressed = _modalCoordinator.ActiveSession?.ComponentType == typeof(DatabaseToolsModal); - // Capture each facet snapshot once - the same data drives BuildCycle AND the fingerprint. var critical = _critical.CurrentCritical; var errors = _errors.ErrorBanners; var attentionEntries = _attention.AttentionEntries; @@ -276,8 +265,6 @@ private void RebuildAndReselectLocked() exportProgress, infos); - // Fingerprint is built from the UNFILTERED source - otherwise modal close would re-introduce - // Attention and wrongly trigger priorityOverride on every reentry. var currentSourceFingerprint = ComputeSourceFingerprintFromSnapshot( critical, errors, @@ -291,7 +278,6 @@ private void RebuildAndReselectLocked() _priorSourceFingerprint, _selectedItem); - // Advance fingerprint before any early-return so the next rebuild compares against current state. _priorSourceFingerprint = currentSourceFingerprint; if (_userPreferredItem is not null @@ -318,7 +304,6 @@ private void RebuildAndReselectLocked() return; } - // Priority order: explicit override > priority watermark > user-preferred (gated) > current > clamp. if (TryApplySelection(_pendingOverrideItem, items)) { _pendingOverrideItem = null; return; } if (TryApplySelection(priorityOverride, items)) @@ -327,9 +312,6 @@ private void RebuildAndReselectLocked() return; } - // Gate user-preferred restore against a still-active priority-stolen item only. This does NOT - // block restore when a higher-priority item that COEXISTED with the user's preference is - // present - only blocks against a newly-arrived steal that is still in the cycle. if (_priorityStolenSelection is null && TryApplySelection(_userPreferredItem, items)) { diff --git a/src/EventLogExpert.Runtime/Banner/BannerViewSelector.cs b/src/EventLogExpert.UI/Banner/BannerViewSelector.cs similarity index 96% rename from src/EventLogExpert.Runtime/Banner/BannerViewSelector.cs rename to src/EventLogExpert.UI/Banner/BannerViewSelector.cs index ddb177baa..0078e0d4b 100644 --- a/src/EventLogExpert.Runtime/Banner/BannerViewSelector.cs +++ b/src/EventLogExpert.UI/Banner/BannerViewSelector.cs @@ -1,9 +1,10 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. +using EventLogExpert.Runtime.Banner; using EventLogExpert.Runtime.Database; -namespace EventLogExpert.Runtime.Banner; +namespace EventLogExpert.UI.Banner; public enum BannerView { diff --git a/src/EventLogExpert.UI/Banner/CriticalBanner.razor.cs b/src/EventLogExpert.UI/Banner/CriticalBanner.razor.cs index 86514d79f..79eb67dd7 100644 --- a/src/EventLogExpert.UI/Banner/CriticalBanner.razor.cs +++ b/src/EventLogExpert.UI/Banner/CriticalBanner.razor.cs @@ -13,6 +13,8 @@ namespace EventLogExpert.UI.Banner; public sealed partial class CriticalBanner : ComponentBase, IDisposable { + private static readonly TimeSpan s_copiedFeedbackDuration = TimeSpan.FromSeconds(2); + private CancellationTokenSource? _copiedFeedbackCts; private string? _recoveryFailureMessage; private Button? _reloadButton; @@ -65,7 +67,7 @@ private async Task OnCopyDetailsClickedAsync(Exception ex) try { - await Task.Delay(TimeSpan.FromSeconds(2), cts.Token); + await Task.Delay(s_copiedFeedbackDuration, cts.Token); if (ReferenceEquals(_copiedFeedbackCts, cts)) { diff --git a/src/EventLogExpert.UI/Banner/IBannerCycleStateService.cs b/src/EventLogExpert.UI/Banner/IBannerCycleStateService.cs index 1f5ff4bad..1490c2d86 100644 --- a/src/EventLogExpert.UI/Banner/IBannerCycleStateService.cs +++ b/src/EventLogExpert.UI/Banner/IBannerCycleStateService.cs @@ -1,8 +1,6 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. -using EventLogExpert.Runtime.Banner; - namespace EventLogExpert.UI.Banner; public interface IBannerCycleStateService diff --git a/src/EventLogExpert.UI/Common/AppStateComponentBase.cs b/src/EventLogExpert.UI/Common/AppStateComponentBase.cs new file mode 100644 index 000000000..e9b28ae71 --- /dev/null +++ b/src/EventLogExpert.UI/Common/AppStateComponentBase.cs @@ -0,0 +1,77 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Common.Sources; +using Microsoft.AspNetCore.Components; + +namespace EventLogExpert.UI.Common; + +public abstract class AppStateComponentBase : ComponentBase, IAsyncDisposable +{ + private readonly List _subscriptions = []; + + private bool _disposed; + + protected bool IsDisposed => _disposed; + + public async ValueTask DisposeAsync() + { + await DisposeAsyncCore(disposing: true); + GC.SuppressFinalize(this); + } + + protected virtual ValueTask DisposeAsyncCore(bool disposing) + { + if (!disposing || _disposed) { return ValueTask.CompletedTask; } + + _disposed = true; + + foreach (var subscription in _subscriptions) { subscription.Dispose(); } + + _subscriptions.Clear(); + + return ValueTask.CompletedTask; + } + + protected void ObserveSource(Action subscribe, Action unsubscribe) => + _subscriptions.Add(new SourceSubscription(subscribe, unsubscribe, () => InvokeAsync(StateHasChanged))); + + protected void ObserveSource(Action subscribe, Action unsubscribe, Func onChangedAsync) => + _subscriptions.Add(new SourceSubscription(subscribe, unsubscribe, () => InvokeAsync(onChangedAsync))); + + protected void ObserveSource(IChangeNotifier source) + { + ArgumentNullException.ThrowIfNull(source); + + ObserveSource(handler => source.Changed += handler, handler => source.Changed -= handler); + } + + protected void ObserveSource(IChangeNotifier source, Func onChangedAsync) + { + ArgumentNullException.ThrowIfNull(source); + + ObserveSource(handler => source.Changed += handler, handler => source.Changed -= handler, onChangedAsync); + } + + protected void RequestGuardedRender(Action render) + { + if (_disposed) { return; } + + _ = DispatchGuardedRenderAsync(render); + } + + private async Task DispatchGuardedRenderAsync(Action render) + { + try + { + await InvokeAsync(() => + { + if (_disposed) { return; } + + render(); + }); + } + catch (ObjectDisposedException) { /* Renderer torn down between the guard and the dispatch; nothing to render. */ } + catch (OperationCanceledException) { /* Circuit shutting down; nothing to render. */ } + } +} diff --git a/src/EventLogExpert.UI/Common/PresentationViewComponentBase.cs b/src/EventLogExpert.UI/Common/PresentationViewComponentBase.cs new file mode 100644 index 000000000..6c41c4ef1 --- /dev/null +++ b/src/EventLogExpert.UI/Common/PresentationViewComponentBase.cs @@ -0,0 +1,64 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.LogTable; +using Microsoft.AspNetCore.Components; + +namespace EventLogExpert.UI.Common; + +public abstract class PresentationViewComponentBase : AppStateComponentBase +{ + private bool _disposed; + + private int _hasPendingRenderDispatch; + + protected OrderedViewPresentation Presentation { get; private set; } = null!; + + [Inject] + protected IOrderedViewSource ViewSource { get; init; } = null!; + + protected override async ValueTask DisposeAsyncCore(bool disposing) + { + if (disposing) + { + _disposed = true; + ViewSource.Updated -= OnViewUpdated; + } + + await base.DisposeAsyncCore(disposing); + } + + protected override void OnInitialized() + { + ViewSource.Updated += OnViewUpdated; + Presentation = ViewSource.Current; + + base.OnInitialized(); + } + + protected virtual void OnPresentationChanged() { } + + private void AdoptLatestPresentation() + { + Volatile.Write(ref _hasPendingRenderDispatch, 0); + Presentation = ViewSource.Current; + OnPresentationChanged(); + StateHasChanged(); + } + + private async Task DispatchRenderAsync() + { + if (_disposed) { return; } + + try { await InvokeAsync(AdoptLatestPresentation); } + catch (ObjectDisposedException) { } + catch (OperationCanceledException) { } + } + + private void OnViewUpdated(OrderedViewPresentation presentation) + { + if (Interlocked.Exchange(ref _hasPendingRenderDispatch, 1) != 0) { return; } + + _ = DispatchRenderAsync(); + } +} diff --git a/src/EventLogExpert.UI/Common/SourceSubscription.cs b/src/EventLogExpert.UI/Common/SourceSubscription.cs new file mode 100644 index 000000000..29e8a1018 --- /dev/null +++ b/src/EventLogExpert.UI/Common/SourceSubscription.cs @@ -0,0 +1,48 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.UI.Common; + +public sealed class SourceSubscription : IDisposable +{ + private readonly Func _render; + private readonly Action _unsubscribe; + + private volatile bool _disposed; + + public SourceSubscription(Action subscribe, Action unsubscribe, Func render) + { + ArgumentNullException.ThrowIfNull(subscribe); + ArgumentNullException.ThrowIfNull(unsubscribe); + ArgumentNullException.ThrowIfNull(render); + + _render = render; + _unsubscribe = () => unsubscribe(OnChanged); + subscribe(OnChanged); + } + + public void Dispose() + { + if (_disposed) { return; } + + _disposed = true; + _unsubscribe(); + } + + // that lands during teardown (the source is a singleton that outlives this component). + private void OnChanged() + { + if (_disposed) { return; } + + _ = RenderAsync(); + } + + private async Task RenderAsync() + { + if (_disposed) { return; } + + try { await _render(); } + catch (ObjectDisposedException) { } + catch (OperationCanceledException) { } + } +} diff --git a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor index 3a6af3786..e8a1f4e4f 100644 --- a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor +++ b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor @@ -1,4 +1,4 @@ -@inherits FluxorComponent +@inherits AppStateComponentBase
@@ -30,7 +30,7 @@ } } - @if (FilterApplied.Value) + @if (FilterAppliedSource.IsFilteringEnabled) {
diff --git a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs index 03955ced5..f0c508572 100644 --- a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs +++ b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs @@ -16,15 +16,13 @@ using EventLogExpert.UI.Focus; using EventLogExpert.UI.Inputs; using EventLogExpert.UI.Modal; -using Fluxor; -using Fluxor.Blazor.Web.Components; using Microsoft.AspNetCore.Components; using System.Collections.Frozen; using System.Collections.Immutable; namespace EventLogExpert.UI.Dashboard; -public sealed partial class EmptyStateDashboard : FluxorComponent +public sealed partial class EmptyStateDashboard : AppStateComponentBase { private const string ElevationReasonId = "empty-dashboard-elevation-reason"; @@ -76,14 +74,12 @@ public sealed partial class EmptyStateDashboard : FluxorComponent [Inject] private IScenarioFavoriteCommands FavoriteCommands { get; init; } = null!; - [Inject] private IStateSelection> Favorites { get; init; } = null!; + [Inject] private IScenarioFavoritesSource FavoritesSource { get; init; } = null!; - [Inject] private IStateSelection FilterApplied { get; init; } = null!; + [Inject] private IFilterAppliedSource FilterAppliedSource { get; init; } = null!; [Inject] private IFilterPaneCommands FilterCommands { get; init; } = null!; - [Inject] private IState ScenarioFavorites { get; init; } = null!; - [Inject] private IScenarioLaunchService ScenarioLaunch { get; init; } = null!; [Inject] private IScenarioQueryService ScenarioQuery { get; init; } = null!; @@ -121,10 +117,9 @@ public sealed partial class EmptyStateDashboard : FluxorComponent protected override async ValueTask DisposeAsyncCore(bool disposing) { - if (disposing) + if (disposing && !_disposed) { _disposed = true; - Favorites.SelectedValueChanged -= OnFavoritesChanged; await _lifetimeCts.CancelAsync(); _lifetimeCts.Dispose(); } @@ -160,9 +155,12 @@ protected override async Task OnAfterRenderAsync(bool firstRender) protected override void OnInitialized() { - FilterApplied.Select(state => state.AppliedFilter.IsFilteringEnabled); - Favorites.Select(state => state.FavoriteScenarioIds); - Favorites.SelectedValueChanged += OnFavoritesChanged; + ObserveSource(FilterAppliedSource); + ObserveSource( + handler => FavoritesSource.Changed += handler, + handler => FavoritesSource.Changed -= handler, + OnFavoritesChangedAsync); + FavoriteCommands.Load(); base.OnInitialized(); } @@ -267,8 +265,6 @@ private static bool HasReactiveFolderFallback(ScenarioLaunchResult result) => or ChannelLaunchOutcome.NotPresent or ChannelLaunchOutcome.Failed); - // Analytic/Debug channels never have their access evaluated (NotEvaluated), so they must not be - // treated as blocked; only a genuine RequiresElevation or a read-failure Unknown disables launch. private bool AccessAllowsLaunch(string channel) => _readinessByChannel.GetValueOrDefault( channel, @@ -277,8 +273,6 @@ private bool AccessAllowsLaunch(string channel) => private void CancelFolderScan() { - // No-op once the scan has committed to opening or a cancel is already in flight. The null-field guard plus the - // null-before-Dispose ordering in the finally means Cancel() is never called on a disposed source. if (_openingLogs || _cancelRequested || _folderLaunchCts is null) { return; } _cancelRequested = true; @@ -311,8 +305,6 @@ private Task EnableChannelAsync(string channel) => return; } - // The committed change is authoritative; re-run the full readiness fetch (never a single-channel probe, - // which would make LivePresence treat one channel as the complete set) so the pill reflects real state. await RefreshReadinessAsync(); if (!_readinessByChannel.TryGetValue(channel, out var refreshed) @@ -340,7 +332,7 @@ private IReadOnlyList GetOptionalChannelReadiness(ScenarioDefi scenario.OptionalChannels.IsDefaultOrEmpty ? [] : ReadinessFor(scenario.OptionalChannels); private bool IsFavored(ScenarioDefinition scenario) => - ScenarioFavorites.Value.FavoriteScenarioIds.Contains(scenario.Id); + FavoritesSource.FavoriteScenarioIds.Contains(scenario.Id); private bool IsLivePresent(ScenarioDefinition scenario) => !_livePresence.Known || scenario.Channels.All(_livePresence.Present.Contains); @@ -391,9 +383,6 @@ private async Task LaunchScenarioFromFolderCoreAsync(ScenarioDefinition scenario if (_scanningScenario is not null) { - // Hide the scan chip before any result dialog. The banner-retry path does not run in the dashboard's - // event loop, so it will not auto-render; the explicit render is required there. Clear the scan-end - // focus intent too, so an Opening-set request cannot steal focus from a result modal opened below. _scanningScenario = null; _openingLogs = false; _cancelRequested = false; @@ -406,34 +395,26 @@ private async Task LaunchScenarioFromFolderCoreAsync(ScenarioDefinition scenario if (DescribeFolderLaunch(scenario, result) is not { } message) { - // Cancelled: no dialog takes focus and the Cancel chip that had focus is gone, so restore it. if (scanStarted) { RestoreFocusAfterScan(); } return; } - // A launch that opens logs is self-evident (the workspace changes) and only needs the screen-reader detail; - // the outcomes that leave the dashboard unchanged need a visible dialog so a sighted user sees them. switch (result.Outcome) { case ScenarioFolderOutcome.Completed: Announcer.Announce(message); break; case ScenarioFolderOutcome.Error: - // ShowErrorAlert surfaces a banner, which does not capture focus; restore it so a scan that showed the - // Cancel chip does not strand keyboard focus on . if (scanStarted) { RestoreFocusAfterScan(); } await AlertDialogService.ShowErrorAlert("Open from folder", message); break; default: - // ShowAlert opens a focus-capturing modal, so it owns focus; issuing a restore here would fight it. await AlertDialogService.ShowAlert("Open from folder", message, "OK"); break; } - // Runs on the UI dispatcher via SafeInvokeAsync and must never throw out of onPhase: it executes outside the - // service's cancellation catch, so an escaping teardown exception would surface a normal launch as a fault. async Task OnFolderScanPhaseAsync(ScenarioFolderPhase phase) => await SafeInvokeAsync(() => { @@ -460,19 +441,18 @@ await SafeInvokeAsync(() => }); } - private async void OnFavoritesChanged(object? _, ImmutableHashSet __) + private Task OnFavoritesChangedAsync() { - try - { - await InvokeAsync(() => - { - RebuildCategories(); - ReconcileActiveTab(); - StateHasChanged(); - }); - } - catch (ObjectDisposedException) { } - catch (OperationCanceledException) { } + if (_disposed) { return Task.CompletedTask; } + + RebuildCategories(); + ReconcileActiveTab(); + + if (_disposed) { return Task.CompletedTask; } + + StateHasChanged(); + + return Task.CompletedTask; } private Task OpenApplicationAndSystemAsync() => @@ -589,10 +569,6 @@ private async Task RunGuardedAsync(Func action) try { - // Render immediately (inside the try, so the finally still clears _isBusy if this ever threw) so the - // busy-gated controls disable right away. On a normal button click Blazor auto-renders at the first await - // inside the action, but the banner-retry path runs outside the dashboard's event loop and gets no - // automatic render, so without this the controls would stay visibly enabled during the folder picker. await SafeInvokeAsync(StateHasChanged); await action(); } @@ -600,7 +576,6 @@ private async Task RunGuardedAsync(Func action) { _isBusy = false; - // Re-render after clearing the busy flag for the same non-event-loop reason, so the controls re-enable. await SafeInvokeAsync(StateHasChanged); } } @@ -609,8 +584,6 @@ private async Task SafeInvokeAsync(Action render) { if (_disposed) { return; } - // Matches the OnFavoritesChanged teardown pattern: a render can race the dashboard unmounting when a folder - // launch opens logs; treat disposal and cancellation as expected. try { await InvokeAsync(render); } catch (ObjectDisposedException) { } catch (OperationCanceledException) { } diff --git a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs index c9cede922..a991d86f7 100644 --- a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs +++ b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs @@ -6,10 +6,10 @@ using EventLogExpert.Runtime.Banner; using EventLogExpert.Runtime.Database; using EventLogExpert.Runtime.Database.Upgrade; -using EventLogExpert.Runtime.Menu; using EventLogExpert.UI.Common; using EventLogExpert.UI.Focus; using EventLogExpert.UI.Inputs; +using EventLogExpert.UI.Menu; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using System.Globalization; @@ -18,7 +18,6 @@ namespace EventLogExpert.UI.Database; public sealed partial class DatabaseEntryRow : ComponentBase { - // Classification reads one extra stamp so "9+" means more than nine distinct versions. private const int OsStampDisplayCap = 9; private static readonly IReadOnlyDictionary s_ariaHiddenTrueAttributes = @@ -86,14 +85,12 @@ private enum ActionKind private bool IsRestoreBlocked => IsUpgradeBlocked || IsUpgrading || UpgradeProgress is not null; - // Bare revisions and non-positive builds carry no provenance without edition/display version. private IReadOnlyList MeaningfulOsStamps => _meaningfulOsStamps; [Inject] private IMenuService MenuService { get; init; } = null!; private string OsStampAriaLabel => $"Source OS: {OsStampDetail}"; - // Keep full detail in both title and accessible label so OS info is not hover-only. private string OsStampDetail => _osStampDetail; private string OsStampSummary => _osStampSummary; @@ -160,7 +157,6 @@ protected override void OnParametersSet() private static string? FormatBuildRevision(int? build, int? revision) { - // UBR is meaningful only with a real build; revision 0 is valid for RTM builds. if (build is not > 0) { return null; } return revision is not null diff --git a/src/EventLogExpert.UI/Database/DatabaseRecoveryHost.cs b/src/EventLogExpert.UI/Database/DatabaseRecoveryHost.cs index 957e45094..e27baedca 100644 --- a/src/EventLogExpert.UI/Database/DatabaseRecoveryHost.cs +++ b/src/EventLogExpert.UI/Database/DatabaseRecoveryHost.cs @@ -5,18 +5,10 @@ using EventLogExpert.Runtime.Banner; using EventLogExpert.Runtime.Common.Threading; using EventLogExpert.Runtime.Database; -using EventLogExpert.Runtime.Modal; using EventLogExpert.UI.Modal; namespace EventLogExpert.UI.Database; -/// -/// Coordinates the recovery banner and modal lifecycle in response to -/// . Registered as a singleton and force-instantiated in MauiProgram so -/// it begins observing state at startup. Crashes in event handlers route through -/// to preserve the original Main.razor placement intent -/// (UnhandledExceptionHandler coverage equivalent). -/// public sealed class DatabaseRecoveryHost : IDisposable { private readonly ICriticalErrorService _criticalErrorService; diff --git a/src/EventLogExpert.UI/Database/DatabaseRecoveryModal.razor.cs b/src/EventLogExpert.UI/Database/DatabaseRecoveryModal.razor.cs index 4b204a390..af31b8a07 100644 --- a/src/EventLogExpert.UI/Database/DatabaseRecoveryModal.razor.cs +++ b/src/EventLogExpert.UI/Database/DatabaseRecoveryModal.razor.cs @@ -4,7 +4,6 @@ using EventLogExpert.Logging.Abstractions; using EventLogExpert.Runtime.Banner; using EventLogExpert.Runtime.Database; -using EventLogExpert.Runtime.Modal; using EventLogExpert.UI.Modal; using Microsoft.AspNetCore.Components; diff --git a/src/EventLogExpert.UI/DatabaseTools/DatabaseToolsModal.razor.cs b/src/EventLogExpert.UI/DatabaseTools/DatabaseToolsModal.razor.cs index 5df17dd1c..f2b078e75 100644 --- a/src/EventLogExpert.UI/DatabaseTools/DatabaseToolsModal.razor.cs +++ b/src/EventLogExpert.UI/DatabaseTools/DatabaseToolsModal.razor.cs @@ -2,12 +2,12 @@ // // Licensed under the MIT License. using EventLogExpert.Logging.Abstractions; -using EventLogExpert.Runtime.Alerts; using EventLogExpert.Runtime.Announcement; using EventLogExpert.Runtime.Database; using EventLogExpert.Runtime.EventLog; -using EventLogExpert.Runtime.Modal; +using EventLogExpert.UI.Alerts; using EventLogExpert.UI.DatabaseTools.Tabs; +using EventLogExpert.UI.Modal; using Microsoft.AspNetCore.Components; namespace EventLogExpert.UI.DatabaseTools; @@ -120,7 +120,6 @@ protected override async Task OnRequestCloseAsync(ModalCloseRequest reques if (!closeAnyway.Accepted) { return false; } } - // Async prompts can race Manage-tab upgrades; re-check before closing. if (_manageTab is { IsUpgradeInFlight: true }) { return false; } } @@ -139,7 +138,6 @@ protected override async Task OnRequestCloseAsync(ModalCloseRequest reques if (!confirm.Accepted) { return false; } } - // Covers upgrades started during the running-operation prompt. if (_manageTab is { IsUpgradeInFlight: true }) { return false; } return true; diff --git a/src/EventLogExpert.UI/DatabaseTools/IInlineAlertSurface.cs b/src/EventLogExpert.UI/DatabaseTools/IInlineAlertSurface.cs index 6f7629495..745478e8c 100644 --- a/src/EventLogExpert.UI/DatabaseTools/IInlineAlertSurface.cs +++ b/src/EventLogExpert.UI/DatabaseTools/IInlineAlertSurface.cs @@ -1,15 +1,10 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. -using EventLogExpert.Runtime.Alerts; +using EventLogExpert.UI.Alerts; namespace EventLogExpert.UI.DatabaseTools; -/// -/// Cascading-value surface that lets a child component (e.g. ManageDatabasesTab) raise an inline alert through -/// its hosting modal. Implemented by via implicit interface satisfaction against the -/// inherited public ShowInlineAlertAsync from ModalBase. -/// internal interface IInlineAlertSurface { Task ShowInlineAlertAsync( diff --git a/src/EventLogExpert.UI/DatabaseTools/Tabs/CreateDatabaseTab.razor.cs b/src/EventLogExpert.UI/DatabaseTools/Tabs/CreateDatabaseTab.razor.cs index 027015e3c..8058f225e 100644 --- a/src/EventLogExpert.UI/DatabaseTools/Tabs/CreateDatabaseTab.razor.cs +++ b/src/EventLogExpert.UI/DatabaseTools/Tabs/CreateDatabaseTab.razor.cs @@ -6,9 +6,9 @@ using EventLogExpert.DatabaseTools.CreateDatabase; using EventLogExpert.Eventing.OfflineImaging.Wim; using EventLogExpert.Logging.Abstractions; -using EventLogExpert.Runtime.Alerts; using EventLogExpert.Runtime.Common.Versioning; using EventLogExpert.Runtime.DatabaseTools.Elevation; +using EventLogExpert.UI.Alerts; using Microsoft.AspNetCore.Components; using System.Globalization; using System.Text.RegularExpressions; @@ -94,7 +94,6 @@ protected override CreateDatabaseRequest BuildRequest() var source = string.IsNullOrWhiteSpace(_sourcePath) ? null : _sourcePath.Trim(); var wimIndex = _wimIndex is > 0 ? _wimIndex : null; - // Only user-marked folders force Directory; image files must auto-detect from extension. var imageKind = IsMarkedFolderImage ? OfflineImageKind.Directory : (OfflineImageKind?)null; return new( @@ -108,7 +107,6 @@ protected override CreateDatabaseRequest BuildRequest() Overwrite: _overwriteConfirmedFor is not null && string.Equals(_overwriteConfirmedFor, _targetPath.Trim(), StringComparison.Ordinal)); } - // The confirmed overwrite target is snapshotted so edits during the prompt cannot inherit it. protected override async Task ConfirmBeforeDispatchAsync() { _overwriteConfirmedFor = null; @@ -192,7 +190,6 @@ private async Task LoadEditionsAsync() } else if (_imageEditions.All(edition => edition.Index != _wimIndex)) { - // A loaded matching index is preserved; otherwise show the first edition as visible confirmation. _wimIndex = _imageEditions[0].Index; } } @@ -248,8 +245,6 @@ private async Task PickTargetAsync() if (!string.IsNullOrEmpty(path)) { _targetPath = path; } } - // Directory.Exists can block for seconds on an unresponsive UNC path; the source box re-renders on every keystroke, so - // the folder-existence check runs off the UI thread and only its cached result drives the markup. private async Task RefreshSourceIsDirectoryAsync(string path) { string trimmed = path.Trim(); diff --git a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.cs b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.cs index 38b97030b..997be70c1 100644 --- a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.cs +++ b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.cs @@ -2,12 +2,12 @@ // // Licensed under the MIT License. using EventLogExpert.Logging.Abstractions; -using EventLogExpert.Runtime.Alerts; using EventLogExpert.Runtime.Announcement; using EventLogExpert.Runtime.Banner; using EventLogExpert.Runtime.Database; using EventLogExpert.Runtime.Database.Upgrade; using EventLogExpert.Runtime.EventLog; +using EventLogExpert.UI.Alerts; using EventLogExpert.UI.Common; using EventLogExpert.UI.Database; using EventLogExpert.UI.Focus; @@ -20,6 +20,8 @@ namespace EventLogExpert.UI.DatabaseTools.Tabs; public sealed partial class ManageDatabasesTab : ComponentBase, IAsyncDisposable { + private const int MaxListedFileNames = 5; + private static readonly TimeSpan s_cancelTimeout = TimeSpan.FromSeconds(30); private readonly Dictionary _pendingToggles = new(StringComparer.OrdinalIgnoreCase); @@ -130,12 +132,6 @@ public async Task ExitSelectionModeWithFocusAsync() await FocusRestoreAsync(_selectButton); } - /// - /// Save wrapper invoked by on the unsaved-changes close prompt and by the - /// inline Save button. Returns true on success or no-op; false when blocked by an in-flight upgrade or when the - /// coordinator apply faulted. Sets bookkeeping when active state actually - /// changed. - /// internal async Task ApplyPendingTogglesAsync() { if (IsUpgradeBlocked) { return false; } @@ -207,8 +203,8 @@ protected override void OnInitialized() private static string BuildBulkPlainMessage(IReadOnlyList fileNames) { - var list = string.Join(", ", fileNames.Take(5)); - var more = fileNames.Count > 5 ? $", and {fileNames.Count - 5} more" : string.Empty; + var list = string.Join(", ", fileNames.Take(MaxListedFileNames)); + var more = fileNames.Count > MaxListedFileNames ? $", and {fileNames.Count - MaxListedFileNames} more" : string.Empty; return $"Are you sure you want to remove these {fileNames.Count} databases? ({list}{more})"; } @@ -233,7 +229,6 @@ private static string GetSkipReason(DatabaseEntry entry, bool isUpgrading) private async Task AnnounceBulkUpgradeOutcomeAsync(UpgradeBatchResult? result, int attempted) { - // Gate-denied (singleton upgrade gate held by another caller). if (result is null) { if (AlertSurface is null) { return; } @@ -253,10 +248,6 @@ private async Task AnnounceBulkUpgradeOutcomeAsync(UpgradeBatchResult? result, i return; } - // Exception-fallback: coordinator's RunOperationAsync returns an empty - // result on a thrown exception. The error banner already fired upstream; - // suppress the success-shaped announcement that would otherwise read - // "Upgraded 0 databases" alongside it. if (attempted > 0 && result.Succeeded.Count == 0 && result.Failed.Count == 0 @@ -329,10 +320,10 @@ private async Task AskOverwriteAsync(string fileName, CancellationToken ca private string BuildCancelThenRemoveMessage(IReadOnlyList fileNames, IReadOnlyList upgradingFiles) { - var upgradingList = string.Join(", ", upgradingFiles.Take(5)); - var moreUpgrading = upgradingFiles.Count > 5 ? $", and {upgradingFiles.Count - 5} more" : string.Empty; - var fileList = string.Join(", ", fileNames.Take(5)); - var moreFiles = fileNames.Count > 5 ? $", and {fileNames.Count - 5} more" : string.Empty; + var upgradingList = string.Join(", ", upgradingFiles.Take(MaxListedFileNames)); + var moreUpgrading = upgradingFiles.Count > MaxListedFileNames ? $", and {upgradingFiles.Count - MaxListedFileNames} more" : string.Empty; + var fileList = string.Join(", ", fileNames.Take(MaxListedFileNames)); + var moreFiles = fileNames.Count > MaxListedFileNames ? $", and {fileNames.Count - MaxListedFileNames} more" : string.Empty; string baseMessage = $"Upgrade in progress for: {upgradingList}{moreUpgrading}. " + $"This will cancel the upgrade batch(es) \u2014 which may include other files not in your selection \u2014 " + @@ -394,9 +385,6 @@ void CompletionHandler(object? sender, UpgradeBatchCompletedEventArgs args) } } - // Coordinator-only - adding IsFileInAnyKnownBatch here would 30s-hang on background batches - // (no UpgradeStateChanged event ever fires to re-trigger). pendingBatches + stillAlive below - // already gate Remove via UpgradeBatchCompleted. void StateChangedHandler() { if (!fileNames.Any(f => Coordinator.IsUpgradeInFlight(f))) @@ -412,8 +400,6 @@ void StateChangedHandler() { foreach (var (batchId, files) in batchToFiles) { - // stillAlive includes IsFileInAnyKnownBatch so pendingBatches stays unset for - // pre-progress / queued files until the real UpgradeBatchCompleted fires. bool stillAlive = files.Any(file => { var entry = DatabaseService.Entries.FirstOrDefault( @@ -546,8 +532,6 @@ private void ExitSelectionMode() private async Task FocusAfterBulkUpgradeAsync() { - // After clean-success auto-exit, the bulk strip is gone; focus the Select - // button so keyboard users have a stable anchor. if (!_isSelectionModeActive) { await FocusRestoreAsync(_selectButton); @@ -577,8 +561,6 @@ private async ValueTask FocusEntryRowNameAsync(string fileName) await FocusRestoreAsync(_importButton); } - // Lookup by batch membership (active + queued) for the cancel-then-remove flow. Distinct from - // GetUpgradeProgressForEntry, which matches by CurrentEntryName for per-row display only. private CancellableBatch? GetCancellableBatchForFile(string fileName) { var manage = ProgressBannerService.ManageDatabasesProgress; @@ -773,8 +755,6 @@ private async Task OnBulkUpgradeClickAsync() await AnnounceBulkUpgradeOutcomeAsync(result, eligible.Count); - // Auto-exit only on a fully-clean batch: any partial-failure/cancellation - // leaves the relevant rows selected so the user can retry or inspect. bool cleanSuccess = result is not null && result.Failed.Count == 0 && result.Cancelled.Count == 0 @@ -813,9 +793,6 @@ private void OnDatabaseEntriesChanged(object? sender, EventArgs e) UpdateSelectionAnnouncement(); } - // Always recompute when any selection exists: a row's status / backup / - // upgrade state can change in-place (classification completion, backup - // restore, upgrade finished) without changing the entry list itself. if (_selectedForBulk.Count > 0) { RecomputeEligibleCount(); @@ -828,10 +805,6 @@ private void OnDatabaseEntriesChanged(object? sender, EventArgs e) foreach (var key in deadRefs) { _rowRefs.Remove(key); } - // When the entries list collapses to zero, the Select button and entry rows - // disappear from the DOM; restore focus to Import so keyboard users don't lose - // their anchor. Exit selection mode in the same step so the bulk strip doesn't - // render above an empty-state placeholder. if (currentNames.Count == 0) { if (_isSelectionModeActive) @@ -1044,8 +1017,6 @@ private async Task RemoveDatabasesAsync(IReadOnlyList fileNames, FocusTa _focusRestorationTarget = (anchorFileName, completeTarget); } - // Auto-exit on a fully-clean bulk delete only: any failure leaves the - // failed rows selected so the user can retry without re-selecting them. if (_isSelectionModeActive && succeeded.Count > 0 && failed.Count == 0) { ExitSelectionMode(); diff --git a/src/EventLogExpert.UI/DependencyInjection/UiServiceCollectionExtensions.cs b/src/EventLogExpert.UI/DependencyInjection/UIServiceCollectionExtensions.cs similarity index 51% rename from src/EventLogExpert.UI/DependencyInjection/UiServiceCollectionExtensions.cs rename to src/EventLogExpert.UI/DependencyInjection/UIServiceCollectionExtensions.cs index 150c7660c..8ff69c855 100644 --- a/src/EventLogExpert.UI/DependencyInjection/UiServiceCollectionExtensions.cs +++ b/src/EventLogExpert.UI/DependencyInjection/UIServiceCollectionExtensions.cs @@ -4,21 +4,16 @@ using EventLogExpert.UI.Keyboard; using EventLogExpert.UI.LogTable.Find; using EventLogExpert.UI.Menu; +using EventLogExpert.UI.Modal; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Microsoft.Extensions.DependencyInjection; -public static class UiServiceCollectionExtensions +public static class UIServiceCollectionExtensions { extension(IServiceCollection services) { - /// Registers the EventLogExpert UI-library services into the host container. - /// - /// The registered resolves IMenuActionService, - /// IModalCoordinator, and ISettingsService from the container - the host must register those - /// abstractions (the Runtime layer via AddEventLogRuntime plus the host's menu adapter) before resolving UI - /// services. - /// - public IServiceCollection AddEventLogUiServices() + public IServiceCollection AddEventLogUIServices() { ArgumentNullException.ThrowIfNull(services); @@ -27,6 +22,10 @@ public IServiceCollection AddEventLogUiServices() services.AddSingleton(); services.AddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; } } diff --git a/src/EventLogExpert.UI/DetailsPane/DetailsPane.razor b/src/EventLogExpert.UI/DetailsPane/DetailsPane.razor index 0138d6a96..c2a94b157 100644 --- a/src/EventLogExpert.UI/DetailsPane/DetailsPane.razor +++ b/src/EventLogExpert.UI/DetailsPane/DetailsPane.razor @@ -1,4 +1,8 @@ -@inherits FluxorComponent +@inherits AppStateComponentBase + +@{ + bool showsLiveModel = _model is not null && _selectedHandle == EventFocus.Current?.CurrentHandle; +}