From db5c5ab1b14016c8c630304001c46e03dcfc3aea Mon Sep 17 00:00:00 2001 From: jschick04 Date: Sat, 18 Jul 2026 15:48:03 +0000 Subject: [PATCH 1/5] Hide the timeline by default and activate it from update scenarios --- .../Histogram/Effects.cs | 31 --------- .../Histogram/HistogramState.cs | 2 +- .../IHistogramPreferencesProvider.cs | 9 --- .../Scenarios/ScenarioLaunchService.cs | 7 ++ .../Catalog/ScenarioDefinition.cs | 3 + .../built-in/updates-and-policy.json | 3 + .../Serialization/ScenarioCatalogLoader.cs | 1 + .../Serialization/ScenarioDto.cs | 2 + .../Settings/HistogramPreferencesAdapter.cs | 17 ----- .../MauiProgramExtensions.cs | 2 - ...RuntimeServiceCollectionExtensionsTests.cs | 3 + .../Histogram/HistogramStateTests.cs | 16 +++++ .../Scenarios/ScenarioLaunchServiceTests.cs | 67 +++++++++++++++++-- .../BuiltInCatalogValidationTests.cs | 22 ++++++ .../ScenarioCatalogLoaderTests.cs | 26 +++++++ 15 files changed, 147 insertions(+), 64 deletions(-) delete mode 100644 src/EventLogExpert.Runtime/Histogram/Effects.cs delete mode 100644 src/EventLogExpert.Runtime/Histogram/IHistogramPreferencesProvider.cs delete mode 100644 src/EventLogExpert/Adapters/Settings/HistogramPreferencesAdapter.cs create mode 100644 tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramStateTests.cs diff --git a/src/EventLogExpert.Runtime/Histogram/Effects.cs b/src/EventLogExpert.Runtime/Histogram/Effects.cs deleted file mode 100644 index 45184a2f..00000000 --- a/src/EventLogExpert.Runtime/Histogram/Effects.cs +++ /dev/null @@ -1,31 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -using Fluxor; - -namespace EventLogExpert.Runtime.Histogram; - -internal sealed class Effects(IHistogramPreferencesProvider preferences) -{ - private readonly IHistogramPreferencesProvider _preferences = preferences; - - [EffectMethod] - public Task HandleSetHistogramVisible(SetHistogramVisibleAction action, IDispatcher dispatcher) - { - // Skip the redundant write during startup hydration, which dispatches the persisted value straight back. - if (_preferences.HistogramVisiblePreference != action.IsVisible) - { - _preferences.HistogramVisiblePreference = action.IsVisible; - } - - return Task.CompletedTask; - } - - [EffectMethod] - public Task HandleStoreInitialized(StoreInitializedAction action, IDispatcher dispatcher) - { - dispatcher.Dispatch(new SetHistogramVisibleAction(_preferences.HistogramVisiblePreference)); - - return Task.CompletedTask; - } -} diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramState.cs b/src/EventLogExpert.Runtime/Histogram/HistogramState.cs index ef88314b..0c1772a6 100644 --- a/src/EventLogExpert.Runtime/Histogram/HistogramState.cs +++ b/src/EventLogExpert.Runtime/Histogram/HistogramState.cs @@ -8,5 +8,5 @@ namespace EventLogExpert.Runtime.Histogram; [FeatureState] public sealed record HistogramState { - public bool IsVisible { get; init; } = true; + public bool IsVisible { get; init; } } diff --git a/src/EventLogExpert.Runtime/Histogram/IHistogramPreferencesProvider.cs b/src/EventLogExpert.Runtime/Histogram/IHistogramPreferencesProvider.cs deleted file mode 100644 index 87485d0e..00000000 --- a/src/EventLogExpert.Runtime/Histogram/IHistogramPreferencesProvider.cs +++ /dev/null @@ -1,9 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -namespace EventLogExpert.Runtime.Histogram; - -public interface IHistogramPreferencesProvider -{ - bool HistogramVisiblePreference { get; set; } -} diff --git a/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs b/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs index e38b608f..9953237d 100644 --- a/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs +++ b/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs @@ -4,6 +4,7 @@ using EventLogExpert.Filtering.Evaluation; using EventLogExpert.Runtime.EventLog; using EventLogExpert.Runtime.FilterPane; +using EventLogExpert.Runtime.Histogram; using EventLogExpert.Runtime.Menu; using EventLogExpert.Scenarios.Catalog; using Fluxor; @@ -14,10 +15,12 @@ internal sealed class ScenarioLaunchService( BuiltInScenarioRegistry registry, IMenuActionService menuActionService, IState filterPaneState, + IState histogramState, IDispatcher dispatcher) : IScenarioLaunchService { private readonly IDispatcher _dispatcher = dispatcher; private readonly IState _filterPaneState = filterPaneState; + private readonly IState _histogramState = histogramState; private readonly IMenuActionService _menuActionService = menuActionService; private readonly BuiltInScenarioRegistry _registry = registry; @@ -42,6 +45,10 @@ public async Task LaunchAsync( _dispatcher.Dispatch(new CloseAllLogsAction()); _dispatcher.Dispatch(new RestoreFilterPaneStateAction(priorFilterState)); } + else if (scenario.ActivatesTimeline && !_histogramState.Value.IsVisible) + { + _menuActionService.SetHistogramVisible(true); + } return new ScenarioLaunchResult(result.Opened, result.Empty, result.Failed); } diff --git a/src/EventLogExpert.Scenarios/Catalog/ScenarioDefinition.cs b/src/EventLogExpert.Scenarios/Catalog/ScenarioDefinition.cs index bf0772b2..abec3bbf 100644 --- a/src/EventLogExpert.Scenarios/Catalog/ScenarioDefinition.cs +++ b/src/EventLogExpert.Scenarios/Catalog/ScenarioDefinition.cs @@ -32,6 +32,9 @@ public sealed record ScenarioDefinition /// True when any required channel needs process elevation to read. public bool RequiresAdmin { get; init; } + /// True when launching this scenario should reveal the event-rate timeline for rate-over-time triage. + public bool ActivatesTimeline { get; init; } + /// The ordered filter rows, each materialising to one Basic filter. public required ImmutableArray Filters { get; init; } diff --git a/src/EventLogExpert.Scenarios/Scenarios/built-in/updates-and-policy.json b/src/EventLogExpert.Scenarios/Scenarios/built-in/updates-and-policy.json index 8ab5b3a7..b3f95386 100644 --- a/src/EventLogExpert.Scenarios/Scenarios/built-in/updates-and-policy.json +++ b/src/EventLogExpert.Scenarios/Scenarios/built-in/updates-and-policy.json @@ -34,6 +34,7 @@ "group": "UpdatesAndPolicy", "channels": [ "System" ], "requiresAdmin": false, + "activatesTimeline": true, "priority": 0, "order": 1, "version": 1, @@ -68,6 +69,7 @@ "group": "UpdatesAndPolicy", "channels": [ "Setup" ], "requiresAdmin": false, + "activatesTimeline": true, "priority": 1, "order": 2, "version": 1, @@ -102,6 +104,7 @@ "group": "UpdatesAndPolicy", "channels": [ "System" ], "requiresAdmin": false, + "activatesTimeline": true, "priority": 0, "order": 3, "version": 1, diff --git a/src/EventLogExpert.Scenarios/Serialization/ScenarioCatalogLoader.cs b/src/EventLogExpert.Scenarios/Serialization/ScenarioCatalogLoader.cs index 2fffe523..1017762d 100644 --- a/src/EventLogExpert.Scenarios/Serialization/ScenarioCatalogLoader.cs +++ b/src/EventLogExpert.Scenarios/Serialization/ScenarioCatalogLoader.cs @@ -274,6 +274,7 @@ private static void ProcessScenario( Gating = gating, SourceGates = [.. dto.SourceGates ?? []], RequiresAdmin = dto.RequiresAdmin, + ActivatesTimeline = dto.ActivatesTimeline, Filters = [.. rows], Priority = dto.Priority, Order = dto.Order, diff --git a/src/EventLogExpert.Scenarios/Serialization/ScenarioDto.cs b/src/EventLogExpert.Scenarios/Serialization/ScenarioDto.cs index 9ad4f083..27bf6f18 100644 --- a/src/EventLogExpert.Scenarios/Serialization/ScenarioDto.cs +++ b/src/EventLogExpert.Scenarios/Serialization/ScenarioDto.cs @@ -5,6 +5,8 @@ namespace EventLogExpert.Scenarios.Serialization; internal sealed class ScenarioDto { + public bool ActivatesTimeline { get; set; } + public List? Channels { get; set; } public List? Filters { get; set; } diff --git a/src/EventLogExpert/Adapters/Settings/HistogramPreferencesAdapter.cs b/src/EventLogExpert/Adapters/Settings/HistogramPreferencesAdapter.cs deleted file mode 100644 index e087ba1e..00000000 --- a/src/EventLogExpert/Adapters/Settings/HistogramPreferencesAdapter.cs +++ /dev/null @@ -1,17 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -using EventLogExpert.Runtime.Histogram; - -namespace EventLogExpert.Adapters.Settings; - -internal sealed class HistogramPreferencesAdapter : IHistogramPreferencesProvider -{ - private const string HistogramVisible = "histogram-visible"; - - public bool HistogramVisiblePreference - { - get => Preferences.Default.Get(HistogramVisible, true); - set => Preferences.Default.Set(HistogramVisible, value); - } -} diff --git a/src/EventLogExpert/DependencyInjection/MauiProgramExtensions.cs b/src/EventLogExpert/DependencyInjection/MauiProgramExtensions.cs index a1ecaca0..6d0737d7 100644 --- a/src/EventLogExpert/DependencyInjection/MauiProgramExtensions.cs +++ b/src/EventLogExpert/DependencyInjection/MauiProgramExtensions.cs @@ -19,7 +19,6 @@ using EventLogExpert.Runtime.Common.Threading; using EventLogExpert.Runtime.Database; using EventLogExpert.Runtime.DetailsPane; -using EventLogExpert.Runtime.Histogram; using EventLogExpert.Runtime.LogTable; using EventLogExpert.Runtime.Menu; using EventLogExpert.Runtime.Modal; @@ -113,7 +112,6 @@ public IServiceCollection AddMauiPreferenceAdapters() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); return services; diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/DependencyInjection/RuntimeServiceCollectionExtensionsTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/DependencyInjection/RuntimeServiceCollectionExtensionsTests.cs index 1aad7cce..cf39e1df 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/DependencyInjection/RuntimeServiceCollectionExtensionsTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/DependencyInjection/RuntimeServiceCollectionExtensionsTests.cs @@ -23,6 +23,7 @@ using EventLogExpert.Runtime.EventLog; using EventLogExpert.Runtime.FilterLibrary; using EventLogExpert.Runtime.FilterPane; +using EventLogExpert.Runtime.Histogram; using EventLogExpert.Runtime.LogTable; using EventLogExpert.Runtime.Menu; using EventLogExpert.Runtime.Modal; @@ -242,6 +243,7 @@ public async Task AddEventLogRuntime_ShouldResolveHostFacingAbstraction(Type ser services.AddSingleton(Substitute.For>()); services.AddSingleton(Substitute.For>()); services.AddSingleton(Substitute.For>()); + services.AddSingleton(Substitute.For>()); services.AddSingleton(Substitute.For>()); services.AddSingleton(new FileLocationOptions(Path.Combine(Path.GetTempPath(), "EventLogExpertTests"))); services.AddSingleton(); @@ -340,6 +342,7 @@ private static void RegisterHostDependencies(IServiceCollection services, ISetti services.AddSingleton(Substitute.For>()); services.AddSingleton(Substitute.For>()); services.AddSingleton(Substitute.For>()); + services.AddSingleton(Substitute.For>()); services.AddSingleton(Substitute.For>()); services.AddSingleton(new FileLocationOptions(Path.Combine(Path.GetTempPath(), "EventLogExpertTests"))); services.AddSingleton(); diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramStateTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramStateTests.cs new file mode 100644 index 00000000..dcd0c0d3 --- /dev/null +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramStateTests.cs @@ -0,0 +1,16 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Histogram; + +namespace EventLogExpert.Runtime.Tests.Histogram; + +public sealed class HistogramStateTests +{ + [Fact] + public void IsVisible_DefaultsToHidden() + { + // The timeline is off on every launch - there is no persisted preference; a scenario or the View menu reveals it. + Assert.False(new HistogramState().IsVisible); + } +} diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs index 81baeead..d666fbee 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs @@ -4,6 +4,7 @@ using EventLogExpert.Filtering.TestUtils; using EventLogExpert.Runtime.EventLog; using EventLogExpert.Runtime.FilterPane; +using EventLogExpert.Runtime.Histogram; using EventLogExpert.Runtime.Menu; using EventLogExpert.Runtime.Scenarios; using EventLogExpert.Scenarios.Catalog; @@ -14,6 +15,48 @@ namespace EventLogExpert.Runtime.Tests.Scenarios; public sealed class ScenarioLaunchServiceTests { + [Fact] + public async Task LaunchAsync_ActivatingScenario_WhenTimelineAlreadyVisible_DoesNotReshow() + { + var (service, _, menu, scenario) = + Create(new OpenLogsBatchResult(1, 0, 0, 0, []), activatesTimeline: true, timelineVisible: true); + + await service.LaunchAsync(scenario, dateWindow: null); + + menu.DidNotReceive().SetHistogramVisible(Arg.Any()); + } + + [Fact] + public async Task LaunchAsync_ActivatingScenario_WhenTimelineHidden_ShowsTimeline() + { + var (service, _, menu, scenario) = Create(new OpenLogsBatchResult(1, 0, 0, 0, []), activatesTimeline: true); + + await service.LaunchAsync(scenario, dateWindow: null); + + menu.Received(1).SetHistogramVisible(true); + } + + [Fact] + public async Task LaunchAsync_ActivatingScenario_ZeroOpenButCombining_ShowsTimeline() + { + var (service, _, menu, scenario) = Create(new OpenLogsBatchResult(0, 0, 1, 0, []), activatesTimeline: true); + + await service.LaunchAsync(scenario, dateWindow: null, combineLog: true); + + menu.Received(1).SetHistogramVisible(true); + } + + [Fact] + public async Task LaunchAsync_ActivatingScenario_ZeroOpenFreshView_DoesNotShowTimeline() + { + var (service, _, menu, scenario) = + Create(new OpenLogsBatchResult(0, 1, 0, 0, ["System"]), activatesTimeline: true); + + await service.LaunchAsync(scenario, dateWindow: null); + + menu.DidNotReceive().SetHistogramVisible(Arg.Any()); + } + [Fact] public async Task LaunchAsync_AppliesFiltersThenDate_BeforeOpening() { @@ -30,6 +73,16 @@ public async Task LaunchAsync_AppliesFiltersThenDate_BeforeOpening() }); } + [Fact] + public async Task LaunchAsync_NonActivatingScenario_DoesNotShowTimeline() + { + var (service, _, menu, scenario) = Create(new OpenLogsBatchResult(1, 0, 0, 0, [])); + + await service.LaunchAsync(scenario, dateWindow: null); + + menu.DidNotReceive().SetHistogramVisible(Arg.Any()); + } + [Fact] public async Task LaunchAsync_NullWindow_ClearsDateFilter() { @@ -136,7 +189,10 @@ public async Task LaunchAsync_ZeroOpenFreshView_RestoresFilterStateCapturedBefor dispatcher.When(d => d.Dispatch(Arg.Any())) .Do(_ => filterPaneState.Value.Returns(afterApplyState)); - var service = new ScenarioLaunchService(registry, menu, filterPaneState, dispatcher); + var histogramState = Substitute.For>(); + histogramState.Value.Returns(new HistogramState()); + + var service = new ScenarioLaunchService(registry, menu, filterPaneState, histogramState, dispatcher); await service.LaunchAsync(scenario, dateWindow: null); @@ -145,9 +201,9 @@ public async Task LaunchAsync_ZeroOpenFreshView_RestoresFilterStateCapturedBefor } private static (IScenarioLaunchService Service, IDispatcher Dispatcher, IMenuActionService Menu, ScenarioDefinition Scenario) - Create(OpenLogsBatchResult openResult) + Create(OpenLogsBatchResult openResult, bool activatesTimeline = false, bool timelineVisible = false) { - var scenario = ScenarioTestData.Single("a", "System", 1000); + var scenario = ScenarioTestData.Single("a", "System", 1000) with { ActivatesTimeline = activatesTimeline }; var registry = ScenarioTestData.Registry(scenario); var menu = Substitute.For(); @@ -156,8 +212,11 @@ private static (IScenarioLaunchService Service, IDispatcher Dispatcher, IMenuAct var filterPaneState = Substitute.For>(); filterPaneState.Value.Returns(new FilterPaneState()); + var histogramState = Substitute.For>(); + histogramState.Value.Returns(new HistogramState { IsVisible = timelineVisible }); + var dispatcher = Substitute.For(); - var service = new ScenarioLaunchService(registry, menu, filterPaneState, dispatcher); + var service = new ScenarioLaunchService(registry, menu, filterPaneState, histogramState, dispatcher); return (service, dispatcher, menu, scenario); } diff --git a/tests/Unit/EventLogExpert.Scenarios.Tests/BuiltInCatalogValidationTests.cs b/tests/Unit/EventLogExpert.Scenarios.Tests/BuiltInCatalogValidationTests.cs index fbf4d9b8..29a9bc54 100644 --- a/tests/Unit/EventLogExpert.Scenarios.Tests/BuiltInCatalogValidationTests.cs +++ b/tests/Unit/EventLogExpert.Scenarios.Tests/BuiltInCatalogValidationTests.cs @@ -264,6 +264,15 @@ static ResolvedEvent Event(string source, int id) => Assert.False(saved.Compiled!.Predicate(Event("Disk", 153)), "Source matching is case-sensitive (Ordinal): the real provider is 'disk', so 'Disk' must not match"); } + [Fact] + public void NonUpdateScenario_DoesNotActivateTimeline() + { + // Activation is per-scenario, not catalog-wide: a neighbouring policy scenario must stay opted out. + var scenario = s_registry.Scenarios.Single(scenario => scenario.Id == "group-policy-processing-errors"); + + Assert.False(scenario.ActivatesTimeline); + } + [Theory] [InlineData("EventLog", 6008, "Red")] [InlineData("Microsoft-Windows-Kernel-Power", 41, "Red")] @@ -345,6 +354,19 @@ public void ServicingOutcomes_MatchesServicingEventByIdAtInformationLevel(int id Assert.DoesNotContain(filterSet, saved => saved.Color != expected && saved.Compiled!.Predicate(servicingEvent)); } + [Theory] + [InlineData("windows-update-diagnostics")] + [InlineData("windows-setup-servicing-errors")] + [InlineData("update-reboot-crash-correlation")] + public void UpdateScenario_ActivatesTimeline(string scenarioId) + { + // The three update-triage scenarios reveal the event-rate timeline on launch (rate-over-time is the point of the + // reboot/crash correlation and the servicing/update outcome views); lock it so a JSON edit that drops the flag is caught. + var scenario = s_registry.Scenarios.Single(scenario => scenario.Id == scenarioId); + + Assert.True(scenario.ActivatesTimeline); + } + [Fact] public void WindowsUpdateDiagnostics_DoesNotMatchNonWuClientError() { diff --git a/tests/Unit/EventLogExpert.Scenarios.Tests/ScenarioCatalogLoaderTests.cs b/tests/Unit/EventLogExpert.Scenarios.Tests/ScenarioCatalogLoaderTests.cs index 4002df01..7225000d 100644 --- a/tests/Unit/EventLogExpert.Scenarios.Tests/ScenarioCatalogLoaderTests.cs +++ b/tests/Unit/EventLogExpert.Scenarios.Tests/ScenarioCatalogLoaderTests.cs @@ -8,6 +8,32 @@ namespace EventLogExpert.Scenarios.Tests; public sealed class ScenarioCatalogLoaderTests { + [Fact] + public void Load_ActivatesTimeline_WhenAbsent_DefaultsFalse() + { + var result = Load(Wrap(""" + { "id": "x", "name": "X", "purpose": "p", "group": "SystemHealth", + "channels": [ "System" ], + "filters": [ { "comparison": { "property": "Id", "value": "1000" } } ] } + """)); + + var scenario = Assert.Single(result.Scenarios); + Assert.False(scenario.ActivatesTimeline); + } + + [Fact] + public void Load_ActivatesTimeline_WhenTrue_IsParsed() + { + var result = Load(Wrap(""" + { "id": "x", "name": "X", "purpose": "p", "group": "SystemHealth", + "channels": [ "System" ], "activatesTimeline": true, + "filters": [ { "comparison": { "property": "Id", "value": "1000" } } ] } + """)); + + var scenario = Assert.Single(result.Scenarios); + Assert.True(scenario.ActivatesTimeline); + } + [Fact] public void Load_AdminChannelWithoutRequiresAdmin_IsError() { From ba6fa30a83bcaa40aa123e1febce515459225952 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Sat, 18 Jul 2026 18:31:00 +0000 Subject: [PATCH 2/5] Open a scenario's logs from a folder of exported event logs --- .../RuntimeServiceCollectionExtensions.cs | 1 + .../Menu/IMenuActionService.cs | 2 + .../Scenarios/EvtxChannelReadResult.cs | 15 ++ .../Scenarios/EvtxChannelReader.cs | 42 ++++ .../Scenarios/EvtxFolderScanResult.cs | 28 +++ .../Scenarios/IEvtxChannelReader.cs | 10 + .../Scenarios/IEvtxFolderEnumerator.cs | 10 + .../Scenarios/IScenarioLaunchService.cs | 7 + .../Scenarios/ScenarioFolderLaunchResult.cs | 43 ++++ .../Scenarios/ScenarioLaunchService.cs | 175 +++++++++++++++ .../Dashboard/EmptyStateDashboard.razor | 1 + .../Dashboard/EmptyStateDashboard.razor.cs | 51 +++++ .../Dashboard/ScenarioBrowserPanel.razor | 1 + .../Dashboard/ScenarioBrowserPanel.razor.cs | 2 + .../Dashboard/ScenarioDetail.razor | 7 + .../Dashboard/ScenarioDetail.razor.cs | 11 + .../Dashboard/ScenarioDetail.razor.css | 30 +++ .../LogTable/LogTablePane.razor.cs | 9 +- .../FilePicker/MauiEvtxFolderEnumerator.cs | 21 ++ .../Adapters/Menu/MauiMenuActionService.cs | 7 + .../MauiProgramExtensions.cs | 2 + ...RuntimeServiceCollectionExtensionsTests.cs | 4 + .../Scenarios/EvtxChannelReaderTests.cs | 38 ++++ .../Scenarios/ScenarioLaunchServiceTests.cs | 199 +++++++++++++++++- .../Dashboard/EmptyStateDashboardTests.cs | 53 +++++ .../Dashboard/ScenarioDetailTests.cs | 36 ++++ 26 files changed, 801 insertions(+), 4 deletions(-) create mode 100644 src/EventLogExpert.Runtime/Scenarios/EvtxChannelReadResult.cs create mode 100644 src/EventLogExpert.Runtime/Scenarios/EvtxChannelReader.cs create mode 100644 src/EventLogExpert.Runtime/Scenarios/EvtxFolderScanResult.cs create mode 100644 src/EventLogExpert.Runtime/Scenarios/IEvtxChannelReader.cs create mode 100644 src/EventLogExpert.Runtime/Scenarios/IEvtxFolderEnumerator.cs create mode 100644 src/EventLogExpert.Runtime/Scenarios/ScenarioFolderLaunchResult.cs create mode 100644 src/EventLogExpert/Adapters/FilePicker/MauiEvtxFolderEnumerator.cs create mode 100644 tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/EvtxChannelReaderTests.cs diff --git a/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs b/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs index 1a6c87b9..72a63460 100644 --- a/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs +++ b/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs @@ -274,6 +274,7 @@ public IServiceCollection AddEventLogRuntime() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/EventLogExpert.Runtime/Menu/IMenuActionService.cs b/src/EventLogExpert.Runtime/Menu/IMenuActionService.cs index 3c19ad27..55db1b99 100644 --- a/src/EventLogExpert.Runtime/Menu/IMenuActionService.cs +++ b/src/EventLogExpert.Runtime/Menu/IMenuActionService.cs @@ -40,6 +40,8 @@ public interface IMenuActionService Task OpenLiveLogsAsync(IEnumerable logNames, bool combineLog); + Task OpenLogFilesAsync(IEnumerable filePaths, bool combineLog); + Task OpenSettingsAsync(); Task SaveFiltersAsFilterSetAsync(); diff --git a/src/EventLogExpert.Runtime/Scenarios/EvtxChannelReadResult.cs b/src/EventLogExpert.Runtime/Scenarios/EvtxChannelReadResult.cs new file mode 100644 index 00000000..4056481c --- /dev/null +++ b/src/EventLogExpert.Runtime/Scenarios/EvtxChannelReadResult.cs @@ -0,0 +1,15 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Scenarios; + +internal readonly record struct EvtxChannelReadResult(string? Channel, bool Failed) +{ + /// A log that opened but had no records, so no channel could be read; not a failure. + public static EvtxChannelReadResult Empty { get; } = new(null, false); + + /// A file that could not be opened or read. + public static EvtxChannelReadResult Unreadable { get; } = new(null, true); + + public static EvtxChannelReadResult FromChannel(string channel) => new(channel, false); +} diff --git a/src/EventLogExpert.Runtime/Scenarios/EvtxChannelReader.cs b/src/EventLogExpert.Runtime/Scenarios/EvtxChannelReader.cs new file mode 100644 index 00000000..ee7f38b5 --- /dev/null +++ b/src/EventLogExpert.Runtime/Scenarios/EvtxChannelReader.cs @@ -0,0 +1,42 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Channels; +using EventLogExpert.Eventing.Readers; + +namespace EventLogExpert.Runtime.Scenarios; + +/// +/// Reads a file's channel with a single lightweight record read (System render only, no description resolution). +/// Fully non-throwing so one corrupt or locked file cannot fault a parallel folder scan. +/// +internal sealed class EvtxChannelReader : IEvtxChannelReader +{ + public EvtxChannelReadResult ReadChannel(string filePath) + { + try + { + using var reader = new EventLogReader(filePath, LogPathType.File, renderXml: false, reverseDirection: false); + + if (!reader.IsValid) { return EvtxChannelReadResult.Unreadable; } + + if (!reader.TryGetEvents(out var events, batchSize: 1)) + { + // A false with no error code is a clean end-of-log (empty); a Win32 error is a read failure. + return reader.LastErrorCode is null ? EvtxChannelReadResult.Empty : EvtxChannelReadResult.Unreadable; + } + + if (events.Length == 0) { return EvtxChannelReadResult.Empty; } + + var record = events[0]; + + return record.IsSuccess && !string.IsNullOrEmpty(record.LogName) + ? EvtxChannelReadResult.FromChannel(record.LogName) + : EvtxChannelReadResult.Unreadable; + } + catch (Exception) + { + return EvtxChannelReadResult.Unreadable; + } + } +} diff --git a/src/EventLogExpert.Runtime/Scenarios/EvtxFolderScanResult.cs b/src/EventLogExpert.Runtime/Scenarios/EvtxFolderScanResult.cs new file mode 100644 index 00000000..8756bc30 --- /dev/null +++ b/src/EventLogExpert.Runtime/Scenarios/EvtxFolderScanResult.cs @@ -0,0 +1,28 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.Scenarios; + +/// +/// Discriminated result for so a folder that cannot be read +/// is reported distinctly from one that merely holds no .evtx files. +/// +public abstract record EvtxFolderScanResult +{ + private EvtxFolderScanResult() { } + + public sealed record Files(ImmutableArray Paths) : EvtxFolderScanResult; + + public sealed record Empty : EvtxFolderScanResult + { + private Empty() { } + + public static Empty Instance { get; } = new(); + } + + public sealed record AccessDenied(string Message) : EvtxFolderScanResult; + + public sealed record IoError(string Message) : EvtxFolderScanResult; +} diff --git a/src/EventLogExpert.Runtime/Scenarios/IEvtxChannelReader.cs b/src/EventLogExpert.Runtime/Scenarios/IEvtxChannelReader.cs new file mode 100644 index 00000000..39d2adae --- /dev/null +++ b/src/EventLogExpert.Runtime/Scenarios/IEvtxChannelReader.cs @@ -0,0 +1,10 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Scenarios; + +/// Reads the channel (log name) an exported .evtx file's events belong to. +internal interface IEvtxChannelReader +{ + EvtxChannelReadResult ReadChannel(string filePath); +} diff --git a/src/EventLogExpert.Runtime/Scenarios/IEvtxFolderEnumerator.cs b/src/EventLogExpert.Runtime/Scenarios/IEvtxFolderEnumerator.cs new file mode 100644 index 00000000..cf31566a --- /dev/null +++ b/src/EventLogExpert.Runtime/Scenarios/IEvtxFolderEnumerator.cs @@ -0,0 +1,10 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Scenarios; + +/// Lists the top-level .evtx files in a folder, surfacing access and IO failures distinctly. +public interface IEvtxFolderEnumerator +{ + EvtxFolderScanResult EnumerateTopLevel(string folderPath); +} diff --git a/src/EventLogExpert.Runtime/Scenarios/IScenarioLaunchService.cs b/src/EventLogExpert.Runtime/Scenarios/IScenarioLaunchService.cs index d6734c71..2d8f283b 100644 --- a/src/EventLogExpert.Runtime/Scenarios/IScenarioLaunchService.cs +++ b/src/EventLogExpert.Runtime/Scenarios/IScenarioLaunchService.cs @@ -14,4 +14,11 @@ public interface IScenarioLaunchService /// filter; false opens a fresh view, true merges into the active workspace. /// Task LaunchAsync(ScenarioDefinition scenario, DateFilter? dateWindow, bool combineLog = false); + + /// + /// Prompts for a folder, opens the exported .evtx files whose channel matches the scenario, and applies + /// the scenario's filters to a fresh view. The scenario's filters and channels are read from the definition, so this + /// works even for logs not present on the local host. + /// + Task LaunchFromFolderAsync(ScenarioDefinition scenario, DateFilter? dateWindow); } diff --git a/src/EventLogExpert.Runtime/Scenarios/ScenarioFolderLaunchResult.cs b/src/EventLogExpert.Runtime/Scenarios/ScenarioFolderLaunchResult.cs new file mode 100644 index 00000000..367d03ab --- /dev/null +++ b/src/EventLogExpert.Runtime/Scenarios/ScenarioFolderLaunchResult.cs @@ -0,0 +1,43 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.Scenarios; + +/// The terminal state of . +public enum ScenarioFolderOutcome +{ + Cancelled, + Error, + NoMatchingLogs, + NoLogsOpened, + Completed +} + +/// The result of launching a scenario against exported logs in a folder. +public sealed record ScenarioFolderLaunchResult +{ + public required ScenarioFolderOutcome Outcome { get; init; } + + public int Matched { get; init; } + + public int Unreadable { get; init; } + + public int Opened { get; init; } + + public int Empty { get; init; } + + public int Failed { get; init; } + + public ImmutableArray MatchedChannels { get; init; } = []; + + public ImmutableArray MissingChannels { get; init; } = []; + + public string? Message { get; init; } + + public static ScenarioFolderLaunchResult Cancelled { get; } = new() { Outcome = ScenarioFolderOutcome.Cancelled }; + + public static ScenarioFolderLaunchResult Error(string message, int unreadable = 0) => + new() { Outcome = ScenarioFolderOutcome.Error, Message = message, Unreadable = unreadable }; +} diff --git a/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs b/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs index 9953237d..03109d5c 100644 --- a/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs +++ b/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs @@ -2,12 +2,15 @@ // // Licensed under the MIT License. using EventLogExpert.Filtering.Evaluation; +using EventLogExpert.Runtime.Common.Files; using EventLogExpert.Runtime.EventLog; using EventLogExpert.Runtime.FilterPane; using EventLogExpert.Runtime.Histogram; using EventLogExpert.Runtime.Menu; using EventLogExpert.Scenarios.Catalog; using Fluxor; +using System.Collections.Concurrent; +using System.Collections.Immutable; namespace EventLogExpert.Runtime.Scenarios; @@ -16,10 +19,16 @@ internal sealed class ScenarioLaunchService( IMenuActionService menuActionService, IState filterPaneState, IState histogramState, + IFolderPickerService folderPicker, + IEvtxFolderEnumerator folderEnumerator, + IEvtxChannelReader channelReader, IDispatcher dispatcher) : IScenarioLaunchService { + private readonly IEvtxChannelReader _channelReader = channelReader; private readonly IDispatcher _dispatcher = dispatcher; private readonly IState _filterPaneState = filterPaneState; + private readonly IEvtxFolderEnumerator _folderEnumerator = folderEnumerator; + private readonly IFolderPickerService _folderPicker = folderPicker; private readonly IState _histogramState = histogramState; private readonly IMenuActionService _menuActionService = menuActionService; private readonly BuiltInScenarioRegistry _registry = registry; @@ -52,4 +61,170 @@ public async Task LaunchAsync( return new ScenarioLaunchResult(result.Opened, result.Empty, result.Failed); } + + public async Task LaunchFromFolderAsync(ScenarioDefinition scenario, DateFilter? dateWindow) + { + ArgumentNullException.ThrowIfNull(scenario); + + string? folder; + + try + { + folder = await _folderPicker.PickFolderAsync(); + } + catch (InvalidOperationException ex) + { + return ScenarioFolderLaunchResult.Error(ex.Message); + } + + if (folder is null) { return ScenarioFolderLaunchResult.Cancelled; } + + switch (_folderEnumerator.EnumerateTopLevel(folder)) + { + case EvtxFolderScanResult.AccessDenied denied: + return ScenarioFolderLaunchResult.Error(denied.Message); + case EvtxFolderScanResult.IoError ioError: + return ScenarioFolderLaunchResult.Error(ioError.Message); + case EvtxFolderScanResult.Empty: + return new ScenarioFolderLaunchResult + { + Outcome = ScenarioFolderOutcome.NoMatchingLogs, + MissingChannels = AllTargetChannels(scenario) + }; + case EvtxFolderScanResult.Files files: + return await OpenMatchedLogsAsync(scenario, dateWindow, await ScanAndMatchAsync(files.Paths, scenario)); + default: + return ScenarioFolderLaunchResult.Error("The folder could not be read."); + } + } + + private static ImmutableArray AllTargetChannels(ScenarioDefinition scenario) => + [.. TargetChannelSet(scenario).OrderBy(channel => channel, StringComparer.OrdinalIgnoreCase)]; + + private static string FilesWord(int count) => count == 1 ? "1 file" : $"{count} files"; + + private static ImmutableArray MissingChannels(ScenarioDefinition scenario, ImmutableArray matchedChannels) + { + var matched = new HashSet(matchedChannels, StringComparer.OrdinalIgnoreCase); + + return + [ + .. TargetChannelSet(scenario) + .Where(channel => !matched.Contains(channel)) + .OrderBy(channel => channel, StringComparer.OrdinalIgnoreCase) + ]; + } + + private static HashSet TargetChannelSet(ScenarioDefinition scenario) + { + var targets = new HashSet(scenario.Channels, StringComparer.OrdinalIgnoreCase); + + foreach (var channel in scenario.OptionalChannels) { targets.Add(channel); } + + return targets; + } + + private async Task OpenMatchedLogsAsync( + ScenarioDefinition scenario, + DateFilter? dateWindow, + FolderMatch match) + { + if (match.Paths.Count == 0) + { + return match.Unreadable > 0 + ? ScenarioFolderLaunchResult.Error( + $"No matching logs were found; {FilesWord(match.Unreadable)} could not be read.", match.Unreadable) + : new ScenarioFolderLaunchResult + { + Outcome = ScenarioFolderOutcome.NoMatchingLogs, + MissingChannels = AllTargetChannels(scenario) + }; + } + + var priorFilterState = _filterPaneState.Value; + + _dispatcher.Dispatch(new ReplaceFiltersAction(_registry.BuildFilterSet(scenario))); + _dispatcher.Dispatch(new SetFilterDateRangeAction(dateWindow)); + + var result = await _menuActionService.OpenLogFilesAsync(match.Paths, combineLog: false); + + var missing = MissingChannels(scenario, match.MatchedChannels); + + if (result.Opened == 0) + { + _dispatcher.Dispatch(new CloseAllLogsAction()); + _dispatcher.Dispatch(new RestoreFilterPaneStateAction(priorFilterState)); + + return new ScenarioFolderLaunchResult + { + Outcome = ScenarioFolderOutcome.NoLogsOpened, + Matched = match.Paths.Count, + Unreadable = match.Unreadable, + Empty = result.Empty, + Failed = result.Failed, + MatchedChannels = match.MatchedChannels, + MissingChannels = missing + }; + } + + if (scenario.ActivatesTimeline && !_histogramState.Value.IsVisible) + { + _menuActionService.SetHistogramVisible(true); + } + + return new ScenarioFolderLaunchResult + { + Outcome = ScenarioFolderOutcome.Completed, + Matched = match.Paths.Count, + Unreadable = match.Unreadable, + Opened = result.Opened, + Empty = result.Empty, + Failed = result.Failed, + MatchedChannels = match.MatchedChannels, + MissingChannels = missing + }; + } + + private async Task ScanAndMatchAsync(ImmutableArray files, ScenarioDefinition scenario) + { + var probed = new ConcurrentBag<(string Path, EvtxChannelReadResult Result)>(); + + await Parallel.ForEachAsync( + files, + new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, + (file, _) => + { + probed.Add((file, _channelReader.ReadChannel(file))); + + return ValueTask.CompletedTask; + }); + + var targets = TargetChannelSet(scenario); + var matched = new List<(string Path, string Channel)>(); + var unreadable = 0; + + foreach (var (path, result) in probed) + { + if (result.Channel is { } channel) + { + if (targets.Contains(channel)) { matched.Add((path, channel)); } + } + else if (result.Failed) + { + unreadable++; + } + } + + var ordered = matched + .DistinctBy(entry => entry.Path, StringComparer.OrdinalIgnoreCase) + .OrderBy(entry => Path.GetFileName(entry.Path), StringComparer.OrdinalIgnoreCase) + .ToList(); + + ImmutableArray matchedChannels = + [.. ordered.Select(entry => entry.Channel).Distinct(StringComparer.OrdinalIgnoreCase)]; + + return new FolderMatch([.. ordered.Select(entry => entry.Path)], matchedChannels, unreadable); + } + + private sealed record FolderMatch(IReadOnlyList Paths, ImmutableArray MatchedChannels, int Unreadable); } diff --git a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor index f012596b..9dbfa170 100644 --- a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor +++ b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor @@ -76,6 +76,7 @@ IsFavored="IsFavored" IsScenarioDisabled="IsScenarioDisabled" OnLaunch="LaunchScenarioAsync" + OnLaunchFromFolder="LaunchScenarioFromFolderAsync" OnSelect="scenario => Select(category, scenario)" OnToggleFavorite="ToggleFavorite" Scenarios="ScenariosFor(category)" diff --git a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs index b481a6a4..ae4e808d 100644 --- a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs +++ b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs @@ -2,6 +2,7 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.Channels; +using EventLogExpert.Runtime.Alerts; using EventLogExpert.Runtime.Announcement; using EventLogExpert.Runtime.Common.Versioning; using EventLogExpert.Runtime.EventLog; @@ -47,6 +48,8 @@ public sealed partial class EmptyStateDashboard : FluxorComponent [Inject] private IMenuActionService Actions { get; init; } = null!; + [Inject] private IAlertDialogService AlertDialogService { get; init; } = null!; + [Inject] private IAnnouncementService Announcer { get; init; } = null!; [Inject] private IScenarioFavoriteCommands FavoriteCommands { get; init; } = null!; @@ -134,6 +137,24 @@ protected override async Task OnInitializedAsync() await base.OnInitializedAsync(); } + private static string? DescribeFolderLaunch(ScenarioDefinition scenario, ScenarioFolderLaunchResult result) + { + var scanNote = result.Unreadable > 0 ? $" {FolderFilesWord(result.Unreadable)} could not be inspected." : string.Empty; + + return result.Outcome switch + { + ScenarioFolderOutcome.Cancelled => null, + ScenarioFolderOutcome.Error => result.Message ?? "The selected folder could not be opened.", + ScenarioFolderOutcome.NoMatchingLogs => $"No {scenario.Name} logs were found in the selected folder.", + ScenarioFolderOutcome.NoLogsOpened => + $"Matched {FolderLogsWord(result.Matched)} for {scenario.Name} but none could be loaded " + + $"({result.Empty} empty, {result.Failed} failed).{scanNote}", + ScenarioFolderOutcome.Completed => + $"Opened {FolderLogsWord(result.Opened)} for {scenario.Name}.{FolderMissingNote(result.MissingChannels)}{scanNote}", + _ => null + }; + } + private static string DescribeLaunch(ScenarioDefinition scenario, ScenarioLaunchResult result) { if (result.Opened == 0) @@ -146,6 +167,13 @@ private static string DescribeLaunch(ScenarioDefinition scenario, ScenarioLaunch : $"Opened {scenario.Name}."; } + private static string FolderFilesWord(int count) => count == 1 ? "1 file" : $"{count} files"; + + private static string FolderLogsWord(int count) => count == 1 ? "1 log" : $"{count} logs"; + + private static string FolderMissingNote(ImmutableArray missing) => + missing.IsDefaultOrEmpty ? string.Empty : $" Not found: {string.Join(", ", missing)}."; + private void ClearFilter() => FilterCommands.ClearAllFilters(); private IEnumerable FavoriteScenarios() => @@ -168,6 +196,29 @@ private Task LaunchScenarioAsync(ScenarioDefinition scenario) => Announcer.Announce(DescribeLaunch(scenario, result)); }); + private Task LaunchScenarioFromFolderAsync(ScenarioDefinition scenario) => + RunGuardedAsync(async () => + { + var result = await ScenarioLaunch.LaunchFromFolderAsync(scenario, null); + + if (DescribeFolderLaunch(scenario, result) is not { } message) { 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: + await AlertDialogService.ShowErrorAlert("Open from folder", message); + break; + default: + await AlertDialogService.ShowAlert("Open from folder", message, "OK"); + break; + } + }); + private async void OnFavoritesChanged(object? _, ImmutableHashSet __) { try diff --git a/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor b/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor index dcbaca7f..e5f43d48 100644 --- a/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor +++ b/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor @@ -33,6 +33,7 @@ IsDisabled="IsScenarioDisabled(Selected)" IsFavored="IsFavored(Selected)" OnLaunch="() => OnLaunch.InvokeAsync(Selected)" + OnLaunchFromFolder="() => OnLaunchFromFolder.InvokeAsync(Selected)" OnToggleFavorite="() => OnToggleFavorite.InvokeAsync(Selected)" Scenario="Selected" /> } diff --git a/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor.cs b/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor.cs index 671faa02..519c107f 100644 --- a/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor.cs +++ b/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor.cs @@ -27,6 +27,8 @@ public sealed partial class ScenarioBrowserPanel : IAsyncDisposable [Parameter] public EventCallback OnLaunch { get; set; } + [Parameter] public EventCallback OnLaunchFromFolder { get; set; } + [Parameter] public EventCallback OnSelect { get; set; } [Parameter] public EventCallback OnToggleFavorite { get; set; } diff --git a/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor b/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor index b8f644dc..74cb1745 100644 --- a/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor +++ b/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor @@ -55,5 +55,12 @@ OnClick="LaunchAsync"> Launch + + Open from folder + diff --git a/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor.cs b/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor.cs index 5cdd66e7..bde3eb4f 100644 --- a/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor.cs +++ b/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor.cs @@ -23,6 +23,8 @@ public sealed partial class ScenarioDetail [Parameter] public EventCallback OnLaunch { get; set; } + [Parameter] public EventCallback OnLaunchFromFolder { get; set; } + [Parameter] public EventCallback OnToggleFavorite { get; set; } [Parameter][EditorRequired] public ScenarioDefinition Scenario { get; set; } = null!; @@ -53,5 +55,14 @@ private async Task LaunchAsync() await OnLaunch.InvokeAsync(); } + // Opening exported files from a folder needs no elevation, so this stays available even when the live Launch is + // admin-gated (IsDisabled); only an in-flight operation (IsBusy) blocks it. + private async Task LaunchFromFolderAsync() + { + if (IsBusy) { return; } + + await OnLaunchFromFolder.InvokeAsync(); + } + private readonly record struct FilterLine(string Text, HighlightColor Color); } diff --git a/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor.css b/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor.css index b869cbed..21e2c5ea 100644 --- a/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor.css +++ b/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor.css @@ -182,3 +182,33 @@ .scenario-detail__actions ::deep .scenario-detail__launch[aria-disabled="true"] > .bi { color: var(--text-secondary); } + +.scenario-detail__actions ::deep .scenario-detail__open-folder { + display: inline-flex; + align-items: center; + gap: 0.4rem; + min-height: var(--control-height); + padding: 0.4rem 0.85rem; + border: 1px solid var(--border-divider); + border-radius: 6px; + background-color: transparent; + color: var(--text-primary); + font-weight: 600; + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.scenario-detail__actions ::deep .scenario-detail__open-folder:hover:not(:disabled) { + border-color: var(--clr-lightblue); + box-shadow: var(--shadow-menu); +} + +.scenario-detail__actions ::deep .scenario-detail__open-folder:focus-visible { + outline: 2px solid var(--clr-lightblue); + outline-offset: 2px; +} + +.scenario-detail__actions ::deep .scenario-detail__open-folder:disabled { + color: var(--text-secondary); + cursor: default; +} diff --git a/src/EventLogExpert.UI/LogTable/LogTablePane.razor.cs b/src/EventLogExpert.UI/LogTable/LogTablePane.razor.cs index 91a30474..673d3cb7 100644 --- a/src/EventLogExpert.UI/LogTable/LogTablePane.razor.cs +++ b/src/EventLogExpert.UI/LogTable/LogTablePane.razor.cs @@ -640,7 +640,10 @@ private string GetGroupValueText(EventGroup group) string? color = null; // Highlight filters can reference EventData, so evaluate against the full rehydrated event; the result is // cached per locator (stable within a generation) so the rehydrate happens at most once per physical row. - var detail = _activeDisplayedEvents.GetDetail(row.Loc); + // Resolve defensively: during a tab switch or reload the virtualized rows can momentarily carry a locator from + // the prior view/generation. A stale row yields no highlight this frame and recomputes once the view + // reconciles - far better than throwing out of the render tree. + if (!_activeDisplayedEvents.TryGetDetail(row.Loc, out var detail)) { return null; } foreach (var filter in _activeHighlightFilters) { @@ -881,8 +884,10 @@ private async Task InitializeTableEventHandlers() private void InvokeCellContextMenu(MouseEventArgs args, DisplayRow row, ColumnName? column) { + // A stale row (mid tab-switch/reload) has no resolvable detail; skip the menu rather than throw. + if (!_activeDisplayedEvents.TryGetDetail(row.Loc, out var detail)) { return; } + var items = new List(); - var detail = _activeDisplayedEvents.GetDetail(row.Loc); AppendCellFilterItems(items, detail, column); items.AddRange(ShowContextMenuItems(detail)); diff --git a/src/EventLogExpert/Adapters/FilePicker/MauiEvtxFolderEnumerator.cs b/src/EventLogExpert/Adapters/FilePicker/MauiEvtxFolderEnumerator.cs new file mode 100644 index 00000000..9e5b5946 --- /dev/null +++ b/src/EventLogExpert/Adapters/FilePicker/MauiEvtxFolderEnumerator.cs @@ -0,0 +1,21 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Scenarios; +using EventLogExpert.WindowsPlatform.Activation; + +namespace EventLogExpert.Adapters.FilePicker; + +/// MAUI adapter that maps onto the Runtime folder-scan contract. +internal sealed class MauiEvtxFolderEnumerator : IEvtxFolderEnumerator +{ + public EvtxFolderScanResult EnumerateTopLevel(string folderPath) => + EvtxFolderEnumerator.EnumerateEvtxTopLevel(folderPath) switch + { + EvtxEnumerationResult.Success success => new EvtxFolderScanResult.Files([.. success.Files]), + EvtxEnumerationResult.Empty => EvtxFolderScanResult.Empty.Instance, + EvtxEnumerationResult.AccessDenied denied => new EvtxFolderScanResult.AccessDenied(denied.Message), + EvtxEnumerationResult.IoError ioError => new EvtxFolderScanResult.IoError(ioError.Message), + _ => new EvtxFolderScanResult.IoError("The folder could not be read.") + }; +} diff --git a/src/EventLogExpert/Adapters/Menu/MauiMenuActionService.cs b/src/EventLogExpert/Adapters/Menu/MauiMenuActionService.cs index eed6da90..063dc051 100644 --- a/src/EventLogExpert/Adapters/Menu/MauiMenuActionService.cs +++ b/src/EventLogExpert/Adapters/Menu/MauiMenuActionService.cs @@ -413,6 +413,13 @@ await _dialogService.ShowAlert( return OpenLogStatus.Opened; } + public Task OpenLogFilesAsync(IEnumerable filePaths, bool combineLog) + { + ArgumentNullException.ThrowIfNull(filePaths); + + return OpenLogsBatchCoreAsync(filePaths.Select(path => (path, LogPathType.File)), combineLog); + } + public Task OpenLogsBatchAsync(IEnumerable<(string Path, LogPathType Type)> logs, bool combineLog) => OpenLogsBatchCoreAsync(logs, combineLog); diff --git a/src/EventLogExpert/DependencyInjection/MauiProgramExtensions.cs b/src/EventLogExpert/DependencyInjection/MauiProgramExtensions.cs index 6d0737d7..3ce3c223 100644 --- a/src/EventLogExpert/DependencyInjection/MauiProgramExtensions.cs +++ b/src/EventLogExpert/DependencyInjection/MauiProgramExtensions.cs @@ -22,6 +22,7 @@ using EventLogExpert.Runtime.LogTable; using EventLogExpert.Runtime.Menu; using EventLogExpert.Runtime.Modal; +using EventLogExpert.Runtime.Scenarios; using EventLogExpert.Runtime.Settings; using EventLogExpert.UI.Alerts; using EventLogExpert.WindowsPlatform.Activation; @@ -103,6 +104,7 @@ public IServiceCollection AddMauiPlatformAdapters() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/DependencyInjection/RuntimeServiceCollectionExtensionsTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/DependencyInjection/RuntimeServiceCollectionExtensionsTests.cs index cf39e1df..7163f642 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/DependencyInjection/RuntimeServiceCollectionExtensionsTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/DependencyInjection/RuntimeServiceCollectionExtensionsTests.cs @@ -248,6 +248,8 @@ public async Task AddEventLogRuntime_ShouldResolveHostFacingAbstraction(Type ser services.AddSingleton(new FileLocationOptions(Path.Combine(Path.GetTempPath(), "EventLogExpertTests"))); services.AddSingleton(); services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); services.AddEventLogRuntime(); @@ -347,5 +349,7 @@ private static void RegisterHostDependencies(IServiceCollection services, ISetti services.AddSingleton(new FileLocationOptions(Path.Combine(Path.GetTempPath(), "EventLogExpertTests"))); services.AddSingleton(); services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); } } diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/EvtxChannelReaderTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/EvtxChannelReaderTests.cs new file mode 100644 index 00000000..e2fe0f2c --- /dev/null +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/EvtxChannelReaderTests.cs @@ -0,0 +1,38 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Scenarios; + +namespace EventLogExpert.Runtime.Tests.Scenarios; + +public sealed class EvtxChannelReaderTests +{ + [Fact] + public void ReadChannel_FileThatIsNotAnEvtx_ReturnsUnreadable() + { + var path = Path.Combine(Path.GetTempPath(), $"eventlogexpert-notevtx-{Guid.NewGuid():N}.evtx"); + File.WriteAllText(path, "this is not an event log"); + + try + { + var result = new EvtxChannelReader().ReadChannel(path); + + Assert.True(result.Failed); + Assert.Null(result.Channel); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ReadChannel_MissingFile_ReturnsUnreadable() + { + var result = new EvtxChannelReader().ReadChannel( + Path.Combine(Path.GetTempPath(), $"eventlogexpert-missing-{Guid.NewGuid():N}.evtx")); + + Assert.True(result.Failed); + Assert.Null(result.Channel); + } +} diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs index d666fbee..51f4ce86 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs @@ -2,6 +2,7 @@ // // Licensed under the MIT License. using EventLogExpert.Filtering.TestUtils; +using EventLogExpert.Runtime.Common.Files; using EventLogExpert.Runtime.EventLog; using EventLogExpert.Runtime.FilterPane; using EventLogExpert.Runtime.Histogram; @@ -10,6 +11,7 @@ using EventLogExpert.Scenarios.Catalog; using Fluxor; using NSubstitute; +using NSubstitute.ExceptionExtensions; namespace EventLogExpert.Runtime.Tests.Scenarios; @@ -192,7 +194,12 @@ public async Task LaunchAsync_ZeroOpenFreshView_RestoresFilterStateCapturedBefor var histogramState = Substitute.For>(); histogramState.Value.Returns(new HistogramState()); - var service = new ScenarioLaunchService(registry, menu, filterPaneState, histogramState, dispatcher); + var folderPicker = Substitute.For(); + var folderEnumerator = Substitute.For(); + var channelReader = Substitute.For(); + + var service = new ScenarioLaunchService( + registry, menu, filterPaneState, histogramState, folderPicker, folderEnumerator, channelReader, dispatcher); await service.LaunchAsync(scenario, dateWindow: null); @@ -200,6 +207,161 @@ public async Task LaunchAsync_ZeroOpenFreshView_RestoresFilterStateCapturedBefor .Dispatch(Arg.Is(action => action != null && ReferenceEquals(action.State, priorState))); } + [Fact] + public async Task LaunchFromFolderAsync_ActivatingScenarioWhenHidden_ShowsTimeline() + { + var (service, _, menu, picker, enumerator, reader, scenario) = + CreateFolder(FolderScenario() with { ActivatesTimeline = true }, timelineVisible: false); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle").Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); + reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); + menu.OpenLogFilesAsync(Arg.Any>(), false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); + + await service.LaunchFromFolderAsync(scenario, dateWindow: null); + + menu.Received(1).SetHistogramVisible(true); + } + + [Fact] + public async Task LaunchFromFolderAsync_CompletedWithUnreadableFile_PlumbsUnreadableCount() + { + var (service, _, menu, picker, enumerator, reader, scenario) = CreateFolder(FolderScenario()); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle") + .Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx", "C:\\bundle\\bad.evtx"])); + reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); + reader.ReadChannel("C:\\bundle\\bad.evtx").Returns(EvtxChannelReadResult.Unreadable); + menu.OpenLogFilesAsync(Arg.Any>(), false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); + + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null); + + Assert.Equal(ScenarioFolderOutcome.Completed, result.Outcome); + Assert.Equal(1, result.Matched); + Assert.Equal(1, result.Unreadable); + } + + [Fact] + public async Task LaunchFromFolderAsync_EmptyFolder_ReturnsNoMatchingLogs() + { + var (service, _, menu, picker, enumerator, _, scenario) = CreateFolder(FolderScenario()); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle").Returns(EvtxFolderScanResult.Empty.Instance); + + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null); + + Assert.Equal(ScenarioFolderOutcome.NoMatchingLogs, result.Outcome); + await menu.DidNotReceive().OpenLogFilesAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task LaunchFromFolderAsync_EnumerationAccessDenied_ReturnsError() + { + var (service, _, _, picker, enumerator, _, scenario) = CreateFolder(FolderScenario()); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle").Returns(new EvtxFolderScanResult.AccessDenied("access denied")); + + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null); + + Assert.Equal(ScenarioFolderOutcome.Error, result.Outcome); + Assert.Equal("access denied", result.Message); + } + + [Fact] + public async Task LaunchFromFolderAsync_MatchedButNoneOpened_RollsBackAndReturnsNoLogsOpened() + { + var (service, dispatcher, menu, picker, enumerator, reader, scenario) = CreateFolder(FolderScenario()); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle").Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); + reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); + menu.OpenLogFilesAsync(Arg.Any>(), false).Returns(new OpenLogsBatchResult(0, 1, 0, 0, [])); + + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null); + + Assert.Equal(ScenarioFolderOutcome.NoLogsOpened, result.Outcome); + Assert.Equal(1, result.Empty); + dispatcher.Received(1).Dispatch(Arg.Any()); + menu.DidNotReceive().SetHistogramVisible(Arg.Any()); + } + + [Fact] + public async Task LaunchFromFolderAsync_MatchOpened_ReturnsCompletedAndAppliesFiltersToMatchedFiles() + { + var (service, dispatcher, menu, picker, enumerator, reader, scenario) = CreateFolder(FolderScenario()); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle").Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); + reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); + menu.OpenLogFilesAsync(Arg.Any>(), false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); + + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null); + + Assert.Equal(ScenarioFolderOutcome.Completed, result.Outcome); + Assert.Equal(1, result.Opened); + Assert.Equal(1, result.Matched); + + Received.InOrder(() => + { + dispatcher.Dispatch(Arg.Any()); + dispatcher.Dispatch(Arg.Any()); + }); + + await menu.Received(1).OpenLogFilesAsync( + Arg.Is>(paths => paths.Single() == "C:\\bundle\\System.evtx"), false); + } + + [Fact] + public async Task LaunchFromFolderAsync_NoChannelMatch_ReturnsNoMatchingLogsWithMissingChannel() + { + var (service, _, menu, picker, enumerator, reader, scenario) = CreateFolder(FolderScenario()); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle").Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\other.evtx"])); + reader.ReadChannel("C:\\bundle\\other.evtx").Returns(EvtxChannelReadResult.FromChannel("Application")); + + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null); + + Assert.Equal(ScenarioFolderOutcome.NoMatchingLogs, result.Outcome); + Assert.Contains("System", result.MissingChannels); + await menu.DidNotReceive().OpenLogFilesAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task LaunchFromFolderAsync_NoMatchButUnreadable_ReturnsError() + { + var (service, _, _, picker, enumerator, reader, scenario) = CreateFolder(FolderScenario()); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle").Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\corrupt.evtx"])); + reader.ReadChannel("C:\\bundle\\corrupt.evtx").Returns(EvtxChannelReadResult.Unreadable); + + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null); + + Assert.Equal(ScenarioFolderOutcome.Error, result.Outcome); + Assert.Equal(1, result.Unreadable); + } + + [Fact] + public async Task LaunchFromFolderAsync_PickerCancelled_ReturnsCancelledAndTouchesNothing() + { + var (service, dispatcher, menu, picker, _, _, scenario) = CreateFolder(FolderScenario()); + picker.PickFolderAsync().Returns((string?)null); + + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null); + + Assert.Equal(ScenarioFolderOutcome.Cancelled, result.Outcome); + await menu.DidNotReceive().OpenLogFilesAsync(Arg.Any>(), Arg.Any()); + dispatcher.DidNotReceive().Dispatch(Arg.Any()); + } + + [Fact] + public async Task LaunchFromFolderAsync_PickerThrows_ReturnsError() + { + var (service, _, _, picker, _, _, scenario) = CreateFolder(FolderScenario()); + picker.PickFolderAsync().ThrowsAsync(new InvalidOperationException("picker boom")); + + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null); + + Assert.Equal(ScenarioFolderOutcome.Error, result.Outcome); + Assert.Equal("picker boom", result.Message); + } + private static (IScenarioLaunchService Service, IDispatcher Dispatcher, IMenuActionService Menu, ScenarioDefinition Scenario) Create(OpenLogsBatchResult openResult, bool activatesTimeline = false, bool timelineVisible = false) { @@ -215,9 +377,42 @@ private static (IScenarioLaunchService Service, IDispatcher Dispatcher, IMenuAct var histogramState = Substitute.For>(); histogramState.Value.Returns(new HistogramState { IsVisible = timelineVisible }); + var folderPicker = Substitute.For(); + var folderEnumerator = Substitute.For(); + var channelReader = Substitute.For(); + var dispatcher = Substitute.For(); - var service = new ScenarioLaunchService(registry, menu, filterPaneState, histogramState, dispatcher); + var service = new ScenarioLaunchService( + registry, menu, filterPaneState, histogramState, folderPicker, folderEnumerator, channelReader, dispatcher); return (service, dispatcher, menu, scenario); } + + private static (IScenarioLaunchService Service, IDispatcher Dispatcher, IMenuActionService Menu, + IFolderPickerService FolderPicker, IEvtxFolderEnumerator Enumerator, IEvtxChannelReader ChannelReader, + ScenarioDefinition Scenario) + CreateFolder(ScenarioDefinition scenario, bool timelineVisible = false) + { + var registry = ScenarioTestData.Registry(scenario); + + var menu = Substitute.For(); + + var filterPaneState = Substitute.For>(); + filterPaneState.Value.Returns(new FilterPaneState()); + + var histogramState = Substitute.For>(); + histogramState.Value.Returns(new HistogramState { IsVisible = timelineVisible }); + + var folderPicker = Substitute.For(); + var enumerator = Substitute.For(); + var channelReader = Substitute.For(); + + var dispatcher = Substitute.For(); + var service = new ScenarioLaunchService( + registry, menu, filterPaneState, histogramState, folderPicker, enumerator, channelReader, dispatcher); + + return (service, dispatcher, menu, folderPicker, enumerator, channelReader, scenario); + } + + private static ScenarioDefinition FolderScenario() => ScenarioTestData.Single("a", "System", 1000); } diff --git a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs index 2d13c375..e28e2d65 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs @@ -4,6 +4,7 @@ using AngleSharp.Dom; using Bunit; using EventLogExpert.Filtering.Evaluation; +using EventLogExpert.Runtime.Alerts; using EventLogExpert.Runtime.Announcement; using EventLogExpert.Runtime.Common.Versioning; using EventLogExpert.Runtime.EventLog; @@ -24,10 +25,12 @@ public sealed class EmptyStateDashboardTests : BunitContext { private const string ActiveDetailLaunch = ".sidebar-tabs-tabpanel.active .scenario-detail__launch"; private const string ActiveDetailName = ".sidebar-tabs-tabpanel.active .scenario-detail__name"; + private const string ActiveDetailOpenFolder = ".sidebar-tabs-tabpanel.active .scenario-detail__open-folder"; private const string ActiveDetailStar = ".sidebar-tabs-tabpanel.active .scenario-detail__star"; private const string ActiveOption = ".sidebar-tabs-tabpanel.active [role='option']"; private readonly IMenuActionService _actions = Substitute.For(); + private readonly IAlertDialogService _alertDialog = Substitute.For(); private readonly IAnnouncementService _announcer = Substitute.For(); private readonly IScenarioFavoriteCommands _favoriteCommands = Substitute.For(); private readonly IState _favorites = Substitute.For>(); @@ -49,6 +52,7 @@ public EmptyStateDashboardTests() _favoritesSelection.Value.Returns(ImmutableHashSet.Empty); Services.AddSingleton(_actions); + Services.AddSingleton(_alertDialog); Services.AddSingleton(_announcer); Services.AddSingleton(_favoriteCommands); Services.AddSingleton(_favorites); @@ -139,6 +143,55 @@ public void DetailLaunch_WhenLaunchOpensNothing_AnnouncesFailure() _announcer.Received(1).Announce(Arg.Is(message => message != null && message.Contains("No channels")))); } + [Fact] + public void DetailOpenFromFolder_WhenCompleted_AnnouncesWithoutAlert() + { + _scenarioLaunch.LaunchFromFolderAsync(Arg.Any(), Arg.Any()) + .Returns(new ScenarioFolderLaunchResult { Outcome = ScenarioFolderOutcome.Completed, Opened = 1, Matched = 1 }); + _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(ActiveDetailOpenFolder))); + + cut.Find(ActiveDetailOpenFolder).Click(); + + cut.WaitForAssertion(() => + { + _announcer.Received(1).Announce(Arg.Any()); + _alertDialog.DidNotReceive().ShowAlert(Arg.Any(), Arg.Any(), Arg.Any()); + }); + } + + [Fact] + public void DetailOpenFromFolder_WhenError_ShowsErrorAlert() + { + _scenarioLaunch.LaunchFromFolderAsync(Arg.Any(), Arg.Any()) + .Returns(ScenarioFolderLaunchResult.Error("access denied")); + _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(ActiveDetailOpenFolder))); + + cut.Find(ActiveDetailOpenFolder).Click(); + + cut.WaitForAssertion(() => _alertDialog.Received(1).ShowErrorAlert("Open from folder", "access denied")); + } + + [Fact] + public void DetailOpenFromFolder_WhenNoMatchingLogs_ShowsVisibleAlert() + { + _scenarioLaunch.LaunchFromFolderAsync(Arg.Any(), Arg.Any()) + .Returns(new ScenarioFolderLaunchResult { Outcome = ScenarioFolderOutcome.NoMatchingLogs }); + _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(ActiveDetailOpenFolder))); + + cut.Find(ActiveDetailOpenFolder).Click(); + + cut.WaitForAssertion(() => _alertDialog.Received(1).ShowAlert("Open from folder", Arg.Any(), "OK")); + } + [Fact] public void DetailStar_TogglesFavorite() { diff --git a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/ScenarioDetailTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/ScenarioDetailTests.cs index a6f37815..61bcc6a8 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/ScenarioDetailTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/ScenarioDetailTests.cs @@ -197,6 +197,42 @@ public void Launch_WhenEnabled_InvokesLaunch() Assert.True(launched); } + [Fact] + public void OpenFromFolder_Click_InvokesLaunchFromFolder() + { + bool launched = false; + + var cut = Render(parameters => parameters + .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes")) + .Add(detail => detail.ElevationReasonId, "reason") + .Add(detail => detail.OnLaunchFromFolder, () => launched = true)); + + cut.Find(".scenario-detail__open-folder").Click(); + + Assert.True(launched); + } + + [Fact] + public void OpenFromFolder_WhenLiveLaunchAdminDisabled_StaysAvailable() + { + bool launched = false; + + // Opening exported files from a folder needs no elevation, so the folder action ignores the live-launch admin gate. + var cut = Render(parameters => parameters + .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes")) + .Add(detail => detail.ElevationReasonId, "reason") + .Add(detail => detail.IsDisabled, true) + .Add(detail => detail.OnLaunchFromFolder, () => launched = true)); + + var folder = cut.Find(".scenario-detail__open-folder"); + + Assert.Null(folder.GetAttribute("aria-disabled")); + + folder.Click(); + + Assert.True(launched); + } + [Fact] public void RendersNamePurposeAndEyebrow() { From f301af6adeb5e316a31e22a620453ba450005694 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Sat, 18 Jul 2026 19:55:27 +0000 Subject: [PATCH 3/5] Expose the full scenario catalog for folder launch --- .../Scenarios/ChannelPresenceProbe.cs | 14 +-- .../Scenarios/IChannelPresenceProbe.cs | 3 + .../Scenarios/IScenarioQueryService.cs | 5 +- .../Scenarios/LivePresence.cs | 10 ++ .../Scenarios/ScenarioQueryService.cs | 18 ++-- .../Dashboard/EmptyStateDashboard.razor | 36 +++++-- .../Dashboard/EmptyStateDashboard.razor.cs | 15 ++- .../Dashboard/ScenarioBrowserPanel.razor | 8 +- .../Dashboard/ScenarioBrowserPanel.razor.cs | 4 +- .../Dashboard/ScenarioDetail.razor | 12 ++- .../Dashboard/ScenarioDetail.razor.cs | 6 +- .../Dashboard/ScenarioDetail.razor.css | 7 ++ .../Scenarios/ChannelPresenceProbeTests.cs | 21 +++++ .../Scenarios/ScenarioLaunchServiceTests.cs | 2 +- .../Scenarios/ScenarioQueryServiceTests.cs | 51 +++++++++- .../Dashboard/EmptyStateDashboardTests.cs | 36 +++++++ .../Dashboard/ScenarioBrowserPanelTests.cs | 29 ++++-- .../Dashboard/ScenarioDetailTests.cs | 94 ++++++++++++------- 18 files changed, 289 insertions(+), 82 deletions(-) create mode 100644 src/EventLogExpert.Runtime/Scenarios/LivePresence.cs diff --git a/src/EventLogExpert.Runtime/Scenarios/ChannelPresenceProbe.cs b/src/EventLogExpert.Runtime/Scenarios/ChannelPresenceProbe.cs index 58b88276..c0a064b1 100644 --- a/src/EventLogExpert.Runtime/Scenarios/ChannelPresenceProbe.cs +++ b/src/EventLogExpert.Runtime/Scenarios/ChannelPresenceProbe.cs @@ -29,7 +29,13 @@ internal ChannelPresenceProbe(ITraceLogger traceLogger, Func _readChannels = readChannels; } - public IReadOnlySet GetPresentChannels() + public IReadOnlySet GetPresentChannels() => TryGetPresentChannels() ?? s_empty; + + public bool IsPresent(string logName) => GetPresentChannels().Contains(logName); + + public Task PrimeAsync() => Task.Run(GetPresentChannels); + + public IReadOnlySet? TryGetPresentChannels() { var cached = _cache; @@ -46,7 +52,7 @@ public IReadOnlySet GetPresentChannels() // A failed read is not cached, so the next read retries. if (loaded is not null) { _cache = loaded; } - return loaded ?? s_empty; + return loaded; } finally { @@ -54,10 +60,6 @@ public IReadOnlySet GetPresentChannels() } } - public bool IsPresent(string logName) => GetPresentChannels().Contains(logName); - - public Task PrimeAsync() => Task.Run(GetPresentChannels); - private IReadOnlySet? TryReadChannels() { try diff --git a/src/EventLogExpert.Runtime/Scenarios/IChannelPresenceProbe.cs b/src/EventLogExpert.Runtime/Scenarios/IChannelPresenceProbe.cs index 375e29ac..80e9b5dc 100644 --- a/src/EventLogExpert.Runtime/Scenarios/IChannelPresenceProbe.cs +++ b/src/EventLogExpert.Runtime/Scenarios/IChannelPresenceProbe.cs @@ -13,4 +13,7 @@ internal interface IChannelPresenceProbe /// Warms the cache on a background thread. Task PrimeAsync(); + + /// Present channel names, or null when the channel set could not be read. + IReadOnlySet? TryGetPresentChannels(); } diff --git a/src/EventLogExpert.Runtime/Scenarios/IScenarioQueryService.cs b/src/EventLogExpert.Runtime/Scenarios/IScenarioQueryService.cs index 83f28d7c..fc73eed8 100644 --- a/src/EventLogExpert.Runtime/Scenarios/IScenarioQueryService.cs +++ b/src/EventLogExpert.Runtime/Scenarios/IScenarioQueryService.cs @@ -11,6 +11,9 @@ public interface IScenarioQueryService /// Scenarios whose channels match a currently-loaded log name. IReadOnlyList GetInAppScenarios(IReadOnlyCollection loadedLogNames); - /// Scenarios with at least one required channel present on the host. + /// A snapshot of which channels exist on this host, for gating live launches. + LivePresence GetLivePresence(); + + /// Every channel-presence scenario in the catalog, regardless of local availability. IReadOnlyList GetSplashScenarios(); } diff --git a/src/EventLogExpert.Runtime/Scenarios/LivePresence.cs b/src/EventLogExpert.Runtime/Scenarios/LivePresence.cs new file mode 100644 index 00000000..ee0dc882 --- /dev/null +++ b/src/EventLogExpert.Runtime/Scenarios/LivePresence.cs @@ -0,0 +1,10 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Scenarios; + +/// +/// A snapshot of the host's channel set. is false when the channel set could not be read, in +/// which case callers should treat every scenario as launchable rather than falsely reporting it offline. +/// +public sealed record LivePresence(bool Known, IReadOnlySet Present); diff --git a/src/EventLogExpert.Runtime/Scenarios/ScenarioQueryService.cs b/src/EventLogExpert.Runtime/Scenarios/ScenarioQueryService.cs index 3fe485af..ac3d1902 100644 --- a/src/EventLogExpert.Runtime/Scenarios/ScenarioQueryService.cs +++ b/src/EventLogExpert.Runtime/Scenarios/ScenarioQueryService.cs @@ -2,6 +2,7 @@ // // Licensed under the MIT License. using EventLogExpert.Scenarios.Catalog; +using System.Collections.Frozen; namespace EventLogExpert.Runtime.Scenarios; @@ -20,14 +21,17 @@ public IReadOnlyList GetInAppScenarios(IReadOnlyCollection scenario.Channels.Any(loaded.Contains))]; } - public IReadOnlyList GetSplashScenarios() + public LivePresence GetLivePresence() { - var present = _presenceProbe.GetPresentChannels(); + var present = _presenceProbe.TryGetPresentChannels(); - return [ - .._registry.Scenarios - .Where(scenario => scenario.Gating == ScenarioGating.ChannelPresence) - .Where(scenario => scenario.Channels.Any(present.Contains)) - ]; + return present is null + ? new LivePresence(false, FrozenSet.Empty) + : new LivePresence(true, present); } + + public IReadOnlyList GetSplashScenarios() => + [ + .._registry.Scenarios.Where(scenario => scenario.Gating == ScenarioGating.ChannelPresence) + ]; } diff --git a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor index 9dbfa170..8b2fa515 100644 --- a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor +++ b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor @@ -27,24 +27,44 @@

Quick Launch

- + Application + System (live) - + Application (live) - + System (live) @if (Version.IsAdmin) { - + Security (live) } else { - + Security (live) } @@ -65,15 +85,15 @@ } else if (_categories.Count == 0) { -

No scenarios are available for this machine.

+

No scenarios are available.

} else { - Scenarios)> _categories = []; private bool _isBusy; + private LivePresence _livePresence = new(false, FrozenSet.Empty); private bool _pendingTabFocus; private SidebarTabs? _sidebarTabs; private IReadOnlyList? _splashScenarios; @@ -126,7 +128,10 @@ protected override void OnInitialized() protected override async Task OnInitializedAsync() { - _splashScenarios = await Task.Run(ScenarioQuery.GetSplashScenarios); + // Both the catalog and the host channel set are read once, off the UI thread; the presence snapshot warms the + // probe cache that GetSplashScenarios no longer touches now that it returns the full catalog. + (_splashScenarios, _livePresence) = await Task.Run( + () => (ScenarioQuery.GetSplashScenarios(), ScenarioQuery.GetLivePresence())); RebuildCategories(); if (_categories.Count > 0 && !_categories.Any(category => category.Category.Equals(_activeCategory))) @@ -187,7 +192,13 @@ _splashScenarios is null private bool IsFavored(ScenarioDefinition scenario) => ScenarioFavorites.Value.FavoriteScenarioIds.Contains(scenario.Id); - private bool IsScenarioDisabled(ScenarioDefinition scenario) => scenario.RequiresAdmin && !Version.IsAdmin; + // A scenario is "live present" when the host exposes one of its channels. When the channel set could not be read + // (Unknown), every scenario stays launchable so a probe failure never falsely reports a scenario offline. + private bool IsLivePresent(ScenarioDefinition scenario) => + !_livePresence.Known || scenario.Channels.Any(_livePresence.Present.Contains); + + private bool IsScenarioDisabled(ScenarioDefinition scenario) => + (scenario.RequiresAdmin && !Version.IsAdmin) || !IsLivePresent(scenario); private Task LaunchScenarioAsync(ScenarioDefinition scenario) => RunGuardedAsync(async () => diff --git a/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor b/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor index e5f43d48..5c9eae81 100644 --- a/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor +++ b/src/EventLogExpert.UI/Dashboard/ScenarioBrowserPanel.razor @@ -8,9 +8,7 @@
    @foreach (var scenario in Scenarios) { -
  • @if (Selected is not null) { - IsFavored { get; set; } = static _ => false; + [Parameter][EditorRequired] public Func IsLivePresent { get; set; } = static _ => true; + [Parameter][EditorRequired] public Func IsScenarioDisabled { get; set; } = static _ => false; [Parameter] public EventCallback OnLaunch { get; set; } diff --git a/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor b/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor index 74cb1745..e17cab11 100644 --- a/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor +++ b/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor @@ -18,9 +18,9 @@ @if (Scenario.RequiresAdmin) {
    - + - Requires administrator + Live launch requires administrator.
    } @@ -41,13 +41,19 @@
} + @if (IsDisabled && !IsLivePresent) + { +

+ This log is not on this computer. Use Open from folder to analyze an exported copy. +

+ }
- throw new InvalidOperationException("event log service unavailable")); + + Assert.Null(probe.TryGetPresentChannels()); + } + + [Fact] + public void TryGetPresentChannels_WhenReadSucceeds_ReturnsChannels() + { + var probe = new ChannelPresenceProbe(Logger(), static () => ["System", "Security"]); + + var channels = probe.TryGetPresentChannels(); + + Assert.NotNull(channels); + Assert.Contains("System", channels); + } + private static ITraceLogger Logger() => Substitute.For(); } diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs index 51f4ce86..13142e3e 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs @@ -305,7 +305,7 @@ public async Task LaunchFromFolderAsync_MatchOpened_ReturnsCompletedAndAppliesFi }); await menu.Received(1).OpenLogFilesAsync( - Arg.Is>(paths => paths.Single() == "C:\\bundle\\System.evtx"), false); + Arg.Is>(paths => paths != null && paths.Single() == "C:\\bundle\\System.evtx"), false); } [Fact] diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioQueryServiceTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioQueryServiceTests.cs index 2fa2ac07..00e903ed 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioQueryServiceTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioQueryServiceTests.cs @@ -2,6 +2,7 @@ // // Licensed under the MIT License. using EventLogExpert.Runtime.Scenarios; +using EventLogExpert.Scenarios.Catalog; using NSubstitute; namespace EventLogExpert.Runtime.Tests.Scenarios; @@ -41,10 +42,50 @@ public void GetInAppScenarios_ShowsCombinedScenario_WhenAnyChannelLoaded() } [Fact] - public void GetSplashScenarios_HidesScenariosWhoseChannelsAreAbsent() + public void GetLivePresence_WhenChannelsReadable_IsKnownWithChannels() { var probe = Substitute.For(); - probe.GetPresentChannels().Returns(new HashSet(StringComparer.OrdinalIgnoreCase) { "System" }); + probe.TryGetPresentChannels().Returns(new HashSet(StringComparer.OrdinalIgnoreCase) { "System" }); + + var service = new ScenarioQueryService(ScenarioTestData.Registry(), probe); + + var presence = service.GetLivePresence(); + + Assert.True(presence.Known); + Assert.Contains("System", presence.Present); + } + + [Fact] + public void GetLivePresence_WhenChannelsUnreadable_IsUnknown() + { + var probe = Substitute.For(); + probe.TryGetPresentChannels().Returns((IReadOnlySet?)null); + + var service = new ScenarioQueryService(ScenarioTestData.Registry(), probe); + + var presence = service.GetLivePresence(); + + Assert.False(presence.Known); + Assert.Empty(presence.Present); + } + + [Fact] + public void GetSplashScenarios_ExcludesNonChannelPresenceGating() + { + var registry = ScenarioTestData.Registry( + ScenarioTestData.Single("channel-gated", "System", 1000), + ScenarioTestData.Single("source-gated", "Application", 1001) with { Gating = ScenarioGating.SourceRegistration }); + + var service = new ScenarioQueryService(registry, Substitute.For()); + + Assert.Equal(["channel-gated"], service.GetSplashScenarios().Select(scenario => scenario.Id)); + } + + [Fact] + public void GetSplashScenarios_ReturnsAllChannelPresenceScenarios_RegardlessOfLocalAvailability() + { + var probe = Substitute.For(); + probe.TryGetPresentChannels().Returns(new HashSet(StringComparer.OrdinalIgnoreCase) { "System" }); var registry = ScenarioTestData.Registry( ScenarioTestData.Single("present", "System", 1000), @@ -52,6 +93,10 @@ public void GetSplashScenarios_HidesScenariosWhoseChannelsAreAbsent() var service = new ScenarioQueryService(registry, probe); - Assert.Equal(["present"], service.GetSplashScenarios().Select(scenario => scenario.Id)); + var ids = service.GetSplashScenarios().Select(scenario => scenario.Id).ToHashSet(); + + Assert.Contains("present", ids); + Assert.Contains("absent", ids); + Assert.Equal(2, ids.Count); } } diff --git a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs index e28e2d65..d13fdf98 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs @@ -48,6 +48,8 @@ public EmptyStateDashboardTests() _scenarioLaunch.LaunchAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new ScenarioLaunchResult(1, 0, 0)); _scenarioQuery.GetSplashScenarios().Returns([]); + _scenarioQuery.GetLivePresence() + .Returns(new LivePresence(true, new HashSet(StringComparer.OrdinalIgnoreCase) { "System" })); _favorites.Value.Returns(new ScenarioFavoritesState()); _favoritesSelection.Value.Returns(ImmutableHashSet.Empty); @@ -127,6 +129,20 @@ public void DetailLaunch_InvokesScenarioLaunchAndAnnounces() }); } + [Fact] + public void DetailLaunch_WhenChannelNotOnHost_IsDisabledWithOfflineNote() + { + _scenarioQuery.GetLivePresence() + .Returns(new LivePresence(true, new HashSet(StringComparer.OrdinalIgnoreCase))); + _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(ActiveDetailLaunch))); + + Assert.Equal("true", cut.Find(ActiveDetailLaunch).GetAttribute("aria-disabled")); + Assert.NotEmpty(cut.FindAll(".sidebar-tabs-tabpanel.active .scenario-detail__unavailable")); + } + [Fact] public void DetailLaunch_WhenLaunchOpensNothing_AnnouncesFailure() { @@ -143,6 +159,26 @@ public void DetailLaunch_WhenLaunchOpensNothing_AnnouncesFailure() _announcer.Received(1).Announce(Arg.Is(message => message != null && message.Contains("No channels")))); } + [Fact] + public void DetailLaunch_WhenPresenceUnknown_StaysLaunchable() + { + _scenarioQuery.GetLivePresence() + .Returns(new LivePresence(false, new HashSet(StringComparer.OrdinalIgnoreCase))); + _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(ActiveDetailLaunch))); + + var launch = cut.Find(ActiveDetailLaunch); + Assert.Equal("false", launch.GetAttribute("aria-disabled")); + Assert.Empty(cut.FindAll(".sidebar-tabs-tabpanel.active .scenario-detail__unavailable")); + + launch.Click(); + + cut.WaitForAssertion(() => _scenarioLaunch.Received(1) + .LaunchAsync(Arg.Is(scenario => scenario != null && scenario.Id == "application-crashes"), null)); + } + [Fact] public void DetailOpenFromFolder_WhenCompleted_AnnouncesWithoutAlert() { diff --git a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/ScenarioBrowserPanelTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/ScenarioBrowserPanelTests.cs index 42d3a4bb..21597f3c 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/ScenarioBrowserPanelTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/ScenarioBrowserPanelTests.cs @@ -21,7 +21,6 @@ public void Click_SelectsOption() var cut = Render(parameters => parameters .Add(panel => panel.Scenarios, scenarios) .Add(panel => panel.Selected, scenarios[0]) - .Add(panel => panel.ElevationReasonId, "reason") .Add(panel => panel.IsFavored, _ => false) .Add(panel => panel.IsScenarioDisabled, _ => false) .Add(panel => panel.OnSelect, scenario => selected = scenario)); @@ -32,7 +31,7 @@ public void Click_SelectsOption() } [Fact] - public void DisabledOption_IsSelectableButDoesNotLaunch() + public void DisabledOption_IsDimmedAndSelectable_ButDoesNotLaunch() { var disabled = Scenario("locked", "Locked", requiresAdmin: true); bool launched = false; @@ -40,12 +39,15 @@ public void DisabledOption_IsSelectableButDoesNotLaunch() var cut = Render(parameters => parameters .Add(panel => panel.Scenarios, [disabled]) .Add(panel => panel.Selected, disabled) - .Add(panel => panel.ElevationReasonId, "reason") .Add(panel => panel.IsFavored, _ => false) .Add(panel => panel.IsScenarioDisabled, _ => true) .Add(panel => panel.OnLaunch, _ => launched = true)); - Assert.Equal("true", cut.Find("[role='option']").GetAttribute("aria-disabled")); + var option = cut.Find("[role='option']"); + + // The option stays operable (selectable), so it must not be aria-disabled; the muted class is the only cue. + Assert.Null(option.GetAttribute("aria-disabled")); + Assert.Contains("scenario-browser__option--disabled", option.GetAttribute("class")); cut.Find(".scenario-detail__launch").Click(); @@ -57,7 +59,6 @@ public void EmptyList_ShowsEmptyState() { var cut = Render(parameters => parameters .Add(panel => panel.Scenarios, []) - .Add(panel => panel.ElevationReasonId, "reason") .Add(panel => panel.IsFavored, _ => false) .Add(panel => panel.IsScenarioDisabled, _ => false)); @@ -65,6 +66,21 @@ public void EmptyList_ShowsEmptyState() Assert.Empty(cut.FindAll(".scenario-detail")); } + [Fact] + public void OfflineScenario_ShowsUnavailableNoteFromLivePresenceFunc() + { + var scenario = Scenario("offline", "Offline"); + + var cut = Render(parameters => parameters + .Add(panel => panel.Scenarios, [scenario]) + .Add(panel => panel.Selected, scenario) + .Add(panel => panel.IsFavored, _ => false) + .Add(panel => panel.IsScenarioDisabled, _ => true) + .Add(panel => panel.IsLivePresent, _ => false)); + + Assert.NotEmpty(cut.FindAll(".scenario-detail__unavailable")); + } + [Fact] public void Option_RendersNameOnly_WithPurposeInTitle() { @@ -73,7 +89,6 @@ public void Option_RendersNameOnly_WithPurposeInTitle() var cut = Render(parameters => parameters .Add(panel => panel.Scenarios, scenarios) .Add(panel => panel.Selected, scenarios[0]) - .Add(panel => panel.ElevationReasonId, "reason") .Add(panel => panel.IsFavored, _ => false) .Add(panel => panel.IsScenarioDisabled, _ => false)); @@ -93,7 +108,6 @@ public void SelectionFollowsFocus_ArrowDown_SelectsNextScenario() var cut = Render(parameters => parameters .Add(panel => panel.Scenarios, scenarios) .Add(panel => panel.Selected, scenarios[0]) - .Add(panel => panel.ElevationReasonId, "reason") .Add(panel => panel.IsFavored, _ => false) .Add(panel => panel.IsScenarioDisabled, _ => false) .Add(panel => panel.OnSelect, scenario => selected = scenario)); @@ -111,7 +125,6 @@ public void SelectionStateless_SelectedParamDrivesDetail() var cut = Render(parameters => parameters .Add(panel => panel.Scenarios, scenarios) .Add(panel => panel.Selected, scenarios[1]) - .Add(panel => panel.ElevationReasonId, "reason") .Add(panel => panel.IsFavored, _ => false) .Add(panel => panel.IsScenarioDisabled, _ => false)); diff --git a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/ScenarioDetailTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/ScenarioDetailTests.cs index 61bcc6a8..530153b4 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/ScenarioDetailTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/ScenarioDetailTests.cs @@ -18,8 +18,7 @@ public sealed class ScenarioDetailTests : BunitContext public void AdminBadge_WhenNotRequiresAdmin_IsAbsent() { var cut = Render(parameters => parameters - .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes")) - .Add(detail => detail.ElevationReasonId, "reason")); + .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes"))); Assert.Empty(cut.FindAll(".scenario-detail__admin-badge")); } @@ -31,12 +30,11 @@ public void AdminBadge_WhenRequiresAdmin_IsRendered() .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes") with { RequiresAdmin = true - }) - .Add(detail => detail.ElevationReasonId, "reason")); + })); var badge = cut.Find(".scenario-detail__admin-badge"); - Assert.Contains("Requires administrator", badge.TextContent); + Assert.Contains("Live launch requires administrator", badge.TextContent); Assert.NotEmpty(cut.FindAll(".scenario-detail__admin-badge .bi-shield-lock")); } @@ -47,8 +45,7 @@ public void Facts_ShowsRequiredChannels() .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes") with { Channels = ["Application", "System"] - }) - .Add(detail => detail.ElevationReasonId, "reason")); + })); var values = cut.FindAll(".scenario-detail__fact-value"); @@ -59,8 +56,7 @@ public void Facts_ShowsRequiredChannels() public void Facts_WhenNoOptionalChannels_OmitsAlsoIfPresentLine() { var cut = Render(parameters => parameters - .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes")) - .Add(detail => detail.ElevationReasonId, "reason")); + .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes"))); Assert.Empty(cut.FindAll(".scenario-detail__fact-muted")); } @@ -73,8 +69,7 @@ public void Facts_WhenOptionalChannelsPresent_ShowsAlsoIfPresentLine() { Channels = ["Application"], OptionalChannels = ["Setup", "Security"] - }) - .Add(detail => detail.ElevationReasonId, "reason")); + })); Assert.Equal( "Also if present: Setup, Security", @@ -92,8 +87,7 @@ public void Filters_RenderOneFormattedLinePerRow() new ScenarioFilterRow(EqualsFilter(EventProperty.Id, "100")), new ScenarioFilterRow(EqualsFilter(EventProperty.Level, "Error")) ] - }) - .Add(detail => detail.ElevationReasonId, "reason")); + })); var lines = cut.FindAll(".scenario-detail__filter"); @@ -109,8 +103,7 @@ public void Filters_WhenColorSet_RendersSwatchWithDataHighlight() .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes") with { Filters = [new ScenarioFilterRow(EqualsFilter(EventProperty.Id, "100"), Color: HighlightColor.Red)] - }) - .Add(detail => detail.ElevationReasonId, "reason")); + })); Assert.Equal("red", cut.Find(".scenario-detail__swatch").GetAttribute("data-highlight")); } @@ -119,8 +112,7 @@ public void Filters_WhenColorSet_RendersSwatchWithDataHighlight() public void Filters_WhenEmpty_OmitsFiltersSection() { var cut = Render(parameters => parameters - .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes")) - .Add(detail => detail.ElevationReasonId, "reason")); + .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes"))); Assert.Empty(cut.FindAll(".scenario-detail__filter")); Assert.DoesNotContain( @@ -135,8 +127,7 @@ public void Filters_WhenExcluded_PrefixesExclude() .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes") with { Filters = [new ScenarioFilterRow(EqualsFilter(EventProperty.Id, "100"), IsExcluded: true)] - }) - .Add(detail => detail.ElevationReasonId, "reason")); + })); Assert.Equal("Exclude Id == 100", cut.Find(".scenario-detail__filter").TextContent.Trim()); } @@ -152,8 +143,7 @@ public void Filters_WhenTryFormatFails_RowIsSkipped() new ScenarioFilterRow(EqualsFilter(EventProperty.Level, " ")), new ScenarioFilterRow(EqualsFilter(EventProperty.Id, "100")) ] - }) - .Add(detail => detail.ElevationReasonId, "reason")); + })); var lines = cut.FindAll(".scenario-detail__filter"); @@ -162,20 +152,25 @@ public void Filters_WhenTryFormatFails_RowIsSkipped() } [Fact] - public void Launch_WhenDisabled_IsGuardedAndDescribed() + public void Launch_WhenAdminGated_IsGuardedAndDescribesAdminBadge() { bool launched = false; var cut = Render(parameters => parameters - .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes")) - .Add(detail => detail.ElevationReasonId, "reason") + .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes") with + { + RequiresAdmin = true + }) .Add(detail => detail.IsDisabled, true) .Add(detail => detail.OnLaunch, () => launched = true)); var launch = cut.Find(".scenario-detail__launch"); + var describedBy = launch.GetAttribute("aria-describedby"); Assert.Equal("true", launch.GetAttribute("aria-disabled")); - Assert.Equal("reason", launch.GetAttribute("aria-describedby")); + Assert.False(string.IsNullOrEmpty(describedBy)); + Assert.Contains("administrator", cut.Find($"#{describedBy}").TextContent, StringComparison.OrdinalIgnoreCase); + Assert.Empty(cut.FindAll(".scenario-detail__unavailable")); launch.Click(); @@ -189,7 +184,6 @@ public void Launch_WhenEnabled_InvokesLaunch() var cut = Render(parameters => parameters .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes")) - .Add(detail => detail.ElevationReasonId, "reason") .Add(detail => detail.OnLaunch, () => launched = true)); cut.Find(".scenario-detail__launch").Click(); @@ -197,6 +191,41 @@ public void Launch_WhenEnabled_InvokesLaunch() Assert.True(launched); } + [Fact] + public void Launch_WhenOffline_IsGuardedAndDescribesUnavailableNote() + { + bool launched = false; + + var cut = Render(parameters => parameters + .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes")) + .Add(detail => detail.IsDisabled, true) + .Add(detail => detail.IsLivePresent, false) + .Add(detail => detail.OnLaunch, () => launched = true)); + + var launch = cut.Find(".scenario-detail__launch"); + var describedBy = launch.GetAttribute("aria-describedby"); + var note = cut.Find(".scenario-detail__unavailable"); + + Assert.Equal("true", launch.GetAttribute("aria-disabled")); + Assert.Equal(note.Id, describedBy); + Assert.Contains("Open from folder", note.TextContent); + + launch.Click(); + + Assert.False(launched); + } + + [Fact] + public void OfflineNote_WhenLivePresent_IsAbsent() + { + var cut = Render(parameters => parameters + .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes")) + .Add(detail => detail.IsDisabled, true) + .Add(detail => detail.IsLivePresent, true)); + + Assert.Empty(cut.FindAll(".scenario-detail__unavailable")); + } + [Fact] public void OpenFromFolder_Click_InvokesLaunchFromFolder() { @@ -204,7 +233,6 @@ public void OpenFromFolder_Click_InvokesLaunchFromFolder() var cut = Render(parameters => parameters .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes")) - .Add(detail => detail.ElevationReasonId, "reason") .Add(detail => detail.OnLaunchFromFolder, () => launched = true)); cut.Find(".scenario-detail__open-folder").Click(); @@ -213,15 +241,16 @@ public void OpenFromFolder_Click_InvokesLaunchFromFolder() } [Fact] - public void OpenFromFolder_WhenLiveLaunchAdminDisabled_StaysAvailable() + public void OpenFromFolder_WhenOffline_StaysAvailable() { bool launched = false; - // Opening exported files from a folder needs no elevation, so the folder action ignores the live-launch admin gate. + // Opening exported files from a folder needs no elevation and no local channel, so the folder action stays + // available even when the live Launch is disabled because the log is not on this computer. var cut = Render(parameters => parameters .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes")) - .Add(detail => detail.ElevationReasonId, "reason") .Add(detail => detail.IsDisabled, true) + .Add(detail => detail.IsLivePresent, false) .Add(detail => detail.OnLaunchFromFolder, () => launched = true)); var folder = cut.Find(".scenario-detail__open-folder"); @@ -237,8 +266,7 @@ public void OpenFromFolder_WhenLiveLaunchAdminDisabled_StaysAvailable() public void RendersNamePurposeAndEyebrow() { var cut = Render(parameters => parameters - .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes")) - .Add(detail => detail.ElevationReasonId, "reason")); + .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes"))); Assert.Equal("Application crashes", cut.Find(".scenario-detail__name").TextContent); Assert.Equal("Purpose", cut.Find(".scenario-detail__purpose").TextContent); @@ -252,7 +280,6 @@ public void Star_Click_InvokesToggleFavorite() var cut = Render(parameters => parameters .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes")) - .Add(detail => detail.ElevationReasonId, "reason") .Add(detail => detail.OnToggleFavorite, () => toggled = true)); cut.Find(".scenario-detail__star").Click(); @@ -265,7 +292,6 @@ public void Star_WhenFavored_IsPressedWithFilledIcon() { var cut = Render(parameters => parameters .Add(detail => detail.Scenario, Scenario("application-crashes", "Application crashes")) - .Add(detail => detail.ElevationReasonId, "reason") .Add(detail => detail.IsFavored, true)); Assert.Equal("true", cut.Find(".scenario-detail__star").GetAttribute("aria-pressed")); From affbdc07566316fab0ac1e2b933026ff1911b04b Mon Sep 17 00:00:00 2001 From: jschick04 Date: Mon, 20 Jul 2026 17:04:49 +0000 Subject: [PATCH 4/5] Make the scenario folder scan cancellable --- .../Scenarios/IEvtxFolderEnumerator.cs | 2 +- .../Scenarios/IScenarioLaunchService.cs | 7 +- .../Scenarios/ScenarioLaunchService.cs | 78 +++++++++---- .../Dashboard/EmptyStateDashboard.razor.cs | 5 +- .../Activation/EvtxFolderEnumerator.cs | 18 ++- .../FilePicker/MauiEvtxFolderEnumerator.cs | 4 +- .../Scenarios/ScenarioLaunchServiceTests.cs | 108 ++++++++++++++---- .../Dashboard/EmptyStateDashboardTests.cs | 6 +- .../EvtxFolderEnumeratorTests.cs | 36 +++--- 9 files changed, 196 insertions(+), 68 deletions(-) diff --git a/src/EventLogExpert.Runtime/Scenarios/IEvtxFolderEnumerator.cs b/src/EventLogExpert.Runtime/Scenarios/IEvtxFolderEnumerator.cs index cf31566a..f59ffdc9 100644 --- a/src/EventLogExpert.Runtime/Scenarios/IEvtxFolderEnumerator.cs +++ b/src/EventLogExpert.Runtime/Scenarios/IEvtxFolderEnumerator.cs @@ -6,5 +6,5 @@ namespace EventLogExpert.Runtime.Scenarios; /// Lists the top-level .evtx files in a folder, surfacing access and IO failures distinctly. public interface IEvtxFolderEnumerator { - EvtxFolderScanResult EnumerateTopLevel(string folderPath); + EvtxFolderScanResult EnumerateTopLevel(string folderPath, CancellationToken cancellationToken); } diff --git a/src/EventLogExpert.Runtime/Scenarios/IScenarioLaunchService.cs b/src/EventLogExpert.Runtime/Scenarios/IScenarioLaunchService.cs index 2d8f283b..20aff4da 100644 --- a/src/EventLogExpert.Runtime/Scenarios/IScenarioLaunchService.cs +++ b/src/EventLogExpert.Runtime/Scenarios/IScenarioLaunchService.cs @@ -18,7 +18,10 @@ public interface IScenarioLaunchService /// /// Prompts for a folder, opens the exported .evtx files whose channel matches the scenario, and applies /// the scenario's filters to a fresh view. The scenario's filters and channels are read from the definition, so this - /// works even for logs not present on the local host. + /// works even for logs not present on the local host. The folder enumeration and per-file channel probe run off the + /// caller's thread and honor , returning + /// if the scan is abandoned before any log is opened. /// - Task LaunchFromFolderAsync(ScenarioDefinition scenario, DateFilter? dateWindow); + Task LaunchFromFolderAsync( + ScenarioDefinition scenario, DateFilter? dateWindow, CancellationToken cancellationToken = default); } diff --git a/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs b/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs index 03109d5c..dea6c0ce 100644 --- a/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs +++ b/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs @@ -62,7 +62,10 @@ public async Task LaunchAsync( return new ScenarioLaunchResult(result.Opened, result.Empty, result.Failed); } - public async Task LaunchFromFolderAsync(ScenarioDefinition scenario, DateFilter? dateWindow) + public async Task LaunchFromFolderAsync( + ScenarioDefinition scenario, + DateFilter? dateWindow, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(scenario); @@ -79,23 +82,50 @@ public async Task LaunchFromFolderAsync(ScenarioDefi if (folder is null) { return ScenarioFolderLaunchResult.Cancelled; } - switch (_folderEnumerator.EnumerateTopLevel(folder)) + FolderMatch match; + + try { - case EvtxFolderScanResult.AccessDenied denied: - return ScenarioFolderLaunchResult.Error(denied.Message); - case EvtxFolderScanResult.IoError ioError: - return ScenarioFolderLaunchResult.Error(ioError.Message); - case EvtxFolderScanResult.Empty: - return new ScenarioFolderLaunchResult - { - Outcome = ScenarioFolderOutcome.NoMatchingLogs, - MissingChannels = AllTargetChannels(scenario) - }; - case EvtxFolderScanResult.Files files: - return await OpenMatchedLogsAsync(scenario, dateWindow, await ScanAndMatchAsync(files.Paths, scenario)); - default: - return ScenarioFolderLaunchResult.Error("The folder could not be read."); + var scan = await Task.Run( + () => _folderEnumerator.EnumerateTopLevel(folder, cancellationToken), cancellationToken); + + cancellationToken.ThrowIfCancellationRequested(); + + switch (scan) + { + case EvtxFolderScanResult.AccessDenied denied: + return ScenarioFolderLaunchResult.Error(denied.Message); + case EvtxFolderScanResult.IoError ioError: + return ScenarioFolderLaunchResult.Error(ioError.Message); + case EvtxFolderScanResult.Empty: + return new ScenarioFolderLaunchResult + { + Outcome = ScenarioFolderOutcome.NoMatchingLogs, + MissingChannels = AllTargetChannels(scenario) + }; + case EvtxFolderScanResult.Files files: + match = await ScanAndMatchAsync(files.Paths, scenario, cancellationToken); + + // The parallel probe can finish before it observes a late cancellation; re-check here so a scan + // cancelled while probing does not fall through to opening logs or dispatching filters. + cancellationToken.ThrowIfCancellationRequested(); + + break; + default: + return ScenarioFolderLaunchResult.Error("The folder could not be read."); + } + } + catch (OperationCanceledException) + { + return ScenarioFolderLaunchResult.Cancelled; } + + // A late cancellation (for example, dashboard disposal) can land in the synchronous gap between the in-try + // recheck and the commit. Gate it here WITHOUT throwing so a cancelled scan never dispatches filters or opens + // logs, while a genuine fault surfacing from the open still propagates (the commit stays outside the catch). + if (cancellationToken.IsCancellationRequested) { return ScenarioFolderLaunchResult.Cancelled; } + + return await OpenMatchedLogsAsync(scenario, dateWindow, match); } private static ImmutableArray AllTargetChannels(ScenarioDefinition scenario) => @@ -185,19 +215,27 @@ private async Task OpenMatchedLogsAsync( }; } - private async Task ScanAndMatchAsync(ImmutableArray files, ScenarioDefinition scenario) + private async Task ScanAndMatchAsync( + ImmutableArray files, + ScenarioDefinition scenario, + CancellationToken cancellationToken) { var probed = new ConcurrentBag<(string Path, EvtxChannelReadResult Result)>(); await Parallel.ForEachAsync( files, - new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, - (file, _) => + new ParallelOptions + { + MaxDegreeOfParallelism = Environment.ProcessorCount, + CancellationToken = cancellationToken + }, + (file, token) => { + token.ThrowIfCancellationRequested(); probed.Add((file, _channelReader.ReadChannel(file))); return ValueTask.CompletedTask; - }); + }).ConfigureAwait(false); var targets = TargetChannelSet(scenario); var matched = new List<(string Path, string Channel)>(); diff --git a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs index cb201a04..ca363b74 100644 --- a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs +++ b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor.cs @@ -37,6 +37,7 @@ public sealed partial class EmptyStateDashboard : FluxorComponent "group-policy-processing-errors" ]; + private readonly CancellationTokenSource _lifetimeCts = new(); private readonly Dictionary _selectedByCategory = new(); private SplashCategory _activeCategory; @@ -100,6 +101,8 @@ protected override async ValueTask DisposeAsyncCore(bool disposing) if (disposing) { Favorites.SelectedValueChanged -= OnFavoritesChanged; + await _lifetimeCts.CancelAsync(); + _lifetimeCts.Dispose(); } await base.DisposeAsyncCore(disposing); @@ -210,7 +213,7 @@ private Task LaunchScenarioAsync(ScenarioDefinition scenario) => private Task LaunchScenarioFromFolderAsync(ScenarioDefinition scenario) => RunGuardedAsync(async () => { - var result = await ScenarioLaunch.LaunchFromFolderAsync(scenario, null); + var result = await ScenarioLaunch.LaunchFromFolderAsync(scenario, null, _lifetimeCts.Token); if (DescribeFolderLaunch(scenario, result) is not { } message) { return; } diff --git a/src/EventLogExpert.WindowsPlatform/Activation/EvtxFolderEnumerator.cs b/src/EventLogExpert.WindowsPlatform/Activation/EvtxFolderEnumerator.cs index 240ab201..a5655331 100644 --- a/src/EventLogExpert.WindowsPlatform/Activation/EvtxFolderEnumerator.cs +++ b/src/EventLogExpert.WindowsPlatform/Activation/EvtxFolderEnumerator.cs @@ -8,14 +8,26 @@ public static class EvtxFolderEnumerator private const string EvtxSearchPattern = "*.evtx"; private const string OpenFolderFailedTitle = "Open Folder Failed"; - public static EvtxEnumerationResult EnumerateEvtxTopLevel(string folderPath) + public static EvtxEnumerationResult EnumerateEvtxTopLevel(string folderPath, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(folderPath); try { - var files = Directory.EnumerateFiles(folderPath, EvtxSearchPattern, SearchOption.TopDirectoryOnly) - .ToList(); + cancellationToken.ThrowIfCancellationRequested(); + + var files = new List(); + + foreach (var file in Directory.EnumerateFiles(folderPath, EvtxSearchPattern, SearchOption.TopDirectoryOnly)) + { + cancellationToken.ThrowIfCancellationRequested(); + files.Add(file); + } + + // A folder that yields no matches never enters the loop body, and Directory.EnumerateFiles has no overload that + // observes the token mid-scan, so re-check here: cancellation requested while the directory was being walked is + // then surfaced as OperationCanceledException rather than a normal Empty/Success result. + cancellationToken.ThrowIfCancellationRequested(); if (files.Count == 0) { diff --git a/src/EventLogExpert/Adapters/FilePicker/MauiEvtxFolderEnumerator.cs b/src/EventLogExpert/Adapters/FilePicker/MauiEvtxFolderEnumerator.cs index 9e5b5946..88d5edb3 100644 --- a/src/EventLogExpert/Adapters/FilePicker/MauiEvtxFolderEnumerator.cs +++ b/src/EventLogExpert/Adapters/FilePicker/MauiEvtxFolderEnumerator.cs @@ -9,8 +9,8 @@ namespace EventLogExpert.Adapters.FilePicker; /// MAUI adapter that maps onto the Runtime folder-scan contract. internal sealed class MauiEvtxFolderEnumerator : IEvtxFolderEnumerator { - public EvtxFolderScanResult EnumerateTopLevel(string folderPath) => - EvtxFolderEnumerator.EnumerateEvtxTopLevel(folderPath) switch + public EvtxFolderScanResult EnumerateTopLevel(string folderPath, CancellationToken cancellationToken) => + EvtxFolderEnumerator.EnumerateEvtxTopLevel(folderPath, cancellationToken) switch { EvtxEnumerationResult.Success success => new EvtxFolderScanResult.Files([.. success.Files]), EvtxEnumerationResult.Empty => EvtxFolderScanResult.Empty.Instance, diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs index 13142e3e..7db02717 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs @@ -41,7 +41,7 @@ public async Task LaunchAsync_ActivatingScenario_WhenTimelineHidden_ShowsTimelin [Fact] public async Task LaunchAsync_ActivatingScenario_ZeroOpenButCombining_ShowsTimeline() { - var (service, _, menu, scenario) = Create(new OpenLogsBatchResult(0, 0, 1, 0, []), activatesTimeline: true); + var (service, _, menu, scenario) = Create(new OpenLogsBatchResult(0, 0, 0, 1, []), activatesTimeline: true); await service.LaunchAsync(scenario, dateWindow: null, combineLog: true); @@ -130,7 +130,7 @@ public async Task LaunchAsync_SomethingOpened_DoesNotRestoreFilters() [Fact] public async Task LaunchAsync_ZeroOpenButCombining_DoesNotDispatchCloseAll() { - var (service, dispatcher, _, scenario) = Create(new OpenLogsBatchResult(0, 0, 1, 0, [])); + var (service, dispatcher, _, scenario) = Create(new OpenLogsBatchResult(0, 0, 0, 1, [])); await service.LaunchAsync(scenario, dateWindow: null, combineLog: true); @@ -140,7 +140,7 @@ public async Task LaunchAsync_ZeroOpenButCombining_DoesNotDispatchCloseAll() [Fact] public async Task LaunchAsync_ZeroOpenButCombining_DoesNotRestoreFilters() { - var (service, dispatcher, _, scenario) = Create(new OpenLogsBatchResult(0, 0, 1, 0, [])); + var (service, dispatcher, _, scenario) = Create(new OpenLogsBatchResult(0, 0, 0, 1, [])); await service.LaunchAsync(scenario, dateWindow: null, combineLog: true); @@ -213,27 +213,93 @@ public async Task LaunchFromFolderAsync_ActivatingScenarioWhenHidden_ShowsTimeli var (service, _, menu, picker, enumerator, reader, scenario) = CreateFolder(FolderScenario() with { ActivatesTimeline = true }, timelineVisible: false); picker.PickFolderAsync().Returns("C:\\bundle"); - enumerator.EnumerateTopLevel("C:\\bundle").Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); menu.OpenLogFilesAsync(Arg.Any>(), false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); - await service.LaunchFromFolderAsync(scenario, dateWindow: null); + await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); menu.Received(1).SetHistogramVisible(true); } + [Fact] + public async Task LaunchFromFolderAsync_CancelledDuringEnumeration_ReturnsCancelledWithoutOpening() + { + var (service, dispatcher, menu, picker, enumerator, _, scenario) = CreateFolder(FolderScenario()); + using var cts = new CancellationTokenSource(); + picker.PickFolderAsync().Returns("C:\\bundle"); + + // The enumerator observes cancellation mid-scan and returns a normal (non-throwing) Empty result; the service must + // re-check the token after the off-thread enumeration and report Cancelled instead of opening logs or alerting. + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()) + .Returns(_ => + { + cts.Cancel(); + + return EvtxFolderScanResult.Empty.Instance; + }); + + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null, cts.Token); + + Assert.Equal(ScenarioFolderOutcome.Cancelled, result.Outcome); + await menu.DidNotReceive().OpenLogFilesAsync(Arg.Any>(), Arg.Any()); + dispatcher.DidNotReceive().Dispatch(Arg.Any()); + } + + [Fact] + public async Task LaunchFromFolderAsync_CancelledDuringProbe_ReturnsCancelledWithoutOpening() + { + var (service, dispatcher, menu, picker, enumerator, reader, scenario) = CreateFolder(FolderScenario()); + using var cts = new CancellationTokenSource(); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()) + .Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); + + // The per-file probe observes cancellation as it completes; the parallel loop still produces a match, so the + // service must re-check after probing and report Cancelled rather than opening the matched log. + reader.ReadChannel("C:\\bundle\\System.evtx").Returns(_ => + { + cts.Cancel(); + + return EvtxChannelReadResult.FromChannel("System"); + }); + + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null, cts.Token); + + Assert.Equal(ScenarioFolderOutcome.Cancelled, result.Outcome); + await menu.DidNotReceive().OpenLogFilesAsync(Arg.Any>(), Arg.Any()); + dispatcher.DidNotReceive().Dispatch(Arg.Any()); + } + + [Fact] + public async Task LaunchFromFolderAsync_CancelledToken_ReturnsCancelledWithoutOpening() + { + var (service, dispatcher, menu, picker, enumerator, _, scenario) = CreateFolder(FolderScenario()); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()) + .Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); + + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null, cts.Token); + + Assert.Equal(ScenarioFolderOutcome.Cancelled, result.Outcome); + await menu.DidNotReceive().OpenLogFilesAsync(Arg.Any>(), Arg.Any()); + dispatcher.DidNotReceive().Dispatch(Arg.Any()); + } + [Fact] public async Task LaunchFromFolderAsync_CompletedWithUnreadableFile_PlumbsUnreadableCount() { var (service, _, menu, picker, enumerator, reader, scenario) = CreateFolder(FolderScenario()); picker.PickFolderAsync().Returns("C:\\bundle"); - enumerator.EnumerateTopLevel("C:\\bundle") + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()) .Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx", "C:\\bundle\\bad.evtx"])); reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); reader.ReadChannel("C:\\bundle\\bad.evtx").Returns(EvtxChannelReadResult.Unreadable); menu.OpenLogFilesAsync(Arg.Any>(), false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); - var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null); + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); Assert.Equal(ScenarioFolderOutcome.Completed, result.Outcome); Assert.Equal(1, result.Matched); @@ -245,9 +311,9 @@ public async Task LaunchFromFolderAsync_EmptyFolder_ReturnsNoMatchingLogs() { var (service, _, menu, picker, enumerator, _, scenario) = CreateFolder(FolderScenario()); picker.PickFolderAsync().Returns("C:\\bundle"); - enumerator.EnumerateTopLevel("C:\\bundle").Returns(EvtxFolderScanResult.Empty.Instance); + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(EvtxFolderScanResult.Empty.Instance); - var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null); + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); Assert.Equal(ScenarioFolderOutcome.NoMatchingLogs, result.Outcome); await menu.DidNotReceive().OpenLogFilesAsync(Arg.Any>(), Arg.Any()); @@ -258,9 +324,9 @@ public async Task LaunchFromFolderAsync_EnumerationAccessDenied_ReturnsError() { var (service, _, _, picker, enumerator, _, scenario) = CreateFolder(FolderScenario()); picker.PickFolderAsync().Returns("C:\\bundle"); - enumerator.EnumerateTopLevel("C:\\bundle").Returns(new EvtxFolderScanResult.AccessDenied("access denied")); + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.AccessDenied("access denied")); - var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null); + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); Assert.Equal(ScenarioFolderOutcome.Error, result.Outcome); Assert.Equal("access denied", result.Message); @@ -271,11 +337,11 @@ public async Task LaunchFromFolderAsync_MatchedButNoneOpened_RollsBackAndReturns { var (service, dispatcher, menu, picker, enumerator, reader, scenario) = CreateFolder(FolderScenario()); picker.PickFolderAsync().Returns("C:\\bundle"); - enumerator.EnumerateTopLevel("C:\\bundle").Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); menu.OpenLogFilesAsync(Arg.Any>(), false).Returns(new OpenLogsBatchResult(0, 1, 0, 0, [])); - var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null); + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); Assert.Equal(ScenarioFolderOutcome.NoLogsOpened, result.Outcome); Assert.Equal(1, result.Empty); @@ -288,11 +354,11 @@ public async Task LaunchFromFolderAsync_MatchOpened_ReturnsCompletedAndAppliesFi { var (service, dispatcher, menu, picker, enumerator, reader, scenario) = CreateFolder(FolderScenario()); picker.PickFolderAsync().Returns("C:\\bundle"); - enumerator.EnumerateTopLevel("C:\\bundle").Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); menu.OpenLogFilesAsync(Arg.Any>(), false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); - var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null); + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); Assert.Equal(ScenarioFolderOutcome.Completed, result.Outcome); Assert.Equal(1, result.Opened); @@ -313,10 +379,10 @@ public async Task LaunchFromFolderAsync_NoChannelMatch_ReturnsNoMatchingLogsWith { var (service, _, menu, picker, enumerator, reader, scenario) = CreateFolder(FolderScenario()); picker.PickFolderAsync().Returns("C:\\bundle"); - enumerator.EnumerateTopLevel("C:\\bundle").Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\other.evtx"])); + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\other.evtx"])); reader.ReadChannel("C:\\bundle\\other.evtx").Returns(EvtxChannelReadResult.FromChannel("Application")); - var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null); + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); Assert.Equal(ScenarioFolderOutcome.NoMatchingLogs, result.Outcome); Assert.Contains("System", result.MissingChannels); @@ -328,10 +394,10 @@ public async Task LaunchFromFolderAsync_NoMatchButUnreadable_ReturnsError() { var (service, _, _, picker, enumerator, reader, scenario) = CreateFolder(FolderScenario()); picker.PickFolderAsync().Returns("C:\\bundle"); - enumerator.EnumerateTopLevel("C:\\bundle").Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\corrupt.evtx"])); + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\corrupt.evtx"])); reader.ReadChannel("C:\\bundle\\corrupt.evtx").Returns(EvtxChannelReadResult.Unreadable); - var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null); + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); Assert.Equal(ScenarioFolderOutcome.Error, result.Outcome); Assert.Equal(1, result.Unreadable); @@ -343,7 +409,7 @@ public async Task LaunchFromFolderAsync_PickerCancelled_ReturnsCancelledAndTouch var (service, dispatcher, menu, picker, _, _, scenario) = CreateFolder(FolderScenario()); picker.PickFolderAsync().Returns((string?)null); - var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null); + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); Assert.Equal(ScenarioFolderOutcome.Cancelled, result.Outcome); await menu.DidNotReceive().OpenLogFilesAsync(Arg.Any>(), Arg.Any()); @@ -356,7 +422,7 @@ public async Task LaunchFromFolderAsync_PickerThrows_ReturnsError() var (service, _, _, picker, _, _, scenario) = CreateFolder(FolderScenario()); picker.PickFolderAsync().ThrowsAsync(new InvalidOperationException("picker boom")); - var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null); + var result = await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); Assert.Equal(ScenarioFolderOutcome.Error, result.Outcome); Assert.Equal("picker boom", result.Message); diff --git a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs index d13fdf98..8184157e 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Dashboard/EmptyStateDashboardTests.cs @@ -182,7 +182,7 @@ public void DetailLaunch_WhenPresenceUnknown_StaysLaunchable() [Fact] public void DetailOpenFromFolder_WhenCompleted_AnnouncesWithoutAlert() { - _scenarioLaunch.LaunchFromFolderAsync(Arg.Any(), Arg.Any()) + _scenarioLaunch.LaunchFromFolderAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new ScenarioFolderLaunchResult { Outcome = ScenarioFolderOutcome.Completed, Opened = 1, Matched = 1 }); _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); @@ -201,7 +201,7 @@ public void DetailOpenFromFolder_WhenCompleted_AnnouncesWithoutAlert() [Fact] public void DetailOpenFromFolder_WhenError_ShowsErrorAlert() { - _scenarioLaunch.LaunchFromFolderAsync(Arg.Any(), Arg.Any()) + _scenarioLaunch.LaunchFromFolderAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(ScenarioFolderLaunchResult.Error("access denied")); _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); @@ -216,7 +216,7 @@ public void DetailOpenFromFolder_WhenError_ShowsErrorAlert() [Fact] public void DetailOpenFromFolder_WhenNoMatchingLogs_ShowsVisibleAlert() { - _scenarioLaunch.LaunchFromFolderAsync(Arg.Any(), Arg.Any()) + _scenarioLaunch.LaunchFromFolderAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new ScenarioFolderLaunchResult { Outcome = ScenarioFolderOutcome.NoMatchingLogs }); _scenarioQuery.GetSplashScenarios().Returns([Scenario("application-crashes", "Application crashes")]); diff --git a/tests/Unit/EventLogExpert.Windows.Tests/EvtxFolderEnumeratorTests.cs b/tests/Unit/EventLogExpert.Windows.Tests/EvtxFolderEnumeratorTests.cs index 910a4e5f..c98f1d0e 100644 --- a/tests/Unit/EventLogExpert.Windows.Tests/EvtxFolderEnumeratorTests.cs +++ b/tests/Unit/EventLogExpert.Windows.Tests/EvtxFolderEnumeratorTests.cs @@ -10,12 +10,7 @@ namespace EventLogExpert.Windows.Tests; public sealed class EvtxFolderEnumeratorTests : IDisposable { - private readonly string _tempRoot; - - public EvtxFolderEnumeratorTests() - { - _tempRoot = EvtxFolderFixtures.CreateTempTestFolder(); - } + private readonly string _tempRoot = EvtxFolderFixtures.CreateTempTestFolder(); public void Dispose() { @@ -30,7 +25,7 @@ public void EnumerateTopLevel_DoesNotRecurseIntoSubfolders() EvtxFolderFixtures.WriteEmptyFile(sub, "nested.evtx"); EvtxFolderFixtures.WriteEmptyFile(_tempRoot, "top.evtx"); - var result = EvtxFolderEnumerator.EnumerateEvtxTopLevel(_tempRoot); + var result = EvtxFolderEnumerator.EnumerateEvtxTopLevel(_tempRoot, TestContext.Current.CancellationToken); var success = Assert.IsType(result); Assert.Single(success.Files); @@ -40,7 +35,7 @@ public void EnumerateTopLevel_DoesNotRecurseIntoSubfolders() [Fact] public void EnumerateTopLevel_OnEmptyFolder_ReturnsEmptyVariant() { - var result = EvtxFolderEnumerator.EnumerateEvtxTopLevel(_tempRoot); + var result = EvtxFolderEnumerator.EnumerateEvtxTopLevel(_tempRoot, TestContext.Current.CancellationToken); Assert.IsType(result); } @@ -53,7 +48,7 @@ public void EnumerateTopLevel_OnFolderWithEvtxAndOtherFiles_ReturnsOnlyEvtx() EvtxFolderFixtures.WriteEmptyFile(_tempRoot, "ignored.txt"); EvtxFolderFixtures.WriteEmptyFile(_tempRoot, "ignored.log"); - var result = EvtxFolderEnumerator.EnumerateEvtxTopLevel(_tempRoot); + var result = EvtxFolderEnumerator.EnumerateEvtxTopLevel(_tempRoot, TestContext.Current.CancellationToken); var success = Assert.IsType(result); Assert.Equal(2, success.Files.Count); @@ -75,7 +70,7 @@ public void EnumerateTopLevel_OnLongPathBeyondMax_StillReturnsEvtxFiles() Directory.CreateDirectory(longPathPrefixed); EvtxFolderFixtures.WriteEmptyFile(longPathPrefixed, "longpath.evtx"); - var result = EvtxFolderEnumerator.EnumerateEvtxTopLevel(longPathPrefixed); + var result = EvtxFolderEnumerator.EnumerateEvtxTopLevel(longPathPrefixed, TestContext.Current.CancellationToken); var success = Assert.IsType(result); Assert.Single(success.Files); @@ -87,7 +82,7 @@ public void EnumerateTopLevel_OnNonexistentFolder_ReturnsIoErrorVariant() { var nonexistent = Path.Combine(_tempRoot, "does-not-exist"); - var result = EvtxFolderEnumerator.EnumerateEvtxTopLevel(nonexistent); + var result = EvtxFolderEnumerator.EnumerateEvtxTopLevel(nonexistent, TestContext.Current.CancellationToken); Assert.IsType(result); } @@ -95,14 +90,25 @@ public void EnumerateTopLevel_OnNonexistentFolder_ReturnsIoErrorVariant() [Fact] public void EnumerateTopLevel_RejectsNullOrWhitespace() { - Assert.Throws(() => EvtxFolderEnumerator.EnumerateEvtxTopLevel("")); - Assert.Throws(() => EvtxFolderEnumerator.EnumerateEvtxTopLevel(" ")); + Assert.Throws(() => EvtxFolderEnumerator.EnumerateEvtxTopLevel("", TestContext.Current.CancellationToken)); + Assert.Throws(() => EvtxFolderEnumerator.EnumerateEvtxTopLevel(" ", TestContext.Current.CancellationToken)); + } + + [Fact] + public void EnumerateTopLevel_WithCancelledToken_ThrowsOperationCanceled() + { + EvtxFolderFixtures.WriteEmptyFile(_tempRoot, "a.evtx"); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Assert.Throws( + () => EvtxFolderEnumerator.EnumerateEvtxTopLevel(_tempRoot, cts.Token)); } [Fact] public void ToAlertCopy_OnEmpty_ReturnsNull() { - var result = EvtxFolderEnumerator.EnumerateEvtxTopLevel(_tempRoot); + var result = EvtxFolderEnumerator.EnumerateEvtxTopLevel(_tempRoot, TestContext.Current.CancellationToken); Assert.Null(EvtxFolderEnumerator.ToAlertCopy(result)); } @@ -111,7 +117,7 @@ public void ToAlertCopy_OnEmpty_ReturnsNull() public void ToAlertCopy_OnSuccess_ReturnsNull() { EvtxFolderFixtures.WriteEmptyFile(_tempRoot, "a.evtx"); - var result = EvtxFolderEnumerator.EnumerateEvtxTopLevel(_tempRoot); + var result = EvtxFolderEnumerator.EnumerateEvtxTopLevel(_tempRoot, TestContext.Current.CancellationToken); Assert.Null(EvtxFolderEnumerator.ToAlertCopy(result)); } From 94d82fc562be90542a44a10da9c8d5f743a3b23a Mon Sep 17 00:00:00 2001 From: jschick04 Date: Mon, 20 Jul 2026 18:42:40 +0000 Subject: [PATCH 5/5] Centralize the background-I/O parallelism limit --- .../Concurrency/ConcurrencyLimits.cs | 15 +++++++++++++++ .../EventLog/OpenLogEffects.cs | 2 +- .../Scenarios/ScenarioLaunchService.cs | 3 ++- .../Concurrency/ConcurrencyLimitsTests.cs | 19 +++++++++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 src/EventLogExpert.Runtime/Concurrency/ConcurrencyLimits.cs create mode 100644 tests/Unit/EventLogExpert.Runtime.Tests/Concurrency/ConcurrencyLimitsTests.cs diff --git a/src/EventLogExpert.Runtime/Concurrency/ConcurrencyLimits.cs b/src/EventLogExpert.Runtime/Concurrency/ConcurrencyLimits.cs new file mode 100644 index 00000000..be82d66f --- /dev/null +++ b/src/EventLogExpert.Runtime/Concurrency/ConcurrencyLimits.cs @@ -0,0 +1,15 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Concurrency; + +/// Shared concurrency limits for background, I/O-bound work across the runtime. +internal static class ConcurrencyLimits +{ + /// + /// Max degree of parallelism for background I/O-bound work (live-log resolution, exported-log folder scans). + /// Capped at one below the processor count and floored at 1, so a burst of concurrent file opens leaves a core for the + /// UI and never saturates the disk. Centralized here so every I/O fan-out shares one policy. + /// + internal static int MaxBackgroundIoParallelism { get; } = Math.Max(1, Environment.ProcessorCount - 1); +} diff --git a/src/EventLogExpert.Runtime/EventLog/OpenLogEffects.cs b/src/EventLogExpert.Runtime/EventLog/OpenLogEffects.cs index c276c8e5..162996ef 100644 --- a/src/EventLogExpert.Runtime/EventLog/OpenLogEffects.cs +++ b/src/EventLogExpert.Runtime/EventLog/OpenLogEffects.cs @@ -44,7 +44,7 @@ internal sealed class OpenLogEffects( // EvtNext batch: benchmarked Win11 throughput sweet spot; 512 regresses. private const int ReadBatchSize = 256; - private static readonly int s_maxGlobalConcurrency = Math.Max(1, Environment.ProcessorCount - 1); + private static readonly int s_maxGlobalConcurrency = ConcurrencyLimits.MaxBackgroundIoParallelism; private static readonly PrioritySemaphore s_resolutionGate = new(s_maxGlobalConcurrency); private readonly LogCloseCoordinator _closeCoordinator = closeCoordinator; diff --git a/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs b/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs index dea6c0ce..abdf9b8b 100644 --- a/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs +++ b/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs @@ -3,6 +3,7 @@ using EventLogExpert.Filtering.Evaluation; using EventLogExpert.Runtime.Common.Files; +using EventLogExpert.Runtime.Concurrency; using EventLogExpert.Runtime.EventLog; using EventLogExpert.Runtime.FilterPane; using EventLogExpert.Runtime.Histogram; @@ -226,7 +227,7 @@ await Parallel.ForEachAsync( files, new ParallelOptions { - MaxDegreeOfParallelism = Environment.ProcessorCount, + MaxDegreeOfParallelism = ConcurrencyLimits.MaxBackgroundIoParallelism, CancellationToken = cancellationToken }, (file, token) => diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Concurrency/ConcurrencyLimitsTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Concurrency/ConcurrencyLimitsTests.cs new file mode 100644 index 00000000..66a15bbf --- /dev/null +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Concurrency/ConcurrencyLimitsTests.cs @@ -0,0 +1,19 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Concurrency; + +namespace EventLogExpert.Runtime.Tests.Concurrency; + +public sealed class ConcurrencyLimitsTests +{ + [Fact] + public void MaxBackgroundIoParallelism_StaysWithinTheSafeRange() + { + var limit = ConcurrencyLimits.MaxBackgroundIoParallelism; + + // Floored at 1 (Parallel.ForEachAsync / the resolution gate need a positive degree even on a single-core host) + // and never more than one below the core count (leaves headroom for the UI thread on multi-core hosts). + Assert.InRange(limit, 1, Math.Max(1, Environment.ProcessorCount - 1)); + } +}