Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c33d5b6
Add bUnit regression guard for #526 banner chevron cycle
jschick04 May 27, 2026
5515c88
Replace settings empty-state placeholder + improve toggle aria-label
jschick04 May 27, 2026
8e24c30
Restructure settings UI for IA + accessibility plumbing
jschick04 May 27, 2026
292bffc
Add IAnnouncementService + AnnouncerHost for SR completion announcements
jschick04 May 27, 2026
c8fc3bc
Improve database entry row affordances (PR 10 c3)
jschick04 May 27, 2026
77dd76c
Refactored radio group components and added a switch based component,…
jschick04 May 28, 2026
142b20f
Extracted database management from Settings to DatabaseToolsModal Man…
jschick04 May 28, 2026
9ffaacb
Made StateChanged event nullable, awaited InvokeAsync in DatabaseEntr…
jschick04 May 29, 2026
fe6e404
Set the database-state sticky flag on successful import so close-moda…
jschick04 May 29, 2026
c4c780d
Awaited InvokeAsync in Retry Upgrade and Restore lambdas, and passed …
jschick04 May 29, 2026
252e347
Routed attention banner to Databases modal, fixed Settings focus ring…
jschick04 May 28, 2026
34cfca5
Surface upgrade progress per row in DatabaseToolsModal Manage tab
jschick04 May 29, 2026
f5f1f68
Suppress AttentionBanner while DatabaseToolsModal is the active modal
jschick04 May 29, 2026
407c639
Lift banner cycle into ModalChrome via singleton state service with c…
jschick04 May 29, 2026
2c27bda
Synchronized BannerCycleStateService cycle mutations, isolated Upgrad…
jschick04 May 29, 2026
aaa0081
Widened Restore button guard to honor per-row UpgradeProgress so impo…
jschick04 May 29, 2026
0673a9f
Changed how removing databases is handled and added multi select for …
jschick04 May 29, 2026
2b4718a
Extracted reusable Checkbox component and removed pending toggle high…
jschick04 May 29, 2026
a38aa5f
Scoped bulk-selection cleanup to only the removed file names instead …
jschick04 May 29, 2026
062bc18
Restored close-and-reopen warning in remove confirmation and surfaced…
jschick04 May 29, 2026
fd96151
Counted removals as failed when Coordinator catches exception after c…
jschick04 May 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/EventLogExpert.Runtime/Announcement/AnnouncementService.cs
Original file line number Diff line number Diff line change
@@ -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}");
}
}
}
}
18 changes: 18 additions & 0 deletions src/EventLogExpert.Runtime/Announcement/IAnnouncementService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// // Copyright (c) Microsoft Corporation.
// // Licensed under the MIT License.

namespace EventLogExpert.Runtime.Announcement;

/// <summary>
/// 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.
/// </summary>
public interface IAnnouncementService
{
event Action? StateChanged;

string CurrentAnnouncement { get; }

void Announce(string message);
}
22 changes: 11 additions & 11 deletions src/EventLogExpert.Runtime/Banner/BannerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ internal sealed class BannerService
private ImmutableHashSet<string> _dismissedAttentionFileNames = ImmutableHashSet<string>.Empty;
private ImmutableList<ErrorBannerEntry> _errorBanners = ImmutableList<ErrorBannerEntry>.Empty;
private ImmutableList<BannerInfoEntry> _infoBanners = ImmutableList<BannerInfoEntry>.Empty;
private BannerProgressEntry? _manageDatabasesProgress;
private Func<Task>? _recoveryCallback;
private object? _recoveryToken;
private BannerProgressEntry? _settingsProgress;

public BannerService(IDatabaseService databaseService, ITraceLogger traceLogger)
{
Expand Down Expand Up @@ -128,9 +128,9 @@ public IReadOnlyList<BannerInfoEntry> 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()
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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)
{
Expand All @@ -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;
}
}

Expand Down
9 changes: 7 additions & 2 deletions src/EventLogExpert.Runtime/Banner/BannerViewSelector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public static IReadOnlyList<BannerCycleItem> BuildCycle(
IReadOnlyList<ErrorBannerEntry> errorBanners,
IReadOnlyList<DatabaseEntry> attentionEntries,
bool attentionDismissed,
bool attentionSuppressedByModalContext,
BannerProgressEntry? backgroundProgress,
IReadOnlyList<BannerInfoEntry> infoBanners)
{
Expand All @@ -36,16 +37,20 @@ public static IReadOnlyList<BannerCycleItem> BuildCycle(
return [new BannerCycleItem(BannerView.Critical, 0, null)];
}

bool includeAttention = attentionEntries.Count > 0
&& !attentionDismissed
&& !attentionSuppressedByModalContext;

var items = new List<BannerCycleItem>(
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++)
{
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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ public interface IProgressBannerService

BannerProgressEntry? BackgroundProgress { get; }

BannerProgressEntry? SettingsProgress { get; }
BannerProgressEntry? ManageDatabasesProgress { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, (DatabaseStatus Status, bool BackupExists)>(StringComparer.OrdinalIgnoreCase)
{
[fileName] = result
});
}

private static DatabaseStatus MapSchemaVersionToStatus(int currentVersion) =>
currentVersion switch
{
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/EventLogExpert.Runtime/Database/DatabaseService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ public Task RemoveAsync(
public Task<bool> 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<UpgradeBatchResult> UpgradeBatchAsync(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ Task<RemoveOutcome> RemoveDatabaseAsync(

Task UpgradeDatabaseAsync(
string fileName,
UpgradeProgressScope scope = UpgradeProgressScope.SettingsTriggered,
UpgradeProgressScope scope = UpgradeProgressScope.ManageDatabasesTriggered,
CancellationToken cancellationToken = default);
}
2 changes: 2 additions & 0 deletions src/EventLogExpert.Runtime/Database/IDatabaseService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ Task RemoveAsync(

Task<bool> RestoreFromBackupAsync(string fileName, CancellationToken cancellationToken = default);

Task RetryClassificationAsync(string fileName, CancellationToken cancellationToken = default);

void Toggle(string fileName);

Task<UpgradeBatchResult> UpgradeBatchAsync(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ namespace EventLogExpert.Runtime.Database.Upgrade;
public enum UpgradeProgressScope
{
Background,
SettingsTriggered
ManageDatabasesTriggered
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -83,6 +84,8 @@ public static IServiceCollection AddEventLogRuntime(this IServiceCollection serv
services.AddSingleton<IErrorBannerService>(static sp => sp.GetRequiredService<BannerService>());
services.AddSingleton<IInfoBannerService>(static sp => sp.GetRequiredService<BannerService>());

services.AddSingleton<IAnnouncementService, AnnouncementService>();

services.AddSingleton<DebugLogService>();
services.AddSingleton<ITraceLogger>(static sp => sp.GetRequiredService<DebugLogService>());
services.AddSingleton<IFileLogger>(static sp => sp.GetRequiredService<DebugLogService>());
Expand Down
2 changes: 1 addition & 1 deletion src/EventLogExpert.Runtime/Menu/IMenuActionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public interface IMenuActionService

void LoadNewEvents();

Task OpenDatabaseToolsAsync();
Task<bool> OpenDatabaseToolsAsync();

Task OpenDocsAsync();

Expand Down
3 changes: 3 additions & 0 deletions src/EventLogExpert.UI/Announcement/AnnouncerHost.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div aria-atomic="true" aria-live="polite" class="visually-hidden" id="app-announcer" role="status">
@AnnouncementService.CurrentAnnouncement
</div>
23 changes: 23 additions & 0 deletions src/EventLogExpert.UI/Announcement/AnnouncerHost.razor.cs
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading