diff --git a/src/EventLogExpert.Runtime/Announcement/AnnouncementService.cs b/src/EventLogExpert.Runtime/Announcement/AnnouncementService.cs new file mode 100644 index 00000000..ee407a69 --- /dev/null +++ b/src/EventLogExpert.Runtime/Announcement/AnnouncementService.cs @@ -0,0 +1,61 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; + +namespace EventLogExpert.Runtime.Announcement; + +public sealed class AnnouncementService : IAnnouncementService +{ + private readonly Lock _stateLock = new(); + private readonly ITraceLogger _traceLogger; + + private string _currentAnnouncement = string.Empty; + private int _seq; + + public AnnouncementService(ITraceLogger traceLogger) + { + ArgumentNullException.ThrowIfNull(traceLogger); + + _traceLogger = traceLogger; + } + + public event Action? StateChanged; + + public string CurrentAnnouncement + { + get { lock (_stateLock) { return _currentAnnouncement; } } + } + + public void Announce(string message) + { + ArgumentNullException.ThrowIfNull(message); + + lock (_stateLock) + { + _seq++; + // ZWS toggle on odd seq guarantees DOM text mutation between consecutive identical + // announcements; SR live regions do not re-announce when the text node does not change. + _currentAnnouncement = (_seq % 2 == 0) ? message : message + "\u200B"; + } + + RaiseStateChangedSafely(); + } + + private void RaiseStateChangedSafely() + { + var handler = StateChanged; + + if (handler is null) { return; } + + foreach (Delegate subscriber in handler.GetInvocationList()) + { + try { ((Action)subscriber).Invoke(); } + catch (Exception ex) + { + _traceLogger.Warning( + $"{nameof(AnnouncementService)}.{nameof(StateChanged)} subscriber threw: {ex}"); + } + } + } +} diff --git a/src/EventLogExpert.Runtime/Announcement/IAnnouncementService.cs b/src/EventLogExpert.Runtime/Announcement/IAnnouncementService.cs new file mode 100644 index 00000000..f46089f5 --- /dev/null +++ b/src/EventLogExpert.Runtime/Announcement/IAnnouncementService.cs @@ -0,0 +1,18 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Announcement; + +/// +/// Singleton service backing the application-level polite live region. Survives modal teardown so completion +/// announcements (e.g., "Settings saved", "Database imported") are still announced by screen readers after the +/// originating modal closes. +/// +public interface IAnnouncementService +{ + event Action? StateChanged; + + string CurrentAnnouncement { get; } + + void Announce(string message); +} diff --git a/src/EventLogExpert.Runtime/Banner/BannerService.cs b/src/EventLogExpert.Runtime/Banner/BannerService.cs index da5c2f3e..b93c6b5f 100644 --- a/src/EventLogExpert.Runtime/Banner/BannerService.cs +++ b/src/EventLogExpert.Runtime/Banner/BannerService.cs @@ -26,9 +26,9 @@ internal sealed class BannerService private ImmutableHashSet _dismissedAttentionFileNames = ImmutableHashSet.Empty; private ImmutableList _errorBanners = ImmutableList.Empty; private ImmutableList _infoBanners = ImmutableList.Empty; + private BannerProgressEntry? _manageDatabasesProgress; private Func? _recoveryCallback; private object? _recoveryToken; - private BannerProgressEntry? _settingsProgress; public BannerService(IDatabaseService databaseService, ITraceLogger traceLogger) { @@ -128,9 +128,9 @@ public IReadOnlyList InfoBanners get { lock (_stateLock) { return _infoBanners; } } } - public BannerProgressEntry? SettingsProgress + public BannerProgressEntry? ManageDatabasesProgress { - get { lock (_stateLock) { return _settingsProgress; } } + get { lock (_stateLock) { return _manageDatabasesProgress; } } } public void ClearCritical() @@ -309,8 +309,8 @@ private void AssignProgressSlot(UpgradeProgressScope scope, BannerProgressEntry case UpgradeProgressScope.Background: _backgroundProgress = entry; break; - case UpgradeProgressScope.SettingsTriggered: - _settingsProgress = entry; + case UpgradeProgressScope.ManageDatabasesTriggered: + _manageDatabasesProgress = entry; break; default: throw new ArgumentOutOfRangeException(nameof(scope), scope, "Unknown upgrade progress scope."); @@ -371,9 +371,9 @@ private void OnUpgradeBatchCompleted(object? sender, UpgradeBatchCompletedEventA _backgroundProgress = null; cleared = true; } - else if (_settingsProgress is not null && _settingsProgress.BatchId == args.BatchId) + else if (_manageDatabasesProgress is not null && _manageDatabasesProgress.BatchId == args.BatchId) { - _settingsProgress = null; + _manageDatabasesProgress = null; cleared = true; } } @@ -391,7 +391,7 @@ private void OnUpgradeBatchProgress(object? sender, UpgradeBatchProgressEventArg lock (_stateLock) { BannerProgressEntry? backgroundSlot = _backgroundProgress; - BannerProgressEntry? settingsSlot = _settingsProgress; + BannerProgressEntry? manageDatabasesSlot = _manageDatabasesProgress; if (backgroundSlot is not null && backgroundSlot.BatchId == args.BatchId) { @@ -404,16 +404,16 @@ private void OnUpgradeBatchProgress(object? sender, UpgradeBatchProgressEventArg }; _backgroundProgress = next; } - else if (settingsSlot is not null && settingsSlot.BatchId == args.BatchId) + else if (manageDatabasesSlot is not null && manageDatabasesSlot.BatchId == args.BatchId) { - next = settingsSlot with + next = manageDatabasesSlot with { CurrentBatchPosition = args.Position, CurrentEntryName = args.FileName, CurrentPhase = args.Phase, QueuedBatchesAfter = _databaseService.QueuedBatchCount }; - _settingsProgress = next; + _manageDatabasesProgress = next; } } diff --git a/src/EventLogExpert.Runtime/Banner/BannerViewSelector.cs b/src/EventLogExpert.Runtime/Banner/BannerViewSelector.cs index d18bfa92..b9c4dce2 100644 --- a/src/EventLogExpert.Runtime/Banner/BannerViewSelector.cs +++ b/src/EventLogExpert.Runtime/Banner/BannerViewSelector.cs @@ -24,6 +24,7 @@ public static IReadOnlyList BuildCycle( IReadOnlyList errorBanners, IReadOnlyList attentionEntries, bool attentionDismissed, + bool attentionSuppressedByModalContext, BannerProgressEntry? backgroundProgress, IReadOnlyList infoBanners) { @@ -36,8 +37,12 @@ public static IReadOnlyList BuildCycle( return [new BannerCycleItem(BannerView.Critical, 0, null)]; } + bool includeAttention = attentionEntries.Count > 0 + && !attentionDismissed + && !attentionSuppressedByModalContext; + var items = new List( - errorBanners.Count + (attentionEntries.Count > 0 && !attentionDismissed ? 1 : 0) + errorBanners.Count + (includeAttention ? 1 : 0) + (backgroundProgress is not null ? 1 : 0) + infoBanners.Count); for (int i = 0; i < errorBanners.Count; i++) @@ -45,7 +50,7 @@ public static IReadOnlyList BuildCycle( items.Add(new BannerCycleItem(BannerView.Error, i, errorBanners[i].Id)); } - if (attentionEntries.Count > 0 && !attentionDismissed) + if (includeAttention) { items.Add(new BannerCycleItem(BannerView.Attention, 0, null)); } diff --git a/src/EventLogExpert.Runtime/Banner/IProgressBannerService.cs b/src/EventLogExpert.Runtime/Banner/IProgressBannerService.cs index 4a489403..47360b7d 100644 --- a/src/EventLogExpert.Runtime/Banner/IProgressBannerService.cs +++ b/src/EventLogExpert.Runtime/Banner/IProgressBannerService.cs @@ -9,5 +9,5 @@ public interface IProgressBannerService BannerProgressEntry? BackgroundProgress { get; } - BannerProgressEntry? SettingsProgress { get; } + BannerProgressEntry? ManageDatabasesProgress { get; } } diff --git a/src/EventLogExpert.Runtime/Database/DatabaseClassificationService.cs b/src/EventLogExpert.Runtime/Database/DatabaseClassificationService.cs index b0090fad..190844f6 100644 --- a/src/EventLogExpert.Runtime/Database/DatabaseClassificationService.cs +++ b/src/EventLogExpert.Runtime/Database/DatabaseClassificationService.cs @@ -45,30 +45,7 @@ public async Task ClassifyEntriesAsync(CancellationToken cancellationToken = def foreach (var entry in snapshot) { - cancellationToken.ThrowIfCancellationRequested(); - - try - { - if (!File.Exists(entry.FullPath) || new FileInfo(entry.FullPath).Length == 0) - { - perFile[entry.FileName] = (DatabaseStatus.UnrecognizedSchema, false); - - continue; - } - - var state = _maintenance.CheckSchemaState(entry.FullPath); - var status = MapSchemaVersionToStatus(state.CurrentVersion); - var backupExists = ProbeOrCleanupBackup(entry, status); - - perFile[entry.FileName] = (status, backupExists); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - perFile[entry.FileName] = (DatabaseStatus.ClassificationFailed, false); - - DatabaseRegistry.SafeLog(() => _traceLogger.Warning( - $"{nameof(DatabaseClassificationService)}.{nameof(ClassifyEntriesAsync)} failed to classify '{entry.FileName}': {ex}")); - } + perFile[entry.FileName] = ClassifyEntry(entry, cancellationToken); } return perFile; @@ -79,6 +56,36 @@ public async Task ClassifyEntriesAsync(CancellationToken cancellationToken = def _entryStore.ApplyClassificationResults(statuses); } + public async Task RetryClassificationAsync(string fileName, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var entry = _entryStore.SnapshotEntries() + .FirstOrDefault(e => string.Equals(e.FileName, fileName, StringComparison.OrdinalIgnoreCase)); + if (entry is null) { return; } + + try + { + _entryStore.MarkStatus(fileName, DatabaseStatus.NotClassified); + } + catch (InvalidOperationException) + { + // Entry was removed between SnapshotEntries() and MarkStatus(). Stale UI; nothing to retry. + return; + } + + var result = await Task.Run( + () => ClassifyEntry(entry, cancellationToken), + cancellationToken) + .ConfigureAwait(false); + + _entryStore.ApplyClassificationResults( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [fileName] = result + }); + } + private static DatabaseStatus MapSchemaVersionToStatus(int currentVersion) => currentVersion switch { @@ -88,6 +95,34 @@ private static DatabaseStatus MapSchemaVersionToStatus(int currentVersion) => _ => DatabaseStatus.UnrecognizedSchema, }; + private (DatabaseStatus Status, bool BackupExists) ClassifyEntry( + DatabaseEntry entry, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + if (!File.Exists(entry.FullPath) || new FileInfo(entry.FullPath).Length == 0) + { + return (DatabaseStatus.UnrecognizedSchema, false); + } + + var state = _maintenance.CheckSchemaState(entry.FullPath); + var status = MapSchemaVersionToStatus(state.CurrentVersion); + var backupExists = ProbeOrCleanupBackup(entry, status); + + return (status, backupExists); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + DatabaseRegistry.SafeLog(() => _traceLogger.Warning( + $"{nameof(DatabaseClassificationService)}.{nameof(ClassifyEntry)} failed to classify '{entry.FileName}': {ex}")); + + return (DatabaseStatus.ClassificationFailed, false); + } + } + private bool ProbeOrCleanupBackup(DatabaseEntry entry, DatabaseStatus status) { var backupPath = entry.FullPath + DatabaseFileOperations.UpgradeBackupSuffix; diff --git a/src/EventLogExpert.Runtime/Database/DatabaseOperationCoordinator.cs b/src/EventLogExpert.Runtime/Database/DatabaseOperationCoordinator.cs index 6e484e63..07fa01e3 100644 --- a/src/EventLogExpert.Runtime/Database/DatabaseOperationCoordinator.cs +++ b/src/EventLogExpert.Runtime/Database/DatabaseOperationCoordinator.cs @@ -173,7 +173,7 @@ await _databases.RemoveAsync( public async Task UpgradeDatabaseAsync( string fileName, - UpgradeProgressScope scope = UpgradeProgressScope.SettingsTriggered, + UpgradeProgressScope scope = UpgradeProgressScope.ManageDatabasesTriggered, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(fileName); diff --git a/src/EventLogExpert.Runtime/Database/DatabaseService.cs b/src/EventLogExpert.Runtime/Database/DatabaseService.cs index 67d7d27b..3a7eb89b 100644 --- a/src/EventLogExpert.Runtime/Database/DatabaseService.cs +++ b/src/EventLogExpert.Runtime/Database/DatabaseService.cs @@ -97,6 +97,9 @@ public Task RemoveAsync( public Task RestoreFromBackupAsync(string fileName, CancellationToken cancellationToken = default) => _recoveryService.RestoreFromBackupAsync(fileName, cancellationToken); + public Task RetryClassificationAsync(string fileName, CancellationToken cancellationToken = default) => + _classificationService.RetryClassificationAsync(fileName, cancellationToken); + public void Toggle(string fileName) => _registry.Toggle(fileName); public Task UpgradeBatchAsync( diff --git a/src/EventLogExpert.Runtime/Database/IDatabaseOperationCoordinator.cs b/src/EventLogExpert.Runtime/Database/IDatabaseOperationCoordinator.cs index 8d9f3151..d1d4621a 100644 --- a/src/EventLogExpert.Runtime/Database/IDatabaseOperationCoordinator.cs +++ b/src/EventLogExpert.Runtime/Database/IDatabaseOperationCoordinator.cs @@ -28,6 +28,6 @@ Task RemoveDatabaseAsync( Task UpgradeDatabaseAsync( string fileName, - UpgradeProgressScope scope = UpgradeProgressScope.SettingsTriggered, + UpgradeProgressScope scope = UpgradeProgressScope.ManageDatabasesTriggered, CancellationToken cancellationToken = default); } diff --git a/src/EventLogExpert.Runtime/Database/IDatabaseService.cs b/src/EventLogExpert.Runtime/Database/IDatabaseService.cs index 90dd9146..cf69e120 100644 --- a/src/EventLogExpert.Runtime/Database/IDatabaseService.cs +++ b/src/EventLogExpert.Runtime/Database/IDatabaseService.cs @@ -47,6 +47,8 @@ Task RemoveAsync( Task RestoreFromBackupAsync(string fileName, CancellationToken cancellationToken = default); + Task RetryClassificationAsync(string fileName, CancellationToken cancellationToken = default); + void Toggle(string fileName); Task UpgradeBatchAsync( diff --git a/src/EventLogExpert.Runtime/Database/Upgrade/UpgradeProgressScope.cs b/src/EventLogExpert.Runtime/Database/Upgrade/UpgradeProgressScope.cs index 2956a3c7..12cbaf9e 100644 --- a/src/EventLogExpert.Runtime/Database/Upgrade/UpgradeProgressScope.cs +++ b/src/EventLogExpert.Runtime/Database/Upgrade/UpgradeProgressScope.cs @@ -6,5 +6,5 @@ namespace EventLogExpert.Runtime.Database.Upgrade; public enum UpgradeProgressScope { Background, - SettingsTriggered + ManageDatabasesTriggered } diff --git a/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs b/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs index c19272c9..0b9cd38b 100644 --- a/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs +++ b/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using EventLogExpert.Logging.Abstractions; using EventLogExpert.Provider.Maintenance; using EventLogExpert.Provider.Resolution; +using EventLogExpert.Runtime.Announcement; using EventLogExpert.Runtime.Banner; using EventLogExpert.Runtime.Common.AppTitle; using EventLogExpert.Runtime.Common.Files; @@ -83,6 +84,8 @@ public static IServiceCollection AddEventLogRuntime(this IServiceCollection serv services.AddSingleton(static sp => sp.GetRequiredService()); services.AddSingleton(static sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(static sp => sp.GetRequiredService()); services.AddSingleton(static sp => sp.GetRequiredService()); diff --git a/src/EventLogExpert.Runtime/Menu/IMenuActionService.cs b/src/EventLogExpert.Runtime/Menu/IMenuActionService.cs index 9f9a2ae6..831b00ac 100644 --- a/src/EventLogExpert.Runtime/Menu/IMenuActionService.cs +++ b/src/EventLogExpert.Runtime/Menu/IMenuActionService.cs @@ -23,7 +23,7 @@ public interface IMenuActionService void LoadNewEvents(); - Task OpenDatabaseToolsAsync(); + Task OpenDatabaseToolsAsync(); Task OpenDocsAsync(); diff --git a/src/EventLogExpert.UI/Announcement/AnnouncerHost.razor b/src/EventLogExpert.UI/Announcement/AnnouncerHost.razor new file mode 100644 index 00000000..648a0190 --- /dev/null +++ b/src/EventLogExpert.UI/Announcement/AnnouncerHost.razor @@ -0,0 +1,3 @@ +
+ @AnnouncementService.CurrentAnnouncement +
diff --git a/src/EventLogExpert.UI/Announcement/AnnouncerHost.razor.cs b/src/EventLogExpert.UI/Announcement/AnnouncerHost.razor.cs new file mode 100644 index 00000000..4cc4ce58 --- /dev/null +++ b/src/EventLogExpert.UI/Announcement/AnnouncerHost.razor.cs @@ -0,0 +1,23 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Announcement; +using Microsoft.AspNetCore.Components; + +namespace EventLogExpert.UI.Announcement; + +public sealed partial class AnnouncerHost : ComponentBase, IDisposable +{ + [Inject] private IAnnouncementService AnnouncementService { get; init; } = null!; + + public void Dispose() => AnnouncementService.StateChanged -= OnStateChanged; + + protected override void OnInitialized() + { + AnnouncementService.StateChanged += OnStateChanged; + + base.OnInitialized(); + } + + private void OnStateChanged() => _ = InvokeAsync(StateHasChanged); +} diff --git a/src/EventLogExpert.UI/Announcement/AnnouncerHost.razor.css b/src/EventLogExpert.UI/Announcement/AnnouncerHost.razor.css new file mode 100644 index 00000000..374331c5 --- /dev/null +++ b/src/EventLogExpert.UI/Announcement/AnnouncerHost.razor.css @@ -0,0 +1,11 @@ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} diff --git a/src/EventLogExpert.UI/Banner/AttentionBanner.razor b/src/EventLogExpert.UI/Banner/AttentionBanner.razor index 7a9664c2..dc947f95 100644 --- a/src/EventLogExpert.UI/Banner/AttentionBanner.razor +++ b/src/EventLogExpert.UI/Banner/AttentionBanner.razor @@ -1,12 +1,12 @@