diff --git a/.github/workflows/PullRequest.yml b/.github/workflows/PullRequest.yml index a5318e40..ced8ebb7 100644 --- a/.github/workflows/PullRequest.yml +++ b/.github/workflows/PullRequest.yml @@ -26,10 +26,10 @@ jobs: with: dotnet-version: 10.x - name: Restore workloads - run: dotnet workload restore EventLogExpert.sln + run: dotnet workload restore EventLogExpert.slnx - name: Restore dependencies - run: dotnet restore EventLogExpert.sln + run: dotnet restore EventLogExpert.slnx - name: Build - run: dotnet build EventLogExpert.sln --no-restore + run: dotnet build EventLogExpert.slnx --no-restore - name: Test - run: dotnet test EventLogExpert.sln --no-build --verbosity normal + run: dotnet test EventLogExpert.slnx --no-build --verbosity normal diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index d706851d..5eb0cc19 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -17,5 +17,6 @@ + \ No newline at end of file diff --git a/src/EventLogExpert.Components.Tests/BannerHostTests.cs b/src/EventLogExpert.Components.Tests/BannerHostTests.cs new file mode 100644 index 00000000..81bd2c2f --- /dev/null +++ b/src/EventLogExpert.Components.Tests/BannerHostTests.cs @@ -0,0 +1,234 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Bunit; +using EventLogExpert.Eventing.Helpers; +using EventLogExpert.UI.Interfaces; +using EventLogExpert.UI.Models; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + +namespace EventLogExpert.Components.Tests; + +public sealed class BannerHostTests : BunitContext +{ + private readonly IApplicationRestartService _applicationRestartService = + Substitute.For(); + private readonly IBannerService _bannerService = Substitute.For(); + private readonly IClipboardService _clipboardService = Substitute.For(); + private readonly ITraceLogger _traceLogger = Substitute.For(); + + public BannerHostTests() + { + _bannerService.UnhandledError.Returns((Exception?)null); + _bannerService.CriticalAlerts.Returns([]); + _bannerService.InfoBanners.Returns([]); + + Services.AddSingleton(_bannerService); + Services.AddSingleton(_applicationRestartService); + Services.AddSingleton(_clipboardService); + Services.AddSingleton(_traceLogger); + + JSInterop.Mode = JSRuntimeMode.Loose; + } + + [Fact] + public async Task BannerHost_CopyDetailsClicked_CopiesExceptionAndShowsCopiedChip() + { + var error = new InvalidOperationException("kaboom"); + _bannerService.UnhandledError.Returns(error); + + var component = Render(); + + // Sync Click() returns at the handler's first real async point (the 2s Task.Delay) with + // the chip rendered; ClickAsync would block for the full delay until the chip clears. + component.Find("aside.banner-error .banner-actions button:nth-child(3)").Click(); + + await _clipboardService.Received(1) + .CopyTextAsync(Arg.Is(s => s.Contains("InvalidOperationException") && s.Contains("kaboom"))); + + Assert.Single(component.FindAll("aside.banner-error .banner-feedback .banner-chip")); + } + + [Fact] + public async Task BannerHost_DismissCriticalClicked_CallsDismissCriticalWithEntryId() + { + var alert = new CriticalAlertEntry(Guid.NewGuid(), "Database", "Schema invalid", DateTime.UtcNow); + _bannerService.CriticalAlerts.Returns([alert]); + + var component = Render(); + await component.Find("aside.banner-critical button.banner-dismiss").ClickAsync(new()); + + _bannerService.Received(1).DismissCritical(alert.Id); + } + + [Fact] + public async Task BannerHost_DismissInfoClicked_CallsDismissInfoBannerWithEntryId() + { + var info = new BannerInfoEntry(Guid.NewGuid(), "Notice", "Heads up", BannerSeverity.Info, DateTime.UtcNow); + _bannerService.InfoBanners.Returns([info]); + + var component = Render(); + await component.Find("aside.banner-info button.banner-dismiss").ClickAsync(new()); + + _bannerService.Received(1).DismissInfoBanner(info.Id); + } + + [Fact] + public void BannerHost_ErrorAndCriticalAndInfoAllPresent_RendersOnlyError() + { + _bannerService.UnhandledError.Returns(new InvalidOperationException("kaboom")); + + _bannerService.CriticalAlerts.Returns( + [new CriticalAlertEntry(Guid.NewGuid(), "Critical", "C", DateTime.UtcNow)]); + + _bannerService.InfoBanners.Returns([ + new BannerInfoEntry(Guid.NewGuid(), "Info", "I", BannerSeverity.Info, DateTime.UtcNow) + ]); + + var component = Render(); + + Assert.Single(component.FindAll("aside.banner-error")); + Assert.Empty(component.FindAll("aside.banner-critical")); + Assert.Empty(component.FindAll("aside.banner-info")); + } + + [Fact] + public void BannerHost_InfoSeverity_RendersInfoStyledBanner() + { + var info = new BannerInfoEntry(Guid.NewGuid(), "Notice", "Heads up", BannerSeverity.Info, DateTime.UtcNow); + _bannerService.InfoBanners.Returns([info]); + + var component = Render(); + + Assert.Single(component.FindAll("aside.banner.banner-info")); + Assert.Empty(component.FindAll("aside.banner.banner-warning")); + Assert.Contains("Notice: Heads up", component.Find("aside.banner-info").TextContent); + } + + [Fact] + public void BannerHost_MultipleCriticalAlerts_RendersFirstWithPagination() + { + var first = new CriticalAlertEntry(Guid.NewGuid(), "First", "First message", DateTime.UtcNow); + var second = new CriticalAlertEntry(Guid.NewGuid(), "Second", "Second message", DateTime.UtcNow); + _bannerService.CriticalAlerts.Returns([first, second]); + + var component = Render(); + + var banner = component.Find("aside.banner-critical"); + Assert.Contains("First: First message", banner.TextContent); + Assert.DoesNotContain("Second", banner.TextContent); + + var pagination = component.Find("aside.banner-critical .banner-pagination"); + Assert.Equal("1 of 2", pagination.TextContent.Trim()); + } + + [Fact] + public void BannerHost_NoState_RendersNothing() + { + var component = Render(); + + Assert.Equal(string.Empty, component.Markup.Trim()); + } + + [Fact] + public async Task BannerHost_RecoveryThrows_ShowsRecoveryFailureSubtitle() + { + _bannerService.UnhandledError.Returns(new InvalidOperationException("kaboom")); + _bannerService.TryRecoverAsync().Returns(Task.FromException(new InvalidOperationException("recovery failed"))); + + var component = Render(); + await component.Find("aside.banner-error .banner-actions button:nth-child(1)").ClickAsync(new()); + + var subtitle = component.Find("aside.banner-error .banner-feedback .banner-subtitle"); + Assert.Contains("Recovery failed", subtitle.TextContent); + Assert.Contains("recovery failed", subtitle.TextContent); + } + + [Fact] + public async Task BannerHost_RelaunchClicked_InvokesTryRestartAsync() + { + _bannerService.UnhandledError.Returns(new InvalidOperationException("kaboom")); + _applicationRestartService.TryRestartAsync().Returns(true); + + var component = Render(); + await component.Find("aside.banner-error .banner-actions button:nth-child(2)").ClickAsync(new()); + + await _applicationRestartService.Received(1).TryRestartAsync(); + } + + [Fact] + public async Task BannerHost_RelaunchFails_ShowsRestartFailureSubtitle() + { + _bannerService.UnhandledError.Returns(new InvalidOperationException("kaboom")); + _applicationRestartService.TryRestartAsync().Returns(false); + + var component = Render(); + await component.Find("aside.banner-error .banner-actions button:nth-child(2)").ClickAsync(new()); + + var subtitle = component.Find("aside.banner-error .banner-feedback .banner-subtitle"); + Assert.Contains("Restart failed", subtitle.TextContent); + } + + [Fact] + public async Task BannerHost_ReloadClicked_InvokesTryRecoverAsync() + { + _bannerService.UnhandledError.Returns(new InvalidOperationException("kaboom")); + _bannerService.TryRecoverAsync().Returns(Task.CompletedTask); + + var component = Render(); + await component.Find("aside.banner-error .banner-actions button:nth-child(1)").ClickAsync(new()); + + await _bannerService.Received(1).TryRecoverAsync(); + } + + [Fact] + public void BannerHost_SingleCriticalAlert_RendersWithoutPagination() + { + var alert = new CriticalAlertEntry(Guid.NewGuid(), "Database", "Schema invalid", DateTime.UtcNow); + _bannerService.CriticalAlerts.Returns([alert]); + + var component = Render(); + + var banner = component.Find("aside.banner-critical"); + Assert.Contains("Database: Schema invalid", banner.TextContent); + Assert.Empty(component.FindAll("aside.banner-critical .banner-pagination")); + Assert.Single(component.FindAll("aside.banner-critical button.banner-dismiss")); + } + + [Fact] + public void BannerHost_UnhandledError_RendersErrorBannerWithThreeButtons() + { + var error = new InvalidOperationException("kaboom"); + _bannerService.UnhandledError.Returns(error); + + var component = Render(); + + var banner = component.Find("aside.banner-error"); + Assert.Contains("InvalidOperationException", banner.TextContent); + Assert.Contains("kaboom", banner.TextContent); + + var buttons = component.FindAll("aside.banner-error .banner-actions button"); + Assert.Equal(3, buttons.Count); + Assert.Contains("Reload", buttons[0].TextContent); + Assert.Contains("Relaunch", buttons[1].TextContent); + Assert.Contains("Copy details", buttons[2].TextContent); + } + + [Fact] + public void BannerHost_WarningSeverity_RendersWarningStyledBanner() + { + var info = new BannerInfoEntry(Guid.NewGuid(), + "Slow", + "Performance dip", + BannerSeverity.Warning, + DateTime.UtcNow); + + _bannerService.InfoBanners.Returns([info]); + + var component = Render(); + + Assert.Single(component.FindAll("aside.banner.banner-warning")); + Assert.Empty(component.FindAll("aside.banner.banner-info")); + } +} diff --git a/src/EventLogExpert.Components.Tests/EventLogExpert.Components.Tests.csproj b/src/EventLogExpert.Components.Tests/EventLogExpert.Components.Tests.csproj new file mode 100644 index 00000000..eb9251bf --- /dev/null +++ b/src/EventLogExpert.Components.Tests/EventLogExpert.Components.Tests.csproj @@ -0,0 +1,16 @@ + + + + false + true + + + + + + + + + + + diff --git a/src/EventLogExpert.Components.Tests/GlobalUsings.cs b/src/EventLogExpert.Components.Tests/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/src/EventLogExpert.Components.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/src/EventLogExpert.Components/BannerHost.razor b/src/EventLogExpert.Components/BannerHost.razor new file mode 100644 index 00000000..7c607830 --- /dev/null +++ b/src/EventLogExpert.Components/BannerHost.razor @@ -0,0 +1,86 @@ +@using EventLogExpert.UI.Services +@{ + Exception? error = BannerService.UnhandledError; + IReadOnlyList criticals = BannerService.CriticalAlerts; + IReadOnlyList infos = BannerService.InfoBanners; + BannerView view = _currentView = BannerViewSelector.Select(error, criticals, infos); +} + +@switch (view) +{ + case BannerView.Error when error is { } unhandledError: + + break; + + case BannerView.Critical: + { + CriticalAlertEntry alert = criticals[0]; + + break; + } + + case BannerView.Info: + { + BannerInfoEntry info = infos[0]; + string severityClass = info.Severity == BannerSeverity.Warning ? "banner-warning" : "banner-info"; + + break; + } +} diff --git a/src/EventLogExpert.Components/BannerHost.razor.cs b/src/EventLogExpert.Components/BannerHost.razor.cs new file mode 100644 index 00000000..622d3e65 --- /dev/null +++ b/src/EventLogExpert.Components/BannerHost.razor.cs @@ -0,0 +1,129 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Helpers; +using EventLogExpert.UI.Interfaces; +using EventLogExpert.UI.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace EventLogExpert.Components; + +public sealed partial class BannerHost : ComponentBase, IDisposable +{ + private CancellationTokenSource? _copiedFeedbackCts; + private BannerView _currentView; + private BannerView _previousView = BannerView.None; + private string? _recoveryFailureMessage; + private ElementReference _reloadButtonRef; + private string? _restartFailureMessage; + private bool _showCopiedFeedback; + + [Inject] private IApplicationRestartService ApplicationRestartService { get; init; } = null!; + + [Inject] private IBannerService BannerService { get; init; } = null!; + + [Inject] private IClipboardService ClipboardService { get; init; } = null!; + + [Inject] private ITraceLogger TraceLogger { get; init; } = null!; + + public void Dispose() + { + BannerService.StateChanged -= OnStateChanged; + CancellationTokenSource? cts = _copiedFeedbackCts; + _copiedFeedbackCts = null; + cts?.Cancel(); + cts?.Dispose(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_currentView == BannerView.Error && _previousView != BannerView.Error) + { + try + { + await _reloadButtonRef.FocusAsync(); + } + catch (JSDisconnectedException) { } + catch (TaskCanceledException) { } + } + + _previousView = _currentView; + + await base.OnAfterRenderAsync(firstRender); + } + + protected override void OnInitialized() + { + BannerService.StateChanged += OnStateChanged; + base.OnInitialized(); + } + + private async Task OnCopyDetailsClickedAsync(Exception ex) + { + await ClipboardService.CopyTextAsync(ex.ToString()); + + CancellationTokenSource? previous = _copiedFeedbackCts; + _copiedFeedbackCts = null; + + if (previous is not null) + { + await previous.CancelAsync(); + previous.Dispose(); + } + + var cts = new CancellationTokenSource(); + _copiedFeedbackCts = cts; + _showCopiedFeedback = true; + StateHasChanged(); + + try + { + await Task.Delay(TimeSpan.FromSeconds(2), cts.Token); + + if (ReferenceEquals(_copiedFeedbackCts, cts)) + { + _showCopiedFeedback = false; + StateHasChanged(); + } + } + catch (TaskCanceledException) { } + } + + private void OnDismissCritical(Guid id) => BannerService.DismissCritical(id); + + private void OnDismissInfo(Guid id) => BannerService.DismissInfoBanner(id); + + private async Task OnRelaunchClickedAsync() + { + _recoveryFailureMessage = null; + _restartFailureMessage = null; + + bool success = await ApplicationRestartService.TryRestartAsync(); + + if (!success) + { + _restartFailureMessage = "Restart failed; please close and reopen manually."; + StateHasChanged(); + } + } + + private async Task OnReloadClickedAsync() + { + _recoveryFailureMessage = null; + _restartFailureMessage = null; + + try + { + await BannerService.TryRecoverAsync(); + } + catch (Exception ex) + { + _recoveryFailureMessage = $"Recovery failed: {ex.Message}"; + TraceLogger.Error($"{nameof(BannerHost)}.{nameof(OnReloadClickedAsync)}: recovery threw: {ex}"); + StateHasChanged(); + } + } + + private void OnStateChanged() => _ = InvokeAsync(StateHasChanged); +} diff --git a/src/EventLogExpert.Components/BannerHost.razor.css b/src/EventLogExpert.Components/BannerHost.razor.css new file mode 100644 index 00000000..acc7bf47 --- /dev/null +++ b/src/EventLogExpert.Components/BannerHost.razor.css @@ -0,0 +1,89 @@ +.banner { + position: fixed; + top: var(--menu-bar-height); + left: 0; + right: 0; + z-index: var(--z-error-ui); + + display: flex; + align-items: center; + gap: .75rem; + padding: .5rem 1rem; + + color: var(--clr-white); + box-shadow: var(--shadow-modal); +} + +.banner-error, +.banner-critical { + background-color: var(--clr-red); +} + +.banner-error { + top: 0; +} + +.banner-warning { + background-color: var(--clr-yellow); + color: var(--clr-on-light-highlight); +} + +.banner-info { + background-color: var(--clr-statusbar); + color: var(--text-on-statusbar); +} + +.banner-message { + flex: 1 1 auto; + min-width: 0; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.banner-actions { + display: flex; + align-items: center; + gap: .5rem; + flex: 0 0 auto; +} + +.banner-feedback { + display: flex; + align-items: center; + gap: .5rem; + flex: 0 0 auto; + min-height: 1.5rem; +} + +.banner-chip { + padding: .15rem .5rem; + + background-color: rgba(255, 255, 255, 0.2); + border-radius: .75rem; + font-size: .85rem; +} + +.banner-subtitle { + font-size: .85rem; + opacity: .9; +} + +.banner-pagination { + flex: 0 0 auto; + + font-size: .85rem; + opacity: .85; +} + +.banner-dismiss { + flex: 0 0 auto; + padding: 0 .5rem; + + line-height: 1; + font-size: 1.2rem; + background: transparent; + color: inherit; + border: 0; +} diff --git a/src/EventLogExpert.Components/EventLogExpert.Components.csproj b/src/EventLogExpert.Components/EventLogExpert.Components.csproj new file mode 100644 index 00000000..ee4e801b --- /dev/null +++ b/src/EventLogExpert.Components/EventLogExpert.Components.csproj @@ -0,0 +1,11 @@ + + + + EventLogExpert.Components + + + + + + + diff --git a/src/EventLogExpert.Components/_Imports.razor b/src/EventLogExpert.Components/_Imports.razor new file mode 100644 index 00000000..c9e120af --- /dev/null +++ b/src/EventLogExpert.Components/_Imports.razor @@ -0,0 +1,4 @@ +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Web +@using EventLogExpert.UI.Models +@using EventLogExpert.UI.Services diff --git a/src/EventLogExpert.UI.Tests/Services/BannerServiceTests.cs b/src/EventLogExpert.UI.Tests/Services/BannerServiceTests.cs new file mode 100644 index 00000000..24951206 --- /dev/null +++ b/src/EventLogExpert.UI.Tests/Services/BannerServiceTests.cs @@ -0,0 +1,396 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.UI.Models; +using EventLogExpert.UI.Services; + +namespace EventLogExpert.UI.Tests.Services; + +public sealed class BannerServiceTests +{ + [Fact] + public void ClearError_RaisesStateChanged_AndNullsUnhandledError() + { + // Arrange + var sut = new BannerService(); + sut.ReportError(new InvalidOperationException("boom")); + int stateChangedCount = 0; + sut.StateChanged += () => stateChangedCount++; + + // Act + sut.ClearError(); + + // Assert + Assert.Null(sut.UnhandledError); + Assert.Equal(1, stateChangedCount); + } + + [Fact] + public void DismissCritical_RemovesByGuid_RaisesStateChanged() + { + // Arrange + var sut = new BannerService(); + sut.ReportCritical("First Title", "First Message"); + sut.ReportCritical("Second Title", "Second Message"); + Guid firstId = sut.CriticalAlerts[0].Id; + int stateChangedCount = 0; + sut.StateChanged += () => stateChangedCount++; + + // Act + sut.DismissCritical(firstId); + + // Assert + Assert.Single(sut.CriticalAlerts); + Assert.Equal("Second Title", sut.CriticalAlerts[0].Title); + Assert.Equal(1, stateChangedCount); + } + + [Fact] + public void DismissCritical_WithUnknownId_NoOp() + { + // Arrange + var sut = new BannerService(); + sut.ReportCritical("Title", "Message"); + int stateChangedCount = 0; + sut.StateChanged += () => stateChangedCount++; + + // Act + sut.DismissCritical(Guid.NewGuid()); + + // Assert + Assert.Single(sut.CriticalAlerts); + Assert.Equal(0, stateChangedCount); + } + + [Fact] + public void DismissInfoBanner_RemovesByGuid_RaisesStateChanged() + { + // Arrange + var sut = new BannerService(); + sut.ReportInfoBanner("First Title", "First Message", BannerSeverity.Info); + sut.ReportInfoBanner("Second Title", "Second Message", BannerSeverity.Warning); + Guid firstId = sut.InfoBanners[0].Id; + int stateChangedCount = 0; + sut.StateChanged += () => stateChangedCount++; + + // Act + sut.DismissInfoBanner(firstId); + + // Assert + Assert.Single(sut.InfoBanners); + Assert.Equal("Second Title", sut.InfoBanners[0].Title); + Assert.Equal(1, stateChangedCount); + } + + [Fact] + public void DismissInfoBanner_WithUnknownId_NoOp() + { + // Arrange + var sut = new BannerService(); + sut.ReportInfoBanner("Title", "Message", BannerSeverity.Info); + int stateChangedCount = 0; + sut.StateChanged += () => stateChangedCount++; + + // Act + sut.DismissInfoBanner(Guid.NewGuid()); + + // Assert + Assert.Single(sut.InfoBanners); + Assert.Equal(0, stateChangedCount); + } + + [Fact] + public async Task RegisterRecoveryCallback_DisposingActiveHandle_UnregistersCallback() + { + // Arrange + var sut = new BannerService(); + sut.ReportError(new InvalidOperationException("boom")); + bool callbackInvoked = false; + IDisposable registration = sut.RegisterRecoveryCallback(() => { callbackInvoked = true; return Task.CompletedTask; }); + + // Act + registration.Dispose(); + await sut.TryRecoverAsync(); + + // Assert — callback was unregistered before recovery, so it must not have run. + Assert.False(callbackInvoked); + Assert.Null(sut.UnhandledError); + } + + [Fact] + public async Task RegisterRecoveryCallback_DisposingStaleHandle_DoesNotClearActiveCallback() + { + // Arrange — stale handle from a prior registration must not nuke the newer registration. + var sut = new BannerService(); + sut.ReportError(new InvalidOperationException("boom")); + bool firstInvoked = false; + bool secondInvoked = false; + IDisposable firstRegistration = sut.RegisterRecoveryCallback(() => { firstInvoked = true; return Task.CompletedTask; }); + sut.RegisterRecoveryCallback(() => { secondInvoked = true; return Task.CompletedTask; }); + + // Act — dispose the OLD handle; the second registration must remain active. + firstRegistration.Dispose(); + await sut.TryRecoverAsync(); + + // Assert + Assert.False(firstInvoked); + Assert.True(secondInvoked); + } + + [Fact] + public void RegisterRecoveryCallback_DisposingTwice_IsIdempotent() + { + // Arrange + var sut = new BannerService(); + IDisposable registration = sut.RegisterRecoveryCallback(() => Task.CompletedTask); + + // Act + Assert — second dispose must not throw. + registration.Dispose(); + registration.Dispose(); + } + + [Fact] + public void RegisterRecoveryCallback_WhenCallbackIsNull_Throws() + { + // Arrange + var sut = new BannerService(); + + // Act + Assert + Assert.Throws(() => sut.RegisterRecoveryCallback(null!)); + } + + [Fact] + public async Task RegisterRecoveryCallback_WhenCalledTwice_OverwritesPriorCallback() + { + // Arrange + var sut = new BannerService(); + int firstInvokeCount = 0; + int secondInvokeCount = 0; + sut.RegisterRecoveryCallback(() => { firstInvokeCount++; return Task.CompletedTask; }); + sut.RegisterRecoveryCallback(() => { secondInvokeCount++; return Task.CompletedTask; }); + + // Act + await sut.TryRecoverAsync(); + + // Assert + Assert.Equal(0, firstInvokeCount); + Assert.Equal(1, secondInvokeCount); + } + + [Fact] + public void ReportCritical_AppendsToList_RaisesStateChanged() + { + // Arrange + var sut = new BannerService(); + int stateChangedCount = 0; + sut.StateChanged += () => stateChangedCount++; + + // Act + sut.ReportCritical("Title", "Message"); + + // Assert + Assert.Single(sut.CriticalAlerts); + Assert.Equal("Title", sut.CriticalAlerts[0].Title); + Assert.Equal("Message", sut.CriticalAlerts[0].Message); + Assert.NotEqual(Guid.Empty, sut.CriticalAlerts[0].Id); + Assert.Equal(1, stateChangedCount); + } + + [Fact] + public void ReportCritical_TwiceQueuesBoth_FifoOrder() + { + // Arrange + var sut = new BannerService(); + + // Act + sut.ReportCritical("First Title", "First Message"); + sut.ReportCritical("Second Title", "Second Message"); + + // Assert + Assert.Equal(2, sut.CriticalAlerts.Count); + Assert.Equal("First Title", sut.CriticalAlerts[0].Title); + Assert.Equal("Second Title", sut.CriticalAlerts[1].Title); + } + + [Fact] + public void ReportError_RaisesStateChanged_AndPopulatesUnhandledError() + { + // Arrange + var sut = new BannerService(); + int stateChangedCount = 0; + sut.StateChanged += () => stateChangedCount++; + var error = new InvalidOperationException("boom"); + + // Act + sut.ReportError(error); + + // Assert + Assert.Same(error, sut.UnhandledError); + Assert.Equal(1, stateChangedCount); + } + + [Fact] + public void ReportError_TwiceReplacesPrior_RaisesStateChangedTwice() + { + // Arrange + var sut = new BannerService(); + int stateChangedCount = 0; + sut.StateChanged += () => stateChangedCount++; + var first = new InvalidOperationException("first"); + var second = new InvalidOperationException("second"); + + // Act + sut.ReportError(first); + sut.ReportError(second); + + // Assert + Assert.Same(second, sut.UnhandledError); + Assert.Equal(2, stateChangedCount); + } + + [Fact] + public void ReportError_WithNull_Throws() + { + // Arrange + var sut = new BannerService(); + + // Act + Assert + Assert.Throws(() => sut.ReportError(null!)); + } + + [Fact] + public void ReportInfoBanner_AppendsToList_RaisesStateChanged() + { + // Arrange + var sut = new BannerService(); + int stateChangedCount = 0; + sut.StateChanged += () => stateChangedCount++; + + // Act + sut.ReportInfoBanner("Title", "Message", BannerSeverity.Warning); + + // Assert + Assert.Single(sut.InfoBanners); + Assert.Equal("Title", sut.InfoBanners[0].Title); + Assert.Equal("Message", sut.InfoBanners[0].Message); + Assert.Equal(BannerSeverity.Warning, sut.InfoBanners[0].Severity); + Assert.NotEqual(Guid.Empty, sut.InfoBanners[0].Id); + Assert.Equal(1, stateChangedCount); + } + + [Fact] + public void ReportInfoBanner_TwiceQueuesBoth_FifoOrder() + { + // Arrange + var sut = new BannerService(); + + // Act + sut.ReportInfoBanner("First Title", "First Message", BannerSeverity.Info); + sut.ReportInfoBanner("Second Title", "Second Message", BannerSeverity.Warning); + + // Assert + Assert.Equal(2, sut.InfoBanners.Count); + Assert.Equal("First Title", sut.InfoBanners[0].Title); + Assert.Equal(BannerSeverity.Info, sut.InfoBanners[0].Severity); + Assert.Equal("Second Title", sut.InfoBanners[1].Title); + Assert.Equal(BannerSeverity.Warning, sut.InfoBanners[1].Severity); + } + + [Fact] + public async Task TryRecoverAsync_InvokesRegisteredCallback_ThenClearsError() + { + // Arrange + var sut = new BannerService(); + sut.ReportError(new InvalidOperationException("boom")); + bool callbackInvoked = false; + sut.RegisterRecoveryCallback(() => { callbackInvoked = true; return Task.CompletedTask; }); + + // Act + await sut.TryRecoverAsync(); + + // Assert + Assert.True(callbackInvoked); + Assert.Null(sut.UnhandledError); + } + + [Fact] + public async Task TryRecoverAsync_WhenCallbackThrows_DoesNotClearError() + { + // Arrange + var sut = new BannerService(); + var error = new InvalidOperationException("boom"); + sut.ReportError(error); + sut.RegisterRecoveryCallback(() => throw new InvalidOperationException("recover failed")); + + // Act + Assert — callback exception propagates; error remains so user can retry or see persistent state. + await Assert.ThrowsAsync(() => sut.TryRecoverAsync()); + Assert.Same(error, sut.UnhandledError); + } + + [Fact] + public async Task TryRecoverAsync_WhenNewErrorReportedDuringCallback_DoesNotClearNewError() + { + // Arrange — recovery callback awaits a gate; a different thread reports a new error before the gate releases. + var sut = new BannerService(); + var oldError = new InvalidOperationException("old"); + var newError = new InvalidOperationException("new"); + sut.ReportError(oldError); + var callbackStarted = new TaskCompletionSource(); + var callbackCanFinish = new TaskCompletionSource(); + sut.RegisterRecoveryCallback(async () => + { + callbackStarted.SetResult(); + await callbackCanFinish.Task; + }); + + // Act + Task recoverTask = sut.TryRecoverAsync(); + await callbackStarted.Task; + sut.ReportError(newError); + callbackCanFinish.SetResult(); + await recoverTask; + + // Assert — the newer error survives the recovery completion. + Assert.Same(newError, sut.UnhandledError); + } + + [Fact] + public async Task TryRecoverAsync_WhenNewErrorReportedDuringCallback_DoesNotRaiseExtraStateChanged() + { + // Arrange — verify we do not double-fire StateChanged when the snapshot mismatch path is taken. + var sut = new BannerService(); + sut.ReportError(new InvalidOperationException("old")); + var callbackStarted = new TaskCompletionSource(); + var callbackCanFinish = new TaskCompletionSource(); + sut.RegisterRecoveryCallback(async () => + { + callbackStarted.SetResult(); + await callbackCanFinish.Task; + }); + + Task recoverTask = sut.TryRecoverAsync(); + await callbackStarted.Task; + int stateChangedCount = 0; + sut.StateChanged += () => stateChangedCount++; + sut.ReportError(new InvalidOperationException("new")); + callbackCanFinish.SetResult(); + await recoverTask; + + // Assert — only the in-flight ReportError raised StateChanged; the recovery did NOT add a clear-event. + Assert.Equal(1, stateChangedCount); + } + + [Fact] + public async Task TryRecoverAsync_WithNoRegisteredCallback_StillClearsError_DoesNotThrow() + { + // Arrange + var sut = new BannerService(); + sut.ReportError(new InvalidOperationException("boom")); + + // Act + await sut.TryRecoverAsync(); + + // Assert + Assert.Null(sut.UnhandledError); + } +} diff --git a/src/EventLogExpert.UI.Tests/Services/BannerViewSelectorTests.cs b/src/EventLogExpert.UI.Tests/Services/BannerViewSelectorTests.cs new file mode 100644 index 00000000..1d167bb2 --- /dev/null +++ b/src/EventLogExpert.UI.Tests/Services/BannerViewSelectorTests.cs @@ -0,0 +1,85 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.UI.Models; +using EventLogExpert.UI.Services; + +namespace EventLogExpert.UI.Tests.Services; + +public sealed class BannerViewSelectorTests +{ + private static readonly DateTime s_testTime = new(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void Select_AllEmpty_ReturnsNone() + { + BannerView result = BannerViewSelector.Select(null, [], []); + + Assert.Equal(BannerView.None, result); + } + + [Fact] + public void Select_AllThree_ErrorWins() + { + BannerView result = BannerViewSelector.Select( + new InvalidOperationException("boom"), + [BuildCritical()], + [BuildInfo()]); + + Assert.Equal(BannerView.Error, result); + } + + [Fact] + public void Select_CriticalAndInfo_CriticalWins() + { + BannerView result = BannerViewSelector.Select(null, [BuildCritical()], [BuildInfo()]); + + Assert.Equal(BannerView.Critical, result); + } + + [Fact] + public void Select_ErrorAndCritical_ErrorWins() + { + BannerView result = BannerViewSelector.Select(new InvalidOperationException("boom"), [BuildCritical()], []); + + Assert.Equal(BannerView.Error, result); + } + + [Fact] + public void Select_ErrorAndInfo_ErrorWins() + { + BannerView result = BannerViewSelector.Select(new InvalidOperationException("boom"), [], [BuildInfo()]); + + Assert.Equal(BannerView.Error, result); + } + + [Fact] + public void Select_OnlyCritical_ReturnsCritical() + { + BannerView result = BannerViewSelector.Select(null, [BuildCritical()], []); + + Assert.Equal(BannerView.Critical, result); + } + + [Fact] + public void Select_OnlyError_ReturnsError() + { + BannerView result = BannerViewSelector.Select(new InvalidOperationException("boom"), [], []); + + Assert.Equal(BannerView.Error, result); + } + + [Fact] + public void Select_OnlyInfo_ReturnsInfo() + { + BannerView result = BannerViewSelector.Select(null, [], [BuildInfo()]); + + Assert.Equal(BannerView.Info, result); + } + + private static CriticalAlertEntry BuildCritical() => + new(Guid.NewGuid(), "Critical Title", "Critical Message", s_testTime); + + private static BannerInfoEntry BuildInfo() => + new(Guid.NewGuid(), "Info Title", "Info Message", BannerSeverity.Info, s_testTime); +} diff --git a/src/EventLogExpert.UI.Tests/Services/EmptyLogAlertFormatterTests.cs b/src/EventLogExpert.UI.Tests/Services/EmptyLogAlertFormatterTests.cs new file mode 100644 index 00000000..9caee4ae --- /dev/null +++ b/src/EventLogExpert.UI.Tests/Services/EmptyLogAlertFormatterTests.cs @@ -0,0 +1,56 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.UI.Services; + +namespace EventLogExpert.UI.Tests.Services; + +public sealed class EmptyLogAlertFormatterTests +{ + [Fact] + public void BuildMessage_EmptyList_ThrowsArgumentException() + { + ArgumentException ex = Assert.Throws(() => + EmptyLogAlertFormatter.BuildMessage([])); + + Assert.Equal("displayNames", ex.ParamName); + } + + [Fact] + public void BuildMessage_NullList_ThrowsArgumentNullException() + { + Assert.Throws(() => EmptyLogAlertFormatter.BuildMessage(null!)); + } + + [Fact] + public void BuildMessage_PreservesNameOrderInPluralCase() + { + string result = EmptyLogAlertFormatter.BuildMessage(["Zebra", "Apple", "Mango"]); + + Assert.Equal("3 logs contained no events: Zebra, Apple, Mango", result); + } + + [Fact] + public void BuildMessage_SingleName_UsesSingularPhrasing() + { + string result = EmptyLogAlertFormatter.BuildMessage(["Application.evtx"]); + + Assert.Equal("Log contains no events: Application.evtx", result); + } + + [Fact] + public void BuildMessage_ThreeNames_UsesPluralPhrasingWithCountAndOrderedJoin() + { + string result = EmptyLogAlertFormatter.BuildMessage(["A", "B", "C"]); + + Assert.Equal("3 logs contained no events: A, B, C", result); + } + + [Fact] + public void BuildMessage_TwoNames_UsesPluralPhrasingWithCommaJoin() + { + string result = EmptyLogAlertFormatter.BuildMessage(["A.evtx", "B.evtx"]); + + Assert.Equal("2 logs contained no events: A.evtx, B.evtx", result); + } +} diff --git a/src/EventLogExpert.UI.Tests/Services/ModalAlertDialogServiceTests.cs b/src/EventLogExpert.UI.Tests/Services/ModalAlertDialogServiceTests.cs index b479e5ae..1eeeb09a 100644 --- a/src/EventLogExpert.UI.Tests/Services/ModalAlertDialogServiceTests.cs +++ b/src/EventLogExpert.UI.Tests/Services/ModalAlertDialogServiceTests.cs @@ -2,6 +2,7 @@ // // Licensed under the MIT License. using EventLogExpert.UI.Interfaces; +using EventLogExpert.UI.Models; using EventLogExpert.UI.Services; using NSubstitute; @@ -27,6 +28,7 @@ public async Task DisplayPrompt_WhenActiveHost_ShouldRouteInlineAndReturnTypedVa var sut = new ModalAlertDialogService( modalService, PassthroughMainThread(), + Substitute.For(), _ => Task.FromResult(false), _ => Task.FromResult(string.Empty)); @@ -58,6 +60,7 @@ public async Task DisplayPrompt_WhenInlineCancelled_ShouldReturnEmptyString() var sut = new ModalAlertDialogService( modalService, PassthroughMainThread(), + Substitute.For(), _ => Task.FromResult(false), _ => Task.FromResult(string.Empty)); @@ -79,6 +82,7 @@ public async Task DisplayPrompt_WhenNoActiveHost_ShouldCallStandalonePromptOpene var sut = new ModalAlertDialogService( modalService, PassthroughMainThread(), + Substitute.For(), _ => Task.FromResult(false), parameters => { capturedPrompt = parameters; return Task.FromResult("user-typed"); }); @@ -107,6 +111,7 @@ public async Task ShowAlert_ShouldMarshalThroughMainThreadService() var sut = new ModalAlertDialogService( modalService, mainThread, + Substitute.For(), _ => Task.FromResult(true), _ => Task.FromResult(string.Empty)); @@ -117,6 +122,131 @@ public async Task ShowAlert_ShouldMarshalThroughMainThreadService() await mainThread.Received(1).InvokeOnMainThreadAsync(Arg.Any>()); } + [Fact] + public async Task ShowAlertOneButton_BannerPresentation_DoesNotMarshalThroughMainThreadService() + { + // Arrange — banner-routed alerts skip the UI-thread marshal because the banner service is thread-safe. + var bannerService = Substitute.For(); + var mainThread = Substitute.For(); + + var sut = new ModalAlertDialogService( + Substitute.For(), + mainThread, + bannerService, + _ => Task.FromResult(false), + _ => Task.FromResult(string.Empty)); + + // Act + await sut.ShowAlert("t", "m", "OK", AlertPresentation.Banner); + + // Assert + await mainThread.DidNotReceive().InvokeOnMainThreadAsync(Arg.Any>()); + bannerService.Received(1).ReportInfoBanner("t", "m", BannerSeverity.Warning); + } + + [Fact] + public async Task ShowAlertOneButton_BannerPresentation_RoutesToReportInfoBanner_WithWarningSeverity() + { + // Arrange + var bannerService = Substitute.For(); + var modalService = Substitute.For(); + var standaloneCalled = false; + + var sut = new ModalAlertDialogService( + modalService, + PassthroughMainThread(), + bannerService, + _ => { standaloneCalled = true; return Task.FromResult(false); }, + _ => Task.FromResult(string.Empty)); + + // Act + await sut.ShowAlert("Banner Title", "Banner Message", "OK", AlertPresentation.Banner); + + // Assert + bannerService.Received(1).ReportInfoBanner("Banner Title", "Banner Message", BannerSeverity.Warning); + Assert.False(standaloneCalled); + modalService.DidNotReceive().TryGetActiveAlertHost(out Arg.Any()); + } + + [Fact] + public async Task ShowAlertOneButton_InlineOnlyNoHost_ThrowsInvalidOperationException() + { + // Arrange + var modalService = Substitute.For(); + modalService.TryGetActiveAlertHost(out Arg.Any()).Returns(false); + + var sut = new ModalAlertDialogService( + modalService, + PassthroughMainThread(), + Substitute.For(), + _ => Task.FromResult(false), + _ => Task.FromResult(string.Empty)); + + // Act + Assert + await Assert.ThrowsAsync(() => + sut.ShowAlert("t", "m", "OK", AlertPresentation.InlineOnly)); + } + + [Fact] + public async Task ShowAlertOneButton_InlineOnlyWithHost_RoutesInline() + { + // Arrange + var host = Substitute.For(); + host.ShowInlineAlertAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new InlineAlertResult(true, null))); + + var modalService = Substitute.For(); + modalService.TryGetActiveAlertHost(out Arg.Any()).Returns(call => + { + call[0] = host; + return true; + }); + + var standaloneCalled = false; + var sut = new ModalAlertDialogService( + modalService, + PassthroughMainThread(), + Substitute.For(), + _ => { standaloneCalled = true; return Task.FromResult(false); }, + _ => Task.FromResult(string.Empty)); + + // Act + await sut.ShowAlert("t", "m", "OK", AlertPresentation.InlineOnly); + + // Assert + Assert.False(standaloneCalled); + await host.Received(1).ShowInlineAlertAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ShowAlertOneButton_PopupOnly_AlwaysOpensStandalone_EvenWithHost() + { + // Arrange + var host = Substitute.For(); + var modalService = Substitute.For(); + modalService.TryGetActiveAlertHost(out Arg.Any()).Returns(call => + { + call[0] = host; + return true; + }); + + IReadOnlyDictionary? capturedAlert = null; + var sut = new ModalAlertDialogService( + modalService, + PassthroughMainThread(), + Substitute.For(), + parameters => { capturedAlert = parameters; return Task.FromResult(true); }, + _ => Task.FromResult(string.Empty)); + + // Act + await sut.ShowAlert("t", "m", "Close", AlertPresentation.PopupOnly); + + // Assert + Assert.NotNull(capturedAlert); + Assert.Equal("Close", capturedAlert!["CancelLabel"]); + await host.DidNotReceive().ShowInlineAlertAsync(Arg.Any(), Arg.Any()); + } + [Fact] public async Task ShowAlertOneButton_WhenNoActiveHost_ShouldCallStandaloneOpener() { @@ -128,6 +258,7 @@ public async Task ShowAlertOneButton_WhenNoActiveHost_ShouldCallStandaloneOpener var sut = new ModalAlertDialogService( modalService, PassthroughMainThread(), + Substitute.For(), parameters => { capturedAlert = parameters; return Task.FromResult(true); }, _ => Task.FromResult(string.Empty)); @@ -142,6 +273,73 @@ public async Task ShowAlertOneButton_WhenNoActiveHost_ShouldCallStandaloneOpener Assert.Equal("Close", capturedAlert["CancelLabel"]); } + [Fact] + public async Task ShowAlertTwoButton_BannerPresentation_ThrowsArgumentException() + { + // Arrange + var sut = new ModalAlertDialogService( + Substitute.For(), + PassthroughMainThread(), + Substitute.For(), + _ => Task.FromResult(false), + _ => Task.FromResult(string.Empty)); + + // Act + Assert — Banner is not valid for accept/cancel pairs. + var ex = await Assert.ThrowsAsync(() => + sut.ShowAlert("t", "m", "Yes", "No", AlertPresentation.Banner)); + Assert.Equal("presentation", ex.ParamName); + } + + [Fact] + public async Task ShowAlertTwoButton_InlineOnlyNoHost_ThrowsInvalidOperationException() + { + // Arrange + var modalService = Substitute.For(); + modalService.TryGetActiveAlertHost(out Arg.Any()).Returns(false); + + var sut = new ModalAlertDialogService( + modalService, + PassthroughMainThread(), + Substitute.For(), + _ => Task.FromResult(false), + _ => Task.FromResult(string.Empty)); + + // Act + Assert + await Assert.ThrowsAsync(() => + sut.ShowAlert("t", "m", "Yes", "No", AlertPresentation.InlineOnly)); + } + + [Fact] + public async Task ShowAlertTwoButton_PopupOnly_AlwaysOpensStandalone() + { + // Arrange + var host = Substitute.For(); + var modalService = Substitute.For(); + modalService.TryGetActiveAlertHost(out Arg.Any()).Returns(call => + { + call[0] = host; + return true; + }); + + IReadOnlyDictionary? capturedAlert = null; + var sut = new ModalAlertDialogService( + modalService, + PassthroughMainThread(), + Substitute.For(), + parameters => { capturedAlert = parameters; return Task.FromResult(true); }, + _ => Task.FromResult(string.Empty)); + + // Act + var result = await sut.ShowAlert("t", "m", "Yes", "No", AlertPresentation.PopupOnly); + + // Assert + Assert.True(result); + Assert.NotNull(capturedAlert); + Assert.Equal("Yes", capturedAlert!["AcceptLabel"]); + Assert.Equal("No", capturedAlert["CancelLabel"]); + await host.DidNotReceive().ShowInlineAlertAsync(Arg.Any(), Arg.Any()); + } + [Fact] public async Task ShowAlertTwoButton_WhenActiveHost_ShouldRouteToHostInline() { @@ -161,6 +359,7 @@ public async Task ShowAlertTwoButton_WhenActiveHost_ShouldRouteToHostInline() var sut = new ModalAlertDialogService( modalService, PassthroughMainThread(), + Substitute.For(), _ => { standaloneCalled = true; return Task.FromResult(false); }, _ => Task.FromResult(string.Empty)); @@ -198,6 +397,7 @@ public async Task ShowAlertTwoButton_WhenInlineCancelled_ShouldReturnFalse() var sut = new ModalAlertDialogService( modalService, PassthroughMainThread(), + Substitute.For(), _ => Task.FromResult(false), _ => Task.FromResult(string.Empty)); @@ -208,6 +408,52 @@ public async Task ShowAlertTwoButton_WhenInlineCancelled_ShouldReturnFalse() Assert.False(result); } + [Fact] + public async Task ShowCriticalAlert_DoesNotMarshalThroughMainThreadService() + { + // Arrange — critical alerts go straight to the thread-safe banner service; no UI marshal needed. + var bannerService = Substitute.For(); + var mainThread = Substitute.For(); + + var sut = new ModalAlertDialogService( + Substitute.For(), + mainThread, + bannerService, + _ => Task.FromResult(false), + _ => Task.FromResult(string.Empty)); + + // Act + await sut.ShowCriticalAlert("t", "m"); + + // Assert + await mainThread.DidNotReceive().InvokeOnMainThreadAsync(Arg.Any>()); + bannerService.Received(1).ReportCritical("t", "m"); + } + + [Fact] + public async Task ShowCriticalAlert_RoutesToBannerServiceReportCritical_WithTitleAndMessage() + { + // Arrange + var bannerService = Substitute.For(); + var modalService = Substitute.For(); + var standaloneCalled = false; + + var sut = new ModalAlertDialogService( + modalService, + PassthroughMainThread(), + bannerService, + _ => { standaloneCalled = true; return Task.FromResult(false); }, + _ => Task.FromResult(string.Empty)); + + // Act + await sut.ShowCriticalAlert("Critical Title", "Critical Message"); + + // Assert + bannerService.Received(1).ReportCritical("Critical Title", "Critical Message"); + Assert.False(standaloneCalled); + modalService.DidNotReceive().TryGetActiveAlertHost(out Arg.Any()); + } + private static IMainThreadService PassthroughMainThread() => new MainThreadService(action => { action(); return Task.CompletedTask; }); } diff --git a/src/EventLogExpert.UI.Tests/Services/UpdateServiceTests.cs b/src/EventLogExpert.UI.Tests/Services/UpdateServiceTests.cs index 6ccddd04..00ee0c02 100644 --- a/src/EventLogExpert.UI.Tests/Services/UpdateServiceTests.cs +++ b/src/EventLogExpert.UI.Tests/Services/UpdateServiceTests.cs @@ -37,6 +37,29 @@ public async Task CheckForUpdates_Always_ShouldClearProgressString() mockAppTitleService.Received(1).SetProgressString(null); } + [Fact] + public async Task CheckForUpdates_AutoScanCalledTwice_ShouldOnlyFetchReleasesOnce() + { + // Arrange + var mockCurrentVersionProvider = Substitute.For(); + mockCurrentVersionProvider.CurrentVersion.Returns(new Version(Constants.AppInstalledVersion)); + mockCurrentVersionProvider.IsDevBuild.Returns(false); + + var mockGitHubService = Substitute.For(); + mockGitHubService.GetReleases().Returns(Task.FromResult(GitHubUtils.CreateGitReleaseModels())); + + var updateService = CreateUpdateService( + mockCurrentVersionProvider, + gitHubService: mockGitHubService); + + // Act + await updateService.CheckForUpdates(false, userInitiated: false); + await updateService.CheckForUpdates(false, userInitiated: false); + + // Assert + await mockGitHubService.Received(1).GetReleases(); + } + [Fact] public async Task CheckForUpdates_DeploymentThrowsException_ShouldShowAlert() { @@ -229,6 +252,31 @@ await mockAlertDialogService.DidNotReceive() mockDeploymentService.DidNotReceive().UpdateOnNextRestart(Arg.Any(), Arg.Any()); } + [Fact] + public async Task CheckForUpdates_GetReleasesThrowsThenAutoScan_ShouldNotRetry() + { + // Arrange + var mockCurrentVersionProvider = Substitute.For(); + mockCurrentVersionProvider.CurrentVersion.Returns(new Version(Constants.AppInstalledVersion)); + mockCurrentVersionProvider.IsDevBuild.Returns(false); + + var mockGitHubService = Substitute.For(); + + mockGitHubService.GetReleases().Returns>(_ => + throw new HttpRequestException("Network error")); + + var updateService = CreateUpdateService( + mockCurrentVersionProvider, + gitHubService: mockGitHubService); + + // Act + await updateService.CheckForUpdates(false, userInitiated: false); + await updateService.CheckForUpdates(false, userInitiated: false); + + // Assert + await mockGitHubService.Received(1).GetReleases(); + } + [Fact] public async Task CheckForUpdates_Latest_ShouldUpdateImmediately() { @@ -286,6 +334,29 @@ public async Task CheckForUpdates_Latest_ShouldUpdateOnNextRestart() mockDeploymentService.Received(1).UpdateOnNextRestart(Constants.GitHubLatestUri, userInitiated: false); } + [Fact] + public async Task CheckForUpdates_ManualThenAutoScan_ShouldRunBoth() + { + // Arrange + var mockCurrentVersionProvider = Substitute.For(); + mockCurrentVersionProvider.CurrentVersion.Returns(new Version(Constants.AppInstalledVersion)); + mockCurrentVersionProvider.IsDevBuild.Returns(false); + + var mockGitHubService = Substitute.For(); + mockGitHubService.GetReleases().Returns(Task.FromResult(GitHubUtils.CreateGitReleaseModels())); + + var updateService = CreateUpdateService( + mockCurrentVersionProvider, + gitHubService: mockGitHubService); + + // Act + await updateService.CheckForUpdates(false, userInitiated: true); + await updateService.CheckForUpdates(false, userInitiated: false); + + // Assert + await mockGitHubService.Received(2).GetReleases(); + } + [Fact] public async Task CheckForUpdates_NoReleases_ShouldShowAlert() { diff --git a/src/EventLogExpert.UI/Enums.cs b/src/EventLogExpert.UI/Enums.cs index 13436f2a..e0c25a27 100644 --- a/src/EventLogExpert.UI/Enums.cs +++ b/src/EventLogExpert.UI/Enums.cs @@ -98,6 +98,12 @@ public enum FilterType Cached } +public enum OpenLogStatus +{ + Loaded, + Empty +} + public enum Theme { System, diff --git a/src/EventLogExpert.UI/Interfaces/IApplicationRestartService.cs b/src/EventLogExpert.UI/Interfaces/IApplicationRestartService.cs index 469131e3..98da40e6 100644 --- a/src/EventLogExpert.UI/Interfaces/IApplicationRestartService.cs +++ b/src/EventLogExpert.UI/Interfaces/IApplicationRestartService.cs @@ -8,4 +8,6 @@ public interface IApplicationRestartService /// Registers the application for restart. /// True if registration was successful (return code 0), false otherwise. bool RegisterApplicationRestart(); + + Task TryRestartAsync(string launchArguments = ""); } diff --git a/src/EventLogExpert.UI/Interfaces/IBannerService.cs b/src/EventLogExpert.UI/Interfaces/IBannerService.cs new file mode 100644 index 00000000..0f452031 --- /dev/null +++ b/src/EventLogExpert.UI/Interfaces/IBannerService.cs @@ -0,0 +1,55 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.UI.Models; + +namespace EventLogExpert.UI.Interfaces; + +/// +/// Singleton aggregator for the app-level banner surface. Holds the current unhandled error (highest priority, +/// non-dismissible), the queue of critical alerts (FIFO, individually dismissible), and the queue of info banners +/// (FIFO, individually dismissible). The banner host renders one card at a time by priority: error > critical +/// > info. State is thread-safe; mutations raise after the lock is released so handlers +/// do not run under the service lock. +/// +public interface IBannerService +{ + event Action StateChanged; + + IReadOnlyList CriticalAlerts { get; } + + IReadOnlyList InfoBanners { get; } + + Exception? UnhandledError { get; } + + /// Clear the current unhandled error and raise . + void ClearError(); + + /// Remove a critical alert by id and raise . No-op if the id is not present. + void DismissCritical(Guid id); + + /// Remove an info banner by id and raise . No-op if the id is not present. + void DismissInfoBanner(Guid id); + + /// Append a critical alert to the queue and raise . + void ReportCritical(string title, string message); + + /// Replace the current unhandled error and raise . + void ReportError(Exception ex); + + /// Append an info banner to the queue and raise . + void ReportInfoBanner(string title, string message, BannerSeverity severity); + + /// + /// Register a callback that invokes before clearing the error. Replaces any prior + /// registration immediately. Dispose the returned handle to unregister; disposal is idempotent and a no-op if a + /// newer registration has already replaced this one. + /// + IDisposable RegisterRecoveryCallback(Func recover); + + /// + /// Invoke the registered recovery callback (if any), then clear the error. If no callback is registered, just clears + /// the error. + /// + Task TryRecoverAsync(); +} diff --git a/src/EventLogExpert.UI/Interfaces/IClipboardService.cs b/src/EventLogExpert.UI/Interfaces/IClipboardService.cs new file mode 100644 index 00000000..db7e5008 --- /dev/null +++ b/src/EventLogExpert.UI/Interfaces/IClipboardService.cs @@ -0,0 +1,11 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.UI.Interfaces; + +public interface IClipboardService +{ + Task CopySelectedEvent(CopyType? copyType = null); + + Task CopyTextAsync(string text); +} diff --git a/src/EventLogExpert.UI/Models/BannerInfoEntry.cs b/src/EventLogExpert.UI/Models/BannerInfoEntry.cs new file mode 100644 index 00000000..983df6cb --- /dev/null +++ b/src/EventLogExpert.UI/Models/BannerInfoEntry.cs @@ -0,0 +1,11 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.UI.Models; + +public sealed record BannerInfoEntry( + Guid Id, + string Title, + string Message, + BannerSeverity Severity, + DateTime CreatedUtc); diff --git a/src/EventLogExpert.UI/Models/BannerSeverity.cs b/src/EventLogExpert.UI/Models/BannerSeverity.cs new file mode 100644 index 00000000..eddf50bc --- /dev/null +++ b/src/EventLogExpert.UI/Models/BannerSeverity.cs @@ -0,0 +1,10 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.UI.Models; + +public enum BannerSeverity +{ + Info, + Warning, +} diff --git a/src/EventLogExpert.UI/Models/CriticalAlertEntry.cs b/src/EventLogExpert.UI/Models/CriticalAlertEntry.cs new file mode 100644 index 00000000..d82ad732 --- /dev/null +++ b/src/EventLogExpert.UI/Models/CriticalAlertEntry.cs @@ -0,0 +1,6 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.UI.Models; + +public sealed record CriticalAlertEntry(Guid Id, string Title, string Message, DateTime CreatedUtc); diff --git a/src/EventLogExpert.UI/Services/AlertDialogService.cs b/src/EventLogExpert.UI/Services/AlertDialogService.cs index cc67eacf..5bca294e 100644 --- a/src/EventLogExpert.UI/Services/AlertDialogService.cs +++ b/src/EventLogExpert.UI/Services/AlertDialogService.cs @@ -1,33 +1,40 @@ -// // Copyright (c) Microsoft Corporation. +// // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. +using EventLogExpert.UI.Interfaces; + namespace EventLogExpert.UI.Services; +/// +/// User-facing alert/prompt surface. Implementations decide whether each request renders inline in an active modal, +/// opens a standalone popup, or routes to the singleton banner via . +/// public interface IAlertDialogService { - Task ShowAlert(string title, string message, string cancel); - - Task ShowAlert(string title, string message, string accept, string cancel); - Task DisplayPrompt(string title, string message); Task DisplayPrompt(string title, string message, string initialValue); -} -public sealed class AlertDialogService( - Func oneButtonAlert, - Func> twoButtonAlert, - Func> promptAlert, - Func> promptAlertWithValue) : IAlertDialogService -{ - public async Task ShowAlert(string title, string message, string cancel) => - await oneButtonAlert(title, message, cancel); + /// One-button informational alert. Uses presentation. + Task ShowAlert(string title, string message, string cancel); - public async Task ShowAlert(string title, string message, string accept, string cancel) => - await twoButtonAlert(title, message, accept, cancel); + /// One-button informational alert with explicit presentation control. + Task ShowAlert(string title, string message, string cancel, AlertPresentation presentation); - public async Task DisplayPrompt(string title, string message) => await promptAlert(title, message); + /// Two-button confirmation alert. Uses presentation. + Task ShowAlert(string title, string message, string accept, string cancel); - public async Task DisplayPrompt(string title, string message, string value) => - await promptAlertWithValue(title, message, value); + /// + /// Two-button confirmation alert with explicit presentation control. is not + /// valid for two-button alerts (the banner has no accept/cancel pair) and throws . + /// + Task ShowAlert(string title, string message, string accept, string cancel, AlertPresentation presentation); + + /// + /// Surface a critical alert via . Always routes to the banner queue + /// regardless of whether an inline host is active. The returned task completes immediately after the alert is + /// queued; it does NOT wait for the user to dismiss the banner. Caller is responsible for ensuring it is + /// appropriate to interrupt the user with a banner. + /// + Task ShowCriticalAlert(string title, string message); } diff --git a/src/EventLogExpert.UI/Services/AlertPresentation.cs b/src/EventLogExpert.UI/Services/AlertPresentation.cs new file mode 100644 index 00000000..54b1ba89 --- /dev/null +++ b/src/EventLogExpert.UI/Services/AlertPresentation.cs @@ -0,0 +1,33 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.UI.Services; + +/// +/// Controls how an request is +/// surfaced to the user. +/// +public enum AlertPresentation +{ + /// + /// Default. Use the existing routing: render inline in the active modal host + /// if one is registered, otherwise open a standalone alert popup. + /// + Auto, + + /// + /// Route to with severity. Only + /// valid for one-button overloads (the banner has no accept/cancel pair); using it on a two-button overload throws. + /// + Banner, + + /// + /// Require an active inline alert host. Throws if none is registered. + /// + InlineOnly, + + /// + /// Always open a standalone popup, even if an inline host is registered. + /// + PopupOnly, +} diff --git a/src/EventLogExpert.UI/Services/ApplicationRestartService.cs b/src/EventLogExpert.UI/Services/ApplicationRestartService.cs index f8073492..0d1cf659 100644 --- a/src/EventLogExpert.UI/Services/ApplicationRestartService.cs +++ b/src/EventLogExpert.UI/Services/ApplicationRestartService.cs @@ -1,15 +1,42 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. +using EventLogExpert.Eventing.Helpers; using EventLogExpert.UI.Interfaces; +using Windows.ApplicationModel.Core; namespace EventLogExpert.UI.Services; -public sealed class ApplicationRestartService : IApplicationRestartService +public sealed class ApplicationRestartService(ITraceLogger traceLogger) : IApplicationRestartService { + private readonly ITraceLogger _traceLogger = traceLogger; + public bool RegisterApplicationRestart() { uint result = NativeMethods.RegisterApplicationRestart(null, RestartFlags.NONE); return result == 0; } + + public async Task TryRestartAsync(string launchArguments = "") + { + try + { + // Successful restart terminates process; only failures and pending restarts return here. + AppRestartFailureReason reason = await CoreApplication.RequestRestartAsync(launchArguments); + + if (reason == AppRestartFailureReason.RestartPending) + { + _traceLogger.Info($"{nameof(ApplicationRestartService)}.{nameof(TryRestartAsync)}: restart already pending"); + return true; + } + + _traceLogger.Error($"{nameof(ApplicationRestartService)}.{nameof(TryRestartAsync)}: restart denied: {reason}"); + return false; + } + catch (Exception ex) + { + _traceLogger.Error($"{nameof(ApplicationRestartService)}.{nameof(TryRestartAsync)}: restart threw: {ex}"); + return false; + } + } } diff --git a/src/EventLogExpert.UI/Services/BannerService.cs b/src/EventLogExpert.UI/Services/BannerService.cs new file mode 100644 index 00000000..d1ecfec5 --- /dev/null +++ b/src/EventLogExpert.UI/Services/BannerService.cs @@ -0,0 +1,190 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.UI.Interfaces; +using EventLogExpert.UI.Models; +using System.Collections.Immutable; + +namespace EventLogExpert.UI.Services; + +public sealed class BannerService : IBannerService +{ + private readonly Lock _stateLock = new(); + + private ImmutableList _criticalAlerts = ImmutableList.Empty; + private ImmutableList _infoBanners = ImmutableList.Empty; + private Func? _recoveryCallback; + private object? _recoveryToken; + + private Exception? _unhandledError; + + public event Action? StateChanged; + + public IReadOnlyList CriticalAlerts + { + get { lock (_stateLock) { return _criticalAlerts; } } + } + + public IReadOnlyList InfoBanners + { + get { lock (_stateLock) { return _infoBanners; } } + } + + public Exception? UnhandledError + { + get { lock (_stateLock) { return _unhandledError; } } + } + + public void ClearError() + { + lock (_stateLock) + { + _unhandledError = null; + } + + RaiseStateChanged(); + } + + public void DismissCritical(Guid id) + { + bool removed; + + lock (_stateLock) + { + ImmutableList next = _criticalAlerts.RemoveAll(entry => entry.Id == id); + removed = next.Count != _criticalAlerts.Count; + _criticalAlerts = next; + } + + if (removed) + { + RaiseStateChanged(); + } + } + + public void DismissInfoBanner(Guid id) + { + bool removed; + + lock (_stateLock) + { + ImmutableList next = _infoBanners.RemoveAll(entry => entry.Id == id); + removed = next.Count != _infoBanners.Count; + _infoBanners = next; + } + + if (removed) + { + RaiseStateChanged(); + } + } + + public IDisposable RegisterRecoveryCallback(Func recover) + { + ArgumentNullException.ThrowIfNull(recover); + + var registration = new RecoveryRegistration(this); + + lock (_stateLock) + { + _recoveryCallback = recover; + _recoveryToken = registration; + } + + return registration; + } + + public void ReportCritical(string title, string message) + { + var entry = new CriticalAlertEntry(Guid.NewGuid(), title, message, DateTime.UtcNow); + + lock (_stateLock) + { + _criticalAlerts = _criticalAlerts.Add(entry); + } + + RaiseStateChanged(); + } + + public void ReportError(Exception ex) + { + ArgumentNullException.ThrowIfNull(ex); + + lock (_stateLock) + { + _unhandledError = ex; + } + + RaiseStateChanged(); + } + + public void ReportInfoBanner(string title, string message, BannerSeverity severity) + { + var entry = new BannerInfoEntry(Guid.NewGuid(), title, message, severity, DateTime.UtcNow); + + lock (_stateLock) + { + _infoBanners = _infoBanners.Add(entry); + } + + RaiseStateChanged(); + } + + public async Task TryRecoverAsync() + { + Exception? snapshotError; + Func? callback; + + lock (_stateLock) + { + snapshotError = _unhandledError; + callback = _recoveryCallback; + } + + if (callback is not null) + { + await callback(); + } + + bool cleared = false; + + lock (_stateLock) + { + // Only clear if the error is still the one we set out to recover. If a newer error was reported + // while the callback was running, leave it visible so the user sees the new state. + if (ReferenceEquals(_unhandledError, snapshotError)) + { + _unhandledError = null; + cleared = true; + } + } + + if (cleared) + { + RaiseStateChanged(); + } + } + + private void RaiseStateChanged() => StateChanged?.Invoke(); + + private void UnregisterRecoveryIfActive(object token) + { + lock (_stateLock) + { + if (!ReferenceEquals(_recoveryToken, token)) + { + return; + } + + _recoveryCallback = null; + _recoveryToken = null; + } + } + + private sealed class RecoveryRegistration(BannerService service) : IDisposable + { + private readonly BannerService _service = service; + + public void Dispose() => _service.UnregisterRecoveryIfActive(this); + } +} diff --git a/src/EventLogExpert.UI/Services/BannerViewSelector.cs b/src/EventLogExpert.UI/Services/BannerViewSelector.cs new file mode 100644 index 00000000..0b111f5a --- /dev/null +++ b/src/EventLogExpert.UI/Services/BannerViewSelector.cs @@ -0,0 +1,29 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.UI.Models; + +namespace EventLogExpert.UI.Services; + +public enum BannerView +{ + None, + Error, + Critical, + Info +} + +public static class BannerViewSelector +{ + public static BannerView Select( + Exception? unhandledError, + IReadOnlyList criticalAlerts, + IReadOnlyList infoBanners) + { + if (unhandledError is not null) { return BannerView.Error; } + + if (criticalAlerts.Count > 0) { return BannerView.Critical; } + + return infoBanners.Count > 0 ? BannerView.Info : BannerView.None; + } +} diff --git a/src/EventLogExpert.UI/Services/EmptyLogAlertFormatter.cs b/src/EventLogExpert.UI/Services/EmptyLogAlertFormatter.cs new file mode 100644 index 00000000..73d293f7 --- /dev/null +++ b/src/EventLogExpert.UI/Services/EmptyLogAlertFormatter.cs @@ -0,0 +1,28 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.UI.Services; + +/// +/// Builds the message text shown when one or more logs the user just tried to open contained zero +/// events. Singular and plural cases use distinct phrasings. +/// +public static class EmptyLogAlertFormatter +{ + public static string BuildMessage(IReadOnlyList displayNames) + { + ArgumentNullException.ThrowIfNull(displayNames); + + if (displayNames.Count == 0) + { + throw new ArgumentException("At least one display name is required.", nameof(displayNames)); + } + + if (displayNames.Count == 1) + { + return $"Log contains no events: {displayNames[0]}"; + } + + return $"{displayNames.Count} logs contained no events: {string.Join(", ", displayNames)}"; + } +} diff --git a/src/EventLogExpert.UI/Services/ModalAlertDialogService.cs b/src/EventLogExpert.UI/Services/ModalAlertDialogService.cs index 736a5cea..1d105087 100644 --- a/src/EventLogExpert.UI/Services/ModalAlertDialogService.cs +++ b/src/EventLogExpert.UI/Services/ModalAlertDialogService.cs @@ -2,21 +2,26 @@ // // Licensed under the MIT License. using EventLogExpert.UI.Interfaces; +using EventLogExpert.UI.Models; namespace EventLogExpert.UI.Services; /// /// Routes calls through . Active modals exposing /// get the request as an inline banner; otherwise a standalone alert/prompt modal is -/// opened via the supplied delegates. All routing is marshaled to the main thread so background callers -/// (UpdateService, DeploymentService) are safe. +/// opened via the supplied delegates. requests and +/// bypass the modal routing and go directly to . All +/// popup/inline routing is marshaled to the main thread so background callers (UpdateService, DeploymentService) are +/// safe; banner-routed paths are not marshaled because is thread-safe. /// public sealed class ModalAlertDialogService( IModalService modalService, IMainThreadService mainThreadService, + IBannerService bannerService, Func, Task> openStandaloneAlert, Func, Task> openStandalonePrompt) : IAlertDialogService { + private readonly IBannerService _bannerService = bannerService; private readonly IMainThreadService _mainThreadService = mainThreadService; private readonly IModalService _modalService = modalService; private readonly Func, Task> _openStandaloneAlert = openStandaloneAlert; @@ -28,95 +33,129 @@ public sealed class ModalAlertDialogService( public Task DisplayPrompt(string title, string message, string initialValue) => DisplayPromptCore(title, message, initialValue); - public async Task ShowAlert(string title, string message, string cancel) => - await InvokeOnMainThreadAsync(async () => + public Task ShowAlert(string title, string message, string cancel) => + ShowAlert(title, message, cancel, AlertPresentation.Auto); + + public Task ShowAlert(string title, string message, string cancel, AlertPresentation presentation) => + ShowAlertCore(title, message, accept: null, cancel, presentation); + + public Task ShowAlert(string title, string message, string accept, string cancel) => + ShowAlert(title, message, accept, cancel, AlertPresentation.Auto); + + public Task ShowAlert( + string title, + string message, + string accept, + string cancel, + AlertPresentation presentation) + { + if (presentation == AlertPresentation.Banner) + { + throw new ArgumentException( + $"{nameof(AlertPresentation)}.{nameof(AlertPresentation.Banner)} is not valid for two-button alerts " + + "(the banner has no accept/cancel pair).", + nameof(presentation)); + } + + return ShowAlertCore(title, message, accept, cancel, presentation); + } + + public Task ShowCriticalAlert(string title, string message) + { + _bannerService.ReportCritical(title, message); + + return Task.CompletedTask; + } + + private Task DisplayPromptCore(string title, string message, string? initialValue) => + InvokeOnMainThreadAsync(async () => { if (!_modalService.TryGetActiveAlertHost(out var host)) { - return await _openStandaloneAlert(new Dictionary + return await _openStandalonePrompt(new Dictionary { ["Title"] = title, ["Message"] = message, - ["AcceptLabel"] = null, - ["CancelLabel"] = cancel, + ["InitialValue"] = initialValue ?? string.Empty, }); } try { InlineAlertResult result = await host!.ShowInlineAlertAsync( - new InlineAlertRequest(title, message, null, cancel, false, null), + new InlineAlertRequest(title, message, "OK", "Cancel", true, initialValue), CancellationToken.None); - return result.Accepted; + return result.Accepted ? result.PromptValue ?? string.Empty : string.Empty; } catch (TaskCanceledException) { - return false; + return string.Empty; } }); - public Task ShowAlert(string title, string message, string accept, string cancel) => - InvokeOnMainThreadAsync(async () => + private async Task InvokeOnMainThreadAsync(Func> action) + { + TResult result = default!; + + await _mainThreadService.InvokeOnMainThreadAsync(async () => { result = await action(); }); + + return result; + } + + private Task ShowAlertCore( + string title, + string message, + string? accept, + string cancel, + AlertPresentation presentation) + { + if (presentation == AlertPresentation.Banner) { - if (!_modalService.TryGetActiveAlertHost(out var host)) - { - return await _openStandaloneAlert(new Dictionary - { - ["Title"] = title, - ["Message"] = message, - ["AcceptLabel"] = accept, - ["CancelLabel"] = cancel, - }); - } + _bannerService.ReportInfoBanner(title, message, BannerSeverity.Warning); - try - { - InlineAlertResult result = await host!.ShowInlineAlertAsync( - new InlineAlertRequest(title, message, accept, cancel, false, null), - CancellationToken.None); + return Task.FromResult(false); + } - return result.Accepted; + return InvokeOnMainThreadAsync(async () => + { + bool hostAvailable = _modalService.TryGetActiveAlertHost(out var host); + + if (presentation == AlertPresentation.InlineOnly && !hostAvailable) + { + throw new InvalidOperationException( + $"{nameof(AlertPresentation)}.{nameof(AlertPresentation.InlineOnly)} requires an active inline " + + "alert host but none is registered."); } - catch (TaskCanceledException) + + if (presentation == AlertPresentation.PopupOnly) { - return false; + hostAvailable = false; } - }); - private Task DisplayPromptCore(string title, string message, string? initialValue) => - InvokeOnMainThreadAsync(async () => - { - if (!_modalService.TryGetActiveAlertHost(out var host)) + if (!hostAvailable) { - return await _openStandalonePrompt(new Dictionary + return await _openStandaloneAlert(new Dictionary { ["Title"] = title, ["Message"] = message, - ["InitialValue"] = initialValue ?? string.Empty, + ["AcceptLabel"] = accept, + ["CancelLabel"] = cancel, }); } try { InlineAlertResult result = await host!.ShowInlineAlertAsync( - new InlineAlertRequest(title, message, "OK", "Cancel", true, initialValue), + new InlineAlertRequest(title, message, accept, cancel, false, null), CancellationToken.None); - return result.Accepted ? result.PromptValue ?? string.Empty : string.Empty; + return result.Accepted; } catch (TaskCanceledException) { - return string.Empty; + return false; } }); - - private async Task InvokeOnMainThreadAsync(Func> action) - { - TResult result = default!; - - await _mainThreadService.InvokeOnMainThreadAsync(async () => { result = await action(); }); - - return result; } } diff --git a/src/EventLogExpert.UI/Services/UpdateService.cs b/src/EventLogExpert.UI/Services/UpdateService.cs index 18ccd0ac..35a67ab4 100644 --- a/src/EventLogExpert.UI/Services/UpdateService.cs +++ b/src/EventLogExpert.UI/Services/UpdateService.cs @@ -25,12 +25,20 @@ public sealed class UpdateService( IAlertDialogService alertDialogService) : IUpdateService { private string? _currentRawChanges; + private int _hasAutoChecked; public async Task CheckForUpdates(bool usePreRelease, bool userInitiated = false) { traceLogger.Debug($"{nameof(CheckForUpdates)} was called. {nameof(usePreRelease)} is {usePreRelease}. " + $"{nameof(userInitiated)} is {userInitiated}. {nameof(versionProvider.CurrentVersion)} is {versionProvider.CurrentVersion}."); + if (!userInitiated && Interlocked.CompareExchange(ref _hasAutoChecked, 1, 0) != 0) + { + traceLogger.Debug($"{nameof(CheckForUpdates)} skipping automatic check; one already ran this session."); + + return; + } + if (versionProvider.IsDevBuild) { traceLogger.Debug($"{nameof(CheckForUpdates)} {nameof(versionProvider.IsDevBuild)}: {versionProvider.IsDevBuild}. Skipping update check."); @@ -68,7 +76,8 @@ await alertDialogService.ShowAlert("Update Check Unavailable", if (!usePreRelease && release.IsPreRelease) { continue; } // Need to drop the v off the version number provided by GitHub - if (versionProvider.CurrentVersion.CompareTo(new Version(release.Version.TrimStart('v'))) != 0) { + if (versionProvider.CurrentVersion.CompareTo(new Version(release.Version.TrimStart('v'))) != 0) + { latest = release; break; diff --git a/src/EventLogExpert.sln b/src/EventLogExpert.sln deleted file mode 100644 index e759de4a..00000000 --- a/src/EventLogExpert.sln +++ /dev/null @@ -1,64 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31611.283 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EventLogExpert", "EventLogExpert\EventLogExpert.csproj", "{4AF4EFE3-97D4-42BA-8026-595ED9A9F3AE}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EventLogExpert.Eventing", "EventLogExpert.Eventing\EventLogExpert.Eventing.csproj", "{2524E3CE-D95D-4C70-9614-47906411FBD7}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{5F157DE7-62EB-4577-834B-58EA31AEC78B}" - ProjectSection(SolutionItems) = preProject - .editorconfig = .editorconfig - Directory.Build.props = Directory.Build.props - Directory.Packages.props = Directory.Packages.props - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EventLogExpert.EventDbTool", "EventLogExpert.EventDbTool\EventLogExpert.EventDbTool.csproj", "{6A61AA06-463F-4BB8-AF82-69F8E43A64ED}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EventLogExpert.UI", "EventLogExpert.UI\EventLogExpert.UI.csproj", "{71C03ABA-CCF1-4CE7-9F46-7BE33357BE88}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EventLogExpert.Eventing.Tests", "EventLogExpert.Eventing.Tests\EventLogExpert.Eventing.Tests.csproj", "{C29B552F-0778-46F9-9532-216DB4C0F811}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EventLogExpert.UI.Tests", "EventLogExpert.UI.Tests\EventLogExpert.UI.Tests.csproj", "{866DFEE5-FDC4-4CAE-A573-7ED1210FF593}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {4AF4EFE3-97D4-42BA-8026-595ED9A9F3AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4AF4EFE3-97D4-42BA-8026-595ED9A9F3AE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4AF4EFE3-97D4-42BA-8026-595ED9A9F3AE}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {4AF4EFE3-97D4-42BA-8026-595ED9A9F3AE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4AF4EFE3-97D4-42BA-8026-595ED9A9F3AE}.Release|Any CPU.Build.0 = Release|Any CPU - {4AF4EFE3-97D4-42BA-8026-595ED9A9F3AE}.Release|Any CPU.Deploy.0 = Release|Any CPU - {2524E3CE-D95D-4C70-9614-47906411FBD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2524E3CE-D95D-4C70-9614-47906411FBD7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2524E3CE-D95D-4C70-9614-47906411FBD7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2524E3CE-D95D-4C70-9614-47906411FBD7}.Release|Any CPU.Build.0 = Release|Any CPU - {6A61AA06-463F-4BB8-AF82-69F8E43A64ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6A61AA06-463F-4BB8-AF82-69F8E43A64ED}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6A61AA06-463F-4BB8-AF82-69F8E43A64ED}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6A61AA06-463F-4BB8-AF82-69F8E43A64ED}.Release|Any CPU.Build.0 = Release|Any CPU - {71C03ABA-CCF1-4CE7-9F46-7BE33357BE88}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {71C03ABA-CCF1-4CE7-9F46-7BE33357BE88}.Debug|Any CPU.Build.0 = Debug|Any CPU - {71C03ABA-CCF1-4CE7-9F46-7BE33357BE88}.Release|Any CPU.ActiveCfg = Release|Any CPU - {71C03ABA-CCF1-4CE7-9F46-7BE33357BE88}.Release|Any CPU.Build.0 = Release|Any CPU - {C29B552F-0778-46F9-9532-216DB4C0F811}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C29B552F-0778-46F9-9532-216DB4C0F811}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C29B552F-0778-46F9-9532-216DB4C0F811}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C29B552F-0778-46F9-9532-216DB4C0F811}.Release|Any CPU.Build.0 = Release|Any CPU - {866DFEE5-FDC4-4CAE-A573-7ED1210FF593}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {866DFEE5-FDC4-4CAE-A573-7ED1210FF593}.Debug|Any CPU.Build.0 = Debug|Any CPU - {866DFEE5-FDC4-4CAE-A573-7ED1210FF593}.Release|Any CPU.ActiveCfg = Release|Any CPU - {866DFEE5-FDC4-4CAE-A573-7ED1210FF593}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {61F7FB11-1E47-470C-91E2-47F8143E1572} - EndGlobalSection -EndGlobal diff --git a/src/EventLogExpert.slnx b/src/EventLogExpert.slnx new file mode 100644 index 00000000..960d9585 --- /dev/null +++ b/src/EventLogExpert.slnx @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/EventLogExpert/Components/EventTable.razor.cs b/src/EventLogExpert/Components/EventTable.razor.cs index 42778214..d27ec19d 100644 --- a/src/EventLogExpert/Components/EventTable.razor.cs +++ b/src/EventLogExpert/Components/EventTable.razor.cs @@ -3,7 +3,6 @@ using EventLogExpert.Eventing.Helpers; using EventLogExpert.Eventing.Models; -using EventLogExpert.Services; using EventLogExpert.UI; using EventLogExpert.UI.Interfaces; using EventLogExpert.UI.Models; diff --git a/src/EventLogExpert/EventLogExpert.csproj b/src/EventLogExpert/EventLogExpert.csproj index 36a5b565..9439a263 100644 --- a/src/EventLogExpert/EventLogExpert.csproj +++ b/src/EventLogExpert/EventLogExpert.csproj @@ -60,6 +60,7 @@ + diff --git a/src/EventLogExpert/Main.razor b/src/EventLogExpert/Main.razor index 690f0ef4..b10dc658 100644 --- a/src/EventLogExpert/Main.razor +++ b/src/EventLogExpert/Main.razor @@ -1,15 +1,11 @@ - +@* + Application root. The hybrid shell is intentionally non-routed: there are no @page directives anywhere. + BannerHost lives outside UnhandledExceptionHandler so the error banner survives when child rendering faults. +*@ + + + - - - - - - - -

Sorry, there's nothing at this address.

-
-
-
+
diff --git a/src/EventLogExpert/MainPage.xaml.cs b/src/EventLogExpert/MainPage.xaml.cs index 2b956cea..4c488f3e 100644 --- a/src/EventLogExpert/MainPage.xaml.cs +++ b/src/EventLogExpert/MainPage.xaml.cs @@ -137,15 +137,14 @@ private async void DropGestureRecognizer_OnDrop(object? sender, DropEventArgs e) IReadOnlyList items = await e.PlatformArgs.DragEventArgs.DataView.GetStorageItemsAsync(); - foreach (var item in items) - { - if (item is not StorageFile file || _activeLogs.Value.ContainsKey(file.Path)) - { - continue; - } + var droppedFilePaths = items.OfType() + .Where(file => !string.IsNullOrEmpty(file.Path)) + .Select(file => (file.Path, PathType.FilePath)) + .ToList(); - await _menuActionService.OpenLogAsync(file.Path, PathType.FilePath, true); - } + if (droppedFilePaths.Count == 0) { return; } + + await _menuActionService.OpenLogsBatchAsync(droppedFilePaths, combineLog: true); } private void MainWebView_BlazorWebViewInitialized(object? sender, BlazorWebViewInitializedEventArgs e) @@ -206,15 +205,14 @@ private async Task ProcessCommandLine() { try { - var args = Environment.GetCommandLineArgs(); + var evtxArgs = Environment.GetCommandLineArgs() + .Where(arg => arg.EndsWith(".evtx", StringComparison.OrdinalIgnoreCase)) + .Select(arg => (arg, PathType.FilePath)) + .ToList(); - foreach (var arg in args) - { - if (arg.EndsWith(".evtx", StringComparison.OrdinalIgnoreCase)) - { - await _menuActionService.OpenLogAsync(arg, PathType.FilePath); - } - } + if (evtxArgs.Count == 0) { return; } + + await _menuActionService.OpenLogsBatchAsync(evtxArgs, combineLog: true); } catch (Exception e) { diff --git a/src/EventLogExpert/MauiProgram.cs b/src/EventLogExpert/MauiProgram.cs index ef3dc4b6..9a074b72 100644 --- a/src/EventLogExpert/MauiProgram.cs +++ b/src/EventLogExpert/MauiProgram.cs @@ -98,14 +98,18 @@ public static MauiApp CreateMauiApp() provider.GetRequiredService()); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(static provider => { var modalService = provider.GetRequiredService(); var mainThreadService = provider.GetRequiredService(); + var bannerService = provider.GetRequiredService(); return new ModalAlertDialogService( modalService, mainThreadService, + bannerService, parameters => modalService.Show(parameters.ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value)), async parameters => { diff --git a/src/EventLogExpert/Services/ClipboardService.cs b/src/EventLogExpert/Services/ClipboardService.cs index 0387598e..b8e01fbc 100644 --- a/src/EventLogExpert/Services/ClipboardService.cs +++ b/src/EventLogExpert/Services/ClipboardService.cs @@ -15,11 +15,6 @@ namespace EventLogExpert.Services; -public interface IClipboardService -{ - Task CopySelectedEvent(CopyType? copyType = null); -} - public sealed class ClipboardService : IClipboardService { private readonly IStateSelection> _eventTableColumns; @@ -62,36 +57,39 @@ public async Task CopySelectedEvent(CopyType? copyType = null) } catch (Exception ex) { - _traceLogger.Error($"ClipboardService: failed to copy selected event(s): {ex}"); + _traceLogger.Error($"{nameof(ClipboardService)}.{nameof(CopySelectedEvent)}: failed: {ex}"); } } - private static string FormatXmlForCopy(string xml) + public async Task CopyTextAsync(string text) { + // Same best-effort contract as CopySelectedEvent: callers (banner copy-details, filter + // export, future surfaces) are typically fire-and-forget UI handlers. try { - return XElement.Parse(xml).ToString(); + await Clipboard.SetTextAsync(text).ConfigureAwait(false); } - catch (System.Xml.XmlException) + catch (Exception ex) { - return xml; + _traceLogger.Error($"{nameof(ClipboardService)}.{nameof(CopyTextAsync)}: failed: {ex}"); } } - private string FormatEventForCopy(CopyType copyType, DisplayEventModel @event, string xml) + private static string FormatXmlForCopy(string xml) { - if (copyType == CopyType.Xml) + try { - return string.IsNullOrEmpty(xml) ? string.Empty : FormatXmlForCopy(xml); + return XElement.Parse(xml).ToString(); + } + catch (System.Xml.XmlException) + { + return xml; } - - StringBuilder builder = new(); - - AppendFormattedEvent(builder, copyType, @event, xml); - - return builder.ToString(); } + private static string GetLogShortName(string owningLog) => + owningLog[(owningLog.LastIndexOf('\\') + 1)..]; + private void AppendFormattedEvent( StringBuilder builder, CopyType copyType, @@ -155,6 +153,7 @@ private void AppendFormattedEvent( break; case CopyType.Xml: if (!string.IsNullOrEmpty(xml)) { builder.Append(FormatXmlForCopy(xml)); } + break; case CopyType.Full: default: @@ -180,8 +179,19 @@ private void AppendFormattedEvent( } } - private static string GetLogShortName(string owningLog) => - owningLog[(owningLog.LastIndexOf('\\') + 1)..]; + private string FormatEventForCopy(CopyType copyType, DisplayEventModel @event, string xml) + { + if (copyType == CopyType.Xml) + { + return string.IsNullOrEmpty(xml) ? string.Empty : FormatXmlForCopy(xml); + } + + StringBuilder builder = new(); + + AppendFormattedEvent(builder, copyType, @event, xml); + + return builder.ToString(); + } private async Task GetFormattedEvent(CopyType? copyType) { diff --git a/src/EventLogExpert/Services/MauiMenuActionService.cs b/src/EventLogExpert/Services/MauiMenuActionService.cs index e000e329..135202c1 100644 --- a/src/EventLogExpert/Services/MauiMenuActionService.cs +++ b/src/EventLogExpert/Services/MauiMenuActionService.cs @@ -20,8 +20,9 @@ namespace EventLogExpert.Services; /// /// MAUI implementation of . Owns the cancellation token that gates background -/// log loads (replaces the legacy field on MainPage). Exposes publicly so the page-level -/// drag/drop handler can reuse the same cancellation lifecycle. +/// log loads (replaces the legacy field on MainPage). Exposes publicly so the +/// page-level drag/drop and command-line handlers can batch multiple opens through the same cancellation lifecycle +/// and surface one banner alert per gesture for empty logs. /// public sealed class MauiMenuActionService( IDispatcher dispatcher, @@ -148,12 +149,12 @@ public async Task OpenFileAsync(bool combineLog) await CloseAllLogsAsync(); } - foreach (var file in files) - { - if (file?.FullPath is null) { continue; } + var paths = files + .Where(file => file is not null && !string.IsNullOrEmpty(file.FullPath)) + .Select(file => (file!.FullPath, PathType.FilePath)) + .ToList(); - await OpenLogAsync(file.FullPath, PathType.FilePath, true); - } + await OpenLogsBatchAsync(paths, combineLog: true); } public async Task OpenFolderAsync(bool combineLog) @@ -162,7 +163,9 @@ public async Task OpenFolderAsync(bool combineLog) if (folderPath is null) { return; } - var files = Directory.EnumerateFiles(folderPath, "*.evtx", SearchOption.TopDirectoryOnly).ToList(); + var files = Directory.EnumerateFiles(folderPath, "*.evtx", SearchOption.TopDirectoryOnly) + .Select(file => (file, PathType.FilePath)) + .ToList(); if (files.Count == 0) { return; } @@ -171,21 +174,18 @@ public async Task OpenFolderAsync(bool combineLog) await CloseAllLogsAsync(); } - foreach (var file in files) - { - await OpenLogAsync(file, PathType.FilePath, true); - } + await OpenLogsBatchAsync(files, combineLog: true); } public Task OpenIssueAsync() => OpenBrowserAsync("https://github.com/microsoft/EventLogExpert/issues/new"); - public Task OpenLiveLogAsync(string logName, bool combineLog) => OpenLogAsync(logName, PathType.LogName, combineLog); + public Task OpenLiveLogAsync(string logName, bool combineLog) => + OpenLogsBatchAsync([(logName, PathType.LogName)], combineLog); - public async Task OpenLogAsync(string logPath, PathType pathType, bool combineLog = false) + public async Task OpenLogAsync(string logPath, PathType pathType, bool combineLog = false) { - if (string.IsNullOrWhiteSpace(logPath)) { return; } - - if (combineLog && _eventLogState.Value.ActiveLogs.ContainsKey(logPath)) { return; } + if (string.IsNullOrWhiteSpace(logPath) || + (combineLog && _eventLogState.Value.ActiveLogs.ContainsKey(logPath))) { return OpenLogStatus.Loaded; } EventLogInformation? eventLogInformation; @@ -200,19 +200,18 @@ await _dialogService.ShowAlert( "Please relaunch with \"Run as Administrator\" to open this log", "Ok"); - return; + return OpenLogStatus.Loaded; } catch (Exception ex) { await _dialogService.ShowAlert("Failed to open Log", $"Exception: {ex.Message}", "Ok"); - return; + return OpenLogStatus.Loaded; } if (eventLogInformation.RecordCount is null or <= 0) { - await _dialogService.ShowAlert("Empty log", "Log contains no events", "Ok"); - return; + return OpenLogStatus.Empty; } if (!combineLog) @@ -227,6 +226,37 @@ await _dialogService.ShowAlert( } _dispatcher.Dispatch(new EventLogAction.OpenLog(logPath, pathType, _cancellationTokenSource.Token)); + return OpenLogStatus.Loaded; + } + + /// + /// Opens each log in sequentially and surfaces a single banner alert at the end naming + /// every log that contained zero events. Use this from any call site that may open multiple logs in one user + /// gesture (multi-file picker, folder open, drag-drop, command line) so the user sees one batched alert instead + /// of one popup per empty file. + /// + public async Task OpenLogsBatchAsync(IEnumerable<(string Path, PathType Type)> logs, bool combineLog) + { + ArgumentNullException.ThrowIfNull(logs); + + List? emptyDisplayNames = null; + + foreach (var (path, type) in logs) + { + if (await OpenLogAsync(path, type, combineLog) == OpenLogStatus.Empty) + { + (emptyDisplayNames ??= []).Add(GetEmptyLogDisplayName(path, type)); + } + } + + if (emptyDisplayNames is { Count: > 0 }) + { + await _dialogService.ShowAlert( + "Empty log", + EmptyLogAlertFormatter.BuildMessage(emptyDisplayNames), + "Ok", + AlertPresentation.Banner); + } } public Task OpenSettingsAsync() => ShowModalAsync("settings"); @@ -271,6 +301,15 @@ await _modalService.Show( public void ToggleShowAllEvents() => _dispatcher.Dispatch(new FilterPaneAction.ToggleIsEnabled()); + private static string GetEmptyLogDisplayName(string path, PathType type) + { + if (type != PathType.FilePath) { return path; } + + var fileName = Path.GetFileName(path); + + return string.IsNullOrEmpty(fileName) ? path : fileName; + } + private async Task OpenBrowserAsync(string url) { try diff --git a/src/EventLogExpert/Shared/Components/Filters/FilterGroup.razor.cs b/src/EventLogExpert/Shared/Components/Filters/FilterGroup.razor.cs index 47fa5952..be902980 100644 --- a/src/EventLogExpert/Shared/Components/Filters/FilterGroup.razor.cs +++ b/src/EventLogExpert/Shared/Components/Filters/FilterGroup.razor.cs @@ -2,6 +2,7 @@ // // Licensed under the MIT License. using EventLogExpert.UI; +using EventLogExpert.UI.Interfaces; using EventLogExpert.UI.Models; using EventLogExpert.UI.Services; using EventLogExpert.UI.Store.FilterGroup; @@ -27,6 +28,8 @@ public sealed partial class FilterGroup [Inject] private IAlertDialogService AlertDialogService { get; init; } = null!; + [Inject] private IClipboardService ClipboardService { get; init; } = null!; + [Inject] private IDispatcher Dispatcher { get; init; } = null!; protected override void OnParametersSet() @@ -67,7 +70,7 @@ private async Task ApplyFilters() private void CancelGroup() => Dispatcher.Dispatch(new FilterGroupAction.ToggleGroup(Group.Id)); - private void CopyGroup() + private async Task CopyGroup() { if (Group.Filters.Count <= 0) { return; } @@ -75,7 +78,7 @@ private void CopyGroup() string.Join(" || ", Group.Filters.Select(filter => $"({filter.ComparisonText})")) : Group.Filters[0].ComparisonText; - _ = Clipboard.SetTextAsync(text); + await ClipboardService.CopyTextAsync(text); } private async Task ExportGroup() diff --git a/src/EventLogExpert/Shared/UnhandledExceptionHandler.razor b/src/EventLogExpert/Shared/UnhandledExceptionHandler.razor index 08caac91..024c6cc2 100644 --- a/src/EventLogExpert/Shared/UnhandledExceptionHandler.razor +++ b/src/EventLogExpert/Shared/UnhandledExceptionHandler.razor @@ -8,11 +8,3 @@ else if (ErrorContent is not null) { @ErrorContent(CurrentException) } -else -{ -
- @ChildContent - -
-
-} diff --git a/src/EventLogExpert/Shared/UnhandledExceptionHandler.razor.cs b/src/EventLogExpert/Shared/UnhandledExceptionHandler.razor.cs index e522fe99..e73c0305 100644 --- a/src/EventLogExpert/Shared/UnhandledExceptionHandler.razor.cs +++ b/src/EventLogExpert/Shared/UnhandledExceptionHandler.razor.cs @@ -2,19 +2,42 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Helpers; +using EventLogExpert.UI.Interfaces; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; namespace EventLogExpert.Shared; -public partial class UnhandledExceptionHandler : ErrorBoundary +public partial class UnhandledExceptionHandler : ErrorBoundary, IDisposable { + private IDisposable? _recoveryRegistration; + + [Inject] private IBannerService BannerService { get; set; } = null!; + [Inject] private ITraceLogger TraceLogger { get; set; } = null!; + public void Dispose() + { + _recoveryRegistration?.Dispose(); + } + protected override Task OnErrorAsync(Exception exception) { TraceLogger.Critical($"Unhandled exception in UI:\r\n{exception}"); + BannerService.ReportError(exception); return base.OnErrorAsync(exception); } + + protected override void OnInitialized() + { + base.OnInitialized(); + _recoveryRegistration = BannerService.RegisterRecoveryCallback(RecoverFromBannerAsync); + } + + private Task RecoverFromBannerAsync() + { + Recover(); + return Task.CompletedTask; + } } diff --git a/src/EventLogExpert/_Imports.razor b/src/EventLogExpert/_Imports.razor index 0d1fca76..cda5c472 100644 --- a/src/EventLogExpert/_Imports.razor +++ b/src/EventLogExpert/_Imports.razor @@ -5,8 +5,10 @@ @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop @using EventLogExpert +@using EventLogExpert.Components @using EventLogExpert.Eventing.Helpers @using EventLogExpert.Shared +@using EventLogExpert.Shared.Components @using EventLogExpert.UI.Models @using Fluxor