From 35d83f96c8d8eb5a1cebcdffef9d4ca1d6f6d322 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Wed, 29 Apr 2026 22:12:29 -0500 Subject: [PATCH 01/31] Add V4 provider DB schema with ResolvedFromOwningPublisher and merge helper --- .../EventProviderDbContextTests.cs | 682 ++++++++++++++++-- .../ProviderDetailsMergerTests.cs | 331 +++++++++ .../Providers/ProviderDetailsTests.cs | 108 +++ .../DatabaseUpgradeException.cs | 25 + .../EventProviderDbContext.cs | 358 ++++++--- .../ProviderDatabaseSchemaState.cs | 9 + .../ProviderDatabaseSchemaVersion.cs | 16 + .../ProviderDetailsMerger.cs | 234 ++++++ .../Providers/ProviderDetails.cs | 20 +- 9 files changed, 1613 insertions(+), 170 deletions(-) create mode 100644 src/EventLogExpert.Eventing.Tests/EventProviderDatabase/ProviderDetailsMergerTests.cs create mode 100644 src/EventLogExpert.Eventing.Tests/Providers/ProviderDetailsTests.cs create mode 100644 src/EventLogExpert.Eventing/EventProviderDatabase/DatabaseUpgradeException.cs create mode 100644 src/EventLogExpert.Eventing/EventProviderDatabase/ProviderDatabaseSchemaState.cs create mode 100644 src/EventLogExpert.Eventing/EventProviderDatabase/ProviderDatabaseSchemaVersion.cs create mode 100644 src/EventLogExpert.Eventing/EventProviderDatabase/ProviderDetailsMerger.cs diff --git a/src/EventLogExpert.Eventing.Tests/EventProviderDatabase/EventProviderDbContextTests.cs b/src/EventLogExpert.Eventing.Tests/EventProviderDatabase/EventProviderDbContextTests.cs index 3c3e2551..a1847f5d 100644 --- a/src/EventLogExpert.Eventing.Tests/EventProviderDatabase/EventProviderDbContextTests.cs +++ b/src/EventLogExpert.Eventing.Tests/EventProviderDatabase/EventProviderDbContextTests.cs @@ -6,6 +6,7 @@ using EventLogExpert.Eventing.Models; using EventLogExpert.Eventing.Providers; using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; using NSubstitute; namespace EventLogExpert.Eventing.Tests.EventProviderDatabase; @@ -27,6 +28,35 @@ public void Constructor_ShouldCreateNewDatabase() Assert.True(File.Exists(dbPath)); } + [Fact] + public void Constructor_WithEnsureCreatedFalse_DoesNotCreateDatabase() + { + // Arrange — read-only inspection should not auto-create the schema. We use ReadWriteCreate + // (readOnly: false) so the SQLite file is opened (and a header may be written), then assert + // directly on `sqlite_master` that no table was created. Asserting on file size here would + // be brittle: SQLite may write a header even without DDL, and a 0-byte file passes only by + // coincidence of ReadOnly mode behavior on an empty file. + var dbPath = CreateTempDatabasePath(); + + // Act + using (var context = new EventProviderDbContext(dbPath, readOnly: false, ensureCreated: false)) + { + // Touching the connection forces SQLite to open the file so the assertion below + // exercises the same on-disk state callers would observe. + context.Database.OpenConnection(); + context.Database.CloseConnection(); + } + + // Assert — the ProviderDetails table was NOT created. This is the contract callers care + // about (a fresh, never-upgraded file should not be auto-populated with the V4 schema). + using var verifyConnection = new SqliteConnection($"Data Source={dbPath};Mode=ReadOnly"); + verifyConnection.Open(); + using var verifyCmd = verifyConnection.CreateCommand(); + verifyCmd.CommandText = "SELECT COUNT(*) FROM \"sqlite_master\" WHERE \"type\" = 'table' AND \"name\" = 'ProviderDetails'"; + var tableCount = Convert.ToInt32(verifyCmd.ExecuteScalar()); + Assert.Equal(0, tableCount); + } + [Fact] public void Constructor_WithLogger_ShouldLogInstantiation() { @@ -114,6 +144,38 @@ public void Database_ShouldSupportConcurrentReads() Assert.All(results, count => Assert.Equal(10, count)); } + [Fact] + public void Detection_FreshDatabase_ReportsV4() + { + // Arrange + var dbPath = CreateTempDatabasePath(); + + using var context = new EventProviderDbContext(dbPath, false); + + // Act + var state = context.IsUpgradeNeeded(); + + // Assert + Assert.Equal(4, state.CurrentVersion); + Assert.False(state.NeedsUpgrade); + } + + [Fact] + public void Detection_V3Schema_NeedsV4Upgrade() + { + // Arrange — V3 schema has BLOB columns but no ResolvedFromOwningPublisher and BINARY PK. + var dbPath = CreateTempDatabasePath(); + SeedV3Schema(dbPath); + + // Act + using var context = new EventProviderDbContext(dbPath, false); + var state = context.IsUpgradeNeeded(); + + // Assert + Assert.Equal(3, state.CurrentVersion); + Assert.True(state.NeedsUpgrade); + } + public void Dispose() { foreach (var dbPath in _tempDatabases) @@ -137,12 +199,44 @@ public void IsUpgradeNeeded_ShouldLogResult() // Assert logger.Received(1).Debug(Arg.Is(h => h.ToString().Contains(nameof(EventProviderDbContext.IsUpgradeNeeded)) && - h.ToString().Contains("needsV2Upgrade") && - h.ToString().Contains("needsV3Upgrade"))); + h.ToString().Contains("currentVersion") && + h.ToString().Contains("needsUpgrade"))); } [Fact] - public void IsUpgradeNeeded_WithNewDatabase_ShouldReturnFalseFalse() + public void IsUpgradeNeeded_WithMixedPayloadColumnTypes_ReportsUnknownSentinel() + { + // Arrange — a shape with Parameters BLOB but a non-BLOB Messages column. Without the + // payload-column uniformity check this would have been misclassified as V3 and the + // subsequent ReadCompressedRow call would crash on the (byte[]) cast for Messages. + var dbPath = CreateTempDatabasePath(); + using (var connection = new SqliteConnection($"Data Source={dbPath}")) + { + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = + "CREATE TABLE \"ProviderDetails\" (" + + "\"ProviderName\" TEXT NOT NULL CONSTRAINT \"PK_ProviderDetails\" PRIMARY KEY, " + + "\"Messages\" INTEGER NOT NULL, " + + "\"Parameters\" BLOB NOT NULL, " + + "\"Events\" BLOB NOT NULL, " + + "\"Keywords\" BLOB NOT NULL, " + + "\"Opcodes\" BLOB NOT NULL, " + + "\"Tasks\" BLOB NOT NULL)"; + cmd.ExecuteNonQuery(); + } + + // Act + using var context = new EventProviderDbContext(dbPath, false); + var state = context.IsUpgradeNeeded(); + + // Assert + Assert.Equal(ProviderDatabaseSchemaVersion.Unknown, state.CurrentVersion); + Assert.True(state.NeedsUpgrade); + } + + [Fact] + public void IsUpgradeNeeded_WithNewDatabase_ShouldReportCurrentSchemaAndNoUpgrade() { // Arrange var dbPath = CreateTempDatabasePath(); @@ -150,15 +244,48 @@ public void IsUpgradeNeeded_WithNewDatabase_ShouldReturnFalseFalse() using var context = new EventProviderDbContext(dbPath, false); // Act - var (needsV2, needsV3) = context.IsUpgradeNeeded(); + var state = context.IsUpgradeNeeded(); // Assert - Assert.False(needsV2); - Assert.False(needsV3); + Assert.Equal(ProviderDatabaseSchemaVersion.Current, state.CurrentVersion); + Assert.False(state.NeedsUpgrade); } [Fact] - public void IsUpgradeNeeded_WithV1Schema_ShouldFlagBothUpgrades() + public void IsUpgradeNeeded_WithUnknownShape_ReportsUnknownSentinel() + { + // Arrange — create a ProviderDetails table whose column shape matches none of V1/V2/V3/V4. + // Messages is BLOB but Parameters is TEXT, which never existed as a real schema and signals + // either corruption or a foreign / future format. + var dbPath = CreateTempDatabasePath(); + using (var connection = new SqliteConnection($"Data Source={dbPath}")) + { + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = + "CREATE TABLE \"ProviderDetails\" (" + + "\"ProviderName\" TEXT NOT NULL CONSTRAINT \"PK_ProviderDetails\" PRIMARY KEY, " + + "\"Messages\" BLOB NOT NULL, " + + "\"Parameters\" TEXT NOT NULL, " + + "\"Events\" BLOB NOT NULL, " + + "\"Keywords\" BLOB NOT NULL, " + + "\"Opcodes\" BLOB NOT NULL, " + + "\"Tasks\" BLOB NOT NULL)"; + cmd.ExecuteNonQuery(); + } + + // Act + using var context = new EventProviderDbContext(dbPath, false); + var state = context.IsUpgradeNeeded(); + + // Assert — Unknown sentinel is reported (NeedsUpgrade is true so callers route through + // PerformUpgradeIfNeeded, which throws a distinct error rather than misclassifying as v1). + Assert.Equal(ProviderDatabaseSchemaVersion.Unknown, state.CurrentVersion); + Assert.True(state.NeedsUpgrade); + } + + [Fact] + public void IsUpgradeNeeded_WithV1Schema_ShouldReportV1AndUpgradeNeeded() { // Arrange — V1 schema had no Parameters column; Messages stored as JSON TEXT. var dbPath = CreateTempDatabasePath(); @@ -166,15 +293,15 @@ public void IsUpgradeNeeded_WithV1Schema_ShouldFlagBothUpgrades() // Act using var context = new EventProviderDbContext(dbPath, false); - var (needsV2, needsV3) = context.IsUpgradeNeeded(); + var state = context.IsUpgradeNeeded(); // Assert - Assert.True(needsV2); - Assert.True(needsV3); + Assert.Equal(1, state.CurrentVersion); + Assert.True(state.NeedsUpgrade); } [Fact] - public void IsUpgradeNeeded_WithV2Schema_ShouldFlagBothUpgrades() + public void IsUpgradeNeeded_WithV2Schema_ShouldReportV2AndUpgradeNeeded() { // Arrange — V2 schema kept TEXT payloads and added Parameters as TEXT. var dbPath = CreateTempDatabasePath(); @@ -182,11 +309,11 @@ public void IsUpgradeNeeded_WithV2Schema_ShouldFlagBothUpgrades() // Act using var context = new EventProviderDbContext(dbPath, false); - var (needsV2, needsV3) = context.IsUpgradeNeeded(); + var state = context.IsUpgradeNeeded(); // Assert - Assert.True(needsV2); - Assert.True(needsV3); + Assert.Equal(2, state.CurrentVersion); + Assert.True(state.NeedsUpgrade); } [Fact] @@ -222,7 +349,46 @@ public void PerformUpgradeIfNeeded_WithNewDatabase_ShouldDoNothing() } [Fact] - public void PerformUpgradeIfNeeded_WithV1Schema_ShouldUpgradeAndLeaveParametersEmpty() + public void PerformUpgradeIfNeeded_WithUnknownShape_ThrowsAndPreservesTable() + { + // Arrange — same unknown shape as the detection test. + var dbPath = CreateTempDatabasePath(); + using (var connection = new SqliteConnection($"Data Source={dbPath}")) + { + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = + "CREATE TABLE \"ProviderDetails\" (" + + "\"ProviderName\" TEXT NOT NULL CONSTRAINT \"PK_ProviderDetails\" PRIMARY KEY, " + + "\"Messages\" BLOB NOT NULL, " + + "\"Parameters\" TEXT NOT NULL, " + + "\"Events\" BLOB NOT NULL, " + + "\"Keywords\" BLOB NOT NULL, " + + "\"Opcodes\" BLOB NOT NULL, " + + "\"Tasks\" BLOB NOT NULL)"; + cmd.ExecuteNonQuery(); + + using var insertCmd = connection.CreateCommand(); + insertCmd.CommandText = + "INSERT INTO \"ProviderDetails\" VALUES ('Unknown-Row', X'00', '[]', X'00', X'00', X'00', X'00')"; + insertCmd.ExecuteNonQuery(); + } + + // Act + Assert — distinct error message, file untouched. + DatabaseUpgradeException? thrown; + using (var context = new EventProviderDbContext(dbPath, false)) + { + thrown = Assert.Throws(() => context.PerformUpgradeIfNeeded()); + } + + Assert.Contains("unrecognized schema", thrown.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Equal(dbPath, thrown.DatabasePath); + + AssertProviderDetailsRowCount(dbPath, expectedRows: 1); + } + + [Fact] + public void PerformUpgradeIfNeeded_WithV1Schema_ThrowsAndPreservesLegacyTable() { // Arrange — V1 row has no Parameters column; payloads are JSON TEXT. var dbPath = CreateTempDatabasePath(); @@ -237,26 +403,27 @@ public void PerformUpgradeIfNeeded_WithV1Schema_ShouldUpgradeAndLeaveParametersE opcodesJson: "{}", tasksJson: "{}"); - // Act + // Act + Assert — V1 is no longer auto-upgradable; the upgrade fails fast. + DatabaseUpgradeException? thrown; using (var context = new EventProviderDbContext(dbPath, false)) { - context.PerformUpgradeIfNeeded(); + thrown = Assert.Throws(() => context.PerformUpgradeIfNeeded()); } - // Assert — schema is now V3 and the existing row is preserved with empty Parameters. - using var verify = new EventProviderDbContext(dbPath, true); - var (needsV2After, needsV3After) = verify.IsUpgradeNeeded(); - Assert.False(needsV2After); - Assert.False(needsV3After); + Assert.Contains("v1", thrown.Reason); + Assert.Contains("no longer supported", thrown.Reason); + Assert.Equal(dbPath, thrown.DatabasePath); - var row = verify.ProviderDetails.Single(p => p.ProviderName == "V1Provider"); - Assert.Single(row.Messages); - Assert.Equal("hello", row.Messages[0].Text); - Assert.Empty(row.Parameters); + // The original V1 table is preserved (throw fires before DROP); detection still reports v1. + using var verify = new EventProviderDbContext(dbPath, true); + var stateAfter = verify.IsUpgradeNeeded(); + Assert.Equal(1, stateAfter.CurrentVersion); + Assert.True(stateAfter.NeedsUpgrade); + AssertProviderDetailsRowCount(dbPath, expectedRows: 1); } [Fact] - public void PerformUpgradeIfNeeded_WithV2Schema_ShouldPreserveExistingParametersJson() + public void PerformUpgradeIfNeeded_WithV2Schema_Throws() { // Arrange — V2 row stores Parameters as JSON TEXT. var dbPath = CreateTempDatabasePath(); @@ -271,23 +438,28 @@ public void PerformUpgradeIfNeeded_WithV2Schema_ShouldPreserveExistingParameters opcodesJson: "{}", tasksJson: "{}"); - // Act + // Act + Assert + DatabaseUpgradeException? thrown; using (var context = new EventProviderDbContext(dbPath, false)) { - context.PerformUpgradeIfNeeded(); + thrown = Assert.Throws(() => context.PerformUpgradeIfNeeded()); } - // Assert — Parameters JSON survived the destructive recreate cycle. + Assert.Contains("v2", thrown.Reason); + Assert.Contains("no longer supported", thrown.Reason); + + // Original V2 row preserved; detection still reports v2. using var verify = new EventProviderDbContext(dbPath, true); - var row = verify.ProviderDetails.Single(p => p.ProviderName == "V2Provider"); - Assert.Single(row.Parameters); - Assert.Equal("param-text", row.Parameters.First().Text); + var stateAfter = verify.IsUpgradeNeeded(); + Assert.Equal(2, stateAfter.CurrentVersion); + AssertProviderDetailsRowCount(dbPath, expectedRows: 1); } [Fact] - public void PerformUpgradeIfNeeded_WithV2SchemaAndNullParameters_ShouldYieldEmptyParameters() + public void PerformUpgradeIfNeeded_WithV2SchemaAndNullParameters_Throws() { - // Arrange — V2 row with NULL Parameters column. + // Arrange — V2 row with NULL Parameters column; previously this would have silently + // round-tripped to an empty list. Now it must surface the same hard-fail as any other V2 row. var dbPath = CreateTempDatabasePath(); SeedLegacySchema(dbPath, includeParameters: true, parametersType: "TEXT", messagesType: "TEXT"); InsertLegacyRow( @@ -300,16 +472,12 @@ public void PerformUpgradeIfNeeded_WithV2SchemaAndNullParameters_ShouldYieldEmpt opcodesJson: "{}", tasksJson: "{}"); - // Act - using (var context = new EventProviderDbContext(dbPath, false)) - { - context.PerformUpgradeIfNeeded(); - } + // Act + Assert + using var context = new EventProviderDbContext(dbPath, false); + var thrown = Assert.Throws(() => context.PerformUpgradeIfNeeded()); + Assert.Contains("v2", thrown.Reason); - // Assert - using var verify = new EventProviderDbContext(dbPath, true); - var row = verify.ProviderDetails.Single(p => p.ProviderName == "V2NullParams"); - Assert.Empty(row.Parameters); + AssertProviderDetailsRowCount(dbPath, expectedRows: 1); } [Fact] @@ -629,6 +797,350 @@ public void ProviderDetails_WithSpecialCharacters_ShouldPersist() } } + [Fact] + public void ProviderName_LookupOnV4Schema_UsesPrimaryKeyIndex() + { + // Arrange — populate a V4 database so EXPLAIN QUERY PLAN has rows to plan against. + var dbPath = CreateTempDatabasePath(); + + using (var context = new EventProviderDbContext(dbPath, false)) + { + for (var i = 0; i < 5; i++) + { + context.ProviderDetails.Add(new ProviderDetails + { + ProviderName = $"Provider-{i}", + Messages = [], + Parameters = [], + Events = [], + Keywords = new Dictionary(), + Opcodes = new Dictionary(), + Tasks = new Dictionary() + }); + } + + context.SaveChanges(); + } + + // Act — ask SQLite directly how it would resolve a case-insensitive PK lookup. + // The lookup should use the auto-generated PK index, not a table scan. + using var connection = new SqliteConnection($"Data Source={dbPath}"); + connection.Open(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = "EXPLAIN QUERY PLAN SELECT * FROM \"ProviderDetails\" WHERE \"ProviderName\" = 'provider-2'"; + using var reader = cmd.ExecuteReader(); + + var planText = new System.Text.StringBuilder(); + while (reader.Read()) + { + planText.AppendLine(reader["detail"]?.ToString()); + } + + var plan = planText.ToString(); + + // Assert — weak property: the plan mentions an index (covers `USING INDEX` / `USING COVERING INDEX` / + // `SEARCH ... USING INTEGER PRIMARY KEY` variants without anchoring on exact wording across SQLite versions). + Assert.True( + plan.Contains("USING INDEX", StringComparison.OrdinalIgnoreCase) || + plan.Contains("USING COVERING INDEX", StringComparison.OrdinalIgnoreCase) || + plan.Contains("USING PRIMARY KEY", StringComparison.OrdinalIgnoreCase), + $"Expected ProviderName lookup to use the PK index, but plan was:\n{plan}"); + + // And explicitly: the plan must NOT be a table scan. + Assert.DoesNotContain("SCAN ", plan, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolvedFromOwningPublisher_RoundTripsThroughDatabase() + { + // Arrange + var dbPath = CreateTempDatabasePath(); + var provider = new ProviderDetails + { + ProviderName = "ResolvedRoundTrip", + Messages = [], + Parameters = [], + Events = [], + Keywords = new Dictionary(), + Opcodes = new Dictionary(), + Tasks = new Dictionary(), + ResolvedFromOwningPublisher = "Owning-Publisher-Name" + }; + + // Act + using (var context = new EventProviderDbContext(dbPath, false)) + { + context.ProviderDetails.Add(provider); + context.SaveChanges(); + } + + // Assert + using var verify = new EventProviderDbContext(dbPath, true); + var retrieved = verify.ProviderDetails.Single(p => p.ProviderName == "ResolvedRoundTrip"); + Assert.Equal("Owning-Publisher-Name", retrieved.ResolvedFromOwningPublisher); + } + + [Fact] + public void Schema_V4_PrimaryKey_UsesNoCaseCollation() + { + // Arrange — a fresh database is created at the current schema version. + var dbPath = CreateTempDatabasePath(); + + using (var context = new EventProviderDbContext(dbPath, false)) + { + context.SaveChanges(); + } + + // Act + var collation = ReadProviderNamePrimaryKeyCollation(dbPath); + + // Assert + Assert.Equal("NOCASE", collation, ignoreCase: true); + } + + [Fact] + public void Upgrade_V3_To_V4_HappyPath() + { + // Arrange + var dbPath = CreateTempDatabasePath(); + SeedV3Schema(dbPath); + + var seeded = new ProviderDetails + { + ProviderName = "V3-Provider", + Messages = [new MessageModel { ProviderName = "V3-Provider", RawId = 1, Text = "from-v3" }], + Parameters = [], + Events = [], + Keywords = new Dictionary { { 1L, "kw" } }, + Opcodes = new Dictionary(), + Tasks = new Dictionary() + }; + InsertV3Row(dbPath, seeded); + + // Act + using (var context = new EventProviderDbContext(dbPath, false)) + { + context.PerformUpgradeIfNeeded(); + } + + // Assert + Assert.Equal("NOCASE", ReadProviderNamePrimaryKeyCollation(dbPath), ignoreCase: true); + + using var verify = new EventProviderDbContext(dbPath, true); + var stateAfter = verify.IsUpgradeNeeded(); + Assert.Equal(ProviderDatabaseSchemaVersion.Current, stateAfter.CurrentVersion); + Assert.False(stateAfter.NeedsUpgrade); + + var row = verify.ProviderDetails.Single(); + Assert.Equal("V3-Provider", row.ProviderName); + Assert.Equal("from-v3", row.Messages.Single().Text); + Assert.Equal("kw", row.Keywords[1L]); + Assert.Null(row.ResolvedFromOwningPublisher); + } + + [Fact] + public void Upgrade_V3_To_V4_MergesCaseCollidingProviders() + { + // Arrange — seed two rows whose ProviderName differs only by case. V3 BINARY PK allows this; + // V4 NOCASE PK does not, so the upgrade must merge them. + var dbPath = CreateTempDatabasePath(); + SeedV3Schema(dbPath); + + var first = new ProviderDetails + { + ProviderName = "Microsoft-Foo", + Messages = [new MessageModel { ProviderName = "Microsoft-Foo", RawId = 1, Text = "shared-text" }], + Parameters = [], + Events = [new EventModel { Id = 100, Keywords = [], Description = "shared-event" }], + Keywords = new Dictionary { { 1L, "alpha" } }, + Opcodes = new Dictionary(), + Tasks = new Dictionary() + }; + + var second = new ProviderDetails + { + ProviderName = "microsoft-foo", + Messages = [new MessageModel { ProviderName = "microsoft-foo", RawId = 1, Text = "shared-text" }], + Parameters = [], + Events = [new EventModel { Id = 100, Keywords = [], Description = "shared-event" }], + Keywords = new Dictionary { { 2L, "beta" } }, + Opcodes = new Dictionary(), + Tasks = new Dictionary() + }; + + InsertV3Row(dbPath, first); + InsertV3Row(dbPath, second); + + // Act + using (var context = new EventProviderDbContext(dbPath, false)) + { + context.PerformUpgradeIfNeeded(); + } + + // Assert — merged into a single row using the first-encountered casing as canonical. + using var verify = new EventProviderDbContext(dbPath, true); + var rows = verify.ProviderDetails.ToList(); + Assert.Single(rows); + Assert.Equal("Microsoft-Foo", rows[0].ProviderName); + Assert.Single(rows[0].Messages); + Assert.Single(rows[0].Events); + Assert.Equal(2, rows[0].Keywords.Count); + Assert.Equal("alpha", rows[0].Keywords[1L]); + Assert.Equal("beta", rows[0].Keywords[2L]); + } + + [Fact] + public void Upgrade_V3_To_V4_MergesCaseCollidingProvidersWithParameters() + { + // Arrange — case-colliding rows with non-empty Parameters that share identity (same + // ProviderName, RawId, ShortId, Tag). Without the V3 Parameters BLOB fix this scenario + // was masked because Parameters silently came back empty before the merger ran. + var dbPath = CreateTempDatabasePath(); + SeedV3Schema(dbPath); + + var first = new ProviderDetails + { + ProviderName = "Microsoft-Foo", + Messages = [], + Parameters = + [ + new MessageModel { ProviderName = "Microsoft-Foo", RawId = 1, ShortId = 1, Text = "shared-param" } + ], + Events = [], + Keywords = new Dictionary(), + Opcodes = new Dictionary(), + Tasks = new Dictionary() + }; + + var second = new ProviderDetails + { + ProviderName = "microsoft-foo", + Messages = [], + Parameters = + [ + new MessageModel { ProviderName = "microsoft-foo", RawId = 1, ShortId = 1, Text = "shared-param" }, + new MessageModel { ProviderName = "microsoft-foo", RawId = 2, ShortId = 2, Text = "second-only" } + ], + Events = [], + Keywords = new Dictionary(), + Opcodes = new Dictionary(), + Tasks = new Dictionary() + }; + + InsertV3Row(dbPath, first); + InsertV3Row(dbPath, second); + + // Act + using (var context = new EventProviderDbContext(dbPath, false)) + { + context.PerformUpgradeIfNeeded(); + } + + // Assert — single merged row using first-encountered casing; deduplicated parameters union. + using var verify = new EventProviderDbContext(dbPath, true); + var rows = verify.ProviderDetails.ToList(); + Assert.Single(rows); + Assert.Equal("Microsoft-Foo", rows[0].ProviderName); + var parameters = rows[0].Parameters.ToList(); + Assert.Equal(2, parameters.Count); + Assert.Contains(parameters, p => p.RawId == 1 && p.Text == "shared-param"); + Assert.Contains(parameters, p => p.RawId == 2 && p.Text == "second-only"); + } + + [Fact] + public void Upgrade_V3_To_V4_OnConflict_ThrowsAndPreservesOriginalRows() + { + // Arrange — case-colliding rows whose Keywords disagree on the same numeric key. + var dbPath = CreateTempDatabasePath(); + SeedV3Schema(dbPath); + + InsertV3Row(dbPath, new ProviderDetails + { + ProviderName = "Conflict-Provider", + Messages = [], + Parameters = [], + Events = [], + Keywords = new Dictionary { { 1L, "first" } }, + Opcodes = new Dictionary(), + Tasks = new Dictionary() + }); + + InsertV3Row(dbPath, new ProviderDetails + { + ProviderName = "conflict-provider", + Messages = [], + Parameters = [], + Events = [], + Keywords = new Dictionary { { 1L, "second" } }, + Opcodes = new Dictionary(), + Tasks = new Dictionary() + }); + + // Act + Assert — upgrade fails fast and the V3 table is not dropped. + using (var context = new EventProviderDbContext(dbPath, false)) + { + Assert.Throws(() => context.PerformUpgradeIfNeeded()); + } + + // Original V3 rows are preserved (table still has 2 rows, schema still V3). + using var verifyConnection = new SqliteConnection($"Data Source={dbPath}"); + verifyConnection.Open(); + using var verifyCmd = verifyConnection.CreateCommand(); + verifyCmd.CommandText = "SELECT COUNT(*) FROM \"ProviderDetails\""; + var count = Convert.ToInt32(verifyCmd.ExecuteScalar()); + Assert.Equal(2, count); + } + + [Fact] + public void Upgrade_V3_To_V4_PreservesNonEmptyParameters() + { + // Arrange — V3 row with non-empty Parameters (compressed BLOB). The earlier read path + // mishandled BLOB Parameters and silently emptied them; this test locks in correct preservation. + var dbPath = CreateTempDatabasePath(); + SeedV3Schema(dbPath); + + var seeded = new ProviderDetails + { + ProviderName = "V3-WithParams", + Messages = [], + Parameters = + [ + new MessageModel { ProviderName = "V3-WithParams", RawId = 100, ShortId = 100, Text = "param-one" }, + new MessageModel { ProviderName = "V3-WithParams", RawId = 200, ShortId = 200, Text = "param-two" } + ], + Events = [], + Keywords = new Dictionary(), + Opcodes = new Dictionary(), + Tasks = new Dictionary() + }; + InsertV3Row(dbPath, seeded); + + // Act + using (var context = new EventProviderDbContext(dbPath, false)) + { + context.PerformUpgradeIfNeeded(); + } + + // Assert — both parameter rows survived the destructive upgrade. + using var verify = new EventProviderDbContext(dbPath, true); + var row = verify.ProviderDetails.Single(p => p.ProviderName == "V3-WithParams"); + var parameters = row.Parameters.ToList(); + Assert.Equal(2, parameters.Count); + Assert.Contains(parameters, p => p.RawId == 100 && p.Text == "param-one"); + Assert.Contains(parameters, p => p.RawId == 200 && p.Text == "param-two"); + } + + private static void AssertProviderDetailsRowCount(string dbPath, int expectedRows) + { + using var verifyConnection = new SqliteConnection($"Data Source={dbPath};Mode=ReadOnly"); + verifyConnection.Open(); + using var verifyCmd = verifyConnection.CreateCommand(); + verifyCmd.CommandText = "SELECT COUNT(*) FROM \"ProviderDetails\""; + var count = Convert.ToInt32(verifyCmd.ExecuteScalar()); + Assert.Equal(expectedRows, count); + } + private static void DeleteDatabaseFile(string path) { try @@ -711,6 +1223,66 @@ private static void InsertLegacyRow( cmd.ExecuteNonQuery(); } + private static void InsertV3Row(string dbPath, ProviderDetails details) + { + using var connection = new SqliteConnection($"Data Source={dbPath}"); + connection.Open(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = + "INSERT INTO \"ProviderDetails\" (\"ProviderName\", \"Messages\", \"Parameters\", \"Events\", \"Keywords\", \"Opcodes\", \"Tasks\") " + + "VALUES ($name, $messages, $parameters, $events, $keywords, $opcodes, $tasks)"; + cmd.Parameters.AddWithValue("$name", details.ProviderName); + cmd.Parameters.AddWithValue("$messages", CompressedJsonValueConverter>.ConvertToCompressedJson(details.Messages)); + cmd.Parameters.AddWithValue("$parameters", CompressedJsonValueConverter>.ConvertToCompressedJson(details.Parameters)); + cmd.Parameters.AddWithValue("$events", CompressedJsonValueConverter>.ConvertToCompressedJson(details.Events)); + cmd.Parameters.AddWithValue("$keywords", CompressedJsonValueConverter>.ConvertToCompressedJson(details.Keywords)); + cmd.Parameters.AddWithValue("$opcodes", CompressedJsonValueConverter>.ConvertToCompressedJson(details.Opcodes)); + cmd.Parameters.AddWithValue("$tasks", CompressedJsonValueConverter>.ConvertToCompressedJson(details.Tasks)); + cmd.ExecuteNonQuery(); + } + + private static string ReadProviderNamePrimaryKeyCollation(string dbPath) + { + using var connection = new SqliteConnection($"Data Source={dbPath}"); + connection.Open(); + + string? pkIndexName = null; + + using (var listCmd = connection.CreateCommand()) + { + listCmd.CommandText = "PRAGMA index_list(\"ProviderDetails\")"; + using var reader = listCmd.ExecuteReader(); + + while (reader.Read()) + { + var origin = reader["origin"]?.ToString(); + if (string.Equals(origin, "pk", StringComparison.OrdinalIgnoreCase)) + { + pkIndexName = reader["name"]?.ToString(); + break; + } + } + } + + Assert.False(string.IsNullOrEmpty(pkIndexName), "Expected a primary-key auto-index on ProviderDetails."); + + using var infoCmd = connection.CreateCommand(); + infoCmd.CommandText = $"PRAGMA index_xinfo(\"{pkIndexName}\")"; + using var infoReader = infoCmd.ExecuteReader(); + + while (infoReader.Read()) + { + var name = infoReader["name"]?.ToString(); + if (string.Equals(name, nameof(ProviderDetails.ProviderName), StringComparison.Ordinal)) + { + return infoReader["coll"]?.ToString() ?? string.Empty; + } + } + + return string.Empty; + } + private static void SeedLegacySchema( string dbPath, bool includeParameters, @@ -744,6 +1316,28 @@ private static void SeedLegacySchema( cmd.ExecuteNonQuery(); } + private static void SeedV3Schema(string dbPath) + { + // V3 schema: BLOB payload columns, Parameters as BLOB, no ResolvedFromOwningPublisher + // column, default (BINARY) collation on the ProviderName PK. Built by raw SQL so + // EventProviderDbContext.EnsureCreated() sees the table as already-existing and skips + // its V4 schema generation, exposing the legacy V3 shape to detection and upgrade paths. + using var connection = new SqliteConnection($"Data Source={dbPath}"); + connection.Open(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = + "CREATE TABLE \"ProviderDetails\" (" + + "\"ProviderName\" TEXT NOT NULL CONSTRAINT \"PK_ProviderDetails\" PRIMARY KEY, " + + "\"Messages\" BLOB NOT NULL, " + + "\"Parameters\" BLOB NOT NULL, " + + "\"Events\" BLOB NOT NULL, " + + "\"Keywords\" BLOB NOT NULL, " + + "\"Opcodes\" BLOB NOT NULL, " + + "\"Tasks\" BLOB NOT NULL)"; + cmd.ExecuteNonQuery(); + } + private string CreateTempDatabasePath() { var path = Path.Combine(Path.GetTempPath(), $"test_db_{Guid.NewGuid()}.db"); diff --git a/src/EventLogExpert.Eventing.Tests/EventProviderDatabase/ProviderDetailsMergerTests.cs b/src/EventLogExpert.Eventing.Tests/EventProviderDatabase/ProviderDetailsMergerTests.cs new file mode 100644 index 00000000..dbb53690 --- /dev/null +++ b/src/EventLogExpert.Eventing.Tests/EventProviderDatabase/ProviderDetailsMergerTests.cs @@ -0,0 +1,331 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.EventProviderDatabase; +using EventLogExpert.Eventing.Models; +using EventLogExpert.Eventing.Providers; + +namespace EventLogExpert.Eventing.Tests.EventProviderDatabase; + +public sealed class ProviderDetailsMergerTests +{ + private const string TestDatabasePath = @"C:\test\fake.db"; + + [Fact] + public void MergeCaseInsensitiveDuplicates_DeduplicatesIdenticalMessages() + { + var msg = new MessageModel { ProviderName = "Same-Provider", RawId = 100, ShortId = 100, Text = "duplicate-text", Tag = null }; + var rows = new List + { + CreateProvider("Same-Provider", messages: [msg]), + CreateProvider("same-provider", messages: [msg]) + }; + + var merged = ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(rows, TestDatabasePath); + + Assert.Single(merged); + Assert.Single(merged[0].Messages); + Assert.Equal("duplicate-text", merged[0].Messages[0].Text); + } + + [Fact] + public void MergeCaseInsensitiveDuplicates_NoDuplicates_ReturnsOriginalRows() + { + var rows = new List + { + CreateProvider("Provider-Alpha"), + CreateProvider("Provider-Beta") + }; + + var merged = ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(rows, TestDatabasePath); + + Assert.Equal(2, merged.Count); + Assert.Equal("Provider-Alpha", merged[0].ProviderName); + Assert.Equal("Provider-Beta", merged[1].ProviderName); + } + + [Fact] + public void MergeCaseInsensitiveDuplicates_OnConflictingEventDescription_Throws() + { + var rows = new List + { + CreateProvider("Same-Provider", events: + [ + new EventModel { Id = 100, Version = 0, LogName = "App", Keywords = [], Description = "first" } + ]), + CreateProvider("same-provider", events: + [ + new EventModel { Id = 100, Version = 0, LogName = "App", Keywords = [], Description = "second" } + ]) + }; + + var ex = Assert.Throws(() => + ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(rows, TestDatabasePath)); + Assert.Contains("Event", ex.Reason); + } + + [Fact] + public void MergeCaseInsensitiveDuplicates_OnConflictingKeywordValue_Throws() + { + var rows = new List + { + CreateProvider("Conflict-Provider", keywords: new Dictionary { { 1L, "first" } }), + CreateProvider("conflict-provider", keywords: new Dictionary { { 1L, "second" } }) + }; + + var ex = Assert.Throws(() => + ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(rows, TestDatabasePath)); + Assert.Contains("Keywords", ex.Reason); + Assert.Contains("first", ex.Reason); + Assert.Contains("second", ex.Reason); + } + + [Fact] + public void MergeCaseInsensitiveDuplicates_OnConflictingMessageText_Throws() + { + var rows = new List + { + CreateProvider("Same-Provider", messages: + [ + new MessageModel { ProviderName = "Same-Provider", RawId = 1, ShortId = 1, Text = "first" } + ]), + CreateProvider("same-provider", messages: + [ + new MessageModel { ProviderName = "same-provider", RawId = 1, ShortId = 1, Text = "second" } + ]) + }; + + var ex = Assert.Throws(() => + ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(rows, TestDatabasePath)); + Assert.Contains("Message", ex.Reason); + } + + [Fact] + public void MergeCaseInsensitiveDuplicates_OnConflictingOpcodeValue_Throws() + { + var rows = new List + { + CreateProvider("Conflict-Provider", opcodes: new Dictionary { { 5, "OpcodeA" } }), + CreateProvider("conflict-provider", opcodes: new Dictionary { { 5, "OpcodeB" } }) + }; + + var ex = Assert.Throws(() => + ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(rows, TestDatabasePath)); + Assert.Contains("Opcodes", ex.Reason); + } + + [Fact] + public void MergeCaseInsensitiveDuplicates_OnConflictingTaskValue_Throws() + { + var rows = new List + { + CreateProvider("Conflict-Provider", tasks: new Dictionary { { 7, "TaskA" } }), + CreateProvider("conflict-provider", tasks: new Dictionary { { 7, "TaskB" } }) + }; + + var ex = Assert.Throws(() => + ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(rows, TestDatabasePath)); + Assert.Contains("Tasks", ex.Reason); + } + + [Fact] + public void MergeCaseInsensitiveDuplicates_OnIdenticalKeywordKeyAndValue_DoesNotThrow() + { + var rows = new List + { + CreateProvider("Same-Provider", keywords: new Dictionary { { 1L, "shared" } }), + CreateProvider("same-provider", keywords: new Dictionary { { 1L, "shared" } }) + }; + + var merged = ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(rows, TestDatabasePath); + + Assert.Single(merged); + Assert.Equal("shared", merged[0].Keywords[1L]); + } + + [Fact] + public void MergeCaseInsensitiveDuplicates_PreservesEncounterOrder() + { + var rows = new List + { + CreateProvider("Z-Provider"), + CreateProvider("a-Provider"), + CreateProvider("M-Provider") + }; + + var merged = ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(rows, TestDatabasePath); + + Assert.Equal(["Z-Provider", "a-Provider", "M-Provider"], merged.Select(p => p.ProviderName).ToArray()); + } + + [Fact] + public void MergeCaseInsensitiveDuplicates_ResolvedFromOwningPublisher_BothEqualNonNullSucceeds() + { + var rows = new List + { + CreateProvider("Same-Provider", resolvedFromOwningPublisher: "PublisherX"), + CreateProvider("same-provider", resolvedFromOwningPublisher: "PublisherX") + }; + + var merged = ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(rows, TestDatabasePath); + + Assert.Single(merged); + Assert.Equal("PublisherX", merged[0].ResolvedFromOwningPublisher); + } + + [Fact] + public void MergeCaseInsensitiveDuplicates_ResolvedFromOwningPublisher_BothNonNullDifferentThrows() + { + var rows = new List + { + CreateProvider("Same-Provider", resolvedFromOwningPublisher: "PublisherX"), + CreateProvider("same-provider", resolvedFromOwningPublisher: "PublisherY") + }; + + var ex = Assert.Throws(() => + ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(rows, TestDatabasePath)); + Assert.Contains("ResolvedFromOwningPublisher", ex.Reason); + } + + [Fact] + public void MergeCaseInsensitiveDuplicates_ResolvedFromOwningPublisher_BothNullProducesNull() + { + var rows = new List + { + CreateProvider("Same-Provider", resolvedFromOwningPublisher: null), + CreateProvider("same-provider", resolvedFromOwningPublisher: null) + }; + + var merged = ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(rows, TestDatabasePath); + + Assert.Single(merged); + Assert.Null(merged[0].ResolvedFromOwningPublisher); + } + + [Fact] + public void MergeCaseInsensitiveDuplicates_ResolvedFromOwningPublisher_EitherNonNullWins() + { + var rows = new List + { + CreateProvider("Same-Provider", resolvedFromOwningPublisher: null), + CreateProvider("same-provider", resolvedFromOwningPublisher: "PublisherX") + }; + + var merged = ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(rows, TestDatabasePath); + + Assert.Single(merged); + Assert.Equal("PublisherX", merged[0].ResolvedFromOwningPublisher); + } + + [Fact] + public void MergeCaseInsensitiveDuplicates_ResolvedFromOwningPublisher_TreatsEmptyAsAbsent() + { + var rows = new List + { + CreateProvider("Same-Provider", resolvedFromOwningPublisher: ""), + CreateProvider("same-provider", resolvedFromOwningPublisher: "PublisherX") + }; + + var merged = ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(rows, TestDatabasePath); + + Assert.Single(merged); + Assert.Equal("PublisherX", merged[0].ResolvedFromOwningPublisher); + } + + [Fact] + public void MergeCaseInsensitiveDuplicates_TreatsEventsWithSameKeywordsInDifferentOrderAsEqual() + { + var rows = new List + { + CreateProvider("Same-Provider", events: + [ + new EventModel { Id = 100, Version = 0, LogName = "App", Keywords = [1, 2, 3], Description = "desc" } + ]), + CreateProvider("same-provider", events: + [ + new EventModel { Id = 100, Version = 0, LogName = "App", Keywords = [3, 2, 1], Description = "desc" } + ]) + }; + + var merged = ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(rows, TestDatabasePath); + + Assert.Single(merged); + Assert.Single(merged[0].Events); + } + + [Fact] + public void MergeCaseInsensitiveDuplicates_TreatsMessagesWithDifferentTagsAsDistinct() + { + var rows = new List + { + CreateProvider("Same-Provider", messages: + [ + new MessageModel { ProviderName = "Same-Provider", RawId = 1, ShortId = 1, Text = "shared", Tag = "TagA" } + ]), + CreateProvider("same-provider", messages: + [ + new MessageModel { ProviderName = "same-provider", RawId = 1, ShortId = 1, Text = "shared", Tag = "TagB" } + ]) + }; + + var merged = ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(rows, TestDatabasePath); + + Assert.Single(merged); + Assert.Equal(2, merged[0].Messages.Count); + Assert.Contains(merged[0].Messages, m => m.Tag == "TagA"); + Assert.Contains(merged[0].Messages, m => m.Tag == "TagB"); + } + + [Fact] + public void MergeCaseInsensitiveDuplicates_UnionsKeywordDictionaries() + { + var rows = new List + { + CreateProvider("Same-Provider", keywords: new Dictionary { { 1L, "alpha" } }), + CreateProvider("same-provider", keywords: new Dictionary { { 2L, "beta" } }) + }; + + var merged = ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(rows, TestDatabasePath); + + Assert.Single(merged); + Assert.Equal(2, merged[0].Keywords.Count); + Assert.Equal("alpha", merged[0].Keywords[1L]); + Assert.Equal("beta", merged[0].Keywords[2L]); + } + + [Fact] + public void MergeCaseInsensitiveDuplicates_UsesFirstRowCasingAsCanonical() + { + var rows = new List + { + CreateProvider("MICROSOFT-Foo"), + CreateProvider("microsoft-foo"), + CreateProvider("Microsoft-Foo") + }; + + var merged = ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(rows, TestDatabasePath); + + Assert.Single(merged); + Assert.Equal("MICROSOFT-Foo", merged[0].ProviderName); + } + + private static ProviderDetails CreateProvider( + string name, + IReadOnlyList? messages = null, + IReadOnlyList? events = null, + IDictionary? keywords = null, + IDictionary? opcodes = null, + IDictionary? tasks = null, + string? resolvedFromOwningPublisher = null) => + new() + { + ProviderName = name, + Messages = messages ?? [], + Parameters = [], + Events = events ?? [], + Keywords = keywords ?? new Dictionary(), + Opcodes = opcodes ?? new Dictionary(), + Tasks = tasks ?? new Dictionary(), + ResolvedFromOwningPublisher = resolvedFromOwningPublisher + }; +} diff --git a/src/EventLogExpert.Eventing.Tests/Providers/ProviderDetailsTests.cs b/src/EventLogExpert.Eventing.Tests/Providers/ProviderDetailsTests.cs new file mode 100644 index 00000000..de965e8d --- /dev/null +++ b/src/EventLogExpert.Eventing.Tests/Providers/ProviderDetailsTests.cs @@ -0,0 +1,108 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Models; +using EventLogExpert.Eventing.Providers; + +namespace EventLogExpert.Eventing.Tests.Providers; + +public sealed class ProviderDetailsTests +{ + [Fact] + public void IsEmpty_WhenAllCollectionsEmptyAndNoFallback_ReturnsTrue() + { + var details = new ProviderDetails { ProviderName = "Provider-Name" }; + + Assert.True(details.IsEmpty); + } + + [Fact] + public void IsEmpty_WhenEventsPopulated_ReturnsFalse() + { + var details = new ProviderDetails + { + ProviderName = "Provider-Name", + Events = [new EventModel { Id = 1, LogName = "Application", Keywords = [] }] + }; + + Assert.False(details.IsEmpty); + } + + [Fact] + public void IsEmpty_WhenKeywordsPopulated_ReturnsFalse() + { + var details = new ProviderDetails + { + ProviderName = "Provider-Name", + Keywords = new Dictionary { { 1, "kw" } } + }; + + Assert.False(details.IsEmpty); + } + + [Fact] + public void IsEmpty_WhenMessagesPopulated_ReturnsFalse() + { + var details = new ProviderDetails + { + ProviderName = "Provider-Name", + Messages = [new MessageModel { ShortId = 1, RawId = 1, Text = "x" }] + }; + + Assert.False(details.IsEmpty); + } + + [Fact] + public void IsEmpty_WhenOpcodesPopulated_ReturnsFalse() + { + var details = new ProviderDetails + { + ProviderName = "Provider-Name", + Opcodes = new Dictionary { { 1, "op" } } + }; + + Assert.False(details.IsEmpty); + } + + [Fact] + public void IsEmpty_WhenParametersPopulated_ReturnsFalse() + { + var details = new ProviderDetails + { + ProviderName = "Provider-Name", + Parameters = [new MessageModel { ShortId = 1, RawId = 1, Text = "p" }] + }; + + Assert.False(details.IsEmpty); + } + + [Fact] + public void IsEmpty_WhenResolvedFromOwningPublisherSet_ReturnsFalse() + { + // A non-null ResolvedFromOwningPublisher records that a channel-owner fallback + // succeeded for this row. Even when the collections are empty, the row is NOT + // empty in the "provider not found" sense — fallback resolution did locate a + // publisher. This guards the resolver from re-attempting fallback on a row that + // has already been fallback-resolved (avoids redundant lookups and keeps the + // diagnostic field's semantics consistent with IsEmpty). + var details = new ProviderDetails + { + ProviderName = "Channel/Operational", + ResolvedFromOwningPublisher = "Microsoft-Windows-OwningPublisher" + }; + + Assert.False(details.IsEmpty); + } + + [Fact] + public void IsEmpty_WhenTasksPopulated_ReturnsFalse() + { + var details = new ProviderDetails + { + ProviderName = "Provider-Name", + Tasks = new Dictionary { { 1, "task" } } + }; + + Assert.False(details.IsEmpty); + } +} diff --git a/src/EventLogExpert.Eventing/EventProviderDatabase/DatabaseUpgradeException.cs b/src/EventLogExpert.Eventing/EventProviderDatabase/DatabaseUpgradeException.cs new file mode 100644 index 00000000..b740e6a6 --- /dev/null +++ b/src/EventLogExpert.Eventing/EventProviderDatabase/DatabaseUpgradeException.cs @@ -0,0 +1,25 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Eventing.EventProviderDatabase; + +public sealed class DatabaseUpgradeException : Exception +{ + public DatabaseUpgradeException(string databasePath, string reason) + : base($"Database upgrade failed for '{databasePath}': {reason}") + { + DatabasePath = databasePath; + Reason = reason; + } + + public DatabaseUpgradeException(string databasePath, string reason, Exception innerException) + : base($"Database upgrade failed for '{databasePath}': {reason}", innerException) + { + DatabasePath = databasePath; + Reason = reason; + } + + public string DatabasePath { get; } + + public string Reason { get; } +} diff --git a/src/EventLogExpert.Eventing/EventProviderDatabase/EventProviderDbContext.cs b/src/EventLogExpert.Eventing/EventProviderDatabase/EventProviderDbContext.cs index c4e6ab9f..edcb7c89 100644 --- a/src/EventLogExpert.Eventing/EventProviderDatabase/EventProviderDbContext.cs +++ b/src/EventLogExpert.Eventing/EventProviderDatabase/EventProviderDbContext.cs @@ -6,7 +6,7 @@ using EventLogExpert.Eventing.Providers; using Microsoft.EntityFrameworkCore; using System.Data; -using System.Text.Json; +using System.Data.Common; namespace EventLogExpert.Eventing.EventProviderDatabase; @@ -16,16 +16,23 @@ public sealed class EventProviderDbContext : DbContext private readonly bool _readOnly; public EventProviderDbContext(string path, bool readOnly, ITraceLogger? logger = null) + : this(path, readOnly, true, logger) { } + + public EventProviderDbContext(string path, bool readOnly, bool ensureCreated, ITraceLogger? logger = null) { _logger = logger; - _logger?.Debug($"Instantiating EventProviderDbContext. path: {path} readOnly: {readOnly}"); + _logger?.Debug( + $"Instantiating EventProviderDbContext. path: {path} readOnly: {readOnly} ensureCreated: {ensureCreated}"); Name = System.IO.Path.GetFileNameWithoutExtension(path); Path = path; _readOnly = readOnly; - Database.EnsureCreated(); + if (ensureCreated) + { + Database.EnsureCreated(); + } } public string Name { get; } @@ -34,82 +41,163 @@ public EventProviderDbContext(string path, bool readOnly, ITraceLogger? logger = private string Path { get; } - public (bool needsV2Upgrade, bool needsV3Upgrade) IsUpgradeNeeded() + public ProviderDatabaseSchemaState IsUpgradeNeeded() { - // Use PRAGMA table_info instead of substring-matching sqlite_schema.sql. The previous - // approach matched the literal text `"Parameters" BLOB NOT NULL`, which is brittle across - // EF Core / Microsoft.Data.Sqlite versions (whitespace, quoting, constraint ordering, case) - // and would silently flip a fresh V3 database into the upgrade-needed path, causing an - // unnecessary drop/vacuum/recreate cycle. + // Inspect the on-disk schema via PRAGMA table_info / index_xinfo so we are robust to + // EF / SQLite text variations across versions and across upgrade levels (V1/V2/V3/V4). var connection = Database.GetDbConnection(); Database.OpenConnection(); try { - using var command = connection.CreateCommand(); - command.CommandText = "PRAGMA table_info(\"ProviderDetails\")"; - using var reader = command.ExecuteReader(); - string? messagesType = null; + string? eventsType = null; + string? keywordsType = null; + string? opcodesType = null; + string? tasksType = null; string? parametersType = null; var hasAnyColumn = false; + var hasParametersColumn = false; + var hasResolvedColumn = false; - while (reader.Read()) + using (var command = connection.CreateCommand()) { - hasAnyColumn = true; - - var name = reader["name"]?.ToString(); - var type = reader["type"]?.ToString(); + command.CommandText = "PRAGMA table_info(\"ProviderDetails\")"; + using var reader = command.ExecuteReader(); - if (string.Equals(name, "Messages", StringComparison.Ordinal)) + while (reader.Read()) { - messagesType = type; - } - else if (string.Equals(name, "Parameters", StringComparison.Ordinal)) - { - parametersType = type; + hasAnyColumn = true; + + var columnName = reader["name"]?.ToString(); + var columnType = reader["type"]?.ToString(); + + if (string.Equals(columnName, "Messages", StringComparison.Ordinal)) + { + messagesType = columnType; + } + else if (string.Equals(columnName, "Events", StringComparison.Ordinal)) + { + eventsType = columnType; + } + else if (string.Equals(columnName, "Keywords", StringComparison.Ordinal)) + { + keywordsType = columnType; + } + else if (string.Equals(columnName, "Opcodes", StringComparison.Ordinal)) + { + opcodesType = columnType; + } + else if (string.Equals(columnName, "Tasks", StringComparison.Ordinal)) + { + tasksType = columnType; + } + else if (string.Equals(columnName, "Parameters", StringComparison.Ordinal)) + { + parametersType = columnType; + hasParametersColumn = true; + } + else if (string.Equals(columnName, + nameof(Providers.ProviderDetails.ResolvedFromOwningPublisher), + StringComparison.Ordinal)) + { + hasResolvedColumn = true; + } } } - reader.Close(); + int currentVersion; - // V2 schema stored payload columns as JSON-encoded TEXT. V3 stores them as compressed BLOB. - // V1 had no Parameters column at all; that case naturally falls into needsV3Upgrade=true - // because parametersType remains null below. - var messagesIsText = string.Equals(messagesType?.Trim(), "TEXT", StringComparison.OrdinalIgnoreCase); - var parametersIsBlob = string.Equals(parametersType?.Trim(), "BLOB", StringComparison.OrdinalIgnoreCase); + if (!hasAnyColumn) + { + // Empty file or unrelated database — no upgrade needed. + currentVersion = ProviderDatabaseSchemaVersion.Current; + } + else + { + // V1 and V2 stored every payload column as TEXT JSON; V3 and V4 store them all + // as compressed BLOB. A mixture of TEXT and BLOB payload columns is not any + // recognized schema and is reported as Unknown so the read path can surface a + // distinct error instead of crashing on a cast in ReadCompressedRow. + var payloadColumnsAllText = IsType(messagesType, "TEXT") && + IsType(eventsType, "TEXT") && + IsType(keywordsType, "TEXT") && + IsType(opcodesType, "TEXT") && + IsType(tasksType, "TEXT"); + + var payloadColumnsAllBlob = IsType(messagesType, "BLOB") && + IsType(eventsType, "BLOB") && + IsType(keywordsType, "BLOB") && + IsType(opcodesType, "BLOB") && + IsType(tasksType, "BLOB"); + + switch (payloadColumnsAllText) + { + case true when !hasParametersColumn: currentVersion = 1; break; + case true when IsType(parametersType, "TEXT"): currentVersion = 2; break; + default: + { + if (payloadColumnsAllBlob && IsType(parametersType, "BLOB")) + { + var pkIsNoCase = TryDetectPrimaryKeyNoCaseCollation(connection); + currentVersion = hasResolvedColumn && pkIsNoCase ? 4 : 3; + } + else + { + // Unknown column shape — payload columns are not uniformly TEXT or BLOB, or + // Parameters disagrees with the rest. Report the Unknown sentinel so + // PerformUpgradeIfNeeded can surface a distinct "unrecognized schema" error. + currentVersion = ProviderDatabaseSchemaVersion.Unknown; + } + + break; + } + } + } - // Only flag upgrades when the ProviderDetails table actually exists. If it does not, the - // database is either freshly created (EnsureCreated already ran in the constructor) or - // unrelated to this tool — neither case warrants the destructive drop/vacuum/recreate path. - var needsV2Upgrade = hasAnyColumn && messagesIsText; - var needsV3Upgrade = hasAnyColumn && !parametersIsBlob; + var state = new ProviderDatabaseSchemaState(currentVersion); - _logger?.Debug($"{nameof(EventProviderDbContext)}.{nameof(IsUpgradeNeeded)}() for database {Path}. needsV2Upgrade: {needsV2Upgrade} needsV3Upgrade: {needsV3Upgrade}"); + _logger?.Debug( + $"{nameof(EventProviderDbContext)}.{nameof(IsUpgradeNeeded)}() for database {Path}. currentVersion: {currentVersion} needsUpgrade: {state.NeedsUpgrade}"); - return (needsV2Upgrade, needsV3Upgrade); + return state; } finally { - // Always close the connection we opened explicitly — leaving it open keeps the SQLite - // file locked, blocking other operations (including FileInfo.Length reads on some - // platforms) and undermining EF's normal short-lived-connection lifecycle. Database.CloseConnection(); } } public void PerformUpgradeIfNeeded() { - var (needsV2Upgrade, needsV3Upgrade) = IsUpgradeNeeded(); + var state = IsUpgradeNeeded(); - if (!needsV2Upgrade && !needsV3Upgrade) + if (!state.NeedsUpgrade) { return; } + + // Hard-fail before any destructive step (DROP TABLE) so the on-disk data is preserved + // when the upgrade cannot proceed. Two distinct failure modes: + // * Unknown shape — file is not a recognizable ProviderDetails database, possibly + // corrupt or from a future / incompatible version. + // * V1/V2 — pre-V3 legacy schemas are no longer supported by this build; the user + // must upgrade through an older release that supported V3 first, or delete the file. + if (state.CurrentVersion == ProviderDatabaseSchemaVersion.Unknown) { - return; + throw new DatabaseUpgradeException( + Path, + $"Database '{Path}' has an unrecognized schema. The file may be corrupt or from a newer or incompatible version of EventLogExpert. Delete or replace the file."); + } + + if (state.CurrentVersion is 1 or 2) + { + throw new DatabaseUpgradeException( + Path, + $"Database '{Path}' is at schema v{state.CurrentVersion}; this version is no longer supported. Upgrade through an older EventLogExpert release that supports v3 first, or delete the file."); } var size = new FileInfo(Path).Length; - _logger?.Info($"EventProviderDbContext upgrading database. Size: {size} Path: {Path}"); + _logger?.Info( + $"EventProviderDbContext upgrading database (current v{state.CurrentVersion} → v{ProviderDatabaseSchemaVersion.Current}). Size: {size} Path: {Path}"); var connection = Database.GetDbConnection(); Database.OpenConnection(); @@ -118,59 +206,38 @@ public void PerformUpgradeIfNeeded() try { - using var command = connection.CreateCommand(); + using (var command = connection.CreateCommand()) + { + command.CommandText = "SELECT * FROM \"ProviderDetails\""; - command.CommandText = "SELECT * FROM \"ProviderDetails\""; + using var detailsReader = command.ExecuteReader(); - using (var detailsReader = command.ExecuteReader()) - { - if (needsV2Upgrade) - { - while (detailsReader.Read()) - { - var providerName = (string)detailsReader["ProviderName"]; - var p = new ProviderDetails - { - ProviderName = providerName, - Messages = JsonSerializer.Deserialize>((string)detailsReader["Messages"], ProviderJsonSerializerOptions.Default) ?? new List(), - Parameters = TryReadParametersJson(detailsReader, providerName), - Events = JsonSerializer.Deserialize>((string)detailsReader["Events"], ProviderJsonSerializerOptions.Default) ?? new List(), - Keywords = JsonSerializer.Deserialize>((string)detailsReader["Keywords"], ProviderJsonSerializerOptions.Default) ?? new Dictionary(), - Opcodes = JsonSerializer.Deserialize>((string)detailsReader["Opcodes"], ProviderJsonSerializerOptions.Default) ?? new Dictionary(), - Tasks = JsonSerializer.Deserialize>((string)detailsReader["Tasks"], ProviderJsonSerializerOptions.Default) ?? new Dictionary() - }; - allProviderDetails.Add(p); - } - } - else + while (detailsReader.Read()) { - while (detailsReader.Read()) - { - var providerName = (string)detailsReader["ProviderName"]; - var p = new ProviderDetails - { - ProviderName = providerName, - Messages = CompressedJsonValueConverter>.ConvertFromCompressedJson((byte[])detailsReader["Messages"]) ?? new List(), - Parameters = TryReadParametersJson(detailsReader, providerName), - Events = CompressedJsonValueConverter>.ConvertFromCompressedJson((byte[])detailsReader["Events"]) ?? new List(), - Keywords = CompressedJsonValueConverter>.ConvertFromCompressedJson((byte[])detailsReader["Keywords"]) ?? new Dictionary(), - Opcodes = CompressedJsonValueConverter>.ConvertFromCompressedJson((byte[])detailsReader["Opcodes"]) ?? new Dictionary(), - Tasks = CompressedJsonValueConverter>.ConvertFromCompressedJson((byte[])detailsReader["Tasks"]) ?? new Dictionary() - }; - allProviderDetails.Add(p); - } + var providerName = (string)detailsReader["ProviderName"]; + + var details = ReadCompressedRow(detailsReader, providerName); + + allProviderDetails.Add(details); } } - command.CommandText = "DROP TABLE \"ProviderDetails\""; - command.ExecuteNonQuery(); - command.CommandText = "VACUUM"; - command.ExecuteNonQuery(); + // Pre-DROP merge: detect case-insensitive duplicates and either merge or hard-fail. + // Throwing here (before DROP) preserves the original database contents on conflict. + var merged = ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(allProviderDetails, Path); + + using (var dropCommand = connection.CreateCommand()) + { + dropCommand.CommandText = "DROP TABLE \"ProviderDetails\""; + dropCommand.ExecuteNonQuery(); + dropCommand.CommandText = "VACUUM"; + dropCommand.ExecuteNonQuery(); + } + + allProviderDetails = merged; } finally { - // SaveChanges() below will reopen the connection on demand; releasing it here keeps the - // explicit open/close balanced and prevents leaking the lock if SaveChanges throws. Database.CloseConnection(); } @@ -188,14 +255,18 @@ public void PerformUpgradeIfNeeded() _logger?.Info($"EventProviderDbContext upgrade completed. Size: {size} Path: {Path}"); } - protected override void OnConfiguring(DbContextOptionsBuilder options) - => options.UseSqlite($"Data Source={Path};Mode={(_readOnly ? "ReadOnly" : "ReadWriteCreate")}"); + protected override void OnConfiguring(DbContextOptionsBuilder options) => + options.UseSqlite($"Data Source={Path};Mode={(_readOnly ? "ReadOnly" : "ReadWriteCreate")}"); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity() .HasKey(e => e.ProviderName); + modelBuilder.Entity() + .Property(e => e.ProviderName) + .UseCollation("NOCASE"); + modelBuilder.Entity() .Property(e => e.Messages) .HasConversion>>(); @@ -221,47 +292,98 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasConversion>>(); } - /// - /// Reads the optional Parameters column from the pre-upgrade row, if present, and - /// deserializes the JSON payload. V1 schemas have no Parameters column at all (returns empty - /// list); V2 stored it as JSON-encoded TEXT (preserved here). Any failure to parse logs a - /// warning so silent data loss is diagnosable instead of opaque. - /// - private List TryReadParametersJson(IDataReader reader, string providerName) + private static bool IsType(string? actual, string expected) => + string.Equals(actual?.Trim(), expected, StringComparison.OrdinalIgnoreCase); + + private static ProviderDetails ReadCompressedRow(IDataReader reader, string providerName) => + new() + { + ProviderName = providerName, + Messages = CompressedJsonValueConverter>.ConvertFromCompressedJson((byte[])reader["Messages"]), + Parameters = CompressedJsonValueConverter>.ConvertFromCompressedJson((byte[])reader["Parameters"]), + Events = CompressedJsonValueConverter>.ConvertFromCompressedJson((byte[])reader["Events"]), + Keywords = CompressedJsonValueConverter>.ConvertFromCompressedJson((byte[])reader["Keywords"]), + Opcodes = CompressedJsonValueConverter>.ConvertFromCompressedJson((byte[])reader["Opcodes"]), + Tasks = CompressedJsonValueConverter>.ConvertFromCompressedJson((byte[])reader["Tasks"]), + ResolvedFromOwningPublisher = TryReadResolvedFromOwningPublisher(reader, providerName) + }; + + private static bool TryDetectPrimaryKeyNoCaseCollation(DbConnection connection) { - for (var i = 0; i < reader.FieldCount; i++) + // SQLite stores per-column collation on each index entry rather than on the column itself. + // Find the auto-generated PK index for ProviderDetails, then read the collation for the + // ProviderName entry from PRAGMA index_xinfo. + // + // Returning false on any "could not detect" branch (missing PK index, missing column entry, + // null `coll`) is intentional and safe: the caller treats false as "not V4", which triggers + // the V3->V4 upgrade path. The upgrade unconditionally DROPs and recreates the table via + // EnsureCreated, so a corrupt-or-unrecognized PK index self-heals on the next launch — there + // is no infinite-upgrade-loop risk. + string? pkIndexName = null; + + using (var indexListCommand = connection.CreateCommand()) { - if (!string.Equals(reader.GetName(i), "Parameters", StringComparison.Ordinal)) + indexListCommand.CommandText = "PRAGMA index_list(\"ProviderDetails\")"; + + using var indexListReader = indexListCommand.ExecuteReader(); + + while (indexListReader.Read()) { - continue; + var origin = indexListReader["origin"]?.ToString(); + + if (!string.Equals(origin, "pk", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + pkIndexName = indexListReader["name"]?.ToString(); + + break; } + } - if (reader.IsDBNull(i)) + if (string.IsNullOrEmpty(pkIndexName)) { return false; } + + using var indexInfoCommand = connection.CreateCommand(); + + indexInfoCommand.CommandText = $"PRAGMA index_xinfo(\"{pkIndexName}\")"; + + using var indexInfoReader = indexInfoCommand.ExecuteReader(); + + while (indexInfoReader.Read()) + { + var columnName = indexInfoReader["name"]?.ToString(); + + if (!string.Equals(columnName, nameof(Providers.ProviderDetails.ProviderName), StringComparison.Ordinal)) { - return []; + continue; } - var raw = reader.GetValue(i); + var collation = indexInfoReader["coll"]?.ToString(); - if (raw is string s) - { - if (string.IsNullOrEmpty(s)) { return []; } + return string.Equals(collation, "NOCASE", StringComparison.OrdinalIgnoreCase); + } - try - { - return JsonSerializer.Deserialize>(s, ProviderJsonSerializerOptions.Default) ?? []; - } - catch (JsonException ex) - { - _logger?.Warn($"EventProviderDbContext upgrade: failed to deserialize Parameters JSON for provider '{providerName}' in {Path}: {ex.Message}. Parameters will be empty after upgrade."); - return []; - } + return false; + } + + private static string? TryReadResolvedFromOwningPublisher(IDataReader reader, string providerName) + { + for (var i = 0; i < reader.FieldCount; i++) + { + if (!string.Equals(reader.GetName(i), + nameof(Providers.ProviderDetails.ResolvedFromOwningPublisher), + StringComparison.Ordinal)) + { + continue; } - _logger?.Warn($"EventProviderDbContext upgrade: Parameters column for provider '{providerName}' in {Path} is of unexpected type '{raw.GetType().Name}'. Parameters will be empty after upgrade."); - return []; + return reader.IsDBNull(i) ? null : reader.GetString(i); } - return []; + // Column not present (V3 schema) — leave null. + _ = providerName; + + return null; } } diff --git a/src/EventLogExpert.Eventing/EventProviderDatabase/ProviderDatabaseSchemaState.cs b/src/EventLogExpert.Eventing/EventProviderDatabase/ProviderDatabaseSchemaState.cs new file mode 100644 index 00000000..908c97d2 --- /dev/null +++ b/src/EventLogExpert.Eventing/EventProviderDatabase/ProviderDatabaseSchemaState.cs @@ -0,0 +1,9 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Eventing.EventProviderDatabase; + +public sealed record ProviderDatabaseSchemaState(int CurrentVersion) +{ + public bool NeedsUpgrade => CurrentVersion < ProviderDatabaseSchemaVersion.Current; +} diff --git a/src/EventLogExpert.Eventing/EventProviderDatabase/ProviderDatabaseSchemaVersion.cs b/src/EventLogExpert.Eventing/EventProviderDatabase/ProviderDatabaseSchemaVersion.cs new file mode 100644 index 00000000..1d8f5667 --- /dev/null +++ b/src/EventLogExpert.Eventing/EventProviderDatabase/ProviderDatabaseSchemaVersion.cs @@ -0,0 +1,16 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Eventing.EventProviderDatabase; + +public static class ProviderDatabaseSchemaVersion +{ + public const int Current = 4; + + /// + /// Sentinel used when on-disk schema detection cannot identify any known + /// ProviderDetails shape. Distinguishes "unknown / possibly corrupt" from a + /// known-but-legacy version so callers can surface a more accurate error. + /// + public const int Unknown = 0; +} diff --git a/src/EventLogExpert.Eventing/EventProviderDatabase/ProviderDetailsMerger.cs b/src/EventLogExpert.Eventing/EventProviderDatabase/ProviderDetailsMerger.cs new file mode 100644 index 00000000..ba6d3c1f --- /dev/null +++ b/src/EventLogExpert.Eventing/EventProviderDatabase/ProviderDetailsMerger.cs @@ -0,0 +1,234 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Models; +using EventLogExpert.Eventing.Providers; + +namespace EventLogExpert.Eventing.EventProviderDatabase; + +internal static class ProviderDetailsMerger +{ + public static List MergeCaseInsensitiveDuplicates( + IReadOnlyList rows, + string databasePath) + { + var merged = new List(rows.Count); + var firstIndexByGroup = new Dictionary(StringComparer.OrdinalIgnoreCase); + var groupedRows = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < rows.Count; i++) + { + var row = rows[i]; + var key = row.ProviderName; + + if (firstIndexByGroup.TryAdd(key, i)) + { + groupedRows[key] = [row]; + } + else + { + groupedRows[key].Add(row); + } + } + + foreach (var (_, firstIndex) in firstIndexByGroup.OrderBy(kvp => kvp.Value)) + { + var groupKey = rows[firstIndex].ProviderName; + var group = groupedRows[groupKey]; + + if (group.Count == 1) + { + merged.Add(group[0]); + continue; + } + + merged.Add(MergeGroup(group, databasePath)); + } + + return merged; + } + + private static bool EventsAreEquivalent(EventModel a, EventModel b) => + a.Level == b.Level && + a.Opcode == b.Opcode && + a.Task == b.Task && + KeywordsEqual(a.Keywords, b.Keywords) && + string.Equals(a.Template, b.Template, StringComparison.Ordinal) && + string.Equals(a.Description, b.Description, StringComparison.Ordinal); + + private static bool KeywordsEqual(long[] a, long[] b) + { + if (a.Length == 0 && b.Length == 0) { return true; } + + var setA = new HashSet(a); + var setB = new HashSet(b); + + return setA.SetEquals(setB); + } + + private static IReadOnlyList MergeEvents( + IEnumerable events, + string canonicalProviderName, + string databasePath) + { + var seen = new Dictionary(); + + foreach (var evt in events) + { + var identity = new EventIdentity(evt.Id, evt.Version, evt.LogName); + + if (!seen.TryGetValue(identity, out var existing)) + { + seen[identity] = evt; + continue; + } + + if (!EventsAreEquivalent(existing, evt)) + { + throw new DatabaseUpgradeException( + databasePath, + $"Provider '{canonicalProviderName}' has conflicting Event rows for Id={evt.Id}, " + + $"Version={evt.Version}, LogName='{evt.LogName}'. " + + $"Cannot merge case-insensitive duplicates with different Level/Opcode/Task/Keywords/Template/Description values."); + } + } + + return seen.Values.ToList(); + } + + private static ProviderDetails MergeGroup(List group, string databasePath) + { + var canonicalName = group[0].ProviderName; + + return new ProviderDetails + { + ProviderName = canonicalName, + Messages = MergeMessages(group.SelectMany(r => r.Messages), canonicalName, databasePath), + Parameters = MergeMessages(group.SelectMany(r => r.Parameters), canonicalName, databasePath), + Events = MergeEvents(group.SelectMany(r => r.Events), canonicalName, databasePath), + Keywords = MergeStringDictionary( + group.Select(r => r.Keywords), + canonicalName, + databasePath, + "Keywords", + key => key.ToString()), + Opcodes = MergeStringDictionary( + group.Select(r => r.Opcodes), + canonicalName, + databasePath, + "Opcodes", + key => key.ToString()), + Tasks = MergeStringDictionary( + group.Select(r => r.Tasks), + canonicalName, + databasePath, + "Tasks", + key => key.ToString()), + ResolvedFromOwningPublisher = MergeResolvedFromOwningPublisher( + group.Select(r => r.ResolvedFromOwningPublisher), + canonicalName, + databasePath) + }; + } + + private static IReadOnlyList MergeMessages( + IEnumerable messages, + string canonicalProviderName, + string databasePath) + { + var seen = new Dictionary(); + + foreach (var message in messages) + { + var identity = new MessageIdentity(message.ShortId, message.RawId, message.LogLink, message.Tag); + + if (!seen.TryGetValue(identity, out var existing)) + { + seen[identity] = message; + continue; + } + + if (!MessagesAreEquivalent(existing, message)) + { + throw new DatabaseUpgradeException( + databasePath, + $"Provider '{canonicalProviderName}' has conflicting Message rows for ShortId={message.ShortId}, " + + $"RawId={message.RawId}, LogLink='{message.LogLink}', Tag='{message.Tag}'. " + + $"Cannot merge case-insensitive duplicates with different Text or Template values."); + } + } + + return seen.Values.ToList(); + } + + private static string? MergeResolvedFromOwningPublisher( + IEnumerable values, + string canonicalProviderName, + string databasePath) + { + string? winner = null; + + foreach (var value in values) + { + if (string.IsNullOrEmpty(value)) { continue; } + + if (winner is null) + { + winner = value; + continue; + } + + if (!string.Equals(winner, value, StringComparison.Ordinal)) + { + throw new DatabaseUpgradeException( + databasePath, + $"Provider '{canonicalProviderName}' has conflicting ResolvedFromOwningPublisher values: " + + $"'{winner}' vs '{value}'."); + } + } + + return winner; + } + + private static IDictionary MergeStringDictionary( + IEnumerable> dictionaries, + string canonicalProviderName, + string databasePath, + string memberName, + Func keyDescriber) + where TKey : notnull + { + var merged = new Dictionary(); + + foreach (var dict in dictionaries) + { + foreach (var (key, value) in dict) + { + if (!merged.TryGetValue(key, out var existing)) + { + merged[key] = value; + + continue; + } + + if (!string.Equals(existing, value, StringComparison.Ordinal)) + { + throw new DatabaseUpgradeException( + databasePath, + $"Provider '{canonicalProviderName}' has conflicting {memberName} entries for key {keyDescriber(key)}: " + + $"'{existing}' vs '{value}'."); + } + } + } + + return merged; + } + + private static bool MessagesAreEquivalent(MessageModel a, MessageModel b) => + string.Equals(a.Text, b.Text, StringComparison.Ordinal) && + string.Equals(a.Template, b.Template, StringComparison.Ordinal); + + private readonly record struct MessageIdentity(short ShortId, long RawId, string? LogLink, string? Tag); + + private readonly record struct EventIdentity(long Id, byte Version, string? LogName); +} diff --git a/src/EventLogExpert.Eventing/Providers/ProviderDetails.cs b/src/EventLogExpert.Eventing/Providers/ProviderDetails.cs index 8b5d888f..76c4115f 100644 --- a/src/EventLogExpert.Eventing/Providers/ProviderDetails.cs +++ b/src/EventLogExpert.Eventing/Providers/ProviderDetails.cs @@ -2,7 +2,6 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Models; -using System.ComponentModel.DataAnnotations.Schema; namespace EventLogExpert.Eventing.Providers; @@ -24,15 +23,19 @@ public IReadOnlyList Events } } - /// True when no provider metadata of any kind is loaded (legacy or modern). - /// Used by resolvers to distinguish "provider not found" from "provider known but no matching message". + /// True when no provider metadata of any kind is loaded (legacy or modern) AND + /// no channel-owner fallback has populated this row. Used by resolvers to distinguish + /// "provider not found" from "provider known but no matching message". A non-null + /// means a channel-owner fallback succeeded + /// (even if the metadata collections happen to be empty), so this row is NOT empty. public bool IsEmpty => Events.Count == 0 && Messages.Count == 0 && Keywords.Count == 0 && Opcodes.Count == 0 && Tasks.Count == 0 && - !Parameters.Any(); + !Parameters.Any() && + ResolvedFromOwningPublisher is null; public IDictionary Keywords { get; set; } = new Dictionary(); @@ -58,11 +61,12 @@ public IReadOnlyList Messages /// When the provider name on the event was actually a channel path that the resolver /// followed back to its owning publisher, this records the publisher name that the /// metadata/messages were ultimately loaded from. Null when no channel-owner fallback - /// was used. Diagnostic only — does not participate in any cache key or equality check, - /// and is excluded from the EF Core schema so existing provider databases are unaffected. + /// was used. Does not participate in any cache key or equality check, but does + /// contribute to so a fallback-resolved row is treated as + /// non-empty even if its metadata collections happen to be empty. + /// Persisted as of database schema V4. /// - [NotMapped] - public string? ResolvedFromOwningPublisher { get; set; } // TODO: Should probably be mapped in the DB upgrade + public string? ResolvedFromOwningPublisher { get; set; } public IDictionary Tasks { get; set; } = new Dictionary(); From ae4e4f5339561fb47dc8290a50a9c05845bb2723 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Wed, 29 Apr 2026 22:12:47 -0500 Subject: [PATCH 02/31] Distinguish unrecognized vs obsolete provider DB formats in EventResolver --- .../EventResolvers/EventResolver.cs | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/src/EventLogExpert.Eventing/EventResolvers/EventResolver.cs b/src/EventLogExpert.Eventing/EventResolvers/EventResolver.cs index 459b7c53..b500776b 100644 --- a/src/EventLogExpert.Eventing/EventResolvers/EventResolver.cs +++ b/src/EventLogExpert.Eventing/EventResolvers/EventResolver.cs @@ -197,6 +197,7 @@ private void LoadDatabases(IEnumerable databasePaths) _dbContexts = []; var databasesToLoad = SortDatabases(databasePaths); + var unknownDbs = new List(); var obsoleteDbs = new List(); var newContexts = new List(); @@ -208,9 +209,17 @@ private void LoadDatabases(IEnumerable databasePaths) } var providerDb = new EventProviderDbContext(file, true, Logger); - var (needsv2, needsv3) = providerDb.IsUpgradeNeeded(); + var state = providerDb.IsUpgradeNeeded(); - if (needsv2 || needsv3) + if (state.CurrentVersion == ProviderDatabaseSchemaVersion.Unknown) + { + unknownDbs.Add(file); + providerDb.Dispose(); + + continue; + } + + if (state.NeedsUpgrade) { obsoleteDbs.Add(file); providerDb.Dispose(); @@ -224,18 +233,30 @@ private void LoadDatabases(IEnumerable databasePaths) _dbContexts = [.. newContexts]; - if (obsoleteDbs.Count > 0) + if (unknownDbs.Count <= 0 && obsoleteDbs.Count <= 0) { return; } + + foreach (var db in _dbContexts) { - foreach (var db in _dbContexts) - { - db.Dispose(); - } + db.Dispose(); + } - _dbContexts = []; + _dbContexts = []; + + var messageParts = new List(); + + if (unknownDbs.Count > 0) + { + messageParts.Add("Unrecognized DB format (file may be corrupt or from a newer or incompatible version): " + + string.Join(' ', unknownDbs.Select(Path.GetFileName))); + } - throw new InvalidOperationException("Obsolete DB format: " + + if (obsoleteDbs.Count > 0) + { + messageParts.Add("Obsolete DB format (upgrade required): " + string.Join(' ', obsoleteDbs.Select(Path.GetFileName))); } + + throw new InvalidOperationException(string.Join(" | ", messageParts)); } private void RemoveGateIfSame(string providerName, Lazy gate) => @@ -283,9 +304,7 @@ private bool TryResolveFromDatabase(string providerName) foreach (var dbContext in _dbContexts) { - var details = dbContext.ProviderDetails.FirstOrDefault(p => - EF.Functions.Collate(p.ProviderName, "NOCASE") == - EF.Functions.Collate(providerName, "NOCASE")); + var details = dbContext.ProviderDetails.FirstOrDefault(p => p.ProviderName == providerName); if (details is null) { continue; } From 6eff73daf7056c639b940322bb58e980630750c7 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Wed, 29 Apr 2026 22:14:33 -0500 Subject: [PATCH 03/31] Reject obsolete and unrecognized provider DBs in EventDbTool commands --- .../DiffDatabaseCommandTests.cs | 118 ++++++++++++ .../EventLogExpert.EventDbTool.Tests.csproj | 13 ++ .../GlobalUsings.cs | 1 + .../ProviderSourceTests.cs | 178 ++++++++++++++++++ .../TestUtils/Constants/Constants.Database.cs | 12 ++ .../TestUtils/DatabaseTestUtils.cs | 139 ++++++++++++++ .../DiffDatabaseCommand.cs | 28 +-- .../MergeDatabaseCommand.cs | 29 ++- .../Properties/AssemblyInfo.cs | 6 + .../ProviderSource.cs | 75 +++++++- .../UpgradeDatabaseCommand.cs | 5 +- src/EventLogExpert.slnx | 1 + 12 files changed, 583 insertions(+), 22 deletions(-) create mode 100644 src/EventLogExpert.EventDbTool.Tests/DiffDatabaseCommandTests.cs create mode 100644 src/EventLogExpert.EventDbTool.Tests/EventLogExpert.EventDbTool.Tests.csproj create mode 100644 src/EventLogExpert.EventDbTool.Tests/GlobalUsings.cs create mode 100644 src/EventLogExpert.EventDbTool.Tests/ProviderSourceTests.cs create mode 100644 src/EventLogExpert.EventDbTool.Tests/TestUtils/Constants/Constants.Database.cs create mode 100644 src/EventLogExpert.EventDbTool.Tests/TestUtils/DatabaseTestUtils.cs create mode 100644 src/EventLogExpert.EventDbTool/Properties/AssemblyInfo.cs diff --git a/src/EventLogExpert.EventDbTool.Tests/DiffDatabaseCommandTests.cs b/src/EventLogExpert.EventDbTool.Tests/DiffDatabaseCommandTests.cs new file mode 100644 index 00000000..d2fbb0ef --- /dev/null +++ b/src/EventLogExpert.EventDbTool.Tests/DiffDatabaseCommandTests.cs @@ -0,0 +1,118 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.EventDbTool.Tests.TestUtils; +using EventLogExpert.EventDbTool.Tests.TestUtils.Constants; +using EventLogExpert.Eventing.EventProviderDatabase; +using EventLogExpert.Eventing.Helpers; +using NSubstitute; + +namespace EventLogExpert.EventDbTool.Tests; + +public sealed class DiffDatabaseCommandTests : IDisposable +{ + private readonly List _tempPaths = []; + + [Fact] + public void DiffDatabase_PreservesResolvedFromOwningPublisher() + { + // Arrange — first source has Shared, second source has Shared and Second. + // The diff output should contain only Second, with its ResolvedFromOwningPublisher value + // intact. This locks in the Add(details) projection in DiffDatabase: a hand-projection that + // forgot ResolvedFromOwningPublisher (or any future ProviderDetails member) would fail this. + var first = CreateTempDb(); + var second = CreateTempDb(); + var output = CreateTempDb(); + + DatabaseTestUtils.CreateV4Database(first, + DatabaseTestUtils.BuildProviderDetails(Constants.SharedProviderName)); + + DatabaseTestUtils.CreateV4Database(second, + DatabaseTestUtils.BuildProviderDetails(Constants.SharedProviderName), + DatabaseTestUtils.BuildProviderDetails(Constants.SecondProviderName, Constants.OwningPublisherName)); + + File.Delete(output); + + var logger = Substitute.For(); + + // Act + new DiffDatabaseCommand(logger).DiffDatabase(first, second, output); + + // Assert + Assert.True(File.Exists(output), "Diff should have created the output database."); + + using var verify = new EventProviderDbContext(output, true); + var rows = verify.ProviderDetails.ToList(); + var copied = Assert.Single(rows); + Assert.Equal(Constants.SecondProviderName, copied.ProviderName); + Assert.Equal(Constants.OwningPublisherName, copied.ResolvedFromOwningPublisher); + } + + [Fact] + public void DiffDatabase_WithObsoleteSecondSource_AbortsWithoutCreatingOutput() + { + // Arrange — V3 schema in second source. Diff aborts on stale-but-known schema too. + var first = CreateTempDb(); + var second = CreateTempDb(); + var output = CreateTempDb(); + + DatabaseTestUtils.CreateV4Database(first, + DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName)); + DatabaseTestUtils.CreateV3Database(second); + + File.Delete(output); + + var logger = Substitute.For(); + + // Act + new DiffDatabaseCommand(logger).DiffDatabase(first, second, output); + + // Assert + Assert.False(File.Exists(output), "Diff must not create an output database when a source is obsolete."); + logger.Received().Error(Arg.Is(handler => + handler.ToString().Contains("schema v3") && handler.ToString().Contains(second) && handler.ToString().Contains("upgrade"))); + } + + [Fact] + public void DiffDatabase_WithUnknownFirstSource_AbortsWithoutCreatingOutput() + { + // Arrange — without the upfront ValidateSourceSchemas check, the schema-rejected first + // source would silently yield zero providers, the second source's full contents would be + // copied to output as "missing from first", and the user would get a wrong result with + // only a single error log line buried earlier in the output. + var first = CreateTempDb(); + var second = CreateTempDb(); + var output = CreateTempDb(); + + DatabaseTestUtils.CreateUnknownShapeDatabase(first); + DatabaseTestUtils.CreateV4Database(second, + DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName)); + + File.Delete(output); + + var logger = Substitute.For(); + + // Act + new DiffDatabaseCommand(logger).DiffDatabase(first, second, output); + + // Assert + Assert.False(File.Exists(output), "Diff must not create an output database when a source is invalid."); + logger.Received().Error(Arg.Is(handler => + handler.ToString().Contains("unrecognized schema") && handler.ToString().Contains(first))); + } + + public void Dispose() + { + foreach (var path in _tempPaths) + { + DatabaseTestUtils.DeleteDatabaseFile(path); + } + } + + private string CreateTempDb() + { + var path = DatabaseTestUtils.CreateTempPath(); + _tempPaths.Add(path); + return path; + } +} diff --git a/src/EventLogExpert.EventDbTool.Tests/EventLogExpert.EventDbTool.Tests.csproj b/src/EventLogExpert.EventDbTool.Tests/EventLogExpert.EventDbTool.Tests.csproj new file mode 100644 index 00000000..fe66b9dd --- /dev/null +++ b/src/EventLogExpert.EventDbTool.Tests/EventLogExpert.EventDbTool.Tests.csproj @@ -0,0 +1,13 @@ + + + + false + true + + + + + + + + diff --git a/src/EventLogExpert.EventDbTool.Tests/GlobalUsings.cs b/src/EventLogExpert.EventDbTool.Tests/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/src/EventLogExpert.EventDbTool.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/src/EventLogExpert.EventDbTool.Tests/ProviderSourceTests.cs b/src/EventLogExpert.EventDbTool.Tests/ProviderSourceTests.cs new file mode 100644 index 00000000..60155db4 --- /dev/null +++ b/src/EventLogExpert.EventDbTool.Tests/ProviderSourceTests.cs @@ -0,0 +1,178 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.EventDbTool.Tests.TestUtils; +using EventLogExpert.EventDbTool.Tests.TestUtils.Constants; +using EventLogExpert.Eventing.Helpers; +using NSubstitute; + +namespace EventLogExpert.EventDbTool.Tests; + +public sealed class ProviderSourceTests : IDisposable +{ + private readonly List _tempDirectories = []; + private readonly List _tempPaths = []; + + public void Dispose() + { + foreach (var path in _tempPaths) + { + DatabaseTestUtils.DeleteDatabaseFile(path); + } + + foreach (var dir in _tempDirectories) + { + DatabaseTestUtils.DeleteDirectoryRecursive(dir); + } + } + + [Fact] + public void ValidateSourceSchemas_WithCurrentSchemaDatabase_ReturnsTrue() + { + // Arrange + var dbPath = CreateTempDb(); + DatabaseTestUtils.CreateV4Database(dbPath, DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName)); + var logger = Substitute.For(); + + // Act + var result = ProviderSource.ValidateSourceSchemas(dbPath, logger); + + // Assert + Assert.True(result); + logger.DidNotReceive().Error(Arg.Any()); + } + + [Fact] + public void ValidateSourceSchemas_WithDirectoryContainingAnyInvalidDatabase_ReturnsFalse() + { + // Arrange — one valid, one Unknown shape. The whole directory must fail. + var dir = CreateTempDir(); + var good = Path.Combine(dir, "good.db"); + var bad = Path.Combine(dir, "bad.db"); + DatabaseTestUtils.CreateV4Database(good, DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName)); + DatabaseTestUtils.CreateUnknownShapeDatabase(bad); + var logger = Substitute.For(); + + // Act + var result = ProviderSource.ValidateSourceSchemas(dir, logger); + + // Assert + Assert.False(result); + logger.Received(1).Error(Arg.Is(handler => + handler.ToString().Contains("unrecognized schema") && + handler.ToString().Contains(bad))); + } + + [Fact] + public void ValidateSourceSchemas_WithDirectoryMixingDbAndEvtx_OnlyValidatesDb() + { + // Arrange — only the .db files participate in schema validation; the evtx is ignored. + var dir = CreateTempDir(); + var dbPath = Path.Combine(dir, "valid.db"); + var evtxPath = Path.Combine(dir, "ignored.evtx"); + DatabaseTestUtils.CreateV4Database(dbPath, DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName)); + File.WriteAllBytes(evtxPath, new byte[] { 0x00 }); + var logger = Substitute.For(); + + // Act + var result = ProviderSource.ValidateSourceSchemas(dir, logger); + + // Assert + Assert.True(result); + logger.DidNotReceive().Error(Arg.Any()); + } + + [Fact] + public void ValidateSourceSchemas_WithDirectoryOfValidDatabases_ReturnsTrue() + { + // Arrange + var dir = CreateTempDir(); + var first = Path.Combine(dir, "first.db"); + var second = Path.Combine(dir, "second.db"); + DatabaseTestUtils.CreateV4Database(first, DatabaseTestUtils.BuildProviderDetails(Constants.FirstProviderName)); + DatabaseTestUtils.CreateV4Database(second, DatabaseTestUtils.BuildProviderDetails(Constants.SecondProviderName)); + var logger = Substitute.For(); + + // Act + var result = ProviderSource.ValidateSourceSchemas(dir, logger); + + // Assert + Assert.True(result); + logger.DidNotReceive().Error(Arg.Any()); + } + + [Fact] + public void ValidateSourceSchemas_WithEvtxFile_IsSkipped() + { + // Arrange — .evtx files have no schema concept; ValidateSourceSchemas should ignore them + // entirely (the evtx-specific load path goes through MtaProviderSource elsewhere). + var evtxPath = CreateTempPath(".evtx"); + File.WriteAllBytes(evtxPath, new byte[] { 0x00 }); + var logger = Substitute.For(); + + // Act + var result = ProviderSource.ValidateSourceSchemas(evtxPath, logger); + + // Assert + Assert.True(result); + logger.DidNotReceive().Error(Arg.Any()); + } + + [Fact] + public void ValidateSourceSchemas_WithObsoleteV3Database_ReturnsFalseAndLogsError() + { + // Arrange + var dbPath = CreateTempDb(); + DatabaseTestUtils.CreateV3Database(dbPath); + var logger = Substitute.For(); + + // Act + var result = ProviderSource.ValidateSourceSchemas(dbPath, logger); + + // Assert + Assert.False(result); + logger.Received(1).Error(Arg.Is(handler => + handler.ToString().Contains("schema v3") && + handler.ToString().Contains(dbPath) && + handler.ToString().Contains("upgrade"))); + } + + [Fact] + public void ValidateSourceSchemas_WithUnknownSchemaDatabase_ReturnsFalseAndLogsError() + { + // Arrange + var dbPath = CreateTempDb(); + DatabaseTestUtils.CreateUnknownShapeDatabase(dbPath); + var logger = Substitute.For(); + + // Act + var result = ProviderSource.ValidateSourceSchemas(dbPath, logger); + + // Assert + Assert.False(result); + logger.Received(1).Error(Arg.Is(handler => + handler.ToString().Contains("unrecognized schema") && + handler.ToString().Contains(dbPath))); + } + + private string CreateTempDb() + { + var path = DatabaseTestUtils.CreateTempPath(); + _tempPaths.Add(path); + return path; + } + + private string CreateTempDir() + { + var dir = DatabaseTestUtils.CreateTempDirectory(); + _tempDirectories.Add(dir); + return dir; + } + + private string CreateTempPath(string extension) + { + var path = DatabaseTestUtils.CreateTempPath(extension); + _tempPaths.Add(path); + return path; + } +} diff --git a/src/EventLogExpert.EventDbTool.Tests/TestUtils/Constants/Constants.Database.cs b/src/EventLogExpert.EventDbTool.Tests/TestUtils/Constants/Constants.Database.cs new file mode 100644 index 00000000..0996e7cc --- /dev/null +++ b/src/EventLogExpert.EventDbTool.Tests/TestUtils/Constants/Constants.Database.cs @@ -0,0 +1,12 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.EventDbTool.Tests.TestUtils.Constants; + +public sealed partial class Constants +{ + public const string FirstProviderName = "First-Provider"; + public const string SecondProviderName = "Second-Provider"; + public const string SharedProviderName = "Shared-Provider"; + public const string OwningPublisherName = "Owning-Publisher-Name"; +} diff --git a/src/EventLogExpert.EventDbTool.Tests/TestUtils/DatabaseTestUtils.cs b/src/EventLogExpert.EventDbTool.Tests/TestUtils/DatabaseTestUtils.cs new file mode 100644 index 00000000..e34975fa --- /dev/null +++ b/src/EventLogExpert.EventDbTool.Tests/TestUtils/DatabaseTestUtils.cs @@ -0,0 +1,139 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.EventProviderDatabase; +using EventLogExpert.Eventing.Providers; +using Microsoft.Data.Sqlite; + +namespace EventLogExpert.EventDbTool.Tests.TestUtils; + +internal static class DatabaseTestUtils +{ + public static ProviderDetails BuildProviderDetails(string name, string? resolvedFromOwningPublisher = null) => new() + { + ProviderName = name, + Messages = [], + Parameters = [], + Events = [], + Keywords = new Dictionary(), + Opcodes = new Dictionary(), + Tasks = new Dictionary(), + ResolvedFromOwningPublisher = resolvedFromOwningPublisher + }; + + public static string CreateTempDirectory() + { + var path = Path.Combine(Path.GetTempPath(), $"eventdbtool_test_{Guid.NewGuid()}"); + Directory.CreateDirectory(path); + return path; + } + + public static string CreateTempPath(string extension = ".db") => + Path.Combine(Path.GetTempPath(), $"eventdbtool_test_{Guid.NewGuid()}{extension}"); + + public static void CreateUnknownShapeDatabase(string dbPath) + { + using var connection = new SqliteConnection($"Data Source={dbPath}"); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = + "CREATE TABLE \"ProviderDetails\" (" + + "\"ProviderName\" TEXT NOT NULL CONSTRAINT \"PK_ProviderDetails\" PRIMARY KEY, " + + "\"Messages\" BLOB NOT NULL, " + + "\"Parameters\" TEXT NOT NULL, " + + "\"Events\" BLOB NOT NULL, " + + "\"Keywords\" BLOB NOT NULL, " + + "\"Opcodes\" BLOB NOT NULL, " + + "\"Tasks\" BLOB NOT NULL)"; + cmd.ExecuteNonQuery(); + } + + public static void CreateV3Database(string dbPath) + { + using var connection = new SqliteConnection($"Data Source={dbPath}"); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = + "CREATE TABLE \"ProviderDetails\" (" + + "\"ProviderName\" TEXT NOT NULL CONSTRAINT \"PK_ProviderDetails\" PRIMARY KEY, " + + "\"Messages\" BLOB NOT NULL, " + + "\"Parameters\" BLOB NOT NULL, " + + "\"Events\" BLOB NOT NULL, " + + "\"Keywords\" BLOB NOT NULL, " + + "\"Opcodes\" BLOB NOT NULL, " + + "\"Tasks\" BLOB NOT NULL)"; + cmd.ExecuteNonQuery(); + } + + public static void CreateV4Database(string dbPath, params ProviderDetails[] providers) + { + using var context = new EventProviderDbContext(dbPath, false); + + foreach (var provider in providers) + { + context.ProviderDetails.Add(provider); + } + + context.SaveChanges(); + } + + public static void DeleteDatabaseFile(string path) + { + try + { + if (!File.Exists(path)) { return; } + + SqliteConnection.ClearAllPools(); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + for (var i = 0; i < 10; i++) + { + try + { + File.Delete(path); + break; + } + catch (IOException) + { + Thread.Sleep(200); + } + } + } + catch + { + // Best effort cleanup. + } + } + + public static void DeleteDirectoryRecursive(string path) + { + try + { + if (!Directory.Exists(path)) { return; } + + SqliteConnection.ClearAllPools(); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + for (var i = 0; i < 10; i++) + { + try + { + Directory.Delete(path, recursive: true); + break; + } + catch (IOException) + { + Thread.Sleep(200); + } + } + } + catch + { + // Best effort cleanup. + } + } +} diff --git a/src/EventLogExpert.EventDbTool/DiffDatabaseCommand.cs b/src/EventLogExpert.EventDbTool/DiffDatabaseCommand.cs index bb3a0450..9ce0d97a 100644 --- a/src/EventLogExpert.EventDbTool/DiffDatabaseCommand.cs +++ b/src/EventLogExpert.EventDbTool/DiffDatabaseCommand.cs @@ -56,11 +56,22 @@ public static Command GetCommand() return diffDatabaseCommand; } - private void DiffDatabase(string firstSource, string secondSource, string newDb) + internal void DiffDatabase(string firstSource, string secondSource, string newDb) { if (!ProviderSource.TryValidate(firstSource, Logger)) { return; } + if (!ProviderSource.TryValidate(secondSource, Logger)) { return; } + // Diff treats the first source as the authoritative skip baseline for the second source. + // If any .db file in either source is at an unrecognized or stale schema, ProviderSource + // would log the error and silently yield zero providers from that file, which would cause + // every provider in the second source to be (incorrectly) flagged as "missing from first" + // and copied to the new diff db. Validate up front so a single corrupt or obsolete .db + // anywhere in either source aborts the whole operation rather than producing a wrong result. + if (!ProviderSource.ValidateSourceSchemas(firstSource, Logger)) { return; } + + if (!ProviderSource.ValidateSourceSchemas(secondSource, Logger)) { return; } + if (File.Exists(newDb)) { Logger.Error($"File already exists: {newDb}"); @@ -97,16 +108,11 @@ private void DiffDatabase(string firstSource, string secondSource, string newDb) newDbContext ??= new EventProviderDbContext(newDb, false, Logger); - newDbContext.ProviderDetails.Add(new ProviderDetails - { - ProviderName = details.ProviderName, - Events = details.Events, - Parameters = details.Parameters, - Keywords = details.Keywords, - Messages = details.Messages, - Opcodes = details.Opcodes, - Tasks = details.Tasks - }); + // ProviderSource yields fresh, non-tracked entities — Add(details) is safe and + // avoids hand-projecting fields (which silently dropped ResolvedFromOwningPublisher + // and would silently drop any future ProviderDetails member added without updating + // this projection). Matches the pattern in MergeDatabaseCommand. + newDbContext.ProviderDetails.Add(details); providersCopied.Add(details); } diff --git a/src/EventLogExpert.EventDbTool/MergeDatabaseCommand.cs b/src/EventLogExpert.EventDbTool/MergeDatabaseCommand.cs index cfede9b8..d66a303e 100644 --- a/src/EventLogExpert.EventDbTool/MergeDatabaseCommand.cs +++ b/src/EventLogExpert.EventDbTool/MergeDatabaseCommand.cs @@ -80,6 +80,24 @@ private void MergeDatabase(string source, string targetFile, bool overwriteProvi using var targetContext = new EventProviderDbContext(targetFile, false, Logger); + // Reject pre-V4 target databases up front. Without this gate the merge would happily + // run against an obsolete schema and either silently mis-handle case-insensitive PK + // matching or fail at the SaveChanges step. The user is expected to run the + // `upgrade` command first. + var schemaState = targetContext.IsUpgradeNeeded(); + + if (schemaState.CurrentVersion == ProviderDatabaseSchemaVersion.Unknown) + { + Logger.Error($"Target database '{targetFile}' has an unrecognized schema. The file may be corrupt or from a newer or incompatible version of EventLogExpert. Delete or replace the file."); + return; + } + + if (schemaState.NeedsUpgrade) + { + Logger.Error($"Target database '{targetFile}' is at schema v{schemaState.CurrentVersion} but v{ProviderDatabaseSchemaVersion.Current} is required. Run the 'upgrade' command first."); + return; + } + // Query the overlap in the database by chunking sourceNames into IN-clause batches, // rather than pulling every target ProviderName into memory. Same chunk size as the // delete loop below to stay below SQLite's default parameter limit (999). @@ -93,17 +111,12 @@ private void MergeDatabase(string source, string targetFile, bool overwriteProvi .Take(ProviderSource.MaxInClauseParameters) .ToList(); - // Use NOCASE collation on the column side so the IN-clause matches case-insensitively, - // matching the case-insensitive semantics used elsewhere in the tool (sourceNames is - // an OrdinalIgnoreCase HashSet). Without this, a source name "Microsoft-Foo" would - // not match a target row stored as "microsoft-foo", causing duplicates on copy or - // missed deletes when --overwrite is used. Using NOCASE on the column prevents PK - // index use for this query (table scan), but for an admin one-shot merge the - // correctness trade-off is acceptable. + // The ProviderName column uses NOCASE collation as of schema v4, so a plain + // IN-clause matches case-insensitively while still using the PK index. targetMatchingNames.AddRange( targetContext.ProviderDetails .AsNoTracking() - .Where(p => chunk.Contains(EF.Functions.Collate(p.ProviderName, "NOCASE"))) + .Where(p => chunk.Contains(p.ProviderName)) .Select(p => p.ProviderName)); } diff --git a/src/EventLogExpert.EventDbTool/Properties/AssemblyInfo.cs b/src/EventLogExpert.EventDbTool/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..6e18265f --- /dev/null +++ b/src/EventLogExpert.EventDbTool/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("EventLogExpert.EventDbTool.Tests")] diff --git a/src/EventLogExpert.EventDbTool/ProviderSource.cs b/src/EventLogExpert.EventDbTool/ProviderSource.cs index 09402eb6..0da76bfa 100644 --- a/src/EventLogExpert.EventDbTool/ProviderSource.cs +++ b/src/EventLogExpert.EventDbTool/ProviderSource.cs @@ -112,6 +112,46 @@ public static bool TryValidate(string path, ITraceLogger logger) return false; } + /// + /// Returns true when every .db file enumerated from is at the current + /// schema. When any file is at an unrecognized or stale schema, an actionable error is logged for that + /// file and the method returns false. Use this before workflows where a schema-rejected source file + /// silently producing zero providers would corrupt the result (e.g., diff, where the first source + /// defines the baseline of what to skip from the second source). Single-file workflows that skip + /// individual unreadable sources can rely on the per-file gate inside + /// and friends, which logs and yields no rows for the failing file. .evtx files have no schema + /// and are ignored here. + /// + public static bool ValidateSourceSchemas(string path, ITraceLogger logger) + { + var allOk = true; + + foreach (var file in EnumerateSourceFiles(path, logger)) + { + if (!string.Equals(Path.GetExtension(file), DbExtension, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + try + { + using var providerContext = new EventProviderDbContext(file, true, logger); + + if (!IsSourceSchemaCurrent(providerContext, file, logger)) + { + allOk = false; + } + } + catch (DbException ex) + { + logger.Error($"Cannot open source database '{file}': {ex.Message}"); + allOk = false; + } + } + + return allOk; + } + /// /// Expands into the ordered list of source files: a single .db or .evtx /// when given a file; or all *.db files (sorted) followed by all *.evtx files (sorted) when given a @@ -174,6 +214,9 @@ private static IEnumerable LoadDetailsFromFile( try { using var context = new EventProviderDbContext(file, true, logger); + + if (!IsSourceSchemaCurrent(context, file, logger)) { return []; } + context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; // Filter by name without mutating `seen` so that a subsequent catch does not @@ -239,7 +282,9 @@ private static IEnumerable LoadNamesFromFile(string file, ITraceLogger l try { using var providerContext = new EventProviderDbContext(file, true, logger); - return providerContext.ProviderDetails.AsNoTracking().Select(p => p.ProviderName).ToList(); + + return !IsSourceSchemaCurrent(providerContext, file, logger) ? [] : + providerContext.ProviderDetails.AsNoTracking().Select(p => p.ProviderName).ToList(); } catch (DbException ex) { @@ -258,6 +303,34 @@ private static IEnumerable LoadNamesFromFile(string file, ITraceLogger l return []; } + /// + /// Returns true if points to a database at the current schema + /// version. Otherwise logs an explicit error and returns false. Without this gate, EF would + /// query the source with the V4 model against a V3 schema and either throw a confusing + /// DbException for the missing column or — in the names-only path — silently discover + /// names that subsequently fail to materialize as full provider rows. Distinguishes the + /// "unrecognized schema" case from the "known but stale" case so the error message is actionable. + /// + private static bool IsSourceSchemaCurrent(EventProviderDbContext context, string file, ITraceLogger logger) + { + var state = context.IsUpgradeNeeded(); + + if (!state.NeedsUpgrade) { return true; } + + if (state.CurrentVersion == ProviderDatabaseSchemaVersion.Unknown) + { + logger.Error( + $"Source database '{file}' has an unrecognized schema. The file may be corrupt or from a newer or incompatible version of EventLogExpert. Delete or replace the file."); + } + else + { + logger.Error( + $"Source database '{file}' is at schema v{state.CurrentVersion} but v{ProviderDatabaseSchemaVersion.Current} is required. Run the 'upgrade' command on the source first."); + } + + return false; + } + private static IEnumerable LoadProvidersIterator( string path, ITraceLogger logger, diff --git a/src/EventLogExpert.EventDbTool/UpgradeDatabaseCommand.cs b/src/EventLogExpert.EventDbTool/UpgradeDatabaseCommand.cs index 1df8f0ec..0c489cb4 100644 --- a/src/EventLogExpert.EventDbTool/UpgradeDatabaseCommand.cs +++ b/src/EventLogExpert.EventDbTool/UpgradeDatabaseCommand.cs @@ -49,11 +49,12 @@ private void UpgradeDatabase(string file) using var dbContext = new EventProviderDbContext(file, false, Logger); - var (needsV2, needsV3) = dbContext.IsUpgradeNeeded(); + var state = dbContext.IsUpgradeNeeded(); - if (!(needsV2 || needsV3)) + if (!state.NeedsUpgrade) { Logger.Info($"This database does not need to be upgraded."); + return; } diff --git a/src/EventLogExpert.slnx b/src/EventLogExpert.slnx index 960d9585..bb5e4a76 100644 --- a/src/EventLogExpert.slnx +++ b/src/EventLogExpert.slnx @@ -7,6 +7,7 @@ + From 8b07f3cbc6364a7911d384111ba051dc6658b5d5 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Wed, 29 Apr 2026 23:57:41 -0500 Subject: [PATCH 04/31] Add database entry, status, and import result models --- src/EventLogExpert.UI/Enums.cs | 9 +++++++++ src/EventLogExpert.UI/Models/DatabaseEntry.cs | 6 ++++++ src/EventLogExpert.UI/Models/ImportFailure.cs | 6 ++++++ src/EventLogExpert.UI/Models/ImportResult.cs | 6 ++++++ 4 files changed, 27 insertions(+) create mode 100644 src/EventLogExpert.UI/Models/DatabaseEntry.cs create mode 100644 src/EventLogExpert.UI/Models/ImportFailure.cs create mode 100644 src/EventLogExpert.UI/Models/ImportResult.cs diff --git a/src/EventLogExpert.UI/Enums.cs b/src/EventLogExpert.UI/Enums.cs index e0c25a27..d23b0fb6 100644 --- a/src/EventLogExpert.UI/Enums.cs +++ b/src/EventLogExpert.UI/Enums.cs @@ -35,6 +35,15 @@ public enum CopyType Full } +public enum DatabaseStatus +{ + Ready, + UpgradeRequired, + UpgradeFailed, + UnrecognizedSchema, + ObsoleteSchema +} + public enum HighlightColor { None, diff --git a/src/EventLogExpert.UI/Models/DatabaseEntry.cs b/src/EventLogExpert.UI/Models/DatabaseEntry.cs new file mode 100644 index 00000000..9eaf0c3b --- /dev/null +++ b/src/EventLogExpert.UI/Models/DatabaseEntry.cs @@ -0,0 +1,6 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.UI.Models; + +public sealed record DatabaseEntry(string FileName, string FullPath, bool IsEnabled, DatabaseStatus Status); diff --git a/src/EventLogExpert.UI/Models/ImportFailure.cs b/src/EventLogExpert.UI/Models/ImportFailure.cs new file mode 100644 index 00000000..3521461a --- /dev/null +++ b/src/EventLogExpert.UI/Models/ImportFailure.cs @@ -0,0 +1,6 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.UI.Models; + +public sealed record ImportFailure(string FileName, string Reason); diff --git a/src/EventLogExpert.UI/Models/ImportResult.cs b/src/EventLogExpert.UI/Models/ImportResult.cs new file mode 100644 index 00000000..8fb449c9 --- /dev/null +++ b/src/EventLogExpert.UI/Models/ImportResult.cs @@ -0,0 +1,6 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.UI.Models; + +public sealed record ImportResult(int Imported, IReadOnlyList Failures); From 933db26748ed31e193085c3ed57a4695d91732ee Mon Sep 17 00:00:00 2001 From: jschick04 Date: Wed, 29 Apr 2026 23:58:05 -0500 Subject: [PATCH 05/31] Restructure DatabaseService to single-owner model with per-entry import --- .../IDatabaseCollectionProvider.cs | 2 - .../Services/DatabaseServiceTests.cs | 574 ++++++++++++------ .../EnabledDatabaseCollectionProviderTests.cs | 398 ------------ .../Interfaces/IDatabaseService.cs | 16 +- .../Services/DatabaseService.cs | 340 +++++++++-- .../EnabledDatabaseCollectionProvider.cs | 79 --- src/EventLogExpert/App.xaml.cs | 8 - src/EventLogExpert/MainPage.xaml.cs | 21 - src/EventLogExpert/MauiProgram.cs | 8 +- 9 files changed, 705 insertions(+), 741 deletions(-) delete mode 100644 src/EventLogExpert.UI.Tests/Services/EnabledDatabaseCollectionProviderTests.cs delete mode 100644 src/EventLogExpert.UI/Services/EnabledDatabaseCollectionProvider.cs diff --git a/src/EventLogExpert.Eventing/EventResolvers/IDatabaseCollectionProvider.cs b/src/EventLogExpert.Eventing/EventResolvers/IDatabaseCollectionProvider.cs index e6478e6b..cfb218d0 100644 --- a/src/EventLogExpert.Eventing/EventResolvers/IDatabaseCollectionProvider.cs +++ b/src/EventLogExpert.Eventing/EventResolvers/IDatabaseCollectionProvider.cs @@ -8,6 +8,4 @@ namespace EventLogExpert.Eventing.EventResolvers; public interface IDatabaseCollectionProvider { ImmutableList ActiveDatabases { get; } - - void SetActiveDatabases(IEnumerable activeDatabases); } diff --git a/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs b/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs index cdd0bd30..d9031b0c 100644 --- a/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs +++ b/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs @@ -1,365 +1,565 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. +using EventLogExpert.Eventing.Helpers; using EventLogExpert.UI.Interfaces; +using EventLogExpert.UI.Options; using EventLogExpert.UI.Services; using EventLogExpert.UI.Tests.TestUtils.Constants; using NSubstitute; +using System.IO.Compression; namespace EventLogExpert.UI.Tests.Services; -public sealed class DatabaseServiceTests +public sealed class DatabaseServiceTests : IDisposable { + private readonly string _testDirectory; + + public DatabaseServiceTests() + { + _testDirectory = Path.Combine(Path.GetTempPath(), $"DatabaseServiceTests_{Guid.NewGuid()}"); + Directory.CreateDirectory(_testDirectory); + } + [Fact] - public void DisabledDatabases_AfterUpdate_ShouldReturnUpdatedValues() + public void ActiveDatabases_ShouldReturnFullPathsOfEnabledReadyEntriesOnly() { // Arrange - var mockPreferencesProvider = Substitute.For(); - mockPreferencesProvider.DisabledDatabasesPreference.Returns([Constants.InitialDisabled]); + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); + CreateDatabaseFile(databasePath, Constants.TestDb2); + CreateDatabaseFile(databasePath, Constants.TestDb3); - var mockEnabledDatabaseCollectionProvider = Substitute.For(); - mockEnabledDatabaseCollectionProvider.GetEnabledDatabases().Returns([]); + var preferences = Substitute.For(); + preferences.DisabledDatabasesPreference.Returns([Constants.TestDb2]); - var databaseService = CreateDatabaseService( - mockEnabledDatabaseCollectionProvider, - mockPreferencesProvider); + var service = CreateDatabaseService(preferences); + service.MarkStatus(Constants.TestDb3, DatabaseStatus.UpgradeRequired); // Act - databaseService.UpdateDisabledDatabases([Constants.NewDisabled1, Constants.NewDisabled2]); - var result = databaseService.DisabledDatabases.ToList(); + var activeDatabases = service.ActiveDatabases; - // Assert - Assert.Equal(2, result.Count); - Assert.Contains(Constants.NewDisabled1, result); - Assert.Contains(Constants.NewDisabled2, result); + // Assert: only TestDb1 (TestDb2 disabled, TestDb3 not ready) + Assert.Single(activeDatabases); + Assert.Equal(Path.Join(databasePath, Constants.TestDb1), activeDatabases[0]); } [Fact] - public void DisabledDatabases_WhenAccessed_ShouldReturnFromPreferences() + public void Constructor_WhenCalled_ShouldSeedEntriesFromDisk() { // Arrange - var mockPreferencesProvider = Substitute.For(); - mockPreferencesProvider.DisabledDatabasesPreference.Returns([Constants.DisabledDb1, Constants.DisabledDb2]); - - var databaseService = CreateDatabaseService(preferencesProvider: mockPreferencesProvider); + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); + CreateDatabaseFile(databasePath, Constants.TestDb2); // Act - var result = databaseService.DisabledDatabases.ToList(); + var service = CreateDatabaseService(); // Assert - Assert.Equal(2, result.Count); - Assert.Contains(Constants.DisabledDb1, result); - Assert.Contains(Constants.DisabledDb2, result); + Assert.Equal(2, service.Entries.Count); + Assert.Contains(service.Entries, entry => entry.FileName == Constants.TestDb1); + Assert.Contains(service.Entries, entry => entry.FileName == Constants.TestDb2); + Assert.All(service.Entries, entry => Assert.True(entry.IsEnabled)); + Assert.All(service.Entries, entry => Assert.Equal(DatabaseStatus.Ready, entry.Status)); } [Fact] - public void DisabledDatabases_WhenAccessedMultipleTimes_ShouldCacheResult() + public void Constructor_WhenDatabaseDirectoryDoesNotExist_ShouldHaveEmptyEntries() + { + // Arrange (no directory created) + var service = CreateDatabaseService(); + + // Assert + Assert.Empty(service.Entries); + } + + [Fact] + public void Constructor_WhenDisabledFilenameIsCaseDifferent_ShouldStillMarkDisabled() { // Arrange - var mockPreferencesProvider = Substitute.For(); - mockPreferencesProvider.DisabledDatabasesPreference.Returns([Constants.DisabledDb]); + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); - var databaseService = CreateDatabaseService(preferencesProvider: mockPreferencesProvider); + var preferences = Substitute.For(); + preferences.DisabledDatabasesPreference.Returns([Constants.TestDb1.ToUpper()]); // Act - _ = databaseService.DisabledDatabases; - _ = databaseService.DisabledDatabases; - _ = databaseService.DisabledDatabases; + var service = CreateDatabaseService(preferences); // Assert - _ = mockPreferencesProvider.Received(1).DisabledDatabasesPreference; + Assert.Single(service.Entries); + Assert.False(service.Entries[0].IsEnabled); } [Fact] - public void LoadDatabases_WhenCalled_ShouldInvokeLoadedDatabasesChanged() + public void Constructor_WhenNonDbFilesPresent_ShouldOnlyIncludeDbFiles() { // Arrange - var mockEnabledDatabaseCollectionProvider = Substitute.For(); - mockEnabledDatabaseCollectionProvider.GetEnabledDatabases().Returns([Constants.DatabaseA]); + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); + File.WriteAllText(Path.Combine(databasePath, "ignored.txt"), ""); + File.WriteAllText(Path.Combine(databasePath, "ignored.json"), ""); - var databaseService = CreateDatabaseService(mockEnabledDatabaseCollectionProvider); + // Act + var service = CreateDatabaseService(); - IEnumerable? receivedDatabases = null; - object? receivedSender = null; + // Assert + Assert.Single(service.Entries); + Assert.Equal(Constants.TestDb1, service.Entries[0].FileName); + } - databaseService.LoadedDatabasesChanged += (sender, databases) => + public void Dispose() + { + if (Directory.Exists(_testDirectory)) { - receivedSender = sender; - receivedDatabases = databases; - }; + Directory.Delete(_testDirectory, true); + } + } + + [Fact] + public void Entries_WhenMixedVersionedAndNonVersioned_ShouldSortCorrectly() + { + // Arrange + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.Windows10 + ".db"); + CreateDatabaseFile(databasePath, Constants.Windows11 + ".db"); + CreateDatabaseFile(databasePath, Constants.SimpleDatabase + ".db"); + CreateDatabaseFile(databasePath, Constants.AnotherDb + ".db"); // Act - databaseService.LoadDatabases(); + var service = CreateDatabaseService(); + + // Assert: non-versioned first, then versioned numeric desc + Assert.Equal(4, service.Entries.Count); + Assert.Equal(Constants.AnotherDb + ".db", service.Entries[0].FileName); + Assert.Equal(Constants.SimpleDatabase + ".db", service.Entries[1].FileName); + Assert.Equal(Constants.Windows11 + ".db", service.Entries[2].FileName); + Assert.Equal(Constants.Windows10 + ".db", service.Entries[3].FileName); + } - // Assert - Assert.NotNull(receivedDatabases); - Assert.Same(databaseService, receivedSender); - Assert.Contains(Constants.DatabaseA, receivedDatabases); + [Fact] + public void Entries_WhenNumericVersions_ShouldSortNumericallyNotLexicographically() + { + // Arrange + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.Server1 + ".db"); + CreateDatabaseFile(databasePath, Constants.Server2 + ".db"); + CreateDatabaseFile(databasePath, Constants.Server10 + ".db"); + CreateDatabaseFile(databasePath, Constants.Server20 + ".db"); + + // Act + var service = CreateDatabaseService(); + + // Assert: numeric desc — 20, 10, 2, 1 + Assert.Equal(Constants.Server20 + ".db", service.Entries[0].FileName); + Assert.Equal(Constants.Server10 + ".db", service.Entries[1].FileName); + Assert.Equal(Constants.Server2 + ".db", service.Entries[2].FileName); + Assert.Equal(Constants.Server1 + ".db", service.Entries[3].FileName); } [Fact] - public void LoadDatabases_WhenCalled_ShouldRefreshLoadedDatabases() + public void Entries_WhenSimpleNames_ShouldSortByNameAscThenVersionDesc() { // Arrange - var mockEnabledDatabaseCollectionProvider = Substitute.For(); + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.DatabaseA + ".db"); + CreateDatabaseFile(databasePath, Constants.DatabaseB + ".db"); + CreateDatabaseFile(databasePath, Constants.DatabaseC + ".db"); - mockEnabledDatabaseCollectionProvider.GetEnabledDatabases() - .Returns([Constants.DatabaseA], [Constants.DatabaseB, Constants.DatabaseC]); + // Act + var service = CreateDatabaseService(); - var databaseService = CreateDatabaseService(mockEnabledDatabaseCollectionProvider); + // Assert: "Database X" splits to "Database " + "X"; FirstPart asc then SecondPart desc + Assert.Equal(3, service.Entries.Count); + Assert.Equal(Constants.DatabaseC + ".db", service.Entries[0].FileName); + Assert.Equal(Constants.DatabaseB + ".db", service.Entries[1].FileName); + Assert.Equal(Constants.DatabaseA + ".db", service.Entries[2].FileName); + } + + [Fact] + public async Task ImportAsync_WhenDbFilesProvided_ShouldCopyAndRefresh() + { + // Arrange + CreateDatabaseDirectory(); + var sourceDir = Path.Combine(_testDirectory, "source"); + Directory.CreateDirectory(sourceDir); + + var sourceFile = Path.Combine(sourceDir, Constants.TestDb1); + File.WriteAllText(sourceFile, "test content"); + + var service = CreateDatabaseService(); // Act - var initialResult = databaseService.LoadedDatabases.ToList(); - databaseService.LoadDatabases(); - var refreshedResult = databaseService.LoadedDatabases.ToList(); + var result = await service.ImportAsync([sourceFile], TestContext.Current.CancellationToken); // Assert - Assert.Single(initialResult); - Assert.Equal(Constants.DatabaseA, initialResult[0]); - Assert.Equal(2, refreshedResult.Count); - Assert.Contains(Constants.DatabaseB, refreshedResult); - Assert.Contains(Constants.DatabaseC, refreshedResult); + Assert.Equal(1, result.Imported); + Assert.Empty(result.Failures); + Assert.Single(service.Entries); + Assert.Equal(Constants.TestDb1, service.Entries[0].FileName); } [Fact] - public void LoadDatabases_WhenNoEventHandler_ShouldNotThrow() + public async Task ImportAsync_WhenMixedSuccessAndFailure_ShouldReturnPartialResult() { // Arrange - var mockEnabledDatabaseCollectionProvider = Substitute.For(); - mockEnabledDatabaseCollectionProvider.GetEnabledDatabases().Returns([Constants.DatabaseA]); + var databasePath = CreateDatabaseDirectory(); + var sourceDir = Path.Combine(_testDirectory, "source"); + Directory.CreateDirectory(sourceDir); + + var goodZip = Path.Combine(sourceDir, "good.zip"); + CreateZipWithEntries(goodZip, [(Constants.TestDb1, "good content")]); + + var malformedZip = Path.Combine(sourceDir, "bad.zip"); + File.WriteAllBytes(malformedZip, [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]); - var databaseService = CreateDatabaseService(mockEnabledDatabaseCollectionProvider); + var service = CreateDatabaseService(); - // Act & Assert - var exception = Record.Exception(() => databaseService.LoadDatabases()); - Assert.Null(exception); + // Act + var result = await service.ImportAsync([goodZip, malformedZip], TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(1, result.Imported); + var failure = Assert.Single(result.Failures); + Assert.Equal("bad.zip", failure.FileName); + Assert.True(File.Exists(Path.Combine(databasePath, Constants.TestDb1))); + Assert.Single(service.Entries); } [Fact] - public void LoadedDatabases_WhenAccessed_ShouldReturnSortedDatabases() + public async Task ImportAsync_WhenNoFilesProvided_ShouldReturnZeroAndNotRefresh() { // Arrange - // Names with pattern "Name Version" sort by name asc, then version desc - var mockEnabledDatabaseCollectionProvider = Substitute.For(); - - mockEnabledDatabaseCollectionProvider.GetEnabledDatabases() - .Returns([Constants.DatabaseC, Constants.DatabaseA, Constants.DatabaseB]); + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); - var databaseService = CreateDatabaseService(mockEnabledDatabaseCollectionProvider); + var service = CreateDatabaseService(); + var raisedCount = 0; + service.EntriesChanged += (_, _) => raisedCount++; // Act - var result = databaseService.LoadedDatabases.ToList(); + var result = await service.ImportAsync([], TestContext.Current.CancellationToken); // Assert - // "Database X" splits to FirstPart="Database " + SecondPart="X" - // Sorted by FirstPart asc, then SecondPart desc: C, B, A - Assert.Equal(3, result.Count); - Assert.Equal(Constants.DatabaseC, result[0]); - Assert.Equal(Constants.DatabaseB, result[1]); - Assert.Equal(Constants.DatabaseA, result[2]); + Assert.Equal(0, result.Imported); + Assert.Empty(result.Failures); + Assert.Equal(0, raisedCount); } [Fact] - public void LoadedDatabases_WhenAccessedMultipleTimes_ShouldCacheResult() + public async Task ImportAsync_WhenTokenAlreadyCanceled_ShouldThrowOperationCanceledException() { // Arrange - var mockEnabledDatabaseCollectionProvider = Substitute.For(); + CreateDatabaseDirectory(); + var sourceDir = Path.Combine(_testDirectory, "source"); + Directory.CreateDirectory(sourceDir); + var sourcePath = Path.Combine(sourceDir, Constants.TestDb1); + File.WriteAllText(sourcePath, "test content"); - mockEnabledDatabaseCollectionProvider.GetEnabledDatabases() - .Returns([Constants.DatabaseA]); + var service = CreateDatabaseService(); - var databaseService = CreateDatabaseService(mockEnabledDatabaseCollectionProvider); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act + Assert + await Assert.ThrowsAnyAsync(() => service.ImportAsync([sourcePath], cts.Token)); + } + + [Fact] + public async Task ImportAsync_WhenZipContainsNonDbFiles_ShouldExtractOnlyDbEntries() + { + // Arrange + var databasePath = CreateDatabaseDirectory(); + var sourceDir = Path.Combine(_testDirectory, "source"); + Directory.CreateDirectory(sourceDir); + + var zipPath = Path.Combine(sourceDir, "import.zip"); + CreateZipWithEntries(zipPath, [(Constants.TestDb1, "db content"), ("readme.txt", "ignored")]); + + var service = CreateDatabaseService(); // Act - _ = databaseService.LoadedDatabases; - _ = databaseService.LoadedDatabases; - _ = databaseService.LoadedDatabases; + var result = await service.ImportAsync([zipPath], TestContext.Current.CancellationToken); // Assert - mockEnabledDatabaseCollectionProvider.Received(1).GetEnabledDatabases(); + Assert.Equal(1, result.Imported); + Assert.Empty(result.Failures); + Assert.True(File.Exists(Path.Combine(databasePath, Constants.TestDb1))); + Assert.False(File.Exists(Path.Combine(databasePath, "readme.txt"))); } [Fact] - public void LoadedDatabases_WhenEmpty_ShouldReturnEmptyCollection() + public async Task ImportAsync_WhenZipContainsValidDatabases_ShouldExtractDbFiles() { // Arrange - var mockEnabledDatabaseCollectionProvider = Substitute.For(); - mockEnabledDatabaseCollectionProvider.GetEnabledDatabases().Returns([]); + var databasePath = CreateDatabaseDirectory(); + var sourceDir = Path.Combine(_testDirectory, "source"); + Directory.CreateDirectory(sourceDir); + + var zipPath = Path.Combine(sourceDir, "import.zip"); + CreateZipWithEntries(zipPath, [(Constants.TestDb1, "db1 content"), (Constants.TestDb2, "db2 content")]); - var databaseService = CreateDatabaseService(mockEnabledDatabaseCollectionProvider); + var service = CreateDatabaseService(); // Act - var result = databaseService.LoadedDatabases; + var result = await service.ImportAsync([zipPath], TestContext.Current.CancellationToken); // Assert - Assert.Empty(result); + Assert.Equal(2, result.Imported); + Assert.Empty(result.Failures); + Assert.True(File.Exists(Path.Combine(databasePath, Constants.TestDb1))); + Assert.True(File.Exists(Path.Combine(databasePath, Constants.TestDb2))); + Assert.False(File.Exists(Path.Combine(databasePath, "import.zip"))); } [Fact] - public void LoadedDatabases_WhenVersionedDatabases_ShouldSortByNameThenVersionDescending() + public async Task ImportAsync_WhenZipIsMalformed_ShouldReturnFailureAndNotLeakFiles() { // Arrange - var mockEnabledDatabaseCollectionProvider = Substitute.For(); + var databasePath = CreateDatabaseDirectory(); + var sourceDir = Path.Combine(_testDirectory, "source"); + Directory.CreateDirectory(sourceDir); - mockEnabledDatabaseCollectionProvider.GetEnabledDatabases() - .Returns([Constants.Windows10, Constants.Windows11, Constants.Windows9, Constants.Linux1]); + var malformedZip = Path.Combine(sourceDir, "malformed.zip"); + File.WriteAllBytes(malformedZip, [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]); - var databaseService = CreateDatabaseService(mockEnabledDatabaseCollectionProvider); + var service = CreateDatabaseService(); // Act - var result = databaseService.LoadedDatabases.ToList(); - - // Assert - Numeric sorting: 11 > 10 > 9, 1 - Assert.Equal(4, result.Count); - Assert.Equal(Constants.Linux1, result[0]); - Assert.Equal(Constants.Windows11, result[1]); - Assert.Equal(Constants.Windows10, result[2]); - Assert.Equal(Constants.Windows9, result[3]); + var result = await service.ImportAsync([malformedZip], TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(0, result.Imported); + var failure = Assert.Single(result.Failures); + Assert.Equal("malformed.zip", failure.FileName); + Assert.Contains("Could not open archive", failure.Reason, StringComparison.Ordinal); + Assert.Empty(Directory.GetFiles(databasePath)); } [Fact] - public void LoadedDatabases_WhenNumericVersions_ShouldSortNumericallyNotLexicographically() + public void MarkStatus_ShouldBePreservedAcrossRefresh() { // Arrange - // This test ensures "10" sorts after "2" (numeric) rather than before it (lexicographic) - var mockEnabledDatabaseCollectionProvider = Substitute.For(); + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); - mockEnabledDatabaseCollectionProvider.GetEnabledDatabases() - .Returns([Constants.Server2, Constants.Server10, Constants.Server1, Constants.Server20]); - - var databaseService = CreateDatabaseService(mockEnabledDatabaseCollectionProvider); + var service = CreateDatabaseService(); + service.MarkStatus(Constants.TestDb1, DatabaseStatus.UpgradeFailed); // Act - var result = databaseService.LoadedDatabases.ToList(); - - // Assert - Numeric descending: 20, 10, 2, 1 - Assert.Equal(4, result.Count); - Assert.Equal(Constants.Server20, result[0]); - Assert.Equal(Constants.Server10, result[1]); - Assert.Equal(Constants.Server2, result[2]); - Assert.Equal(Constants.Server1, result[3]); + service.Refresh(); + + // Assert + Assert.Equal(DatabaseStatus.UpgradeFailed, service.Entries[0].Status); } [Fact] - public void LoadedDatabasesChanged_WhenSetToNull_ShouldNotThrowOnLoadDatabases() + public void MarkStatus_WhenStatusChanges_ShouldUpdateAndRaiseEntriesChanged() { // Arrange - var mockEnabledDatabaseCollectionProvider = Substitute.For(); - mockEnabledDatabaseCollectionProvider.GetEnabledDatabases().Returns([Constants.DatabaseA]); + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); + + var service = CreateDatabaseService(); + var raisedCount = 0; + service.EntriesChanged += (_, _) => raisedCount++; - var databaseService = CreateDatabaseService(mockEnabledDatabaseCollectionProvider); - databaseService.LoadedDatabasesChanged = null; + // Act + service.MarkStatus(Constants.TestDb1, DatabaseStatus.UpgradeFailed); - // Act & Assert - var exception = Record.Exception(() => databaseService.LoadDatabases()); - Assert.Null(exception); + // Assert + Assert.Equal(DatabaseStatus.UpgradeFailed, service.Entries[0].Status); + Assert.Equal(1, raisedCount); } [Fact] - public void SortDatabases_WhenMixedVersionedAndNonVersioned_ShouldSortCorrectly() + public void MarkStatus_WhenStatusUnchanged_ShouldNotRaiseEntriesChanged() { // Arrange - var mockEnabledDatabaseCollectionProvider = Substitute.For(); + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); + + var service = CreateDatabaseService(); + var raisedCount = 0; + service.EntriesChanged += (_, _) => raisedCount++; + + // Act (entry already Ready) + service.MarkStatus(Constants.TestDb1, DatabaseStatus.Ready); - mockEnabledDatabaseCollectionProvider.GetEnabledDatabases() - .Returns([Constants.Windows10, Constants.SimpleDatabase, Constants.Windows11, Constants.AnotherDb]); + // Assert + Assert.Equal(0, raisedCount); + } - var databaseService = CreateDatabaseService(mockEnabledDatabaseCollectionProvider); + [Fact] + public void Refresh_WhenCalled_ShouldRaiseEntriesChanged() + { + // Arrange + CreateDatabaseDirectory(); + var service = CreateDatabaseService(); + var raised = false; + service.EntriesChanged += (_, _) => raised = true; // Act - var result = databaseService.LoadedDatabases.ToList(); - - // Assert - Non-versioned first (no space pattern), then versioned with numeric sort - Assert.Equal(4, result.Count); - Assert.Equal(Constants.AnotherDb, result[0]); - Assert.Equal(Constants.SimpleDatabase, result[1]); - Assert.Equal(Constants.Windows11, result[2]); - Assert.Equal(Constants.Windows10, result[3]); + service.Refresh(); + + // Assert + Assert.True(raised); } [Fact] - public void UpdateDisabledDatabases_WhenCalled_ShouldInvokeLoadedDatabasesChanged() + public void Refresh_WhenNewFilesAppear_ShouldPickThemUp() { // Arrange - var mockEnabledDatabaseCollectionProvider = Substitute.For(); - mockEnabledDatabaseCollectionProvider.GetEnabledDatabases().Returns([Constants.DatabaseA]); + var databasePath = CreateDatabaseDirectory(); + var service = CreateDatabaseService(); + Assert.Empty(service.Entries); + + CreateDatabaseFile(databasePath, Constants.TestDb1); + + // Act + service.Refresh(); - var databaseService = CreateDatabaseService(mockEnabledDatabaseCollectionProvider); + // Assert + Assert.Single(service.Entries); + Assert.Equal(Constants.TestDb1, service.Entries[0].FileName); + } + + [Fact] + public void Remove_WhenCalled_ShouldDeleteDatabaseAndSidecars() + { + // Arrange + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); + File.WriteAllText(Path.Combine(databasePath, $"{Constants.TestDb1}-wal"), ""); + File.WriteAllText(Path.Combine(databasePath, $"{Constants.TestDb1}-shm"), ""); - var eventInvoked = false; - databaseService.LoadedDatabasesChanged += (_, _) => eventInvoked = true; + var service = CreateDatabaseService(); // Act - databaseService.UpdateDisabledDatabases([Constants.DisabledDb]); + service.Remove(Constants.TestDb1); // Assert - Assert.True(eventInvoked); + Assert.False(File.Exists(Path.Combine(databasePath, Constants.TestDb1))); + Assert.False(File.Exists(Path.Combine(databasePath, $"{Constants.TestDb1}-wal"))); + Assert.False(File.Exists(Path.Combine(databasePath, $"{Constants.TestDb1}-shm"))); + Assert.Empty(service.Entries); } [Fact] - public void UpdateDisabledDatabases_WhenCalled_ShouldReloadDatabases() + public void Remove_WhenCalled_ShouldRaiseEntriesChanged() { // Arrange - var mockEnabledDatabaseCollectionProvider = Substitute.For(); - mockEnabledDatabaseCollectionProvider.GetEnabledDatabases().Returns([Constants.DatabaseA]); + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); - var databaseService = CreateDatabaseService(mockEnabledDatabaseCollectionProvider); + var service = CreateDatabaseService(); + var raised = false; + service.EntriesChanged += (_, _) => raised = true; // Act - databaseService.UpdateDisabledDatabases([Constants.DisabledDb]); + service.Remove(Constants.TestDb1); - // Assert - GetEnabledDatabases should be called once for reload (LoadedDatabases not accessed before) - mockEnabledDatabaseCollectionProvider.Received(1).GetEnabledDatabases(); + // Assert + Assert.True(raised); + } + + [Fact] + public void Remove_WhenFileNameUnknown_ShouldThrow() + { + // Arrange + CreateDatabaseDirectory(); + var service = CreateDatabaseService(); + + // Act + Assert + Assert.Throws(() => service.Remove("does-not-exist.db")); } [Fact] - public void UpdateDisabledDatabases_WhenCalled_ShouldUpdatePreferences() + public void Toggle_WhenCalled_ShouldFlipIsEnabledAndPersist() { // Arrange - var mockPreferencesProvider = Substitute.For(); - var mockEnabledDatabaseCollectionProvider = Substitute.For(); - mockEnabledDatabaseCollectionProvider.GetEnabledDatabases().Returns([]); + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); - var databaseService = CreateDatabaseService( - mockEnabledDatabaseCollectionProvider, - mockPreferencesProvider); + var preferences = Substitute.For(); + preferences.DisabledDatabasesPreference.Returns([]); - var newDisabledDatabases = new List { Constants.NewDisabled1, Constants.NewDisabled2 }; + var service = CreateDatabaseService(preferences); // Act - databaseService.UpdateDisabledDatabases(newDisabledDatabases); + service.Toggle(Constants.TestDb1); // Assert - mockPreferencesProvider.Received(1).DisabledDatabasesPreference = - Arg.Is>(x => x.Contains(Constants.NewDisabled1) && x.Contains(Constants.NewDisabled2)); + Assert.False(service.Entries[0].IsEnabled); + + preferences.Received(1).DisabledDatabasesPreference = + Arg.Is>(disabled => disabled.Contains(Constants.TestDb1)); } [Fact] - public void UpdateDisabledDatabases_WhenCalledWithEmptyList_ShouldUpdatePreferences() + public void Toggle_WhenCalled_ShouldRaiseEntriesChanged() { // Arrange - var mockPreferencesProvider = Substitute.For(); - var mockEnabledDatabaseCollectionProvider = Substitute.For(); - mockEnabledDatabaseCollectionProvider.GetEnabledDatabases().Returns([]); + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); - var databaseService = CreateDatabaseService( - mockEnabledDatabaseCollectionProvider, - mockPreferencesProvider); + var service = CreateDatabaseService(); + var raised = false; + service.EntriesChanged += (_, _) => raised = true; // Act - databaseService.UpdateDisabledDatabases([]); + service.Toggle(Constants.TestDb1); // Assert - mockPreferencesProvider.Received(1).DisabledDatabasesPreference = - Arg.Is>(x => !x.Any()); + Assert.True(raised); + } + + [Fact] + public void Toggle_WhenFileNameUnknown_ShouldThrow() + { + // Arrange + CreateDatabaseDirectory(); + var service = CreateDatabaseService(); + + // Act + Assert + Assert.Throws(() => service.Toggle("does-not-exist.db")); + } + + private static void CreateDatabaseFile(string directory, string fileName) => + File.WriteAllText(Path.Combine(directory, fileName), string.Empty); + + private static void CreateZipWithEntries(string zipPath, IEnumerable<(string entryName, string content)> entries) + { + using var fileStream = File.Create(zipPath); + using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create); + + foreach (var (entryName, content) in entries) + { + var entry = archive.CreateEntry(entryName); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream); + writer.Write(content); + } } - private static DatabaseService CreateDatabaseService( - IEnabledDatabaseCollectionProvider? enabledDatabaseCollectionProvider = null, - IPreferencesProvider? preferencesProvider = null) + private string CreateDatabaseDirectory() { - return new DatabaseService( - enabledDatabaseCollectionProvider ?? Substitute.For(), - preferencesProvider ?? Substitute.For()); + var path = Path.Join(_testDirectory, "Databases"); + Directory.CreateDirectory(path); + return path; + } + + private DatabaseService CreateDatabaseService(IPreferencesProvider? preferences = null) + { + var fileLocationOptions = new FileLocationOptions(_testDirectory); + var prefs = preferences ?? Substitute.For(); + + if (preferences is null) + { + prefs.DisabledDatabasesPreference.Returns([]); + } + + var traceLogger = Substitute.For(); + return new DatabaseService(fileLocationOptions, prefs, traceLogger); } } diff --git a/src/EventLogExpert.UI.Tests/Services/EnabledDatabaseCollectionProviderTests.cs b/src/EventLogExpert.UI.Tests/Services/EnabledDatabaseCollectionProviderTests.cs deleted file mode 100644 index 4d1bd454..00000000 --- a/src/EventLogExpert.UI.Tests/Services/EnabledDatabaseCollectionProviderTests.cs +++ /dev/null @@ -1,398 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -using EventLogExpert.Eventing.Helpers; -using EventLogExpert.UI.Interfaces; -using EventLogExpert.UI.Options; -using EventLogExpert.UI.Services; -using EventLogExpert.UI.Tests.TestUtils.Constants; -using NSubstitute; - -namespace EventLogExpert.UI.Tests.Services; - -public sealed class EnabledDatabaseCollectionProviderTests : IDisposable -{ - private readonly string _testDirectory; - - public EnabledDatabaseCollectionProviderTests() - { - _testDirectory = Path.Combine(Path.GetTempPath(), $"EnabledDatabaseCollectionProviderTests_{Guid.NewGuid()}"); - Directory.CreateDirectory(_testDirectory); - } - - [Fact] - public void Constructor_WhenCalled_ShouldSetActiveDatabases() - { - // Arrange - var databasePath = CreateDatabaseDirectory(); - CreateDatabaseFile(databasePath, Constants.TestDb1); - CreateDatabaseFile(databasePath, Constants.TestDb2); - - var fileLocationOptions = new FileLocationOptions(_testDirectory); - var mockPreferencesProvider = Substitute.For(); - mockPreferencesProvider.DisabledDatabasesPreference.Returns([]); - var mockTraceLogger = Substitute.For(); - - // Act - var provider = new EnabledDatabaseCollectionProvider( - fileLocationOptions, - mockPreferencesProvider, - mockTraceLogger); - - // Assert - Assert.Equal(2, provider.ActiveDatabases.Count); - Assert.Contains(provider.ActiveDatabases, p => p.Contains(Constants.TestDb1)); - Assert.Contains(provider.ActiveDatabases, p => p.Contains(Constants.TestDb2)); - } - - [Fact] - public void Constructor_WhenCalled_ShouldTraceMessage() - { - // Arrange - var databasePath = CreateDatabaseDirectory(); - var fileLocationOptions = new FileLocationOptions(_testDirectory); - var mockPreferencesProvider = Substitute.For(); - mockPreferencesProvider.DisabledDatabasesPreference.Returns([]); - var mockTraceLogger = Substitute.For(); - - // Act - _ = new EnabledDatabaseCollectionProvider( - fileLocationOptions, - mockPreferencesProvider, - mockTraceLogger); - - // Assert - mockTraceLogger.Received(1).Debug( - Arg.Is(h => h.ToString().Contains(nameof(EnabledDatabaseCollectionProvider.SetActiveDatabases)))); - } - - [Fact] - public void Constructor_WhenDatabaseDirectoryDoesNotExist_ShouldSetEmptyActiveDatabases() - { - // Arrange - var fileLocationOptions = new FileLocationOptions(_testDirectory); - var mockPreferencesProvider = Substitute.For(); - mockPreferencesProvider.DisabledDatabasesPreference.Returns([]); - var mockTraceLogger = Substitute.For(); - - // Act - var provider = new EnabledDatabaseCollectionProvider( - fileLocationOptions, - mockPreferencesProvider, - mockTraceLogger); - - // Assert - Assert.Empty(provider.ActiveDatabases); - } - - [Fact] - public void Constructor_WhenDisabledDatabasesExist_ShouldExcludeFromActiveDatabases() - { - // Arrange - var databasePath = CreateDatabaseDirectory(); - CreateDatabaseFile(databasePath, Constants.TestDb1); - CreateDatabaseFile(databasePath, Constants.TestDb2); - - var fileLocationOptions = new FileLocationOptions(_testDirectory); - var mockPreferencesProvider = Substitute.For(); - mockPreferencesProvider.DisabledDatabasesPreference.Returns([Constants.TestDb1]); - var mockTraceLogger = Substitute.For(); - - // Act - var provider = new EnabledDatabaseCollectionProvider( - fileLocationOptions, - mockPreferencesProvider, - mockTraceLogger); - - // Assert - Assert.Single(provider.ActiveDatabases); - Assert.Contains(provider.ActiveDatabases, p => p.Contains(Constants.TestDb2)); - Assert.DoesNotContain(provider.ActiveDatabases, p => p.Contains(Constants.TestDb1)); - } - - public void Dispose() - { - try - { - if (Directory.Exists(_testDirectory)) - { - Directory.Delete(_testDirectory, true); - } - } - catch - { - // Ignore cleanup errors in tests - } - } - - [Fact] - public void GetEnabledDatabases_WhenAllDatabasesDisabled_ShouldReturnEmpty() - { - // Arrange - var databasePath = CreateDatabaseDirectory(); - CreateDatabaseFile(databasePath, Constants.TestDb1); - - var fileLocationOptions = new FileLocationOptions(_testDirectory); - var mockPreferencesProvider = Substitute.For(); - mockPreferencesProvider.DisabledDatabasesPreference.Returns([Constants.TestDb1]); - var mockTraceLogger = Substitute.For(); - - var provider = new EnabledDatabaseCollectionProvider( - fileLocationOptions, - mockPreferencesProvider, - mockTraceLogger); - - // Act - var result = provider.GetEnabledDatabases(); - - // Assert - Assert.Empty(result); - } - - [Fact] - public void GetEnabledDatabases_WhenCaseInsensitiveMatch_ShouldExcludeDisabled() - { - // Arrange - var databasePath = CreateDatabaseDirectory(); - CreateDatabaseFile(databasePath, Constants.TestDb1); - - var fileLocationOptions = new FileLocationOptions(_testDirectory); - var mockPreferencesProvider = Substitute.For(); - // Use different case for disabled database name - mockPreferencesProvider.DisabledDatabasesPreference.Returns([Constants.TestDb1.ToUpper()]); - var mockTraceLogger = Substitute.For(); - - var provider = new EnabledDatabaseCollectionProvider( - fileLocationOptions, - mockPreferencesProvider, - mockTraceLogger); - - // Act - var result = provider.GetEnabledDatabases(); - - // Assert - Assert.Empty(result); - } - - [Fact] - public void GetEnabledDatabases_WhenDatabaseDirectoryDoesNotExist_ShouldReturnEmpty() - { - // Arrange - var fileLocationOptions = new FileLocationOptions(_testDirectory); - var mockPreferencesProvider = Substitute.For(); - mockPreferencesProvider.DisabledDatabasesPreference.Returns([]); - var mockTraceLogger = Substitute.For(); - - var provider = new EnabledDatabaseCollectionProvider( - fileLocationOptions, - mockPreferencesProvider, - mockTraceLogger); - - // Act - var result = provider.GetEnabledDatabases(); - - // Assert - Assert.Empty(result); - } - - [Fact] - public void GetEnabledDatabases_WhenDatabasesExist_ShouldReturnOnlyDbFiles() - { - // Arrange - var databasePath = CreateDatabaseDirectory(); - CreateDatabaseFile(databasePath, Constants.TestDb1); - CreateDatabaseFile(databasePath, Constants.TestDb2); - File.WriteAllText(Path.Combine(databasePath, "notadatabase.txt"), "test"); - - var fileLocationOptions = new FileLocationOptions(_testDirectory); - var mockPreferencesProvider = Substitute.For(); - mockPreferencesProvider.DisabledDatabasesPreference.Returns([]); - var mockTraceLogger = Substitute.For(); - - var provider = new EnabledDatabaseCollectionProvider( - fileLocationOptions, - mockPreferencesProvider, - mockTraceLogger); - - // Act - var result = provider.GetEnabledDatabases(); - - // Assert - Assert.Equal(2, result.Count); - Assert.Contains(Constants.TestDb1, result); - Assert.Contains(Constants.TestDb2, result); - Assert.DoesNotContain("notadatabase.txt", result); - } - - [Fact] - public void GetEnabledDatabases_WhenNoDisabledDatabases_ShouldReturnAll() - { - // Arrange - var databasePath = CreateDatabaseDirectory(); - CreateDatabaseFile(databasePath, Constants.TestDb1); - CreateDatabaseFile(databasePath, Constants.TestDb2); - CreateDatabaseFile(databasePath, Constants.TestDb3); - - var fileLocationOptions = new FileLocationOptions(_testDirectory); - var mockPreferencesProvider = Substitute.For(); - mockPreferencesProvider.DisabledDatabasesPreference.Returns([]); - var mockTraceLogger = Substitute.For(); - - var provider = new EnabledDatabaseCollectionProvider( - fileLocationOptions, - mockPreferencesProvider, - mockTraceLogger); - - // Act - var result = provider.GetEnabledDatabases(); - - // Assert - Assert.Equal(3, result.Count); - Assert.Contains(Constants.TestDb1, result); - Assert.Contains(Constants.TestDb2, result); - Assert.Contains(Constants.TestDb3, result); - } - - [Fact] - public void GetEnabledDatabases_WhenSomeDatabasesDisabled_ShouldReturnOnlyEnabled() - { - // Arrange - var databasePath = CreateDatabaseDirectory(); - CreateDatabaseFile(databasePath, Constants.TestDb1); - CreateDatabaseFile(databasePath, Constants.TestDb2); - CreateDatabaseFile(databasePath, Constants.TestDb3); - - var fileLocationOptions = new FileLocationOptions(_testDirectory); - var mockPreferencesProvider = Substitute.For(); - mockPreferencesProvider.DisabledDatabasesPreference.Returns([Constants.TestDb2]); - var mockTraceLogger = Substitute.For(); - - var provider = new EnabledDatabaseCollectionProvider( - fileLocationOptions, - mockPreferencesProvider, - mockTraceLogger); - - // Act - var result = provider.GetEnabledDatabases(); - - // Assert - Assert.Equal(2, result.Count); - Assert.Contains(Constants.TestDb1, result); - Assert.Contains(Constants.TestDb3, result); - Assert.DoesNotContain(Constants.TestDb2, result); - } - - [Fact] - public void SetActiveDatabases_WhenCalled_ShouldTraceMessage() - { - // Arrange - var fileLocationOptions = new FileLocationOptions(_testDirectory); - var mockPreferencesProvider = Substitute.For(); - mockPreferencesProvider.DisabledDatabasesPreference.Returns([]); - var mockTraceLogger = Substitute.For(); - - var provider = new EnabledDatabaseCollectionProvider( - fileLocationOptions, - mockPreferencesProvider, - mockTraceLogger); - - mockTraceLogger.ClearReceivedCalls(); - - // Act - provider.SetActiveDatabases([Constants.TestDbPath1, Constants.TestDbPath2]); - - // Assert - mockTraceLogger.Received(1).Debug( - Arg.Is(h => - h.ToString().Contains(nameof(EnabledDatabaseCollectionProvider.SetActiveDatabases)) && - h.ToString().Contains('2'))); - } - - [Fact] - public void SetActiveDatabases_WhenCalled_ShouldUpdateActiveDatabases() - { - // Arrange - var fileLocationOptions = new FileLocationOptions(_testDirectory); - var mockPreferencesProvider = Substitute.For(); - mockPreferencesProvider.DisabledDatabasesPreference.Returns([]); - var mockTraceLogger = Substitute.For(); - - var provider = new EnabledDatabaseCollectionProvider( - fileLocationOptions, - mockPreferencesProvider, - mockTraceLogger); - - var newDatabases = new[] { Constants.TestDbPath1, Constants.TestDbPath2 }; - - // Act - provider.SetActiveDatabases(newDatabases); - - // Assert - Assert.Equal(2, provider.ActiveDatabases.Count); - Assert.Contains(Constants.TestDbPath1, provider.ActiveDatabases); - Assert.Contains(Constants.TestDbPath2, provider.ActiveDatabases); - } - - [Fact] - public void SetActiveDatabases_WhenCalledMultipleTimes_ShouldReplaceActiveDatabases() - { - // Arrange - var fileLocationOptions = new FileLocationOptions(_testDirectory); - var mockPreferencesProvider = Substitute.For(); - mockPreferencesProvider.DisabledDatabasesPreference.Returns([]); - var mockTraceLogger = Substitute.For(); - - var provider = new EnabledDatabaseCollectionProvider( - fileLocationOptions, - mockPreferencesProvider, - mockTraceLogger); - - // Act - provider.SetActiveDatabases([Constants.TestDbPath1]); - provider.SetActiveDatabases([Constants.TestDbPath2, Constants.TestDbPath3]); - - // Assert - Assert.Equal(2, provider.ActiveDatabases.Count); - Assert.DoesNotContain(Constants.TestDbPath1, provider.ActiveDatabases); - Assert.Contains(Constants.TestDbPath2, provider.ActiveDatabases); - Assert.Contains(Constants.TestDbPath3, provider.ActiveDatabases); - } - - [Fact] - public void SetActiveDatabases_WhenCalledWithEmptyCollection_ShouldClearActiveDatabases() - { - // Arrange - var databasePath = CreateDatabaseDirectory(); - CreateDatabaseFile(databasePath, Constants.TestDb1); - - var fileLocationOptions = new FileLocationOptions(_testDirectory); - var mockPreferencesProvider = Substitute.For(); - mockPreferencesProvider.DisabledDatabasesPreference.Returns([]); - var mockTraceLogger = Substitute.For(); - - var provider = new EnabledDatabaseCollectionProvider( - fileLocationOptions, - mockPreferencesProvider, - mockTraceLogger); - - Assert.Single(provider.ActiveDatabases); // Verify initial state - - // Act - provider.SetActiveDatabases([]); - - // Assert - Assert.Empty(provider.ActiveDatabases); - } - - private static void CreateDatabaseFile(string databasePath, string fileName) - { - File.WriteAllText(Path.Combine(databasePath, fileName), string.Empty); - } - - private string CreateDatabaseDirectory() - { - var databasePath = Path.Combine(_testDirectory, "Databases"); - Directory.CreateDirectory(databasePath); - return databasePath; - } -} diff --git a/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs b/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs index a997c95e..cd1ab12f 100644 --- a/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs +++ b/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs @@ -1,17 +1,23 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. +using EventLogExpert.UI.Models; + namespace EventLogExpert.UI.Interfaces; public interface IDatabaseService { - IEnumerable DisabledDatabases { get; } + event EventHandler? EntriesChanged; + + IReadOnlyList Entries { get; } + + Task ImportAsync(IEnumerable sourceFilePaths, CancellationToken cancellationToken = default); - IEnumerable LoadedDatabases { get; } + void MarkStatus(string fileName, DatabaseStatus status); - EventHandler>? LoadedDatabasesChanged { get; set; } + void Refresh(); - void LoadDatabases(); + void Remove(string fileName); - void UpdateDisabledDatabases(IEnumerable databases); + void Toggle(string fileName); } diff --git a/src/EventLogExpert.UI/Services/DatabaseService.cs b/src/EventLogExpert.UI/Services/DatabaseService.cs index 8195a58a..073ae5df 100644 --- a/src/EventLogExpert.UI/Services/DatabaseService.cs +++ b/src/EventLogExpert.UI/Services/DatabaseService.cs @@ -1,77 +1,223 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. +using EventLogExpert.Eventing.EventResolvers; +using EventLogExpert.Eventing.Helpers; using EventLogExpert.UI.Interfaces; +using EventLogExpert.UI.Models; +using EventLogExpert.UI.Options; +using System.Collections.Immutable; +using System.IO.Compression; using System.Text.RegularExpressions; namespace EventLogExpert.UI.Services; -public sealed partial class DatabaseService( - IEnabledDatabaseCollectionProvider enabledDatabaseCollectionProvider, - IPreferencesProvider preferences) : IDatabaseService +public sealed partial class DatabaseService : IDatabaseService, IDatabaseCollectionProvider { - private readonly IEnabledDatabaseCollectionProvider _enabledDatabaseCollectionProvider = - enabledDatabaseCollectionProvider; - private readonly IPreferencesProvider _preferences = preferences; + private readonly FileLocationOptions _fileLocationOptions; + private readonly Lock _mutationLock = new(); + private readonly IPreferencesProvider _preferences; + private readonly ITraceLogger _traceLogger; - public IEnumerable LoadedDatabases + private ImmutableList _entries = []; + + public DatabaseService( + FileLocationOptions fileLocationOptions, + IPreferencesProvider preferences, + ITraceLogger traceLogger) { - get - { - _loadedDatabases ??= SortDatabases(_enabledDatabaseCollectionProvider.GetEnabledDatabases()); + _fileLocationOptions = fileLocationOptions; + _preferences = preferences; + _traceLogger = traceLogger; - return _loadedDatabases; - } + Refresh(); } - private IEnumerable? _disabledDatabases; - private IEnumerable? _loadedDatabases; + public event EventHandler? EntriesChanged; + + public ImmutableList ActiveDatabases => + _entries + .Where(IsActive) + .Select(entry => entry.FullPath) + .ToImmutableList(); - public IEnumerable DisabledDatabases + public IReadOnlyList Entries => _entries; + + public async Task ImportAsync( + IEnumerable sourceFilePaths, + CancellationToken cancellationToken = default) { - get + ArgumentNullException.ThrowIfNull(sourceFilePaths); + + var importedCount = 0; + var failures = new List(); + Directory.CreateDirectory(_fileLocationOptions.DatabasePath); + + foreach (var sourceFilePath in sourceFilePaths) { - _disabledDatabases ??= _preferences.DisabledDatabasesPreference; + cancellationToken.ThrowIfCancellationRequested(); + + if (string.IsNullOrEmpty(sourceFilePath)) { continue; } + + var fileName = Path.GetFileName(sourceFilePath); + + if (string.IsNullOrEmpty(fileName)) { continue; } + + if (Path.GetExtension(fileName).Equals(".zip", StringComparison.OrdinalIgnoreCase)) + { + var (zipImported, zipFailures) = await ImportZipAsync(sourceFilePath, fileName, cancellationToken) + .ConfigureAwait(false); + + importedCount += zipImported; + failures.AddRange(zipFailures); + } + else + { + try + { + var destinationPath = Path.Join(_fileLocationOptions.DatabasePath, fileName); + File.Copy(sourceFilePath, destinationPath, true); + importedCount++; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + failures.Add(new ImportFailure(fileName, ex.Message)); - return _disabledDatabases; + _traceLogger.Warn( + $"{nameof(DatabaseService)}.{nameof(ImportAsync)} failed to copy '{fileName}': {ex}"); + } + } } - private set + + if (importedCount > 0) { - _disabledDatabases = value.ToList().AsReadOnly(); - _preferences.DisabledDatabasesPreference = _disabledDatabases; + Refresh(); } + + return new ImportResult(importedCount, failures); } - public EventHandler>? LoadedDatabasesChanged { get; set; } + public void MarkStatus(string fileName, DatabaseStatus status) + { + lock (_mutationLock) + { + var index = FindEntryIndex(_entries, fileName); + + if (index < 0) + { + throw new InvalidOperationException( + $"{nameof(DatabaseService)}.{nameof(MarkStatus)}: no entry found with file name '{fileName}'."); + } + + var current = _entries[index]; + + if (current.Status == status) { return; } - public void LoadDatabases() + ImmutableList nextSnapshot = _entries.SetItem(index, current with { Status = status }); + _entries = nextSnapshot; + } + + RaiseEntriesChanged(); + } + + public void Refresh() { - _loadedDatabases = SortDatabases(_enabledDatabaseCollectionProvider.GetEnabledDatabases()); + lock (_mutationLock) + { + var disabled = _preferences.DisabledDatabasesPreference.ToHashSet(StringComparer.OrdinalIgnoreCase); + + var existingByFileName = _entries.ToDictionary( + entry => entry.FileName, + StringComparer.OrdinalIgnoreCase); + + var fileNames = EnumerateDatabaseFileNames(); + var sortedFileNames = SortDatabases(fileNames); - LoadedDatabasesChanged?.Invoke(this, LoadedDatabases); + ImmutableList nextSnapshot = sortedFileNames + .Select(fileName => new DatabaseEntry( + fileName, + Path.Join(_fileLocationOptions.DatabasePath, fileName), + !disabled.Contains(fileName), + existingByFileName.TryGetValue(fileName, out var existing) + ? existing.Status + : DatabaseStatus.Ready)) + .ToImmutableList(); + + _entries = nextSnapshot; + } + + RaiseEntriesChanged(); } - public void UpdateDisabledDatabases(IEnumerable databases) + public void Remove(string fileName) { - DisabledDatabases = databases; + lock (_mutationLock) + { + var index = FindEntryIndex(_entries, fileName); + + if (index < 0) + { + throw new InvalidOperationException( + $"{nameof(DatabaseService)}.{nameof(Remove)}: no entry found with file name '{fileName}'."); + } - LoadDatabases(); + DeleteDatabaseFiles(fileName); + + ImmutableList nextSnapshot = _entries.RemoveAt(index); + PersistDisabled(nextSnapshot); + _entries = nextSnapshot; + } + + RaiseEntriesChanged(); } + public void Toggle(string fileName) + { + lock (_mutationLock) + { + var index = FindEntryIndex(_entries, fileName); + + if (index < 0) + { + throw new InvalidOperationException( + $"{nameof(DatabaseService)}.{nameof(Toggle)}: no entry found with file name '{fileName}'."); + } + + var current = _entries[index]; + var updated = current with { IsEnabled = !current.IsEnabled }; + ImmutableList nextSnapshot = _entries.SetItem(index, updated); + + PersistDisabled(nextSnapshot); + _entries = nextSnapshot; + } + + RaiseEntriesChanged(); + } + + private static int FindEntryIndex(ImmutableList snapshot, string fileName) => + snapshot.FindIndex(entry => string.Equals(entry.FileName, fileName, StringComparison.OrdinalIgnoreCase)); + + private static bool IsActive(DatabaseEntry entry) => entry.IsEnabled && entry.Status == DatabaseStatus.Ready; + private static IEnumerable SortDatabases(IEnumerable databases) { if (!databases.Any()) { return []; } - var r = SplitFileName(); + var splitter = SplitFileName(); return databases .Select(name => { - var m = r.Match(name); + // Strip the .db extension before applying the version regex so that "Server 20.db" + // splits into ("Server ", "20") instead of ("Server ", "20.db") — otherwise the + // version capture never parses as a number and numeric sort falls back to + // lexicographic ("Server 2.db" < "Server 20.db" but "Server 20.db" > "Server 10.db"). + var nameWithoutExt = Path.GetFileNameWithoutExtension(name); + var match = splitter.Match(nameWithoutExt); - if (m.Success) + if (match.Success) { - var versionString = m.Groups[2].Value; + var versionString = match.Groups[2].Value; // Try to parse the version as a number for proper numeric ordering. // This ensures "10" sorts after "2" rather than before it (lexicographic). @@ -79,7 +225,8 @@ private static IEnumerable SortDatabases(IEnumerable databases) return new { - FirstPart = m.Groups[1].Value + " ", + OriginalName = name, + FirstPart = match.Groups[1].Value + " ", SecondPart = versionString, NumericVersion = numericVersion }; @@ -87,17 +234,134 @@ private static IEnumerable SortDatabases(IEnumerable databases) return new { - FirstPart = name, + OriginalName = name, + FirstPart = nameWithoutExt, SecondPart = "", NumericVersion = (int?)null }; }) - .OrderBy(n => n.FirstPart) - .ThenByDescending(n => n.NumericVersion ?? int.MinValue) - .ThenByDescending(n => n.SecondPart) - .Select(n => n.FirstPart + n.SecondPart); + .OrderBy(name => name.FirstPart) + .ThenByDescending(name => name.NumericVersion ?? int.MinValue) + .ThenByDescending(name => name.SecondPart) + .Select(name => name.OriginalName); } [GeneratedRegex("^(.+) (\\S+)$")] private static partial Regex SplitFileName(); + + private void DeleteDatabaseFiles(string fileName) + { + // Delete the .db together with its SQLite sidecars (.db-wal, .db-shm) so a re-import + // doesn't pick up stale write-ahead state. + var directory = new DirectoryInfo(_fileLocationOptions.DatabasePath); + + if (!directory.Exists) { return; } + + foreach (var file in directory.GetFiles($"{fileName}*")) + { + file.Delete(); + } + } + + private IEnumerable EnumerateDatabaseFileNames() + { + if (!Directory.Exists(_fileLocationOptions.DatabasePath)) { return []; } + + try + { + return Directory + .EnumerateFiles(_fileLocationOptions.DatabasePath, "*.db") + .Select(Path.GetFileName) + .Where(name => !string.IsNullOrEmpty(name)) + .Cast() + .ToList(); + } + catch (Exception ex) + { + _traceLogger.Warn($"{nameof(DatabaseService)}.{nameof(EnumerateDatabaseFileNames)} failed: {ex}"); + return []; + } + } + + private async Task<(int Imported, IReadOnlyList Failures)> ImportZipAsync( + string sourceZipPath, + string zipFileName, + CancellationToken cancellationToken) + { + var imported = 0; + var failures = new List(); + + ZipArchive archive; + + try + { + archive = await ZipFile.OpenReadAsync(sourceZipPath, cancellationToken); + } + catch (Exception ex) + { + failures.Add(new ImportFailure(zipFileName, $"Could not open archive: {ex.Message}")); + + _traceLogger.Warn( + $"{nameof(DatabaseService)}.{nameof(ImportZipAsync)} failed to open '{zipFileName}': {ex}"); + + return (imported, failures); + } + + await using (archive) + { + foreach (var entry in archive.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (string.IsNullOrEmpty(entry.Name)) { continue; } + + if (!Path.GetExtension(entry.Name).Equals(".db", StringComparison.OrdinalIgnoreCase)) { continue; } + + var destinationPath = Path.Join(_fileLocationOptions.DatabasePath, entry.Name); + + try + { + await using (var entryStream = await entry.OpenAsync(cancellationToken)) + { + await using (var fileStream = File.Create(destinationPath)) + { + await entryStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + } + } + + imported++; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + failures.Add(new ImportFailure(entry.Name, ex.Message)); + + _traceLogger.Warn( + $"{nameof(DatabaseService)}.{nameof(ImportZipAsync)} failed to extract '{entry.Name}' from '{zipFileName}': {ex}"); + + try + { + if (File.Exists(destinationPath)) + { + File.Delete(destinationPath); + } + } + catch (Exception cleanupEx) + { + _traceLogger.Warn( + $"{nameof(DatabaseService)}.{nameof(ImportZipAsync)} failed to clean up partial extract '{destinationPath}': {cleanupEx}"); + } + } + } + } + + return (imported, failures); + } + + private void PersistDisabled(ImmutableList snapshot) => + _preferences.DisabledDatabasesPreference = snapshot + .Where(entry => !entry.IsEnabled) + .Select(entry => entry.FileName) + .ToList(); + + private void RaiseEntriesChanged() => EntriesChanged?.Invoke(this, EventArgs.Empty); } diff --git a/src/EventLogExpert.UI/Services/EnabledDatabaseCollectionProvider.cs b/src/EventLogExpert.UI/Services/EnabledDatabaseCollectionProvider.cs deleted file mode 100644 index 8be6479d..00000000 --- a/src/EventLogExpert.UI/Services/EnabledDatabaseCollectionProvider.cs +++ /dev/null @@ -1,79 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -using EventLogExpert.Eventing.EventResolvers; -using EventLogExpert.Eventing.Helpers; -using EventLogExpert.UI.Interfaces; -using EventLogExpert.UI.Options; -using System.Collections.Immutable; - -namespace EventLogExpert.UI.Services; - -public interface IEnabledDatabaseCollectionProvider -{ - IList GetEnabledDatabases(); -} - -public class EnabledDatabaseCollectionProvider : IDatabaseCollectionProvider, IEnabledDatabaseCollectionProvider -{ - private readonly FileLocationOptions _fileLocationOptions; - private readonly IPreferencesProvider _preferencesProvider; - private readonly ITraceLogger _traceLogger; - - public EnabledDatabaseCollectionProvider( - FileLocationOptions fileLocationOptions, - IPreferencesProvider preferencesProvider, - ITraceLogger traceLogger) - { - _fileLocationOptions = fileLocationOptions; - _preferencesProvider = preferencesProvider; - _traceLogger = traceLogger; - SetActiveDatabases(GetEnabledDatabases().Select(d => Path.Join(_fileLocationOptions.DatabasePath, d))); - } - - public ImmutableList ActiveDatabases { get; private set; } = ImmutableList.Empty; - - /// - /// Returns the enabled database file names only. - /// - /// - public IList GetEnabledDatabases() - { - List databases = new(); - - try - { - if (Directory.Exists(_fileLocationOptions.DatabasePath)) - { - foreach (var item in Directory.EnumerateFiles(_fileLocationOptions.DatabasePath, "*.db")) - { - databases.Add(Path.GetFileName(item)); - } - } - } - catch (Exception ex) - { - _traceLogger.Warn($"{nameof(EnabledDatabaseCollectionProvider)}.{nameof(GetEnabledDatabases)} failed: {ex}"); - } - - var disabledDatabases = _preferencesProvider.DisabledDatabasesPreference; - - if (disabledDatabases.Any()) - { - databases.RemoveAll(enabled => disabledDatabases - .Any(disabled => string.Equals(enabled, disabled, StringComparison.OrdinalIgnoreCase))); - } - - return databases; - } - - /// - /// Complete file path must be specified here. - /// - /// - public void SetActiveDatabases(IEnumerable activeDatabases) - { - _traceLogger.Debug($"{nameof(EnabledDatabaseCollectionProvider)}.{nameof(SetActiveDatabases)} was called with {activeDatabases.Count()} databases."); - ActiveDatabases = activeDatabases.ToImmutableList(); - } -} diff --git a/src/EventLogExpert/App.xaml.cs b/src/EventLogExpert/App.xaml.cs index 9d5d968c..1046e709 100644 --- a/src/EventLogExpert/App.xaml.cs +++ b/src/EventLogExpert/App.xaml.cs @@ -1,13 +1,11 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. -using EventLogExpert.Eventing.EventResolvers; using EventLogExpert.Eventing.Helpers; using EventLogExpert.Services; using EventLogExpert.UI; using EventLogExpert.UI.Interfaces; using EventLogExpert.UI.Models; -using EventLogExpert.UI.Options; using EventLogExpert.UI.Services; using EventLogExpert.UI.Store.EventLog; using Fluxor; @@ -25,12 +23,9 @@ public sealed partial class App : Application public App( IDispatcher fluxorDispatcher, - IDatabaseCollectionProvider databaseCollectionProvider, IStateSelection> activeLogs, - IDatabaseService databaseService, ISettingsService settings, IAppTitleService appTitleService, - FileLocationOptions fileLocationOptions, ITraceLogger traceLogger, MauiMenuActionService menuActionService) { @@ -43,12 +38,9 @@ public App( _mainPage = new MainPage( fluxorDispatcher, - databaseCollectionProvider, activeLogs, - databaseService, settings, appTitleService, - fileLocationOptions, traceLogger, menuActionService); } diff --git a/src/EventLogExpert/MainPage.xaml.cs b/src/EventLogExpert/MainPage.xaml.cs index 4c488f3e..93b72a56 100644 --- a/src/EventLogExpert/MainPage.xaml.cs +++ b/src/EventLogExpert/MainPage.xaml.cs @@ -1,13 +1,11 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. -using EventLogExpert.Eventing.EventResolvers; using EventLogExpert.Eventing.Helpers; using EventLogExpert.Services; using EventLogExpert.UI; using EventLogExpert.UI.Interfaces; using EventLogExpert.UI.Models; -using EventLogExpert.UI.Options; using EventLogExpert.UI.Services; using EventLogExpert.UI.Store.EventLog; using EventLogExpert.UI.Store.EventTable; @@ -29,9 +27,6 @@ public sealed partial class MainPage : ContentPage, IDisposable { private readonly IStateSelection> _activeLogs; private readonly IAppTitleService _appTitleService; - private readonly IDatabaseCollectionProvider _databaseCollectionProvider; - private readonly IDatabaseService _databaseService; - private readonly FileLocationOptions _fileLocationOptions; private readonly MauiMenuActionService _menuActionService; private readonly ISettingsService _settings; private readonly ITraceLogger _traceLogger; @@ -41,12 +36,9 @@ public sealed partial class MainPage : ContentPage, IDisposable public MainPage( IDispatcher fluxorDispatcher, - IDatabaseCollectionProvider databaseCollectionProvider, IStateSelection> activeLogs, - IDatabaseService databaseService, ISettingsService settings, IAppTitleService appTitleService, - FileLocationOptions fileLocationOptions, ITraceLogger traceLogger, MauiMenuActionService menuActionService) { @@ -54,9 +46,6 @@ public MainPage( _activeLogs = activeLogs; _appTitleService = appTitleService; - _databaseCollectionProvider = databaseCollectionProvider; - _databaseService = databaseService; - _fileLocationOptions = fileLocationOptions; _settings = settings; _traceLogger = traceLogger; _menuActionService = menuActionService; @@ -64,7 +53,6 @@ public MainPage( _activeLogs.Select(state => state.ActiveLogs); _activeLogs.SelectedValueChanged += OnActiveLogsChanged; - _databaseService.LoadedDatabasesChanged += OnLoadedDatabasesChanged; _settings.ThemeChanged += OnThemeChanged; fluxorDispatcher.Dispatch(new EventTableAction.LoadColumns()); @@ -78,7 +66,6 @@ public void Dispose() { _disposed = true; _activeLogs.SelectedValueChanged -= OnActiveLogsChanged; - _databaseService.LoadedDatabasesChanged -= OnLoadedDatabasesChanged; _settings.ThemeChanged -= OnThemeChanged; } @@ -188,14 +175,6 @@ private void OnActiveLogsChanged(object? sender, ImmutableDictionary loadedProviders) - { - if (_disposed) { return; } - - _databaseCollectionProvider.SetActiveDatabases(loadedProviders.Select(path => - Path.Join(_fileLocationOptions.DatabasePath, path))); - } - private void OnThemeChanged() => // ThemeChanged may be raised from non-UI threads; marshal to the UI thread before touching // WebView2's profile. diff --git a/src/EventLogExpert/MauiProgram.cs b/src/EventLogExpert/MauiProgram.cs index 9a074b72..663e3c82 100644 --- a/src/EventLogExpert/MauiProgram.cs +++ b/src/EventLogExpert/MauiProgram.cs @@ -71,11 +71,13 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(static provider => + provider.GetRequiredService()); // Provider Services - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(static provider => + provider.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddTransient(); From 52f646910c3c7ef273b50a2e87b53660e28ba065 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Wed, 29 Apr 2026 23:58:21 -0500 Subject: [PATCH 06/31] Buffer SettingsModal toggles and surface per-entry import failures --- .../Shared/Components/SettingsModal.razor | 14 +- .../Shared/Components/SettingsModal.razor.cs | 191 ++++++++++-------- 2 files changed, 115 insertions(+), 90 deletions(-) diff --git a/src/EventLogExpert/Shared/Components/SettingsModal.razor b/src/EventLogExpert/Shared/Components/SettingsModal.razor index d184bbf2..63be15e2 100644 --- a/src/EventLogExpert/Shared/Components/SettingsModal.razor +++ b/src/EventLogExpert/Shared/Components/SettingsModal.razor @@ -36,24 +36,24 @@
- @if (_databases.Any()) + @if (DatabaseService.Entries.Count > 0) { - @foreach (var database in _databases.OrderBy(x => x.name)) + @foreach (var entry in DatabaseService.Entries) {
- @database.name + @entry.FileName - @if (!DatabaseService.LoadedDatabases.Contains(database.name)) + @if (!entry.IsEnabled || entry.Status != DatabaseStatus.Ready) { - }
- +
} } diff --git a/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs b/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs index 610213de..8c850190 100644 --- a/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs +++ b/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs @@ -4,26 +4,24 @@ using EventLogExpert.Shared.Base; using EventLogExpert.UI; using EventLogExpert.UI.Interfaces; -using EventLogExpert.UI.Options; +using EventLogExpert.UI.Models; using EventLogExpert.UI.Services; using EventLogExpert.UI.Store.EventLog; using Fluxor; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Logging; -using System.IO.Compression; using IDispatcher = Fluxor.IDispatcher; namespace EventLogExpert.Shared.Components; public sealed partial class SettingsModal : ModalBase { - private readonly List<(string name, bool isEnabled, bool hasChanged)> _databases = []; + private readonly Dictionary _pendingToggles = new(StringComparer.OrdinalIgnoreCase); private CopyType _copyType; - private bool _databaseRemoved; + private bool _databaseStateChanged; private bool _isPreReleaseEnabled; private LogLevel _logLevel; - private bool _shouldReload; private bool _showDisplayPaneOnSelectionChange; private Theme _theme; private string _timeZoneId = string.Empty; @@ -36,33 +34,39 @@ public sealed partial class SettingsModal : ModalBase [Inject] private IState EventLogState { get; init; } = null!; - [Inject] private FileLocationOptions FileLocationOptions { get; init; } = null!; - [Inject] private ISettingsService Settings { get; init; } = null!; - protected override async Task OnClosingAsync() + protected override async ValueTask DisposeAsyncCore(bool disposing) { - if (_databaseRemoved) + if (disposing) { - DatabaseService.UpdateDisabledDatabases(_databases.Where(db => !db.isEnabled).Select(db => db.name)); + DatabaseService.EntriesChanged -= OnDatabaseEntriesChanged; } - if (_shouldReload) + await base.DisposeAsyncCore(disposing); + } + + protected override async Task OnClosingAsync() + { + if (_databaseStateChanged) { await ReloadOpenLogs(); - _shouldReload = false; + _databaseStateChanged = false; } } protected override void OnInitialized() { LoadFromSettings(); + DatabaseService.EntriesChanged += OnDatabaseEntriesChanged; base.OnInitialized(); } protected override async Task OnSaveAsync() { + await ApplyPendingToggles(); + Settings.CopyType = _copyType; Settings.IsPreReleaseEnabled = _isPreReleaseEnabled; Settings.LogLevel = _logLevel; @@ -70,16 +74,64 @@ protected override async Task OnSaveAsync() Settings.Theme = _theme; Settings.TimeZoneId = _timeZoneId; - if (_databases.Any(database => database.hasChanged)) + await CompleteAsync(true); + } + + private static (string Title, string Message) BuildImportSummary(ImportResult importResult) + { + var imported = importResult.Imported; + var failures = importResult.Failures; + + if (failures.Count == 0) { - DatabaseService.UpdateDisabledDatabases(_databases.Where(db => !db.isEnabled).Select(db => db.name)); + var successMessage = imported > 1 + ? $"{imported} databases have successfully been imported" + : "1 database has successfully been imported"; - _shouldReload = true; + return ("Import Successful", successMessage); } - await CompleteAsync(true); + var failureLines = string.Join(Environment.NewLine, + failures.Select(failure => $"\u2022 {failure.FileName}: {failure.Reason}")); + + if (imported == 0) + { + return ("Import Failed", $"No databases were imported.{Environment.NewLine}{Environment.NewLine}Failed:{Environment.NewLine}{failureLines}"); + } + + var partialMessage = imported > 1 + ? $"{imported} databases imported successfully." + : "1 database imported successfully."; + + return ("Import Completed with Errors", + $"{partialMessage}{Environment.NewLine}{Environment.NewLine}Failed:{Environment.NewLine}{failureLines}"); } + private async Task ApplyPendingToggles() + { + if (_pendingToggles.Count == 0) { return; } + + var toApply = _pendingToggles.ToArray(); + _pendingToggles.Clear(); + + foreach (var (fileName, _) in toApply) + { + try + { + DatabaseService.Toggle(fileName); + } + catch (Exception ex) + { + await AlertDialogService.ShowAlert("Failed to Update Database", + $"An exception occurred while updating '{fileName}': {ex.Message}", + "OK"); + } + } + } + + private bool GetEffectiveEnabled(DatabaseEntry entry) => + _pendingToggles.TryGetValue(entry.FileName, out var pending) ? pending : entry.IsEnabled; + private async Task ImportDatabase() { PickOptions options = new() @@ -98,51 +150,33 @@ private async Task ImportDatabase() if (result.Length <= 0) { return; } - Directory.CreateDirectory(FileLocationOptions.DatabasePath); + var sourcePaths = result + .Where(item => item is not null && !string.IsNullOrEmpty(item.FullPath)) + .Select(item => item!.FullPath) + .ToList(); - List importedFiles = []; + var importResult = await DatabaseService.ImportAsync(sourcePaths); - foreach (var item in result) - { - if (string.IsNullOrEmpty(item?.FileName) || string.IsNullOrEmpty(item.FullPath)) { continue; } - - var destination = Path.Join(FileLocationOptions.DatabasePath, item.FileName); - File.Copy(item.FullPath, destination, true); - - importedFiles.Add(item.FileName); - - if (!Path.GetExtension(destination).Equals(".zip", StringComparison.OrdinalIgnoreCase)) { continue; } - - await ZipFile.ExtractToDirectoryAsync(destination, FileLocationOptions.DatabasePath, true); - File.Delete(destination); - } - - if (importedFiles.Count == 0) + if (importResult.Imported == 0 && importResult.Failures.Count == 0) { await AlertDialogService.ShowAlert("Import Failed", "No valid database files were selected.", "OK"); return; } - var message = importedFiles.Count > 1 ? - $"{importedFiles.Count} databases have successfully been imported" : - $"{importedFiles[0]} has successfully been imported"; + var (title, message) = BuildImportSummary(importResult); + await AlertDialogService.ShowAlert(title, message, "OK"); - await AlertDialogService.ShowAlert("Import Successful", message, "OK"); - - await InvokeAsync(() => CompleteAsync(true)); + if (importResult.Imported > 0) + { + await InvokeAsync(OnSaveAsync); + } } catch (Exception ex) { await AlertDialogService.ShowAlert("Import Failed", $"An exception occurred while importing provider databases: {ex.Message}", "OK"); - - return; } - - DatabaseService.LoadDatabases(); - - await ReloadOpenLogs(); } private void LoadFromSettings() @@ -153,27 +187,12 @@ private void LoadFromSettings() _showDisplayPaneOnSelectionChange = Settings.ShowDisplayPaneOnSelectionChange; _theme = Settings.Theme; _timeZoneId = Settings.TimeZoneId; + } - _databases.Clear(); - - foreach (var database in DatabaseService.LoadedDatabases) - { - _databases.Add((database, true, false)); - } - - foreach (var database in DatabaseService.DisabledDatabases) - { - var index = _databases.FindIndex(x => string.Equals(x.name, database)); - - if (index < 0) - { - _databases.Add((database, false, false)); - } - else - { - _databases[index] = (database, false, false); - } - } + private void OnDatabaseEntriesChanged(object? sender, EventArgs e) + { + _databaseStateChanged = true; + _ = InvokeAsync(StateHasChanged); } private async Task ReloadOpenLogs() @@ -197,21 +216,19 @@ private async Task ReloadOpenLogs() } } - private async Task RemoveDatabase(string name) + private async Task RemoveDatabase(string fileName) { - try - { - var databaseDirectory = new DirectoryInfo(FileLocationOptions.DatabasePath); - - // Using wildcard to also remove the db-shm and db-wal files - foreach (var file in databaseDirectory.GetFiles($"{name}*")) - { - file.Delete(); - } + var confirmed = await AlertDialogService.ShowAlert("Remove Database", + $"Are you sure you want to remove {fileName}?", + "Remove", + "Cancel"); - _databases.RemoveAll(db => string.Equals(db.name, name)); + if (!confirmed) { return; } - _databaseRemoved = true; + try + { + DatabaseService.Remove(fileName); + _pendingToggles.Remove(fileName); } catch (Exception ex) { @@ -221,14 +238,22 @@ await AlertDialogService.ShowAlert("Failed to Remove Database", } } - private void ToggleDatabase(string database) + private void ToggleDatabase(string fileName) { - var index = _databases.FindIndex(x => string.Equals(x.name, database)); + var entry = DatabaseService.Entries.FirstOrDefault( + e => string.Equals(e.FileName, fileName, StringComparison.OrdinalIgnoreCase)); - if (index < 0) { return; } + if (entry is null) { return; } - var db = _databases[index]; + var newValue = !GetEffectiveEnabled(entry); - _databases[index] = (db.name, !db.isEnabled, !db.hasChanged); + if (newValue == entry.IsEnabled) + { + _pendingToggles.Remove(fileName); + } + else + { + _pendingToggles[fileName] = newValue; + } } } From 20b96d7ec7703f733989ffef9f8e5a132fb313fe Mon Sep 17 00:00:00 2001 From: jschick04 Date: Thu, 30 Apr 2026 15:00:00 -0500 Subject: [PATCH 07/31] Treat ProviderDetails-less databases as Unknown schema --- .../EventProviderDatabase/EventProviderDbContext.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/EventLogExpert.Eventing/EventProviderDatabase/EventProviderDbContext.cs b/src/EventLogExpert.Eventing/EventProviderDatabase/EventProviderDbContext.cs index edcb7c89..b444cf29 100644 --- a/src/EventLogExpert.Eventing/EventProviderDatabase/EventProviderDbContext.cs +++ b/src/EventLogExpert.Eventing/EventProviderDatabase/EventProviderDbContext.cs @@ -110,8 +110,12 @@ public ProviderDatabaseSchemaState IsUpgradeNeeded() if (!hasAnyColumn) { - // Empty file or unrelated database — no upgrade needed. - currentVersion = ProviderDatabaseSchemaVersion.Current; + // No ProviderDetails table at all — could be an empty SQLite file or an unrelated + // database. Either way it is not one of our recognized schemas. Report Unknown so + // PerformUpgradeIfNeeded fails closed instead of pretending to be Current and + // letting EventResolver later crash on `ProviderDetails.FirstOrDefault(...)` + // with "no such table". + currentVersion = ProviderDatabaseSchemaVersion.Unknown; } else { From 2776e4615d826362fd3bdeb05c940a5aa3d6e530 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Thu, 30 Apr 2026 14:59:45 -0500 Subject: [PATCH 08/31] Quarantine empty and unrecognized provider databases at classification time --- .../Services/DatabaseServiceTests.cs | 200 +++++++++++++++++- .../TestUtils/DatabaseSeedUtils.cs | 104 +++++++++ src/EventLogExpert.UI/Enums.cs | 3 +- .../Interfaces/IDatabaseService.cs | 8 + .../Services/DatabaseService.cs | 128 ++++++++++- 5 files changed, 439 insertions(+), 4 deletions(-) create mode 100644 src/EventLogExpert.UI.Tests/TestUtils/DatabaseSeedUtils.cs diff --git a/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs b/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs index d9031b0c..040b092a 100644 --- a/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs +++ b/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs @@ -5,7 +5,9 @@ using EventLogExpert.UI.Interfaces; using EventLogExpert.UI.Options; using EventLogExpert.UI.Services; +using EventLogExpert.UI.Tests.TestUtils; using EventLogExpert.UI.Tests.TestUtils.Constants; +using Microsoft.Data.Sqlite; using NSubstitute; using System.IO.Compression; @@ -44,6 +46,190 @@ public void ActiveDatabases_ShouldReturnFullPathsOfEnabledReadyEntriesOnly() Assert.Equal(Path.Join(databasePath, Constants.TestDb1), activeDatabases[0]); } + [Fact] + public async Task ClassifyEntriesAsync_WhenAnyStatusChanges_ShouldRaiseEntriesChangedExactlyOnce() + { + var databasePath = CreateDatabaseDirectory(); + DatabaseSeedUtils.SeedV3Schema(Path.Combine(databasePath, Constants.TestDb1)); + DatabaseSeedUtils.SeedV3Schema(Path.Combine(databasePath, Constants.TestDb2)); + + var service = CreateDatabaseService(); + + var raisedCount = 0; + service.EntriesChanged += (_, _) => raisedCount++; + + await service.ClassifyEntriesAsync(TestContext.Current.CancellationToken); + + Assert.Equal(1, raisedCount); + } + + [Fact] + public async Task ClassifyEntriesAsync_WhenEmptyFile_ShouldClassifyAsUnrecognizedSchemaWithoutMutation() + { + // An empty file would otherwise classify as Ready (PRAGMA inspection sees no tables → + // currentVersion=Current → no upgrade needed). EventResolver would then EnsureCreated + // it on first read, silently rewriting it as a V4 schema. Force it into UnrecognizedSchema + // so the user sees the bad file in Settings instead of having it overwritten invisibly. + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); + + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + var sizeBefore = new FileInfo(dbPath).Length; + + var service = CreateDatabaseService(); + + await service.ClassifyEntriesAsync(TestContext.Current.CancellationToken); + + SqliteConnection.ClearAllPools(); + var sizeAfter = new FileInfo(dbPath).Length; + + Assert.Equal(sizeBefore, sizeAfter); + Assert.False(File.Exists(dbPath + "-wal"), "WAL sidecar should not be created during classification."); + Assert.False(File.Exists(dbPath + "-shm"), "SHM sidecar should not be created during classification."); + + var entry = Assert.Single(service.Entries); + Assert.Equal(DatabaseStatus.UnrecognizedSchema, entry.Status); + } + + [Theory] + [InlineData("v1.db")] + [InlineData("v2.db")] + public async Task ClassifyEntriesAsync_WhenLegacySchema_ShouldDetectAsObsoleteSchema(string fileName) + { + var databasePath = CreateDatabaseDirectory(); + var dbPath = Path.Combine(databasePath, fileName); + + if (fileName == "v1.db") + { + DatabaseSeedUtils.SeedV1Schema(dbPath); + } + else + { + DatabaseSeedUtils.SeedV2Schema(dbPath); + } + + var service = CreateDatabaseService(); + + await service.ClassifyEntriesAsync(TestContext.Current.CancellationToken); + + var entry = Assert.Single(service.Entries); + Assert.Equal(DatabaseStatus.ObsoleteSchema, entry.Status); + } + + [Fact] + public async Task ClassifyEntriesAsync_WhenNoStatusesChange_ShouldNotRaiseEntriesChanged() + { + var databasePath = CreateDatabaseDirectory(); + DatabaseSeedUtils.SeedV4Schema(Path.Combine(databasePath, Constants.TestDb1)); + + var service = CreateDatabaseService(); + + var raisedCount = 0; + service.EntriesChanged += (_, _) => raisedCount++; + + await service.ClassifyEntriesAsync(TestContext.Current.CancellationToken); + + // V4 → Ready, but Ready is also the default Refresh status, so nothing actually changes. + Assert.Equal(0, raisedCount); + } + + [Fact] + public async Task ClassifyEntriesAsync_WhenOneEntryFails_ShouldQuarantineAsClassificationFailed() + { + var databasePath = CreateDatabaseDirectory(); + + var v3Path = Path.Combine(databasePath, "v3.db"); + DatabaseSeedUtils.SeedV3Schema(v3Path); + + var lockedPath = Path.Combine(databasePath, "locked.db"); + DatabaseSeedUtils.SeedV3Schema(lockedPath); + + // Hold an exclusive lock on the second file so EventProviderDbContext cannot open it. + // The classification pass must mark the locked entry as ClassificationFailed so the + // resolver pipeline cannot consume it later (a Ready status would crash IEventResolver + // when it tried to open the same locked file). + using var blockingHandle = new FileStream( + lockedPath, + FileMode.Open, + FileAccess.ReadWrite, + FileShare.None); + + var service = CreateDatabaseService(); + + await service.ClassifyEntriesAsync(TestContext.Current.CancellationToken); + + Assert.Equal(2, service.Entries.Count); + var v3Entry = service.Entries.Single(entry => entry.FileName == "v3.db"); + var lockedEntry = service.Entries.Single(entry => entry.FileName == "locked.db"); + + Assert.Equal(DatabaseStatus.UpgradeRequired, v3Entry.Status); + Assert.Equal(DatabaseStatus.ClassificationFailed, lockedEntry.Status); + + // ClassificationFailed must be excluded from ActiveDatabases so the resolver pipeline + // never tries to open the file. + Assert.DoesNotContain(lockedPath, service.ActiveDatabases); + } + + [Fact] + public async Task ClassifyEntriesAsync_WhenSqliteFileWithoutProviderDetailsTable_ShouldClassifyAsUnrecognizedSchema() + { + // A valid SQLite file that lacks the ProviderDetails table is not one of our schemas + // (V1/V2/V3/V4). Without quarantine, EventResolver would later crash on + // ProviderDetails.FirstOrDefault(...) with "no such table". Force UnrecognizedSchema so + // ActiveDatabases excludes it before the resolver pipeline ever sees it. + var databasePath = CreateDatabaseDirectory(); + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + + await using (var connection = new SqliteConnection($"Data Source={dbPath}")) + { + await connection.OpenAsync(TestContext.Current.CancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = "CREATE TABLE \"SomeOtherTable\" (\"Id\" INTEGER PRIMARY KEY, \"Value\" TEXT);"; + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + SqliteConnection.ClearAllPools(); + + var service = CreateDatabaseService(); + + await service.ClassifyEntriesAsync(TestContext.Current.CancellationToken); + + var entry = Assert.Single(service.Entries); + Assert.Equal(DatabaseStatus.UnrecognizedSchema, entry.Status); + Assert.DoesNotContain(dbPath, service.ActiveDatabases); + } + + [Fact] + public async Task ClassifyEntriesAsync_WhenV3Schema_ShouldDetectAsUpgradeRequired() + { + var databasePath = CreateDatabaseDirectory(); + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + DatabaseSeedUtils.SeedV3Schema(dbPath); + + var service = CreateDatabaseService(); + + await service.ClassifyEntriesAsync(TestContext.Current.CancellationToken); + + var entry = Assert.Single(service.Entries); + Assert.Equal(DatabaseStatus.UpgradeRequired, entry.Status); + } + + [Fact] + public async Task ClassifyEntriesAsync_WhenV4Schema_ShouldDetectAsReady() + { + var databasePath = CreateDatabaseDirectory(); + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + DatabaseSeedUtils.SeedV4Schema(dbPath); + + var service = CreateDatabaseService(); + + await service.ClassifyEntriesAsync(TestContext.Current.CancellationToken); + + var entry = Assert.Single(service.Entries); + Assert.Equal(DatabaseStatus.Ready, entry.Status); + } + [Fact] public void Constructor_WhenCalled_ShouldSeedEntriesFromDisk() { @@ -110,9 +296,21 @@ public void Constructor_WhenNonDbFilesPresent_ShouldOnlyIncludeDbFiles() public void Dispose() { + // SQLite connections opened during ClassifyEntriesAsync are pooled; on Windows the pool + // keeps the file handle open after `Database.CloseConnection`, blocking the recursive + // delete below. Drop all pools before cleanup. + SqliteConnection.ClearAllPools(); + if (Directory.Exists(_testDirectory)) { - Directory.Delete(_testDirectory, true); + try + { + Directory.Delete(_testDirectory, true); + } + catch (IOException) + { + // Best-effort cleanup; a residual SQLite handle should not fail an otherwise-passing test. + } } } diff --git a/src/EventLogExpert.UI.Tests/TestUtils/DatabaseSeedUtils.cs b/src/EventLogExpert.UI.Tests/TestUtils/DatabaseSeedUtils.cs new file mode 100644 index 00000000..afcdc347 --- /dev/null +++ b/src/EventLogExpert.UI.Tests/TestUtils/DatabaseSeedUtils.cs @@ -0,0 +1,104 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.EventProviderDatabase; +using EventLogExpert.Eventing.Helpers; +using Microsoft.Data.Sqlite; + +namespace EventLogExpert.UI.Tests.TestUtils; + +internal static class DatabaseSeedUtils +{ + /// Seeds the V1 ProviderDetails schema (TEXT payload columns, no Parameters column). + /// V1 is the "non-upgradable" leg — it should classify as ObsoleteSchema, NOT UpgradeRequired. + internal static void SeedV1Schema(string dbPath) + { + using (var connection = new SqliteConnection($"Data Source={dbPath}")) + { + connection.Open(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = + "CREATE TABLE \"ProviderDetails\" (" + + "\"ProviderName\" TEXT NOT NULL CONSTRAINT \"PK_ProviderDetails\" PRIMARY KEY, " + + "\"Messages\" TEXT NOT NULL, " + + "\"Events\" TEXT NOT NULL, " + + "\"Keywords\" TEXT NOT NULL, " + + "\"Opcodes\" TEXT NOT NULL, " + + "\"Tasks\" TEXT NOT NULL)"; + cmd.ExecuteNonQuery(); + } + + SqliteConnection.ClearAllPools(); + } + + /// Seeds the V2 ProviderDetails schema (TEXT payload columns, TEXT Parameters column). + /// V2 added Parameters as TEXT JSON before the V3 BLOB rewrite. Should also classify as + /// ObsoleteSchema per Phase B policy. Layout matches EventProviderDbContext.IsUpgradeNeeded + /// detection logic: `payloadColumnsAllText && IsType(parametersType, "TEXT")` → version 2. + internal static void SeedV2Schema(string dbPath) + { + using (var connection = new SqliteConnection($"Data Source={dbPath}")) + { + connection.Open(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = + "CREATE TABLE \"ProviderDetails\" (" + + "\"ProviderName\" TEXT NOT NULL CONSTRAINT \"PK_ProviderDetails\" PRIMARY KEY, " + + "\"Messages\" TEXT NOT NULL, " + + "\"Parameters\" TEXT NOT NULL, " + + "\"Events\" TEXT NOT NULL, " + + "\"Keywords\" TEXT NOT NULL, " + + "\"Opcodes\" TEXT NOT NULL, " + + "\"Tasks\" TEXT NOT NULL)"; + cmd.ExecuteNonQuery(); + } + + SqliteConnection.ClearAllPools(); + } + + /// Seeds an empty file with the V3 ProviderDetails schema (BLOB payload columns, + /// no ResolvedFromOwningPublisher column, default BINARY collation on the PK). Built by raw + /// SQL so a subsequent EnsureCreated() sees the table as already-existing and skips its V4 + /// schema generation, exposing the legacy V3 shape to detection / upgrade paths. Mirrors + /// EventProviderDbContextTests.SeedV3Schema. + internal static void SeedV3Schema(string dbPath) + { + using (var connection = new SqliteConnection($"Data Source={dbPath}")) + { + connection.Open(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = + "CREATE TABLE \"ProviderDetails\" (" + + "\"ProviderName\" TEXT NOT NULL CONSTRAINT \"PK_ProviderDetails\" PRIMARY KEY, " + + "\"Messages\" BLOB NOT NULL, " + + "\"Parameters\" BLOB NOT NULL, " + + "\"Events\" BLOB NOT NULL, " + + "\"Keywords\" BLOB NOT NULL, " + + "\"Opcodes\" BLOB NOT NULL, " + + "\"Tasks\" BLOB NOT NULL)"; + cmd.ExecuteNonQuery(); + } + + // Seed methods are used by tests that subsequently lock the file, copy/restore it, or + // delete it; SqliteConnection pooling keeps the OS handle alive after the using-block + // disposes the managed wrapper. Drop the pool here so callers can manipulate the file + // immediately. + SqliteConnection.ClearAllPools(); + } + + /// Creates a fully-formed V4 schema by letting EnsureCreated build it on a brand-new + /// file. Returns immediately after the context is disposed — the resulting .db reflects the + /// current production schema. + internal static void SeedV4Schema(string dbPath, ITraceLogger? logger = null) + { + using (var context = new EventProviderDbContext(dbPath, readOnly: false, ensureCreated: true, logger)) + { + context.Database.EnsureCreated(); + } + + SqliteConnection.ClearAllPools(); + } +} diff --git a/src/EventLogExpert.UI/Enums.cs b/src/EventLogExpert.UI/Enums.cs index d23b0fb6..fefd3ecf 100644 --- a/src/EventLogExpert.UI/Enums.cs +++ b/src/EventLogExpert.UI/Enums.cs @@ -41,7 +41,8 @@ public enum DatabaseStatus UpgradeRequired, UpgradeFailed, UnrecognizedSchema, - ObsoleteSchema + ObsoleteSchema, + ClassificationFailed } public enum HighlightColor diff --git a/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs b/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs index cd1ab12f..4b68b2fb 100644 --- a/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs +++ b/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs @@ -11,6 +11,14 @@ public interface IDatabaseService IReadOnlyList Entries { get; } + /// + /// Inspects each on disk (in read-only mode without auto-creating a schema) and + /// updates its from the on-disk schema version. Per-entry classification + /// failures (e.g., file locked, file missing) leave that entry at its current status and continue with the rest. + /// Raises exactly once when the pass completes. + /// + Task ClassifyEntriesAsync(CancellationToken cancellationToken = default); + Task ImportAsync(IEnumerable sourceFilePaths, CancellationToken cancellationToken = default); void MarkStatus(string fileName, DatabaseStatus status); diff --git a/src/EventLogExpert.UI/Services/DatabaseService.cs b/src/EventLogExpert.UI/Services/DatabaseService.cs index 073ae5df..c5256da6 100644 --- a/src/EventLogExpert.UI/Services/DatabaseService.cs +++ b/src/EventLogExpert.UI/Services/DatabaseService.cs @@ -1,6 +1,7 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. +using EventLogExpert.Eventing.EventProviderDatabase; using EventLogExpert.Eventing.EventResolvers; using EventLogExpert.Eventing.Helpers; using EventLogExpert.UI.Interfaces; @@ -43,6 +44,106 @@ public DatabaseService( public IReadOnlyList Entries => _entries; + public async Task ClassifyEntriesAsync(CancellationToken cancellationToken = default) + { + // Snapshot under the lock so concurrent Refresh/Toggle/Remove calls cannot trigger an + // index-out-of-range during the IO pass below. + ImmutableList snapshot; + + lock (_mutationLock) + { + snapshot = _entries; + } + + if (snapshot.Count == 0) { return; } + + // Whole pass runs on a worker thread because IsUpgradeNeeded performs synchronous SQLite IO + // (PRAGMA queries via blocking ADO.NET). Driving it from the UI thread would block render. + var statuses = await Task.Run( + () => + { + var perFile = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in snapshot) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + // Empty/missing files would otherwise classify as Ready (IsUpgradeNeeded sees + // no columns → currentVersion=Current → no upgrade needed). EventResolver + // would then call EnsureCreated on first access and silently rewrite the + // file with a V4 schema. Force these into UnrecognizedSchema so they are + // quarantined and surfaced in Settings instead of being mutated invisibly. + if (!File.Exists(entry.FullPath) || new FileInfo(entry.FullPath).Length == 0) + { + perFile[entry.FileName] = DatabaseStatus.UnrecognizedSchema; + continue; + } + + // ensureCreated:false so a missing/empty file is NOT auto-populated with the V4 schema + // during inspection — that would silently mask the very thing classification is for. + using var context = new EventProviderDbContext( + entry.FullPath, + readOnly: false, + ensureCreated: false, + _traceLogger); + + var state = context.IsUpgradeNeeded(); + perFile[entry.FileName] = MapSchemaVersionToStatus(state.CurrentVersion); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Per-entry failure (file locked by another process, file deleted between + // existence check and open, corrupt SQLite header, etc.) — quarantine the + // entry so resolver loading cannot consume it later. Without this, the + // entry would stay at its default Ready status from Refresh and EventResolver + // would crash when it tries to open the same DB. + perFile[entry.FileName] = DatabaseStatus.ClassificationFailed; + + _traceLogger.Warn( + $"{nameof(DatabaseService)}.{nameof(ClassifyEntriesAsync)} failed to classify '{entry.FileName}': {ex}"); + } + } + + return perFile; + }, + cancellationToken).ConfigureAwait(false); + + if (statuses.Count == 0) { return; } + + var changed = false; + + lock (_mutationLock) + { + // Re-resolve indexes against the current snapshot — Refresh/Remove may have run while + // classification was on the worker thread. + var builder = _entries.ToBuilder(); + + for (var i = 0; i < builder.Count; i++) + { + var entry = builder[i]; + + if (!statuses.TryGetValue(entry.FileName, out var newStatus)) { continue; } + + if (entry.Status == newStatus) { continue; } + + builder[i] = entry with { Status = newStatus }; + changed = true; + } + + if (changed) + { + _entries = builder.ToImmutable(); + } + } + + if (changed) + { + RaiseEntriesChanged(); + } + } + public async Task ImportAsync( IEnumerable sourceFilePaths, CancellationToken cancellationToken = default) @@ -89,9 +190,23 @@ public async Task ImportAsync( } } - if (importedCount > 0) + if (importedCount <= 0) { - Refresh(); + return new ImportResult(importedCount, failures); + } + + Refresh(); + + // Classify so freshly-imported V3 databases surface in settings as UpgradeRequired + // (with the "restart required to upgrade" UX) rather than appearing as Ready. + try + { + await ClassifyEntriesAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _traceLogger.Warn( + $"{nameof(DatabaseService)}.{nameof(ImportAsync)} post-import classification failed: {ex}"); } return new ImportResult(importedCount, failures); @@ -199,6 +314,15 @@ private static int FindEntryIndex(ImmutableList snapshot, string private static bool IsActive(DatabaseEntry entry) => entry.IsEnabled && entry.Status == DatabaseStatus.Ready; + private static DatabaseStatus MapSchemaVersionToStatus(int currentVersion) => + currentVersion switch + { + ProviderDatabaseSchemaVersion.Current => DatabaseStatus.Ready, + 3 => DatabaseStatus.UpgradeRequired, + 1 or 2 => DatabaseStatus.ObsoleteSchema, + _ => DatabaseStatus.UnrecognizedSchema, + }; + private static IEnumerable SortDatabases(IEnumerable databases) { if (!databases.Any()) { return []; } From d66eeffaf793e3123adb255fc5e854dac40821be Mon Sep 17 00:00:00 2001 From: jschick04 Date: Fri, 1 May 2026 11:10:00 -0500 Subject: [PATCH 09/31] Default DatabaseEntry status to NotClassified and surface BackupExists --- .../Services/DatabaseServiceTests.cs | 12 +++++++----- src/EventLogExpert.UI/Enums.cs | 1 + src/EventLogExpert.UI/Models/DatabaseEntry.cs | 7 ++++++- .../Services/DatabaseService.cs | 19 ++++++++++++------- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs b/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs index 040b092a..1e51d76c 100644 --- a/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs +++ b/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs @@ -36,6 +36,8 @@ public void ActiveDatabases_ShouldReturnFullPathsOfEnabledReadyEntriesOnly() preferences.DisabledDatabasesPreference.Returns([Constants.TestDb2]); var service = CreateDatabaseService(preferences); + service.MarkStatus(Constants.TestDb1, DatabaseStatus.Ready); + service.MarkStatus(Constants.TestDb2, DatabaseStatus.Ready); service.MarkStatus(Constants.TestDb3, DatabaseStatus.UpgradeRequired); // Act @@ -123,13 +125,13 @@ public async Task ClassifyEntriesAsync_WhenNoStatusesChange_ShouldNotRaiseEntrie DatabaseSeedUtils.SeedV4Schema(Path.Combine(databasePath, Constants.TestDb1)); var service = CreateDatabaseService(); + service.MarkStatus(Constants.TestDb1, DatabaseStatus.Ready); var raisedCount = 0; service.EntriesChanged += (_, _) => raisedCount++; await service.ClassifyEntriesAsync(TestContext.Current.CancellationToken); - // V4 → Ready, but Ready is also the default Refresh status, so nothing actually changes. Assert.Equal(0, raisedCount); } @@ -246,7 +248,8 @@ public void Constructor_WhenCalled_ShouldSeedEntriesFromDisk() Assert.Contains(service.Entries, entry => entry.FileName == Constants.TestDb1); Assert.Contains(service.Entries, entry => entry.FileName == Constants.TestDb2); Assert.All(service.Entries, entry => Assert.True(entry.IsEnabled)); - Assert.All(service.Entries, entry => Assert.Equal(DatabaseStatus.Ready, entry.Status)); + Assert.All(service.Entries, entry => Assert.Equal(DatabaseStatus.NotClassified, entry.Status)); + Assert.All(service.Entries, entry => Assert.False(entry.BackupExists)); } [Fact] @@ -573,18 +576,17 @@ public void MarkStatus_WhenStatusChanges_ShouldUpdateAndRaiseEntriesChanged() [Fact] public void MarkStatus_WhenStatusUnchanged_ShouldNotRaiseEntriesChanged() { - // Arrange var databasePath = CreateDatabaseDirectory(); CreateDatabaseFile(databasePath, Constants.TestDb1); var service = CreateDatabaseService(); + service.MarkStatus(Constants.TestDb1, DatabaseStatus.Ready); + var raisedCount = 0; service.EntriesChanged += (_, _) => raisedCount++; - // Act (entry already Ready) service.MarkStatus(Constants.TestDb1, DatabaseStatus.Ready); - // Assert Assert.Equal(0, raisedCount); } diff --git a/src/EventLogExpert.UI/Enums.cs b/src/EventLogExpert.UI/Enums.cs index fefd3ecf..d69d2bc2 100644 --- a/src/EventLogExpert.UI/Enums.cs +++ b/src/EventLogExpert.UI/Enums.cs @@ -37,6 +37,7 @@ public enum CopyType public enum DatabaseStatus { + NotClassified, Ready, UpgradeRequired, UpgradeFailed, diff --git a/src/EventLogExpert.UI/Models/DatabaseEntry.cs b/src/EventLogExpert.UI/Models/DatabaseEntry.cs index 9eaf0c3b..5f923961 100644 --- a/src/EventLogExpert.UI/Models/DatabaseEntry.cs +++ b/src/EventLogExpert.UI/Models/DatabaseEntry.cs @@ -3,4 +3,9 @@ namespace EventLogExpert.UI.Models; -public sealed record DatabaseEntry(string FileName, string FullPath, bool IsEnabled, DatabaseStatus Status); +public sealed record DatabaseEntry( + string FileName, + string FullPath, + bool IsEnabled, + DatabaseStatus Status, + bool BackupExists = false); diff --git a/src/EventLogExpert.UI/Services/DatabaseService.cs b/src/EventLogExpert.UI/Services/DatabaseService.cs index c5256da6..827275ca 100644 --- a/src/EventLogExpert.UI/Services/DatabaseService.cs +++ b/src/EventLogExpert.UI/Services/DatabaseService.cs @@ -249,13 +249,18 @@ public void Refresh() var sortedFileNames = SortDatabases(fileNames); ImmutableList nextSnapshot = sortedFileNames - .Select(fileName => new DatabaseEntry( - fileName, - Path.Join(_fileLocationOptions.DatabasePath, fileName), - !disabled.Contains(fileName), - existingByFileName.TryGetValue(fileName, out var existing) - ? existing.Status - : DatabaseStatus.Ready)) + .Select(fileName => + { + var isEnabled = !disabled.Contains(fileName); + + return existingByFileName.TryGetValue(fileName, out var existing) + ? existing with { IsEnabled = isEnabled } + : new DatabaseEntry( + fileName, + Path.Join(_fileLocationOptions.DatabasePath, fileName), + isEnabled, + DatabaseStatus.NotClassified); + }) .ToImmutableList(); _entries = nextSnapshot; From 60af27e4c1f7f8fb141559cb962dbfb45bbf536c Mon Sep 17 00:00:00 2001 From: jschick04 Date: Fri, 1 May 2026 12:17:32 -0500 Subject: [PATCH 10/31] Expose non-faulting InitialClassificationTask on IDatabaseService --- .../Services/DatabaseServiceTests.cs | 131 +++++++++++++++++- .../Interfaces/IDatabaseService.cs | 9 ++ .../Services/DatabaseService.cs | 34 ++++- 3 files changed, 163 insertions(+), 11 deletions(-) diff --git a/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs b/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs index 1e51d76c..77a86ca0 100644 --- a/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs +++ b/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs @@ -57,6 +57,12 @@ public async Task ClassifyEntriesAsync_WhenAnyStatusChanges_ShouldRaiseEntriesCh var service = CreateDatabaseService(); + // Reset to NotClassified so the explicit ClassifyEntriesAsync call below produces real + // status changes (V3 → UpgradeRequired). The CreateDatabaseService helper already drained + // the ctor-initiated classification. + service.MarkStatus(Constants.TestDb1, DatabaseStatus.NotClassified); + service.MarkStatus(Constants.TestDb2, DatabaseStatus.NotClassified); + var raisedCount = 0; service.EntriesChanged += (_, _) => raisedCount++; @@ -248,7 +254,6 @@ public void Constructor_WhenCalled_ShouldSeedEntriesFromDisk() Assert.Contains(service.Entries, entry => entry.FileName == Constants.TestDb1); Assert.Contains(service.Entries, entry => entry.FileName == Constants.TestDb2); Assert.All(service.Entries, entry => Assert.True(entry.IsEnabled)); - Assert.All(service.Entries, entry => Assert.Equal(DatabaseStatus.NotClassified, entry.Status)); Assert.All(service.Entries, entry => Assert.False(entry.BackupExists)); } @@ -537,6 +542,117 @@ public async Task ImportAsync_WhenZipIsMalformed_ShouldReturnFailureAndNotLeakFi Assert.Empty(Directory.GetFiles(databasePath)); } + [Fact] + public async Task InitialClassificationTask_NeverFaults_EvenWhenAllEntriesThrow() + { + var databasePath = CreateDatabaseDirectory(); + var db1Path = Path.Combine(databasePath, Constants.TestDb1); + var db2Path = Path.Combine(databasePath, Constants.TestDb2); + DatabaseSeedUtils.SeedV3Schema(db1Path); + DatabaseSeedUtils.SeedV3Schema(db2Path); + + // Hold exclusive locks on every DB file so EventProviderDbContext cannot open any of them. + // The per-entry catch must turn each failure into ClassificationFailed and the outer + // wrapper must keep the exposed task in RanToCompletion. + using var handle1 = new FileStream(db1Path, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + using var handle2 = new FileStream(db2Path, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + var service = CreateDatabaseService(); + + await service.InitialClassificationTask; + + Assert.Equal(TaskStatus.RanToCompletion, service.InitialClassificationTask.Status); + Assert.Equal(2, service.Entries.Count); + Assert.All(service.Entries, entry => Assert.Equal(DatabaseStatus.ClassificationFailed, entry.Status)); + } + + [Fact] + public async Task InitialClassificationTask_WhenAllSchemasValid_CompletesSuccessfullyAndPopulatesStatuses() + { + var databasePath = CreateDatabaseDirectory(); + DatabaseSeedUtils.SeedV4Schema(Path.Combine(databasePath, Constants.TestDb1)); + DatabaseSeedUtils.SeedV3Schema(Path.Combine(databasePath, Constants.TestDb2)); + + var service = CreateDatabaseService(); + + await service.InitialClassificationTask; + + Assert.Equal(TaskStatus.RanToCompletion, service.InitialClassificationTask.Status); + var v4 = service.Entries.Single(entry => entry.FileName == Constants.TestDb1); + var v3 = service.Entries.Single(entry => entry.FileName == Constants.TestDb2); + Assert.Equal(DatabaseStatus.Ready, v4.Status); + Assert.Equal(DatabaseStatus.UpgradeRequired, v3.Status); + } + + [Fact] + public async Task InitialClassificationTask_WhenLoggerThrowsOnWarn_StillCompletesAndAppliesStatuses() + { + var databasePath = CreateDatabaseDirectory(); + var db1Path = Path.Combine(databasePath, Constants.TestDb1); + var db2Path = Path.Combine(databasePath, Constants.TestDb2); + DatabaseSeedUtils.SeedV3Schema(db1Path); + DatabaseSeedUtils.SeedV3Schema(db2Path); + + // Force per-entry classification failures so the per-entry catch fires Warn for every entry. + using var handle1 = new FileStream(db1Path, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + using var handle2 = new FileStream(db2Path, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + // Logger throws on every Warn — simulates debug.log being locked. Without SafeLog the + // per-entry catch would propagate, faulting the worker before statuses are applied. + var throwingLogger = Substitute.For(); + throwingLogger.When(logger => logger.Warn(Arg.Any())) + .Do(_ => throw new IOException("simulated log file lock")); + + var service = CreateDatabaseService(traceLogger: throwingLogger); + + await service.InitialClassificationTask; + + Assert.Equal(TaskStatus.RanToCompletion, service.InitialClassificationTask.Status); + Assert.Equal(2, service.Entries.Count); + Assert.All(service.Entries, entry => Assert.Equal(DatabaseStatus.ClassificationFailed, entry.Status)); + } + + [Fact] + public async Task InitialClassificationTask_WhenSubscriberAndLoggerBothThrow_StillCompletes() + { + var databasePath = CreateDatabaseDirectory(); + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + DatabaseSeedUtils.SeedV3Schema(dbPath); + + // Lock the DB so per-entry classification fails — that fires the per-entry SafeLog, + // which we use as a synchronization point to attach the throwing EntriesChanged + // subscriber BEFORE ClassifyEntriesAsync reaches RaiseEntriesChanged. + using var handle = new FileStream(dbPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + using var subscriberAttached = new ManualResetEventSlim(false); + + var throwingLogger = Substitute.For(); + throwingLogger.When(logger => logger.Warn(Arg.Any())) + .Do(_ => + { + // Worker blocks here until subscriberAttached.Set() below; ensures the + // subscriber is wired before the wrapper-catch path can fire. 10s is a + // safety valve for slow CI — normal completion is sub-millisecond. + subscriberAttached.Wait(TimeSpan.FromSeconds(10)); + throw new IOException("simulated log file lock"); + }); + + var fileLocationOptions = new FileLocationOptions(_testDirectory); + var prefs = Substitute.For(); + prefs.DisabledDatabasesPreference.Returns([]); + var service = new DatabaseService(fileLocationOptions, prefs, throwingLogger); + Assert.Single(service.Entries); + service.EntriesChanged += (_, _) => throw new InvalidOperationException("subscriber fault"); + subscriberAttached.Set(); + + await service.InitialClassificationTask; + + Assert.Equal(TaskStatus.RanToCompletion, service.InitialClassificationTask.Status); + // Per-entry SafeLog (ClassificationFailed) plus wrapper SafeLog (subscriber fault) — both + // fired and both throws were swallowed for the task to RanToCompletion. + throwingLogger.Received(2).Warn(Arg.Any()); + } + [Fact] public void MarkStatus_ShouldBePreservedAcrossRefresh() { @@ -749,7 +865,7 @@ private string CreateDatabaseDirectory() return path; } - private DatabaseService CreateDatabaseService(IPreferencesProvider? preferences = null) + private DatabaseService CreateDatabaseService(IPreferencesProvider? preferences = null, ITraceLogger? traceLogger = null) { var fileLocationOptions = new FileLocationOptions(_testDirectory); var prefs = preferences ?? Substitute.For(); @@ -759,7 +875,14 @@ private DatabaseService CreateDatabaseService(IPreferencesProvider? preferences prefs.DisabledDatabasesPreference.Returns([]); } - var traceLogger = Substitute.For(); - return new DatabaseService(fileLocationOptions, prefs, traceLogger); + var logger = traceLogger ?? Substitute.For(); + var service = new DatabaseService(fileLocationOptions, prefs, logger); + + // Block until ctor-initiated classification finishes so tests observe a stable post-classification + // state. Tests that need to observe pre-classification state (e.g., InitialClassificationTask + // contract tests) construct DatabaseService directly. Safe to block synchronously here because + // ClassifyEntriesAsync runs on Task.Run worker threads and does not capture a sync context. + service.InitialClassificationTask.GetAwaiter().GetResult(); + return service; } } diff --git a/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs b/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs index 4b68b2fb..f8bf9a08 100644 --- a/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs +++ b/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs @@ -11,6 +11,15 @@ public interface IDatabaseService IReadOnlyList Entries { get; } + /// + /// Completes once the ctor-initiated pass finishes. + /// Guaranteed to never fault or cancel — failures (per-entry or infrastructure) are absorbed + /// and surface as on the affected entry. + /// Consumers (e.g., EventLogEffects.HandleOpenLog) await this on every log open; + /// a faulted task here would poison every subsequent open for the rest of app lifetime. + /// + Task InitialClassificationTask { get; } + /// /// Inspects each on disk (in read-only mode without auto-creating a schema) and /// updates its from the on-disk schema version. Per-entry classification diff --git a/src/EventLogExpert.UI/Services/DatabaseService.cs b/src/EventLogExpert.UI/Services/DatabaseService.cs index 827275ca..422f4ea2 100644 --- a/src/EventLogExpert.UI/Services/DatabaseService.cs +++ b/src/EventLogExpert.UI/Services/DatabaseService.cs @@ -32,6 +32,8 @@ public DatabaseService( _traceLogger = traceLogger; Refresh(); + + InitialClassificationTask = StartInitialClassificationAsync(); } public event EventHandler? EntriesChanged; @@ -44,6 +46,8 @@ public DatabaseService( public IReadOnlyList Entries => _entries; + public Task InitialClassificationTask { get; } + public async Task ClassifyEntriesAsync(CancellationToken cancellationToken = default) { // Snapshot under the lock so concurrent Refresh/Toggle/Remove calls cannot trigger an @@ -94,15 +98,11 @@ public async Task ClassifyEntriesAsync(CancellationToken cancellationToken = def } catch (Exception ex) when (ex is not OperationCanceledException) { - // Per-entry failure (file locked by another process, file deleted between - // existence check and open, corrupt SQLite header, etc.) — quarantine the - // entry so resolver loading cannot consume it later. Without this, the - // entry would stay at its default Ready status from Refresh and EventResolver - // would crash when it tries to open the same DB. + // Quarantine so resolver loading cannot consume a broken DB. perFile[entry.FileName] = DatabaseStatus.ClassificationFailed; - _traceLogger.Warn( - $"{nameof(DatabaseService)}.{nameof(ClassifyEntriesAsync)} failed to classify '{entry.FileName}': {ex}"); + SafeLog(() => _traceLogger.Warn( + $"{nameof(DatabaseService)}.{nameof(ClassifyEntriesAsync)} failed to classify '{entry.FileName}': {ex}")); } } @@ -328,6 +328,12 @@ private static DatabaseStatus MapSchemaVersionToStatus(int currentVersion) => _ => DatabaseStatus.UnrecognizedSchema, }; + private static void SafeLog(Action log) + { + try { log(); } + catch { /* Ignore */} + } + private static IEnumerable SortDatabases(IEnumerable databases) { if (!databases.Any()) { return []; } @@ -493,4 +499,18 @@ private void PersistDisabled(ImmutableList snapshot) => .ToList(); private void RaiseEntriesChanged() => EntriesChanged?.Invoke(this, EventArgs.Empty); + + private async Task StartInitialClassificationAsync() + { + try + { + await ClassifyEntriesAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + // Honor InitialClassificationTask never-fault contract; absorbs subscriber throws. + SafeLog(() => _traceLogger.Warn( + $"{nameof(DatabaseService)}.{nameof(StartInitialClassificationAsync)}: initial classification failed: {ex}")); + } + } } From 6e704d6b01b2c71c170d5da69d2db86365615d7f Mon Sep 17 00:00:00 2001 From: jschick04 Date: Fri, 1 May 2026 14:36:38 -0500 Subject: [PATCH 11/31] Detect interrupted upgrades during classification via .upgrade.bak marker --- .../Services/DatabaseServiceTests.cs | 114 ++++++++++++++++++ .../Services/DatabaseService.cs | 59 +++++++-- .../Components/DetailsPane.razor.css | 3 + 3 files changed, 168 insertions(+), 8 deletions(-) diff --git a/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs b/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs index 77a86ca0..50a1b6da 100644 --- a/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs +++ b/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs @@ -141,6 +141,26 @@ public async Task ClassifyEntriesAsync_WhenNoStatusesChange_ShouldNotRaiseEntrie Assert.Equal(0, raisedCount); } + [Fact] + public async Task ClassifyEntriesAsync_WhenObsoleteSchemaWithUpgradeBak_ShouldNotDeleteBakAndBackupExistsFalse() + { + var databasePath = CreateDatabaseDirectory(); + var dbPath = Path.Combine(databasePath, "v1.db"); + DatabaseSeedUtils.SeedV1Schema(dbPath); + + var bakPath = dbPath + DatabaseService.UpgradeBackupSuffix; + File.WriteAllText(bakPath, "stale-backup-contents"); + + var service = CreateDatabaseService(); + + await service.ClassifyEntriesAsync(TestContext.Current.CancellationToken); + + var entry = Assert.Single(service.Entries); + Assert.Equal(DatabaseStatus.ObsoleteSchema, entry.Status); + Assert.False(entry.BackupExists); + Assert.True(File.Exists(bakPath)); + } + [Fact] public async Task ClassifyEntriesAsync_WhenOneEntryFails_ShouldQuarantineAsClassificationFailed() { @@ -208,6 +228,33 @@ public async Task ClassifyEntriesAsync_WhenSqliteFileWithoutProviderDetailsTable Assert.DoesNotContain(dbPath, service.ActiveDatabases); } + [Fact] + public async Task ClassifyEntriesAsync_WhenV3BakAppearsBetweenClassifications_ShouldUpdateBackupExistsAndRaiseEntriesChanged() + { + var databasePath = CreateDatabaseDirectory(); + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + DatabaseSeedUtils.SeedV3Schema(dbPath); + + var service = CreateDatabaseService(); + + var firstEntry = Assert.Single(service.Entries); + Assert.Equal(DatabaseStatus.UpgradeRequired, firstEntry.Status); + Assert.False(firstEntry.BackupExists); + + var bakPath = dbPath + DatabaseService.UpgradeBackupSuffix; + File.WriteAllText(bakPath, "interrupted-upgrade-backup"); + + var raisedCount = 0; + service.EntriesChanged += (_, _) => raisedCount++; + + await service.ClassifyEntriesAsync(TestContext.Current.CancellationToken); + + var entry = Assert.Single(service.Entries); + Assert.Equal(DatabaseStatus.UpgradeRequired, entry.Status); + Assert.True(entry.BackupExists); + Assert.Equal(1, raisedCount); + } + [Fact] public async Task ClassifyEntriesAsync_WhenV3Schema_ShouldDetectAsUpgradeRequired() { @@ -221,6 +268,27 @@ public async Task ClassifyEntriesAsync_WhenV3Schema_ShouldDetectAsUpgradeRequire var entry = Assert.Single(service.Entries); Assert.Equal(DatabaseStatus.UpgradeRequired, entry.Status); + Assert.False(entry.BackupExists); + } + + [Fact] + public async Task ClassifyEntriesAsync_WhenV3SchemaWithUpgradeBak_ShouldDetectAsUpgradeRequiredAndBackupExistsTrue() + { + var databasePath = CreateDatabaseDirectory(); + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + DatabaseSeedUtils.SeedV3Schema(dbPath); + + var bakPath = dbPath + DatabaseService.UpgradeBackupSuffix; + File.WriteAllText(bakPath, "interrupted-upgrade-backup"); + + var service = CreateDatabaseService(); + + await service.ClassifyEntriesAsync(TestContext.Current.CancellationToken); + + var entry = Assert.Single(service.Entries); + Assert.Equal(DatabaseStatus.UpgradeRequired, entry.Status); + Assert.True(entry.BackupExists); + Assert.True(File.Exists(bakPath), ".upgrade.bak must be preserved for V3 entries so recovery can restore it."); } [Fact] @@ -236,6 +304,27 @@ public async Task ClassifyEntriesAsync_WhenV4Schema_ShouldDetectAsReady() var entry = Assert.Single(service.Entries); Assert.Equal(DatabaseStatus.Ready, entry.Status); + Assert.False(entry.BackupExists); + } + + [Fact] + public async Task ClassifyEntriesAsync_WhenV4SchemaWithUpgradeBak_ShouldDeleteBakAndDetectAsReady() + { + var databasePath = CreateDatabaseDirectory(); + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + DatabaseSeedUtils.SeedV4Schema(dbPath); + + var bakPath = dbPath + DatabaseService.UpgradeBackupSuffix; + File.WriteAllText(bakPath, "stale-backup-from-successful-upgrade"); + + var service = CreateDatabaseService(); + + await service.ClassifyEntriesAsync(TestContext.Current.CancellationToken); + + var entry = Assert.Single(service.Entries); + Assert.Equal(DatabaseStatus.Ready, entry.Status); + Assert.False(entry.BackupExists); + Assert.False(File.Exists(bakPath), "Stale .upgrade.bak must be cleaned up once the main file reaches V4."); } [Fact] @@ -706,6 +795,31 @@ public void MarkStatus_WhenStatusUnchanged_ShouldNotRaiseEntriesChanged() Assert.Equal(0, raisedCount); } + [Fact] + public async Task Refresh_AfterClassificationSetsBackupExistsTrue_ShouldPreserveBackupExists() + { + // Regression: c9 refactored Refresh() to use `existing with { IsEnabled = ... }` so all + // other fields (Status, BackupExists) survive. This test pins that invariant — if a + // future change reverts to the positional constructor, BackupExists silently drops to + // false and the recovery dialog stops surfacing interrupted upgrades. + var databasePath = CreateDatabaseDirectory(); + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + DatabaseSeedUtils.SeedV3Schema(dbPath); + File.WriteAllText(dbPath + DatabaseService.UpgradeBackupSuffix, "interrupted-upgrade-backup"); + + var service = CreateDatabaseService(); + + var beforeRefresh = Assert.Single(service.Entries); + Assert.Equal(DatabaseStatus.UpgradeRequired, beforeRefresh.Status); + Assert.True(beforeRefresh.BackupExists); + + service.Refresh(); + + var afterRefresh = Assert.Single(service.Entries); + Assert.Equal(DatabaseStatus.UpgradeRequired, afterRefresh.Status); + Assert.True(afterRefresh.BackupExists); + } + [Fact] public void Refresh_WhenCalled_ShouldRaiseEntriesChanged() { diff --git a/src/EventLogExpert.UI/Services/DatabaseService.cs b/src/EventLogExpert.UI/Services/DatabaseService.cs index 422f4ea2..a9dd207a 100644 --- a/src/EventLogExpert.UI/Services/DatabaseService.cs +++ b/src/EventLogExpert.UI/Services/DatabaseService.cs @@ -15,6 +15,8 @@ namespace EventLogExpert.UI.Services; public sealed partial class DatabaseService : IDatabaseService, IDatabaseCollectionProvider { + public const string UpgradeBackupSuffix = ".upgrade.bak"; + private readonly FileLocationOptions _fileLocationOptions; private readonly Lock _mutationLock = new(); private readonly IPreferencesProvider _preferences; @@ -66,7 +68,7 @@ public async Task ClassifyEntriesAsync(CancellationToken cancellationToken = def var statuses = await Task.Run( () => { - var perFile = new Dictionary(StringComparer.OrdinalIgnoreCase); + var perFile = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var entry in snapshot) { @@ -81,7 +83,7 @@ public async Task ClassifyEntriesAsync(CancellationToken cancellationToken = def // quarantined and surfaced in Settings instead of being mutated invisibly. if (!File.Exists(entry.FullPath) || new FileInfo(entry.FullPath).Length == 0) { - perFile[entry.FileName] = DatabaseStatus.UnrecognizedSchema; + perFile[entry.FileName] = (DatabaseStatus.UnrecognizedSchema, false); continue; } @@ -94,12 +96,14 @@ public async Task ClassifyEntriesAsync(CancellationToken cancellationToken = def _traceLogger); var state = context.IsUpgradeNeeded(); - perFile[entry.FileName] = MapSchemaVersionToStatus(state.CurrentVersion); + var status = MapSchemaVersionToStatus(state.CurrentVersion); + var backupExists = ProbeOrCleanupBackup(entry, status); + + perFile[entry.FileName] = (status, backupExists); } catch (Exception ex) when (ex is not OperationCanceledException) { - // Quarantine so resolver loading cannot consume a broken DB. - perFile[entry.FileName] = DatabaseStatus.ClassificationFailed; + perFile[entry.FileName] = (DatabaseStatus.ClassificationFailed, false); SafeLog(() => _traceLogger.Warn( $"{nameof(DatabaseService)}.{nameof(ClassifyEntriesAsync)} failed to classify '{entry.FileName}': {ex}")); @@ -124,11 +128,12 @@ public async Task ClassifyEntriesAsync(CancellationToken cancellationToken = def { var entry = builder[i]; - if (!statuses.TryGetValue(entry.FileName, out var newStatus)) { continue; } + if (!statuses.TryGetValue(entry.FileName, out var newState)) { continue; } + + if (entry.Status == newState.Status && entry.BackupExists == newState.BackupExists) { continue; } - if (entry.Status == newStatus) { continue; } + builder[i] = entry with { Status = newState.Status, BackupExists = newState.BackupExists }; - builder[i] = entry with { Status = newStatus }; changed = true; } @@ -498,6 +503,44 @@ private void PersistDisabled(ImmutableList snapshot) => .Select(entry => entry.FileName) .ToList(); + private bool ProbeOrCleanupBackup(DatabaseEntry entry, DatabaseStatus status) + { + var backupPath = entry.FullPath + UpgradeBackupSuffix; + + if (status == DatabaseStatus.UpgradeRequired) + { + try + { + return File.Exists(backupPath); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Conservative on probe failure: surface a possibly-spurious recovery prompt + // rather than silently denying the user a chance to restore from .upgrade.bak. + SafeLog(() => _traceLogger.Warn( + $"{nameof(DatabaseService)}.{nameof(ProbeOrCleanupBackup)} probe failed for '{entry.FileName}': {ex}")); + return true; + } + } + + if (status == DatabaseStatus.Ready) + { + // V4 is the canonical post-upgrade state; an existing .upgrade.bak is stale + // (we crashed between rename and cleanup). File.Delete is a no-op if absent. + try + { + File.Delete(backupPath); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + SafeLog(() => _traceLogger.Warn( + $"{nameof(DatabaseService)}.{nameof(ProbeOrCleanupBackup)} stale .upgrade.bak cleanup failed for '{entry.FileName}': {ex}")); + } + } + + return false; + } + private void RaiseEntriesChanged() => EntriesChanged?.Invoke(this, EventArgs.Empty); private async Task StartInitialClassificationAsync() diff --git a/src/EventLogExpert/Components/DetailsPane.razor.css b/src/EventLogExpert/Components/DetailsPane.razor.css index ea245a01..77e73f24 100644 --- a/src/EventLogExpert/Components/DetailsPane.razor.css +++ b/src/EventLogExpert/Components/DetailsPane.razor.css @@ -47,6 +47,8 @@ .details-description { height: 100%; overflow-y: auto; + overflow-x: hidden; + overflow-wrap: anywhere; white-space: pre-wrap; } @@ -60,6 +62,7 @@ height: 100%; overflow-y: auto; overflow-x: hidden; + overflow-wrap: anywhere; text-wrap: pretty; white-space: pre-wrap; } From 0971893c158c5feb1e1237c36e6c35914487febb Mon Sep 17 00:00:00 2001 From: jschick04 Date: Fri, 1 May 2026 17:15:27 -0500 Subject: [PATCH 12/31] Add safe recovery file operations to DatabaseService --- .../Services/DatabaseServiceTests.cs | 264 ++++++++++++++++++ .../Interfaces/IDatabaseService.cs | 21 ++ .../Services/DatabaseService.cs | 149 +++++++++- 3 files changed, 428 insertions(+), 6 deletions(-) diff --git a/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs b/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs index 50a1b6da..b30f5c2f 100644 --- a/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs +++ b/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs @@ -391,6 +391,136 @@ public void Constructor_WhenNonDbFilesPresent_ShouldOnlyIncludeDbFiles() Assert.Equal(Constants.TestDb1, service.Entries[0].FileName); } + [Fact] + public async Task DeleteEntryWithBackupAsync_BackupMissing_StillSucceedsAndRemovesEntry() + { + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); + + var service = CreateDatabaseService(); + + var result = await service.DeleteEntryWithBackupAsync(Constants.TestDb1, TestContext.Current.CancellationToken); + + Assert.True(result); + Assert.False(File.Exists(Path.Combine(databasePath, Constants.TestDb1))); + Assert.Empty(service.Entries); + } + + [Fact] + public async Task DeleteEntryWithBackupAsync_DeletesMainAllSidecarsAndBackup_RemovesFromEntries() + { + var databasePath = CreateDatabaseDirectory(); + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + CreateDatabaseFile(databasePath, Constants.TestDb1); + + var journalPath = dbPath + "-journal"; + var walPath = dbPath + "-wal"; + var shmPath = dbPath + "-shm"; + var bakPath = dbPath + DatabaseService.UpgradeBackupSuffix; + File.WriteAllText(journalPath, "rollback-journal"); + File.WriteAllText(walPath, "wal-content"); + File.WriteAllText(shmPath, "shm-content"); + File.WriteAllText(bakPath, "upgrade-backup"); + + var service = CreateDatabaseService(); + + var result = await service.DeleteEntryWithBackupAsync(Constants.TestDb1, TestContext.Current.CancellationToken); + + Assert.True(result); + Assert.False(File.Exists(dbPath)); + Assert.False(File.Exists(journalPath)); + Assert.False(File.Exists(walPath)); + Assert.False(File.Exists(shmPath)); + Assert.False(File.Exists(bakPath)); + Assert.Empty(service.Entries); + } + + [Fact] + public async Task DeleteEntryWithBackupAsync_DoesNotTouchUserCreatedDotBakFiles() + { + var databasePath = CreateDatabaseDirectory(); + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + CreateDatabaseFile(databasePath, Constants.TestDb1); + + const string userBackupContent = "user-created-content"; + var userBakPath = dbPath + ".bak"; + File.WriteAllText(userBakPath, userBackupContent); + + var service = CreateDatabaseService(); + + var result = await service.DeleteEntryWithBackupAsync(Constants.TestDb1, TestContext.Current.CancellationToken); + + Assert.True(result); + Assert.False(File.Exists(dbPath)); + Assert.True(File.Exists(userBakPath)); + Assert.Equal(userBackupContent, File.ReadAllText(userBakPath)); + } + + [Fact] + public async Task DeleteEntryWithBackupAsync_RaisesEntriesChangedExactlyOnce() + { + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); + + var service = CreateDatabaseService(); + var raisedCount = 0; + service.EntriesChanged += (_, _) => Interlocked.Increment(ref raisedCount); + + await service.DeleteEntryWithBackupAsync(Constants.TestDb1, TestContext.Current.CancellationToken); + + Assert.Equal(1, raisedCount); + } + + [Fact] + public async Task DeleteEntryWithBackupAsync_TokenAlreadyCanceled_Throws() + { + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); + + var service = CreateDatabaseService(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync( + async () => await service.DeleteEntryWithBackupAsync(Constants.TestDb1, cts.Token)); + + Assert.True(File.Exists(Path.Combine(databasePath, Constants.TestDb1))); + } + + [Fact] + public async Task DeleteEntryWithBackupAsync_UnknownFileName_Throws() + { + CreateDatabaseDirectory(); + var service = CreateDatabaseService(); + + await Assert.ThrowsAsync( + async () => await service.DeleteEntryWithBackupAsync("does-not-exist.db", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DeleteEntryWithBackupAsync_WhenSidecarDeleteFails_PreservesMainAndEntryAndReturnsFalse() + { + var databasePath = CreateDatabaseDirectory(); + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + CreateDatabaseFile(databasePath, Constants.TestDb1); + + var walPath = dbPath + "-wal"; + File.WriteAllText(walPath, "wal-content"); + + // Hold the WAL with FileShare.None so File.Delete throws IOException; this simulates the + // single-process race where SQLite still has the sidecar mapped at delete time. The main + // file and the entry must survive so Refresh keeps the entry visible and the user can retry. + using var lockHandle = new FileStream(walPath, FileMode.Open, FileAccess.Read, FileShare.None); + + var service = CreateDatabaseService(); + + var result = await service.DeleteEntryWithBackupAsync(Constants.TestDb1, TestContext.Current.CancellationToken); + + Assert.False(result); + Assert.True(File.Exists(dbPath)); + Assert.Single(service.Entries); + } + public void Dispose() { // SQLite connections opened during ClassifyEntriesAsync are pooled; on Windows the pool @@ -904,6 +1034,140 @@ public void Remove_WhenFileNameUnknown_ShouldThrow() Assert.Throws(() => service.Remove("does-not-exist.db")); } + [Fact] + public async Task RestoreFromBackupAsync_BackupMissing_ReturnsFalseAndDoesNotMutate() + { + var databasePath = CreateDatabaseDirectory(); + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + DatabaseSeedUtils.SeedV4Schema(dbPath); + + var service = CreateDatabaseService(); + var entryBefore = Assert.Single(service.Entries); + + var result = await service.RestoreFromBackupAsync(Constants.TestDb1, TestContext.Current.CancellationToken); + + Assert.False(result); + Assert.True(File.Exists(dbPath)); + var entryAfter = Assert.Single(service.Entries); + Assert.Equal(entryBefore.Status, entryAfter.Status); + } + + [Fact] + public async Task RestoreFromBackupAsync_RaisesEntriesChangedExactlyOnce_AfterReclassification() + { + var databasePath = CreateDatabaseDirectory(); + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + + DatabaseSeedUtils.SeedV4Schema(dbPath); + + var service = CreateDatabaseService(); + + var bakPath = dbPath + DatabaseService.UpgradeBackupSuffix; + DatabaseSeedUtils.SeedV3Schema(bakPath); + + var raisedCount = 0; + service.EntriesChanged += (_, _) => Interlocked.Increment(ref raisedCount); + + await service.RestoreFromBackupAsync(Constants.TestDb1, TestContext.Current.CancellationToken); + + Assert.Equal(1, raisedCount); + } + + [Fact] + public async Task RestoreFromBackupAsync_TokenAlreadyCanceled_Throws() + { + var databasePath = CreateDatabaseDirectory(); + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + DatabaseSeedUtils.SeedV4Schema(dbPath); + + var service = CreateDatabaseService(); + + var bakPath = dbPath + DatabaseService.UpgradeBackupSuffix; + DatabaseSeedUtils.SeedV3Schema(bakPath); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync( + async () => await service.RestoreFromBackupAsync(Constants.TestDb1, cts.Token)); + + Assert.True(File.Exists(bakPath)); + } + + [Fact] + public async Task RestoreFromBackupAsync_UnknownFileName_Throws() + { + CreateDatabaseDirectory(); + var service = CreateDatabaseService(); + + await Assert.ThrowsAsync( + async () => await service.RestoreFromBackupAsync("does-not-exist.db", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task RestoreFromBackupAsync_WhenMainIsV4AndBackupIsV3_RestoresMainDeletesSidecarsAndBackup_StatusBecomesUpgradeRequired() + { + var databasePath = CreateDatabaseDirectory(); + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + + DatabaseSeedUtils.SeedV4Schema(dbPath); + + var service = CreateDatabaseService(); + + var bakPath = dbPath + DatabaseService.UpgradeBackupSuffix; + DatabaseSeedUtils.SeedV3Schema(bakPath); + + var journalPath = dbPath + "-journal"; + var walPath = dbPath + "-wal"; + var shmPath = dbPath + "-shm"; + File.WriteAllText(journalPath, "stale-journal"); + File.WriteAllText(walPath, "stale-wal"); + File.WriteAllText(shmPath, "stale-shm"); + + var result = await service.RestoreFromBackupAsync(Constants.TestDb1, TestContext.Current.CancellationToken); + + Assert.True(result); + Assert.True(File.Exists(dbPath)); + Assert.False(File.Exists(journalPath)); + Assert.False(File.Exists(walPath)); + Assert.False(File.Exists(shmPath)); + Assert.False(File.Exists(bakPath)); + + var entry = Assert.Single(service.Entries); + Assert.Equal(DatabaseStatus.UpgradeRequired, entry.Status); + Assert.False(entry.BackupExists); + } + + [Fact] + public async Task RestoreFromBackupAsync_WhenSidecarDeleteFails_PreservesBackupAndReturnsFalse() + { + var databasePath = CreateDatabaseDirectory(); + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + + DatabaseSeedUtils.SeedV4Schema(dbPath); + + var service = CreateDatabaseService(); + + var bakPath = dbPath + DatabaseService.UpgradeBackupSuffix; + DatabaseSeedUtils.SeedV3Schema(bakPath); + + var walPath = dbPath + "-wal"; + File.WriteAllText(walPath, "wal-content"); + + var mainBytesBefore = File.ReadAllBytes(dbPath); + + // Hold the WAL with FileShare.None so File.Delete throws IOException; this simulates the + // single-process race where SQLite still has the sidecar mapped at restore time. Main must + // be untouched and the backup must survive so the user can retry. + using var lockHandle = new FileStream(walPath, FileMode.Open, FileAccess.Read, FileShare.None); + + var result = await service.RestoreFromBackupAsync(Constants.TestDb1, TestContext.Current.CancellationToken); + + Assert.False(result); + Assert.True(File.Exists(bakPath)); + Assert.Equal(mainBytesBefore, File.ReadAllBytes(dbPath)); + } + [Fact] public void Toggle_WhenCalled_ShouldFlipIsEnabledAndPersist() { diff --git a/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs b/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs index f8bf9a08..ac7f60a8 100644 --- a/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs +++ b/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs @@ -28,6 +28,16 @@ public interface IDatabaseService /// Task ClassifyEntriesAsync(CancellationToken cancellationToken = default); + /// + /// Deletes the -journal/-wal/-shm sidecars and any .upgrade.bak backup, + /// then deletes the main database file last (so a partial failure leaves the entry visible to + /// and retry-able), then removes the entry from . + /// Returns false on any IO failure (logged); the entry is left in place so the caller can + /// surface the error and retry. User-created .bak files (not the .upgrade.bak + /// marker) are never touched. + /// + Task DeleteEntryWithBackupAsync(string fileName, CancellationToken cancellationToken = default); + Task ImportAsync(IEnumerable sourceFilePaths, CancellationToken cancellationToken = default); void MarkStatus(string fileName, DatabaseStatus status); @@ -36,5 +46,16 @@ public interface IDatabaseService void Remove(string fileName); + /// + /// Deletes any stale -journal/-wal/-shm sidecars first (their presence + /// paired with a restored older main would let SQLite roll forward stale transactions into + /// the restored database on next open), then restores the main database file from its + /// .upgrade.bak backup, then deletes the backup. The backup is preserved if any sidecar + /// cleanup fails so the caller can retry. Re-classifies the entry so consumers observe the + /// post-restore state via . Returns false on any IO + /// failure (logged). + /// + Task RestoreFromBackupAsync(string fileName, CancellationToken cancellationToken = default); + void Toggle(string fileName); } diff --git a/src/EventLogExpert.UI/Services/DatabaseService.cs b/src/EventLogExpert.UI/Services/DatabaseService.cs index a9dd207a..07824693 100644 --- a/src/EventLogExpert.UI/Services/DatabaseService.cs +++ b/src/EventLogExpert.UI/Services/DatabaseService.cs @@ -89,11 +89,7 @@ public async Task ClassifyEntriesAsync(CancellationToken cancellationToken = def // ensureCreated:false so a missing/empty file is NOT auto-populated with the V4 schema // during inspection — that would silently mask the very thing classification is for. - using var context = new EventProviderDbContext( - entry.FullPath, - readOnly: false, - ensureCreated: false, - _traceLogger); + using var context = new EventProviderDbContext(entry.FullPath, false, false, _traceLogger); var state = context.IsUpgradeNeeded(); var status = MapSchemaVersionToStatus(state.CurrentVersion); @@ -149,6 +145,37 @@ public async Task ClassifyEntriesAsync(CancellationToken cancellationToken = def } } + public async Task DeleteEntryWithBackupAsync(string fileName, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var entry = LookupEntryOrThrow(fileName, nameof(DeleteEntryWithBackupAsync)); + + var success = await Task.Run(() => DeleteFilesCore(entry)).ConfigureAwait(false); + + if (!success) { return false; } + + bool removed; + + lock (_mutationLock) + { + var index = FindEntryIndex(_entries, fileName); + ImmutableList nextSnapshot = index >= 0 ? _entries.RemoveAt(index) : _entries; + + // Always re-persist even if a concurrent Refresh already removed the entry — otherwise a + // stale fileName lingers in DisabledDatabasesPreference and a re-import comes back disabled. + PersistDisabled(nextSnapshot); + + removed = index >= 0; + + if (removed) { _entries = nextSnapshot; } + } + + if (removed) { RaiseEntriesChanged(); } + + return true; + } + public async Task ImportAsync( IEnumerable sourceFilePaths, CancellationToken cancellationToken = default) @@ -296,6 +323,31 @@ public void Remove(string fileName) RaiseEntriesChanged(); } + public async Task RestoreFromBackupAsync(string fileName, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var entry = LookupEntryOrThrow(fileName, nameof(RestoreFromBackupAsync)); + + var success = await Task.Run(() => RestoreFilesCore(entry)).ConfigureAwait(false); + + if (!success) { return false; } + + try + { + await ClassifyEntriesAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + // Restore already succeeded on disk; absorb any post-classify failure (incl. cancellation) + // so the caller's success signal is preserved. The next entries access re-reads disk anyway. + SafeLog(() => _traceLogger.Warn( + $"{nameof(DatabaseService)}.{nameof(RestoreFromBackupAsync)} post-restore classification failed: {ex}")); + } + + return true; + } + public void Toggle(string fileName) { lock (_mutationLock) @@ -336,7 +388,7 @@ private static DatabaseStatus MapSchemaVersionToStatus(int currentVersion) => private static void SafeLog(Action log) { try { log(); } - catch { /* Ignore */} + catch { /* Ignore */ } } private static IEnumerable SortDatabases(IEnumerable databases) @@ -403,6 +455,21 @@ private void DeleteDatabaseFiles(string fileName) } } + private bool DeleteFilesCore(DatabaseEntry entry) + { + var mainPath = entry.FullPath; + + if (!TryDeleteFile(mainPath + "-journal", nameof(DeleteEntryWithBackupAsync))) { return false; } + + if (!TryDeleteFile(mainPath + "-wal", nameof(DeleteEntryWithBackupAsync))) { return false; } + + if (!TryDeleteFile(mainPath + "-shm", nameof(DeleteEntryWithBackupAsync))) { return false; } + + return TryDeleteFile(mainPath + UpgradeBackupSuffix, nameof(DeleteEntryWithBackupAsync)) && + // Delete main last so a partial failure leaves the entry visible to Refresh and retry-able. + TryDeleteFile(mainPath, nameof(DeleteEntryWithBackupAsync)); + } + private IEnumerable EnumerateDatabaseFileNames() { if (!Directory.Exists(_fileLocationOptions.DatabasePath)) { return []; } @@ -497,6 +564,22 @@ private IEnumerable EnumerateDatabaseFileNames() return (imported, failures); } + private DatabaseEntry LookupEntryOrThrow(string fileName, string callerName) + { + lock (_mutationLock) + { + var index = FindEntryIndex(_entries, fileName); + + if (index < 0) + { + throw new InvalidOperationException( + $"{nameof(DatabaseService)}.{callerName}: no entry found with file name '{fileName}'."); + } + + return _entries[index]; + } + } + private void PersistDisabled(ImmutableList snapshot) => _preferences.DisabledDatabasesPreference = snapshot .Where(entry => !entry.IsEnabled) @@ -519,6 +602,7 @@ private bool ProbeOrCleanupBackup(DatabaseEntry entry, DatabaseStatus status) // rather than silently denying the user a chance to restore from .upgrade.bak. SafeLog(() => _traceLogger.Warn( $"{nameof(DatabaseService)}.{nameof(ProbeOrCleanupBackup)} probe failed for '{entry.FileName}': {ex}")); + return true; } } @@ -543,6 +627,43 @@ private bool ProbeOrCleanupBackup(DatabaseEntry entry, DatabaseStatus status) private void RaiseEntriesChanged() => EntriesChanged?.Invoke(this, EventArgs.Empty); + private bool RestoreFilesCore(DatabaseEntry entry) + { + var mainPath = entry.FullPath; + var backupPath = mainPath + UpgradeBackupSuffix; + + if (!File.Exists(backupPath)) + { + SafeLog(() => _traceLogger.Warn( + $"{nameof(DatabaseService)}.{nameof(RestoreFromBackupAsync)}: '{backupPath}' missing; nothing to restore.")); + + return false; + } + + // Sidecars must be removed before main is overwritten. Otherwise a partial failure leaves a + // restored V3 main paired with stale V4 sidecars, which SQLite can roll forward into the + // restored database on next open. + if (!TryDeleteFile(mainPath + "-journal", nameof(RestoreFromBackupAsync))) { return false; } + + if (!TryDeleteFile(mainPath + "-wal", nameof(RestoreFromBackupAsync))) { return false; } + + if (!TryDeleteFile(mainPath + "-shm", nameof(RestoreFromBackupAsync))) { return false; } + + try + { + File.Copy(backupPath, mainPath, true); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + SafeLog(() => _traceLogger.Warn( + $"{nameof(DatabaseService)}.{nameof(RestoreFromBackupAsync)}: copy from '{backupPath}' to '{mainPath}' failed: {ex}")); + + return false; + } + + return TryDeleteFile(backupPath, nameof(RestoreFromBackupAsync)); + } + private async Task StartInitialClassificationAsync() { try @@ -556,4 +677,20 @@ private async Task StartInitialClassificationAsync() $"{nameof(DatabaseService)}.{nameof(StartInitialClassificationAsync)}: initial classification failed: {ex}")); } } + + private bool TryDeleteFile(string path, string callerName) + { + try + { + File.Delete(path); + + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + SafeLog(() => _traceLogger.Warn($"{nameof(DatabaseService)}.{callerName}: delete failed for '{path}': {ex}")); + + return false; + } + } } From a46c2351357d6d66638ec72230951b76102baadf Mon Sep 17 00:00:00 2001 From: jschick04 Date: Fri, 1 May 2026 17:29:13 -0500 Subject: [PATCH 13/31] Stop Remove from deleting user-created .bak files via wildcard --- .../Services/DatabaseServiceTests.cs | 20 +++++++++++++++++++ .../Services/DatabaseService.cs | 15 ++++++-------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs b/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs index b30f5c2f..02a3a45c 100644 --- a/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs +++ b/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs @@ -984,6 +984,26 @@ public void Refresh_WhenNewFilesAppear_ShouldPickThemUp() Assert.Equal(Constants.TestDb1, service.Entries[0].FileName); } + [Fact] + public void Remove_DoesNotTouchUserCreatedDotBakFiles() + { + var databasePath = CreateDatabaseDirectory(); + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + CreateDatabaseFile(databasePath, Constants.TestDb1); + + const string userBackupContent = "user-created-content"; + var userBakPath = dbPath + ".bak"; + File.WriteAllText(userBakPath, userBackupContent); + + var service = CreateDatabaseService(); + + service.Remove(Constants.TestDb1); + + Assert.False(File.Exists(dbPath)); + Assert.True(File.Exists(userBakPath)); + Assert.Equal(userBackupContent, File.ReadAllText(userBakPath)); + } + [Fact] public void Remove_WhenCalled_ShouldDeleteDatabaseAndSidecars() { diff --git a/src/EventLogExpert.UI/Services/DatabaseService.cs b/src/EventLogExpert.UI/Services/DatabaseService.cs index 07824693..03bbeb1c 100644 --- a/src/EventLogExpert.UI/Services/DatabaseService.cs +++ b/src/EventLogExpert.UI/Services/DatabaseService.cs @@ -443,16 +443,13 @@ private static IEnumerable SortDatabases(IEnumerable databases) private void DeleteDatabaseFiles(string fileName) { - // Delete the .db together with its SQLite sidecars (.db-wal, .db-shm) so a re-import - // doesn't pick up stale write-ahead state. - var directory = new DirectoryInfo(_fileLocationOptions.DatabasePath); + var basePath = Path.Combine(_fileLocationOptions.DatabasePath, fileName); - if (!directory.Exists) { return; } - - foreach (var file in directory.GetFiles($"{fileName}*")) - { - file.Delete(); - } + File.Delete(basePath + "-journal"); + File.Delete(basePath + "-wal"); + File.Delete(basePath + "-shm"); + File.Delete(basePath + UpgradeBackupSuffix); + File.Delete(basePath); } private bool DeleteFilesCore(DatabaseEntry entry) From c4e68f62860f01e3ae6b9d14a33c01e5f075ed5a Mon Sep 17 00:00:00 2001 From: jschick04 Date: Fri, 1 May 2026 18:08:06 -0500 Subject: [PATCH 14/31] Rename BannerService Critical/Error to align with severity taxonomy --- .../BannerHostTests.cs | 134 +++++++-------- .../BannerHost.razor | 31 ++-- .../BannerHost.razor.cs | 4 +- .../BannerHost.razor.css | 2 +- .../Services/BannerServiceTests.cs | 152 +++++++++--------- .../Services/BannerViewSelectorTests.cs | 26 +-- .../Services/ModalAlertDialogServiceTests.cs | 14 +- .../IAlertDialogService.cs} | 20 +-- .../Interfaces/IBannerService.cs | 46 +++--- ...ticalAlertEntry.cs => ErrorBannerEntry.cs} | 2 +- .../Services/AlertPresentation.cs | 2 + .../Services/BannerService.cs | 53 +++--- .../Services/BannerViewSelector.cs | 10 +- .../Services/ModalAlertDialogService.cs | 6 +- .../Filters/FilterCacheModal.razor.cs | 2 +- .../Components/Filters/FilterGroup.razor.cs | 1 - .../Filters/FilterGroupModal.razor.cs | 2 +- .../Components/Filters/FilterRow.razor.cs | 2 +- .../Shared/Components/SettingsModal.razor.cs | 1 - .../Shared/UnhandledExceptionHandler.razor.cs | 4 +- 20 files changed, 257 insertions(+), 257 deletions(-) rename src/EventLogExpert.UI/{Services/AlertDialogService.cs => Interfaces/IAlertDialogService.cs} (60%) rename src/EventLogExpert.UI/Models/{CriticalAlertEntry.cs => ErrorBannerEntry.cs} (54%) diff --git a/src/EventLogExpert.Components.Tests/BannerHostTests.cs b/src/EventLogExpert.Components.Tests/BannerHostTests.cs index 81bd2c2f..38c76cff 100644 --- a/src/EventLogExpert.Components.Tests/BannerHostTests.cs +++ b/src/EventLogExpert.Components.Tests/BannerHostTests.cs @@ -20,8 +20,8 @@ public sealed class BannerHostTests : BunitContext public BannerHostTests() { - _bannerService.UnhandledError.Returns((Exception?)null); - _bannerService.CriticalAlerts.Returns([]); + _bannerService.CurrentCritical.Returns((Exception?)null); + _bannerService.ErrorBanners.Returns([]); _bannerService.InfoBanners.Returns([]); Services.AddSingleton(_bannerService); @@ -35,62 +35,81 @@ public BannerHostTests() [Fact] public async Task BannerHost_CopyDetailsClicked_CopiesExceptionAndShowsCopiedChip() { - var error = new InvalidOperationException("kaboom"); - _bannerService.UnhandledError.Returns(error); + var critical = new InvalidOperationException("kaboom"); + _bannerService.CurrentCritical.Returns(critical); 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(); + component.Find("aside.banner-critical .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")); + Assert.Single(component.FindAll("aside.banner-critical .banner-feedback .banner-chip")); } [Fact] - public async Task BannerHost_DismissCriticalClicked_CallsDismissCriticalWithEntryId() + public void BannerHost_CriticalAndErrorAndInfoAllPresent_RendersOnlyCritical() { - var alert = new CriticalAlertEntry(Guid.NewGuid(), "Database", "Schema invalid", DateTime.UtcNow); - _bannerService.CriticalAlerts.Returns([alert]); + _bannerService.CurrentCritical.Returns(new InvalidOperationException("kaboom")); + + _bannerService.ErrorBanners.Returns( + [new ErrorBannerEntry(Guid.NewGuid(), "Error", "E", DateTime.UtcNow)]); + + _bannerService.InfoBanners.Returns([ + new BannerInfoEntry(Guid.NewGuid(), "Info", "I", BannerSeverity.Info, DateTime.UtcNow) + ]); var component = Render(); - await component.Find("aside.banner-critical button.banner-dismiss").ClickAsync(new()); - _bannerService.Received(1).DismissCritical(alert.Id); + Assert.Single(component.FindAll("aside.banner-critical")); + Assert.Empty(component.FindAll("aside.banner-error")); + Assert.Empty(component.FindAll("aside.banner-info")); } [Fact] - public async Task BannerHost_DismissInfoClicked_CallsDismissInfoBannerWithEntryId() + public void BannerHost_CurrentCritical_RendersCriticalBannerWithThreeButtons() { - var info = new BannerInfoEntry(Guid.NewGuid(), "Notice", "Heads up", BannerSeverity.Info, DateTime.UtcNow); - _bannerService.InfoBanners.Returns([info]); + var critical = new InvalidOperationException("kaboom"); + _bannerService.CurrentCritical.Returns(critical); var component = Render(); - await component.Find("aside.banner-info button.banner-dismiss").ClickAsync(new()); - _bannerService.Received(1).DismissInfoBanner(info.Id); + var banner = component.Find("aside.banner-critical"); + Assert.Contains("InvalidOperationException", banner.TextContent); + Assert.Contains("kaboom", banner.TextContent); + + var buttons = component.FindAll("aside.banner-critical .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_ErrorAndCriticalAndInfoAllPresent_RendersOnlyError() + public async Task BannerHost_DismissErrorClicked_CallsDismissErrorWithEntryId() { - _bannerService.UnhandledError.Returns(new InvalidOperationException("kaboom")); + var entry = new ErrorBannerEntry(Guid.NewGuid(), "Database", "Schema invalid", DateTime.UtcNow); + _bannerService.ErrorBanners.Returns([entry]); - _bannerService.CriticalAlerts.Returns( - [new CriticalAlertEntry(Guid.NewGuid(), "Critical", "C", DateTime.UtcNow)]); + var component = Render(); + await component.Find("aside.banner-error button.banner-dismiss").ClickAsync(new()); - _bannerService.InfoBanners.Returns([ - new BannerInfoEntry(Guid.NewGuid(), "Info", "I", BannerSeverity.Info, DateTime.UtcNow) - ]); + _bannerService.Received(1).DismissError(entry.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()); - Assert.Single(component.FindAll("aside.banner-error")); - Assert.Empty(component.FindAll("aside.banner-critical")); - Assert.Empty(component.FindAll("aside.banner-info")); + _bannerService.Received(1).DismissInfoBanner(info.Id); } [Fact] @@ -107,19 +126,19 @@ public void BannerHost_InfoSeverity_RendersInfoStyledBanner() } [Fact] - public void BannerHost_MultipleCriticalAlerts_RendersFirstWithPagination() + public void BannerHost_MultipleErrorBanners_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 first = new ErrorBannerEntry(Guid.NewGuid(), "First", "First message", DateTime.UtcNow); + var second = new ErrorBannerEntry(Guid.NewGuid(), "Second", "Second message", DateTime.UtcNow); + _bannerService.ErrorBanners.Returns([first, second]); var component = Render(); - var banner = component.Find("aside.banner-critical"); + var banner = component.Find("aside.banner-error"); Assert.Contains("First: First message", banner.TextContent); Assert.DoesNotContain("Second", banner.TextContent); - var pagination = component.Find("aside.banner-critical .banner-pagination"); + var pagination = component.Find("aside.banner-error .banner-pagination"); Assert.Equal("1 of 2", pagination.TextContent.Trim()); } @@ -134,13 +153,13 @@ public void BannerHost_NoState_RendersNothing() [Fact] public async Task BannerHost_RecoveryThrows_ShowsRecoveryFailureSubtitle() { - _bannerService.UnhandledError.Returns(new InvalidOperationException("kaboom")); + _bannerService.CurrentCritical.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()); + await component.Find("aside.banner-critical .banner-actions button:nth-child(1)").ClickAsync(new()); - var subtitle = component.Find("aside.banner-error .banner-feedback .banner-subtitle"); + var subtitle = component.Find("aside.banner-critical .banner-feedback .banner-subtitle"); Assert.Contains("Recovery failed", subtitle.TextContent); Assert.Contains("recovery failed", subtitle.TextContent); } @@ -148,11 +167,11 @@ public async Task BannerHost_RecoveryThrows_ShowsRecoveryFailureSubtitle() [Fact] public async Task BannerHost_RelaunchClicked_InvokesTryRestartAsync() { - _bannerService.UnhandledError.Returns(new InvalidOperationException("kaboom")); + _bannerService.CurrentCritical.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 component.Find("aside.banner-critical .banner-actions button:nth-child(2)").ClickAsync(new()); await _applicationRestartService.Received(1).TryRestartAsync(); } @@ -160,59 +179,40 @@ public async Task BannerHost_RelaunchClicked_InvokesTryRestartAsync() [Fact] public async Task BannerHost_RelaunchFails_ShowsRestartFailureSubtitle() { - _bannerService.UnhandledError.Returns(new InvalidOperationException("kaboom")); + _bannerService.CurrentCritical.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()); + await component.Find("aside.banner-critical .banner-actions button:nth-child(2)").ClickAsync(new()); - var subtitle = component.Find("aside.banner-error .banner-feedback .banner-subtitle"); + var subtitle = component.Find("aside.banner-critical .banner-feedback .banner-subtitle"); Assert.Contains("Restart failed", subtitle.TextContent); } [Fact] public async Task BannerHost_ReloadClicked_InvokesTryRecoverAsync() { - _bannerService.UnhandledError.Returns(new InvalidOperationException("kaboom")); + _bannerService.CurrentCritical.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 component.Find("aside.banner-critical .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() + public void BannerHost_SingleErrorBanner_RendersWithoutPagination() { - var error = new InvalidOperationException("kaboom"); - _bannerService.UnhandledError.Returns(error); + var entry = new ErrorBannerEntry(Guid.NewGuid(), "Database", "Schema invalid", DateTime.UtcNow); + _bannerService.ErrorBanners.Returns([entry]); 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); + Assert.Contains("Database: Schema invalid", banner.TextContent); + Assert.Empty(component.FindAll("aside.banner-error .banner-pagination")); + Assert.Single(component.FindAll("aside.banner-error button.banner-dismiss")); } [Fact] diff --git a/src/EventLogExpert.Components/BannerHost.razor b/src/EventLogExpert.Components/BannerHost.razor index 7c607830..fe297c06 100644 --- a/src/EventLogExpert.Components/BannerHost.razor +++ b/src/EventLogExpert.Components/BannerHost.razor @@ -1,16 +1,15 @@ -@using EventLogExpert.UI.Services @{ - Exception? error = BannerService.UnhandledError; - IReadOnlyList criticals = BannerService.CriticalAlerts; + Exception? currentCritical = BannerService.CurrentCritical; + IReadOnlyList errors = BannerService.ErrorBanners; IReadOnlyList infos = BannerService.InfoBanners; - BannerView view = _currentView = BannerViewSelector.Select(error, criticals, infos); + BannerView view = _currentView = BannerViewSelector.Select(currentCritical, errors, infos); } @switch (view) { - case BannerView.Error when error is { } unhandledError: -
+ +
    + @foreach (var entry in _entries) + { + var rowFailed = _failedFileNames.Contains(entry.FileName); + var radioGroup = $"recovery-action-{entry.FileName}"; + +
  • + @entry.FileName + + +
  • + } +
+ +} diff --git a/src/EventLogExpert.Components/DatabaseRecoveryDialog.razor.cs b/src/EventLogExpert.Components/DatabaseRecoveryDialog.razor.cs new file mode 100644 index 00000000..7b2aac88 --- /dev/null +++ b/src/EventLogExpert.Components/DatabaseRecoveryDialog.razor.cs @@ -0,0 +1,272 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Components.Base; +using EventLogExpert.Eventing.Helpers; +using EventLogExpert.UI.Interfaces; +using EventLogExpert.UI.Models; +using Microsoft.AspNetCore.Components; + +namespace EventLogExpert.Components; + +/// +/// Lets the user restore-from-backup or permanently delete each database whose upgrade was +/// interrupted (i.e., still has a .upgrade.bak sidecar on disk). The dialog chrome +/// () owns <dialog> open/close and Esc handling; this +/// component owns the per-row state and the EntriesChanged subscription so rows resolved +/// externally (or by this dialog itself) disappear live and the dialog auto-dismisses when the +/// affected set becomes empty. +/// +public sealed partial class DatabaseRecoveryDialog : ComponentBase, IAsyncDisposable +{ + private readonly HashSet _failedFileNames = + new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _selectedActions = + new(StringComparer.OrdinalIgnoreCase); + + private ModalChrome? _chrome; + private IReadOnlyList _entries = []; + private bool _isApplying; + private bool _isDismissed; + + private enum RecoveryAction + { + Restore, + Delete + } + + [Parameter] public EventCallback OnDismissed { get; set; } + + [Inject] private IBannerService BannerService { get; init; } = null!; + + [Inject] private IDatabaseService DatabaseService { get; init; } = null!; + + [Inject] private ITraceLogger TraceLogger { get; init; } = null!; + + public ValueTask DisposeAsync() + { + DatabaseService.EntriesChanged -= OnEntriesChanged; + return ValueTask.CompletedTask; + } + + protected override Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && _entries.Count == 0) + { + // The recovery set was already resolved (e.g. concurrently between the parent's mount + // decision and our first render). The conditional in the .razor never rendered the + // ModalChrome; defer the dismiss past the current render lifecycle so the parent's + // OnDismissed handler doesn't run inside our own OnAfterRenderAsync. + _ = InvokeAsync(async () => + { + try + { + await DismissAsync(); + } + catch (Exception unexpected) + { + TraceLogger.Error( + $"DatabaseRecoveryDialog deferred empty-set dismiss threw: {unexpected}"); + } + }); + } + + return base.OnAfterRenderAsync(firstRender); + } + + protected override void OnInitialized() + { + // Subscribe BEFORE snapshotting so any EntriesChanged that fires between the snapshot and the + // subscribe is observed (the symmetric race that BannerService also avoids). + DatabaseService.EntriesChanged += OnEntriesChanged; + RefreshEntriesFromService(); + + base.OnInitialized(); + } + + private async Task ApplyAsync() + { + if (_isApplying) { return; } + + _isApplying = true; + StateHasChanged(); + + // Snapshot to iterate a stable list even if EntriesChanged fires (and mutates _entries) mid-loop + // from one of our own successful Restore/Delete calls. + var rowsToProcess = _entries.ToArray(); + + try + { + foreach (var rowEntry in rowsToProcess) + { + var fileName = rowEntry.FileName; + + if (!_selectedActions.TryGetValue(fileName, out var action)) { continue; } + + // Re-check current service state — concurrent recovery (e.g. another open dialog or a + // separate code path) could have resolved this row between our snapshot and now. Skip + // silently rather than calling the service and getting back a misleading + // InvalidOperationException ("entry not found"). + var liveEntry = DatabaseService.Entries.FirstOrDefault(entry => + string.Equals(entry.FileName, fileName, StringComparison.OrdinalIgnoreCase) && + entry.BackupExists); + + if (liveEntry is null) { continue; } + + // Clear any prior failure mark for this row before attempting; we'll re-add only if + // this attempt itself fails. Per-row scoping (instead of a global pre-clear) preserves + // failure marks for rows we don't end up attempting in this Apply. + _failedFileNames.Remove(fileName); + + bool success; + + try + { + success = action switch + { + RecoveryAction.Restore => await DatabaseService.RestoreFromBackupAsync(fileName), + RecoveryAction.Delete => await DatabaseService.DeleteEntryWithBackupAsync(fileName), + _ => false + }; + } + catch (InvalidOperationException invalidOperation) + { + // DatabaseService raises InvalidOperationException for "entry not found" and + // "another operation in progress". Both mean the world changed underneath us; + // treat as benign skip rather than a user-visible recovery failure. + TraceLogger.Warn( + $"DatabaseRecoveryDialog: skipped '{fileName}' ({action}) — {invalidOperation.Message}"); + continue; + } + catch (Exception unexpected) + { + TraceLogger.Error( + $"DatabaseRecoveryDialog.ApplyAsync threw for '{fileName}' ({action}): {unexpected}"); + success = false; + } + + if (!success) + { + _failedFileNames.Add(fileName); + + var message = action == RecoveryAction.Restore + ? $"Failed to restore '{fileName}' from backup." + : $"Failed to delete '{fileName}'."; + + BannerService.ReportError("Database recovery failed", message); + } + } + } + finally + { + _isApplying = false; + StateHasChanged(); + // EntriesChanged fired by successful Restore/Delete calls will refilter _entries; if the + // affected set ends up empty, OnEntriesChanged → DismissAsync auto-closes the dialog. + } + } + + private void DeleteAll() + { + foreach (var entry in _entries) + { + _selectedActions[entry.FileName] = RecoveryAction.Delete; + } + } + + private async Task DismissAsync() + { + if (_isDismissed) { return; } + + _isDismissed = true; + + try + { + // Best-effort early close so the dialog disappears immediately rather than waiting for + // the parent's re-render to unmount us. ModalChrome.CloseAsync is idempotent and never + // throws; a missing _chrome (e.g. empty-set path that never rendered the chrome) is + // also fine. + if (_chrome is not null) + { + await _chrome.CloseAsync(); + } + } + finally + { + // Always notify the parent so it can remove us from the tree, even if CloseAsync threw + // unexpectedly. The conditional render then unmounts ModalChrome, whose DisposeAsync + // makes a final idempotent JS closeModal call. + await OnDismissed.InvokeAsync(); + } + } + + private Task HandleCancelOrEscAsync() => + _isApplying ? Task.CompletedTask : DismissAsync(); + + private async Task HandleEntriesChangedAsync() + { + try + { + RefreshEntriesFromService(); + + if (_entries.Count == 0) + { + await DismissAsync(); + return; + } + + StateHasChanged(); + } + catch (Exception unexpected) + { + TraceLogger.Error( + $"DatabaseRecoveryDialog.HandleEntriesChangedAsync threw: {unexpected}"); + } + } + + private void OnEntriesChanged(object? sender, EventArgs e) => + _ = InvokeAsync(HandleEntriesChangedAsync); + + private void RefreshEntriesFromService() + { + var snapshot = DatabaseService.Entries + .Where(entry => entry.BackupExists) + .ToArray(); + + _entries = snapshot; + + var presentFileNames = new HashSet( + snapshot.Select(entry => entry.FileName), + StringComparer.OrdinalIgnoreCase); + + // Default Restore for any newly-appeared entry; preserve prior selection for surviving entries. + foreach (var entry in snapshot) + { + _selectedActions.TryAdd(entry.FileName, RecoveryAction.Restore); + } + + // Drop selections + failure marks for entries no longer present (they were resolved externally + // or by a previous Apply iteration). + var staleSelections = _selectedActions.Keys + .Where(name => !presentFileNames.Contains(name)) + .ToArray(); + + foreach (var stale in staleSelections) + { + _selectedActions.Remove(stale); + } + + _failedFileNames.RemoveWhere(name => !presentFileNames.Contains(name)); + } + + private void RestoreAll() + { + foreach (var entry in _entries) + { + _selectedActions[entry.FileName] = RecoveryAction.Restore; + } + } + + private void SelectAction(string fileName, RecoveryAction action) => + _selectedActions[fileName] = action; +} diff --git a/src/EventLogExpert.Components/DatabaseRecoveryDialog.razor.css b/src/EventLogExpert.Components/DatabaseRecoveryDialog.razor.css new file mode 100644 index 00000000..47ffe078 --- /dev/null +++ b/src/EventLogExpert.Components/DatabaseRecoveryDialog.razor.css @@ -0,0 +1,55 @@ +/* Body content styling. Dialog chrome (header, footer, sizing, dialog box) is rendered by + ModalChrome — see EventLogExpert.Components.Base.ModalChrome. */ + +.recovery-description { + margin: 0; + + font-size: .9rem; +} + +.recovery-batch-actions { + display: flex; + gap: .5rem; +} + +.recovery-rows { + margin: 0; + padding: 0; + + list-style: none; +} + +.recovery-row { + display: flex; + align-items: center; + gap: 1rem; + + padding: .35rem .5rem; + border-bottom: 1px solid var(--clr-statusbar); +} + +.recovery-row:last-child { + border-bottom: 0; +} + +.recovery-row-failed { + background-color: rgba(220, 53, 69, 0.15); + border-left: 3px solid var(--clr-red); +} + +.recovery-row-name { + flex: 1 1 auto; + min-width: 0; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.recovery-row-option { + flex: 0 0 auto; + + display: flex; + align-items: center; + gap: .25rem; +} From 7d1f6c0a9d77acf5bae515ba8976ab6dc40bbc80 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Sat, 2 May 2026 10:21:23 -0500 Subject: [PATCH 22/31] Add DatabaseRecoveryHost to surface banner and open recovery dialog for interrupted upgrades --- .../DatabaseRecoveryHostTests.cs | 371 ++++++++++++++++++ .../DatabaseRecoveryHost.razor | 4 + .../DatabaseRecoveryHost.razor.cs | 156 ++++++++ src/EventLogExpert/Main.razor | 5 + 4 files changed, 536 insertions(+) create mode 100644 src/EventLogExpert.Components.Tests/DatabaseRecoveryHostTests.cs create mode 100644 src/EventLogExpert.Components/DatabaseRecoveryHost.razor create mode 100644 src/EventLogExpert.Components/DatabaseRecoveryHost.razor.cs diff --git a/src/EventLogExpert.Components.Tests/DatabaseRecoveryHostTests.cs b/src/EventLogExpert.Components.Tests/DatabaseRecoveryHostTests.cs new file mode 100644 index 00000000..7ccdd200 --- /dev/null +++ b/src/EventLogExpert.Components.Tests/DatabaseRecoveryHostTests.cs @@ -0,0 +1,371 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Bunit; +using EventLogExpert.Eventing.Helpers; +using EventLogExpert.UI; +using EventLogExpert.UI.Interfaces; +using EventLogExpert.UI.Models; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + +namespace EventLogExpert.Components.Tests; + +public sealed class DatabaseRecoveryHostTests : BunitContext +{ + private readonly IBannerService _bannerService = Substitute.For(); + private readonly IDatabaseService _databaseService = Substitute.For(); + private readonly ITraceLogger _traceLogger = Substitute.For(); + + private Func? _capturedRecoveryAction; + private Guid _nextBannerId = Guid.NewGuid(); + + public DatabaseRecoveryHostTests() + { + _databaseService.Entries.Returns([]); + _bannerService.ErrorBanners.Returns([]); + + // Capture the Resolve action callback as a side-effect of any ReportError call so dialog- + // open tests can invoke it without having to re-discover it. _nextBannerId is read inside + // the Returns lambda so each test can stage a distinct id per call. + _bannerService + .ReportError( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Do?>(action => _capturedRecoveryAction = action)) + .Returns(_ => _nextBannerId); + + Services.AddSingleton(_bannerService); + Services.AddSingleton(_databaseService); + Services.AddSingleton(_traceLogger); + + JSInterop.Mode = JSRuntimeMode.Loose; + } + + [Fact] + public void DatabaseRecoveryHost_BannerDismissedExternally_DoesNotRepromptForSameSet() + { + var initialId = Guid.NewGuid(); + _nextBannerId = initialId; + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + _bannerService.ErrorBanners.Returns( + [new ErrorBannerEntry(initialId, "Database upgrade recovery", "...", "Resolve", null, + new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc))]); + + Render(); + _bannerService.ClearReceivedCalls(); + + // User clicks the banner's X button; ErrorBanners no longer contains our id. + _bannerService.ErrorBanners.Returns([]); + _bannerService.StateChanged += Raise.Event(); + + // Subsequent EntriesChanged tick with the SAME set must not re-prompt. + _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + + _bannerService.DidNotReceive().ReportError( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>()); + } + + [Fact] + public void DatabaseRecoveryHost_BannerDismissedExternally_NewBackupEntryAppears_RepromptsWithNewCount() + { + var initialId = Guid.NewGuid(); + _nextBannerId = initialId; + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + _bannerService.ErrorBanners.Returns( + [new ErrorBannerEntry(initialId, "Database upgrade recovery", "...", "Resolve", null, + new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc))]); + + Render(); + _bannerService.ClearReceivedCalls(); + + // External dismissal. + _bannerService.ErrorBanners.Returns([]); + _bannerService.StateChanged += Raise.Event(); + + // A new backup-exists entry appears later. + var newId = Guid.NewGuid(); + _nextBannerId = newId; + _databaseService.Entries.Returns( + [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + + _bannerService.Received(1).ReportError( + "Database upgrade recovery", + "2 databases need recovery from interrupted upgrade.", + "Resolve", + Arg.Any?>()); + } + + [Fact] + public async Task DatabaseRecoveryHost_DialogDismissed_BannerStillVisible_DoesNotDismissBanner() + { + var initialId = Guid.NewGuid(); + _nextBannerId = initialId; + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + var component = Render(); + _bannerService.ClearReceivedCalls(); + + await component.InvokeAsync(() => _capturedRecoveryAction!()); + + await component.Find("button:contains('Cancel')").ClickAsync(new()); + + Assert.Empty(component.FindAll("dialog")); + + // The banner stays — host did NOT call DismissError as part of dialog dismissal. + _bannerService.DidNotReceive().DismissError(Arg.Any()); + } + + [Fact] + public async Task DatabaseRecoveryHost_DialogOpen_NewBackupEntryAppears_RefreshesBannerKeepsDialogOpen() + { + var initialId = Guid.NewGuid(); + _nextBannerId = initialId; + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + var component = Render(); + await component.InvokeAsync(() => _capturedRecoveryAction!()); + + Assert.Single(component.FindAll("dialog")); + + var newId = Guid.NewGuid(); + _nextBannerId = newId; + _databaseService.Entries.Returns( + [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + + _bannerService.Received(1).DismissError(initialId); + _bannerService.Received(1).ReportError( + "Database upgrade recovery", + "2 databases need recovery from interrupted upgrade.", + "Resolve", + Arg.Any?>()); + + Assert.Single(component.FindAll("dialog")); + } + + [Fact] + public void DatabaseRecoveryHost_Disposed_DismissesOwnedBanner() + { + var initialId = Guid.NewGuid(); + _nextBannerId = initialId; + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + var component = Render(); + + component.Instance.Dispose(); + + // Without this, a crash that triggers UnhandledExceptionHandler.OnErrorAsync would dispose + // the host but leave the recovery banner alive in BannerService.ErrorBanners — the new host + // mounted after Recover() would then post a second banner alongside the stale one whose + // Resolve action targets this dead instance. + _bannerService.Received(1).DismissError(initialId); + } + + [Fact] + public void DatabaseRecoveryHost_Disposed_NoLongerRespondsToEntriesChanged() + { + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + var component = Render(); + + component.Instance.Dispose(); + _bannerService.ClearReceivedCalls(); + + _databaseService.Entries.Returns( + [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + + _bannerService.DidNotReceive().DismissError(Arg.Any()); + _bannerService.DidNotReceive().ReportError( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>()); + } + + [Fact] + public void DatabaseRecoveryHost_Disposed_TwiceIsIdempotent() + { + var initialId = Guid.NewGuid(); + _nextBannerId = initialId; + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + var component = Render(); + + component.Instance.Dispose(); + component.Instance.Dispose(); + + _bannerService.Received(1).DismissError(initialId); + } + + [Fact] + public void DatabaseRecoveryHost_Disposed_WithNoOwnedBanner_DoesNotCallDismiss() + { + _databaseService.Entries.Returns([]); + + var component = Render(); + + component.Instance.Dispose(); + + _bannerService.DidNotReceive().DismissError(Arg.Any()); + } + + [Fact] + public void DatabaseRecoveryHost_EntriesChanged_AllRecovered_DismissesBannerAndDoesNotReprompt() + { + var initialId = Guid.NewGuid(); + _nextBannerId = initialId; + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + Render(); + _bannerService.ClearReceivedCalls(); + + _databaseService.Entries.Returns([]); + _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + + _bannerService.Received(1).DismissError(initialId); + _bannerService.DidNotReceive().ReportError( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>()); + } + + [Fact] + public void DatabaseRecoveryHost_EntriesChanged_NewBackupExistsEntry_DismissesOldBannerAndRaisesNewWithUpdatedCount() + { + var initialId = Guid.NewGuid(); + _nextBannerId = initialId; + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + Render(); + + var newId = Guid.NewGuid(); + _nextBannerId = newId; + _databaseService.Entries.Returns( + [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + + _bannerService.Received(1).DismissError(initialId); + _bannerService.Received(1).ReportError( + "Database upgrade recovery", + "2 databases need recovery from interrupted upgrade.", + "Resolve", + Arg.Any?>()); + } + + [Fact] + public void DatabaseRecoveryHost_EntriesChanged_SameBackupSet_DoesNotDismissOrReprompt() + { + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + Render(); + _bannerService.ClearReceivedCalls(); + + // Same set, fresh EntriesChanged tick (e.g., unrelated entry's IsEnabled toggled). + _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + + _bannerService.DidNotReceive().DismissError(Arg.Any()); + _bannerService.DidNotReceive().ReportError( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>()); + } + + [Fact] + public void DatabaseRecoveryHost_EntriesChanged_ShrinkButStillNonEmpty_DismissesOldBannerAndRaisesNewWithUpdatedCount() + { + var initialId = Guid.NewGuid(); + _nextBannerId = initialId; + _databaseService.Entries.Returns( + [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + + Render(); + + // a.db gets resolved externally; b.db remains. + var newId = Guid.NewGuid(); + _nextBannerId = newId; + _databaseService.Entries.Returns([BuildEntry("b.db", backupExists: true)]); + _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + + _bannerService.Received(1).DismissError(initialId); + _bannerService.Received(1).ReportError( + "Database upgrade recovery", + "1 database needs recovery from interrupted upgrade.", + "Resolve", + Arg.Any?>()); + } + + [Fact] + public void DatabaseRecoveryHost_OnInit_MultipleEntries_UsesPluralLabel() + { + _databaseService.Entries.Returns( + [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + + Render(); + + _bannerService.Received(1).ReportError( + "Database upgrade recovery", + "2 databases need recovery from interrupted upgrade.", + "Resolve", + Arg.Any?>()); + } + + [Fact] + public void DatabaseRecoveryHost_OnInit_WithBackupExistsEntries_RaisesErrorBanner() + { + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + Render(); + + _bannerService.Received(1).ReportError( + "Database upgrade recovery", + "1 database needs recovery from interrupted upgrade.", + "Resolve", + Arg.Any?>()); + } + + [Fact] + public void DatabaseRecoveryHost_OnInit_WithNoBackupExistsEntries_DoesNotRaiseBanner() + { + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: false)]); + + Render(); + + _bannerService.DidNotReceive().ReportError( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>()); + } + + [Fact] + public async Task DatabaseRecoveryHost_ResolveActionClicked_OpensDialog() + { + _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + + var component = Render(); + + Assert.NotNull(_capturedRecoveryAction); + Assert.Empty(component.FindAll("dialog")); + + await component.InvokeAsync(() => _capturedRecoveryAction!()); + + Assert.Single(component.FindAll("dialog")); + } + + private static DatabaseEntry BuildEntry(string fileName, bool backupExists) => + new( + fileName, + $@"C:\dbs\{fileName}", + IsEnabled: false, + DatabaseStatus.UpgradeRequired, + BackupExists: backupExists); +} diff --git a/src/EventLogExpert.Components/DatabaseRecoveryHost.razor b/src/EventLogExpert.Components/DatabaseRecoveryHost.razor new file mode 100644 index 00000000..f001e460 --- /dev/null +++ b/src/EventLogExpert.Components/DatabaseRecoveryHost.razor @@ -0,0 +1,4 @@ +@if (_dialogOpen) +{ + +} diff --git a/src/EventLogExpert.Components/DatabaseRecoveryHost.razor.cs b/src/EventLogExpert.Components/DatabaseRecoveryHost.razor.cs new file mode 100644 index 00000000..23acb59d --- /dev/null +++ b/src/EventLogExpert.Components/DatabaseRecoveryHost.razor.cs @@ -0,0 +1,156 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Helpers; +using EventLogExpert.UI.Interfaces; +using EventLogExpert.UI.Models; +using Microsoft.AspNetCore.Components; + +namespace EventLogExpert.Components; + +/// +/// App-root host that watches for entries with +/// set (i.e., interrupted-upgrade backups still on +/// disk), surfaces an error banner with a "Resolve" action, and opens the +/// when the user clicks the action. Mounted inside +/// UnhandledExceptionHandler so any crash in the host or the dialog routes through the +/// critical-error path instead of bypassing it. +/// +public sealed partial class DatabaseRecoveryHost : ComponentBase, IDisposable +{ + private bool _dialogOpen; + private bool _disposed; + private HashSet _promptedFor = new(StringComparer.OrdinalIgnoreCase); + private Guid? _recoveryBannerId; + + [Inject] private IBannerService BannerService { get; init; } = null!; + + [Inject] private IDatabaseService DatabaseService { get; init; } = null!; + + [Inject] private ITraceLogger TraceLogger { get; init; } = null!; + + public void Dispose() + { + if (_disposed) { return; } + + _disposed = true; + DatabaseService.EntriesChanged -= OnEntriesChanged; + BannerService.StateChanged -= OnBannerStateChanged; + + // Dismiss the banner we own so the host's lifetime — not the BannerService's queue — bounds + // the recovery prompt. Without this, a crash that disposes us via UnhandledExceptionHandler + // would leave a stale banner whose Resolve action targets this dead instance; after Recover + // the new host would post a second banner alongside it. + if (_recoveryBannerId is { } id) + { + BannerService.DismissError(id); + _recoveryBannerId = null; + } + } + + protected override void OnInitialized() + { + // Subscribe BEFORE pulling initial state so an EntriesChanged tick that races with our + // OnInitialized can't be dropped between the initial Entries read and the subscription + // taking effect. + DatabaseService.EntriesChanged += OnEntriesChanged; + BannerService.StateChanged += OnBannerStateChanged; + + EvaluateState(); + } + + private void EvaluateState() + { + if (_disposed) { return; } + + HashSet currentBackupSet = DatabaseService.Entries + .Where(entry => entry.BackupExists) + .Select(entry => entry.FileName) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (currentBackupSet.Count == 0) + { + if (_recoveryBannerId is { } activeId) + { + BannerService.DismissError(activeId); + _recoveryBannerId = null; + } + + _promptedFor.Clear(); + return; + } + + if (_recoveryBannerId is { } visibleId) + { + // Banner currently visible — refresh count whenever the set changes (grow OR shrink), + // otherwise leave the banner alone so we don't churn the UI. + if (currentBackupSet.SetEquals(_promptedFor)) { return; } + + BannerService.DismissError(visibleId); + _recoveryBannerId = ReportRecoveryBanner(currentBackupSet.Count); + _promptedFor = currentBackupSet; + return; + } + + // No active banner: either initial state or user dismissed previously. Re-prompt only if + // the set differs from the one the user dismissed. + if (currentBackupSet.SetEquals(_promptedFor)) { return; } + + _recoveryBannerId = ReportRecoveryBanner(currentBackupSet.Count); + _promptedFor = currentBackupSet; + } + + private void HandleBannerStateChanged() + { + if (_disposed) { return; } + + if (_recoveryBannerId is { } id && BannerService.ErrorBanners.All(banner => banner.Id != id)) + { + // The banner with our recorded id is no longer in the queue, so the user dismissed it + // via the banner card's X button (the only path that mutates ErrorBanners without our + // involvement). Clear the id but KEEP _promptedFor so we don't immediately re-prompt + // for the same set on the next EntriesChanged tick. + _recoveryBannerId = null; + } + } + + private void OnBannerStateChanged() => _ = InvokeAsync(HandleBannerStateChanged); + + private void OnDialogDismissed() + { + if (_disposed) { return; } + + _dialogOpen = false; + StateHasChanged(); + } + + private void OnEntriesChanged(object? sender, EventArgs args) => _ = InvokeAsync(EvaluateState); + + private Task OpenRecoveryDialogAsync() + { + // BannerHost currently invokes the action callback on the renderer dispatcher, but route + // through InvokeAsync defensively so the host stays correct if a future caller invokes the + // action from a non-UI context. + return InvokeAsync(() => + { + if (_disposed) { return; } + + _dialogOpen = true; + + StateHasChanged(); + }); + } + + private Guid ReportRecoveryBanner(int count) + { + string message = count == 1 + ? "1 database needs recovery from interrupted upgrade." + : $"{count} databases need recovery from interrupted upgrade."; + + return BannerService.ReportError( + "Database upgrade recovery", + message, + "Resolve", + OpenRecoveryDialogAsync); + } +} diff --git a/src/EventLogExpert/Main.razor b/src/EventLogExpert/Main.razor index b10dc658..316e5c51 100644 --- a/src/EventLogExpert/Main.razor +++ b/src/EventLogExpert/Main.razor @@ -1,11 +1,16 @@ @* 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. + DatabaseRecoveryHost lives INSIDE the handler so any crash in the host or its dialog routes through the + critical-error path (UnhandledExceptionHandler -> BannerService.ReportCritical -> BannerHost) instead of + bypassing it. *@ + + From 250a7203222e30e8ef17e1c0b3b719f5f43928b2 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Sat, 2 May 2026 11:25:14 -0500 Subject: [PATCH 23/31] Add inline upgrade banner and trigger settings-scope upgrades from SettingsModal toggle confirmation --- .../SettingsUpgradeProgressBannerTests.cs | 220 ++++++++++++++++++ .../SettingsUpgradeProgressBanner.razor | 28 +++ .../SettingsUpgradeProgressBanner.razor.cs | 59 +++++ .../SettingsUpgradeProgressBanner.razor.css | 53 +++++ .../Shared/Components/SettingsModal.razor | 3 + .../Shared/Components/SettingsModal.razor.cs | 131 +++++++++++ 6 files changed, 494 insertions(+) create mode 100644 src/EventLogExpert.Components.Tests/SettingsUpgradeProgressBannerTests.cs create mode 100644 src/EventLogExpert.Components/SettingsUpgradeProgressBanner.razor create mode 100644 src/EventLogExpert.Components/SettingsUpgradeProgressBanner.razor.cs create mode 100644 src/EventLogExpert.Components/SettingsUpgradeProgressBanner.razor.css diff --git a/src/EventLogExpert.Components.Tests/SettingsUpgradeProgressBannerTests.cs b/src/EventLogExpert.Components.Tests/SettingsUpgradeProgressBannerTests.cs new file mode 100644 index 00000000..54ae8ba1 --- /dev/null +++ b/src/EventLogExpert.Components.Tests/SettingsUpgradeProgressBannerTests.cs @@ -0,0 +1,220 @@ +// // 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 SettingsUpgradeProgressBannerTests : BunitContext +{ + private readonly IBannerService _bannerService = Substitute.For(); + private readonly ITraceLogger _traceLogger = Substitute.For(); + + public SettingsUpgradeProgressBannerTests() + { + _bannerService.SettingsProgress.Returns((BannerProgressEntry?)null); + _bannerService.BackgroundProgress.Returns((BannerProgressEntry?)null); + + Services.AddSingleton(_bannerService); + Services.AddSingleton(_traceLogger); + + JSInterop.Mode = JSRuntimeMode.Loose; + } + + [Fact] + public void SettingsUpgradeProgressBanner_BackgroundProgressOnly_RendersNothing() + { + // The inline banner observes SettingsProgress only. A background-scope batch must not surface here + // (top-level BannerHost owns that slot). + _bannerService.BackgroundProgress.Returns(BuildProgress(UpgradeProgressScope.Background)); + + var component = Render(); + + Assert.Empty(component.FindAll("aside.settings-upgrade-banner")); + } + + [Fact] + public async Task SettingsUpgradeProgressBanner_CancelClicked_InvokesProgressCancelDelegate() + { + int cancelInvocationCount = 0; + _bannerService.SettingsProgress.Returns(BuildProgress(cancel: () => cancelInvocationCount++)); + + var component = Render(); + await component.Find("aside.settings-upgrade-banner button.banner-action").ClickAsync(new()); + + Assert.Equal(1, cancelInvocationCount); + } + + [Fact] + public async Task SettingsUpgradeProgressBanner_CancelThrows_LogsViaTraceLoggerAndDoesNotPropagate() + { + _bannerService.SettingsProgress.Returns( + BuildProgress(cancel: () => throw new InvalidOperationException("cts disposed"))); + + var component = Render(); + await component.Find("aside.settings-upgrade-banner button.banner-action").ClickAsync(new()); + + Assert.Single(component.FindAll("aside.settings-upgrade-banner")); + + _traceLogger.Received(1).Error(Arg.Is(h => + h.ToString().Contains(nameof(SettingsUpgradeProgressBanner)) && + h.ToString().Contains("cts disposed"))); + } + + [Fact] + public void SettingsUpgradeProgressBanner_DisposeIsIdempotent() + { + var component = Render(); + var instance = component.Instance; + + instance.Dispose(); + instance.Dispose(); + + // Two disposals must not unsubscribe twice (handler delta would go negative). + // Verified by the fact that no exception bubbles out and the component stays alive. + } + + [Fact] + public async Task SettingsUpgradeProgressBanner_DisposeUnsubscribesFromStateChanged() + { + _bannerService.SettingsProgress.Returns((BannerProgressEntry?)null); + + var component = Render(); + component.Instance.Dispose(); + + // After Dispose, raising StateChanged with a now-non-null SettingsProgress must not re-render. + _bannerService.SettingsProgress.Returns(BuildProgress()); + _bannerService.StateChanged += Raise.Event(); + + await Task.Yield(); + + Assert.Empty(component.FindAll("aside.settings-upgrade-banner")); + } + + [Fact] + public void SettingsUpgradeProgressBanner_QueuedBatchesAfterOne_UsesSingularBatchLabel() + { + _bannerService.SettingsProgress.Returns(BuildProgress(queuedBatchesAfter: 1)); + + var component = Render(); + + var subtitle = component.Find("aside.settings-upgrade-banner .banner-subtitle"); + Assert.Contains("+1 batch queued", subtitle.TextContent); + Assert.DoesNotContain("batches", subtitle.TextContent); + } + + [Fact] + public void SettingsUpgradeProgressBanner_QueuedBatchesAfterTwo_UsesPluralBatchesLabel() + { + _bannerService.SettingsProgress.Returns(BuildProgress(queuedBatchesAfter: 2)); + + var component = Render(); + + var subtitle = component.Find("aside.settings-upgrade-banner .banner-subtitle"); + Assert.Contains("+2 batches queued", subtitle.TextContent); + } + + [Fact] + public void SettingsUpgradeProgressBanner_QueuedBatchesAfterZero_DoesNotRenderSubtitle() + { + _bannerService.SettingsProgress.Returns(BuildProgress(queuedBatchesAfter: 0)); + + var component = Render(); + + Assert.Empty(component.FindAll("aside.settings-upgrade-banner .banner-subtitle")); + } + + [Fact] + public void SettingsUpgradeProgressBanner_SettingsProgressNull_RendersNothing() + { + _bannerService.SettingsProgress.Returns((BannerProgressEntry?)null); + + var component = Render(); + + Assert.Empty(component.FindAll("aside.settings-upgrade-banner")); + } + + [Fact] + public void SettingsUpgradeProgressBanner_SettingsProgressWithBatchSizeOne_UsesSingularDatabaseLabel() + { + // Pre-first-tick rendering: empty CurrentEntryName triggers the "Preparing..." string. + _bannerService.SettingsProgress.Returns( + BuildProgress(currentBatchSize: 1, currentEntryName: string.Empty)); + + var component = Render(); + + var banner = component.Find("aside.settings-upgrade-banner"); + Assert.Contains("Preparing upgrade of 1 database", banner.TextContent); + Assert.DoesNotContain("databases", banner.TextContent); + } + + [Fact] + public void SettingsUpgradeProgressBanner_SettingsProgressWithEmptyEntryName_RendersPreparingMessage() + { + _bannerService.SettingsProgress.Returns( + BuildProgress(currentBatchSize: 3, currentEntryName: string.Empty)); + + var component = Render(); + + var banner = component.Find("aside.settings-upgrade-banner"); + Assert.Contains("Preparing upgrade of 3 databases", banner.TextContent); + Assert.DoesNotContain("Upgrading database 0", banner.TextContent); + } + + [Fact] + public void + SettingsUpgradeProgressBanner_SettingsProgressWithEntryName_RendersUpgradeProgressBannerWithCancelButton() + { + _bannerService.SettingsProgress.Returns(BuildProgress( + currentBatchPosition: 2, + currentBatchSize: 5, + currentEntryName: "MyDb.evtx", + currentPhase: UpgradePhase.MigratingSchema)); + + var component = Render(); + + var banner = component.Find("aside.settings-upgrade-banner"); + Assert.Contains("Upgrading database 2 of 5", banner.TextContent); + Assert.Contains("MyDb.evtx", banner.TextContent); + Assert.Contains("MigratingSchema", banner.TextContent); + Assert.Equal("Cancel", component.Find("aside.settings-upgrade-banner button.banner-action").TextContent.Trim()); + Assert.Single(component.FindAll("aside.settings-upgrade-banner .banner-spinner")); + } + + [Fact] + public async Task SettingsUpgradeProgressBanner_StateChangedRaised_RerendersWithNewProgress() + { + _bannerService.SettingsProgress.Returns((BannerProgressEntry?)null); + var component = Render(); + Assert.Empty(component.FindAll("aside.settings-upgrade-banner")); + + _bannerService.SettingsProgress.Returns(BuildProgress(currentEntryName: "x.evtx")); + _bannerService.StateChanged += Raise.Event(); + + await component.WaitForAssertionAsync(() => + Assert.Single(component.FindAll("aside.settings-upgrade-banner"))); + } + + private static BannerProgressEntry BuildProgress( + UpgradeProgressScope scope = UpgradeProgressScope.SettingsTriggered, + int currentBatchPosition = 1, + int currentBatchSize = 1, + string currentEntryName = "x.evtx", + UpgradePhase currentPhase = UpgradePhase.MigratingSchema, + int queuedBatchesAfter = 0, + Action? cancel = null) => + new( + Guid.NewGuid(), + scope, + currentBatchPosition, + currentBatchSize, + currentEntryName, + currentPhase, + queuedBatchesAfter, + cancel ?? (() => { })); +} diff --git a/src/EventLogExpert.Components/SettingsUpgradeProgressBanner.razor b/src/EventLogExpert.Components/SettingsUpgradeProgressBanner.razor new file mode 100644 index 00000000..9bf76f6f --- /dev/null +++ b/src/EventLogExpert.Components/SettingsUpgradeProgressBanner.razor @@ -0,0 +1,28 @@ +@namespace EventLogExpert.Components + +@if (BannerService.SettingsProgress is { } progress) +{ + +} diff --git a/src/EventLogExpert.Components/SettingsUpgradeProgressBanner.razor.cs b/src/EventLogExpert.Components/SettingsUpgradeProgressBanner.razor.cs new file mode 100644 index 00000000..e9b48d25 --- /dev/null +++ b/src/EventLogExpert.Components/SettingsUpgradeProgressBanner.razor.cs @@ -0,0 +1,59 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Helpers; +using EventLogExpert.UI.Interfaces; +using EventLogExpert.UI.Models; +using Microsoft.AspNetCore.Components; + +namespace EventLogExpert.Components; + +/// +/// Inline banner rendered inside SettingsModal that mirrors the top-level upgrade-progress card but +/// observes instead of the background slot. Renders nothing +/// when no settings-scope upgrade batch is in flight. +/// +public sealed partial class SettingsUpgradeProgressBanner : ComponentBase, IDisposable +{ + private bool _disposed; + + [Inject] private IBannerService BannerService { get; init; } = null!; + + [Inject] private ITraceLogger TraceLogger { get; init; } = null!; + + public void Dispose() + { + if (_disposed) { return; } + + _disposed = true; + BannerService.StateChanged -= OnStateChanged; + } + + protected override void OnInitialized() + { + BannerService.StateChanged += OnStateChanged; + base.OnInitialized(); + } + + private async Task OnCancelClickedAsync(BannerProgressEntry entry) + { + try + { + entry.Cancel(); + } + catch (Exception ex) + { + TraceLogger.Error( + $"{nameof(SettingsUpgradeProgressBanner)}.{nameof(OnCancelClickedAsync)}: cancel threw: {ex}"); + } + + await Task.CompletedTask; + } + + private void OnStateChanged() + { + if (_disposed) { return; } + + _ = InvokeAsync(StateHasChanged); + } +} diff --git a/src/EventLogExpert.Components/SettingsUpgradeProgressBanner.razor.css b/src/EventLogExpert.Components/SettingsUpgradeProgressBanner.razor.css new file mode 100644 index 00000000..08146796 --- /dev/null +++ b/src/EventLogExpert.Components/SettingsUpgradeProgressBanner.razor.css @@ -0,0 +1,53 @@ +.settings-upgrade-banner { + position: sticky; + top: 0; + z-index: 1; + + display: flex; + align-items: center; + gap: .75rem; + padding: .5rem 1rem; + margin-bottom: .5rem; + + background-color: var(--clr-statusbar); + color: var(--text-on-statusbar); + box-shadow: var(--shadow-modal); +} + +.banner-spinner { + flex: 0 0 auto; + animation: banner-spin 1.5s linear infinite; +} + +@keyframes banner-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.banner-message { + flex: 1 1 auto; + min-width: 0; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.banner-subtitle { + flex: 0 0 auto; + + font-size: .85rem; + opacity: .9; +} + +.banner-action { + flex: 0 0 auto; + padding: .15rem .75rem; + + background: transparent; + color: inherit; + border: 1px solid currentColor; + border-radius: .25rem; + + &:hover { background-color: rgba(255, 255, 255, 0.15); } +} diff --git a/src/EventLogExpert/Shared/Components/SettingsModal.razor b/src/EventLogExpert/Shared/Components/SettingsModal.razor index 63be15e2..5e440eab 100644 --- a/src/EventLogExpert/Shared/Components/SettingsModal.razor +++ b/src/EventLogExpert/Shared/Components/SettingsModal.razor @@ -6,6 +6,7 @@
+ +
Time Zone: diff --git a/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs b/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs index 7bed4f1f..adf88766 100644 --- a/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs +++ b/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs @@ -20,6 +20,7 @@ public sealed partial class SettingsModal : ModalBase private CopyType _copyType; private bool _databaseStateChanged; private bool _isPreReleaseEnabled; + private bool _isUpgradeInFlight; private LogLevel _logLevel; private bool _showDisplayPaneOnSelectionChange; private Theme _theme; @@ -27,6 +28,8 @@ public sealed partial class SettingsModal : ModalBase [Inject] private IAlertDialogService AlertDialogService { get; init; } = null!; + [Inject] private IBannerService BannerService { get; init; } = null!; + [Inject] private IDatabaseService DatabaseService { get; init; } = null!; [Inject] private IDispatcher Dispatcher { get; init; } = null!; @@ -35,11 +38,23 @@ public sealed partial class SettingsModal : ModalBase [Inject] private ISettingsService Settings { get; init; } = null!; + /// + /// true while a settings-triggered upgrade has been initiated from this modal but not yet observed + /// to drain through the BannerService progress slot. Covers the queued-but-not-yet-started window + /// ( can sit behind another batch before + /// fires) where + /// alone would still be null. Composed with the banner + /// slot so once the consumer picks up the batch and the slot is populated, the predicate stays true through + /// completion. + /// + private bool IsCloseBlocked => _isUpgradeInFlight || BannerService.SettingsProgress is not null; + protected override async ValueTask DisposeAsyncCore(bool disposing) { if (disposing) { DatabaseService.EntriesChanged -= OnDatabaseEntriesChanged; + BannerService.StateChanged -= OnBannerStateChanged; } await base.DisposeAsyncCore(disposing); @@ -54,16 +69,27 @@ protected override async Task OnClosingAsync() } } + protected override Task OnCancelAsync() => + IsCloseBlocked + ? Task.CompletedTask + : base.OnCancelAsync(); + protected override void OnInitialized() { LoadFromSettings(); + DatabaseService.EntriesChanged += OnDatabaseEntriesChanged; + BannerService.StateChanged += OnBannerStateChanged; base.OnInitialized(); } protected override async Task OnSaveAsync() { + if (IsCloseBlocked) { return; } + + if (!await TryRunUpgradesAsync()) { return; } + await ApplyPendingToggles(); Settings.CopyType = _copyType; @@ -128,6 +154,109 @@ await AlertDialogService.ShowAlert("Failed to Update Database", } } + /// + /// Identify pending enable-toggles whose target entry needs an upgrade first, prompt for confirmation, run + /// the batch upgrade through the settings-scope progress slot, then re-stage the entries that succeeded so + /// they are committed by the normal Save flow (no immediate calls). + /// Surfaces per-entry failures via . Returns false + /// when the user declines the upgrade prompt, the upgrade throws, OR the user cancels the in-flight upgrade + /// — the caller should abort the save in any of those cases so the modal stays open and the user can either + /// retry or click Exit to discard. The principle: represents the + /// "commit" step and must only run as part of a successful Save, never as a side effect of the upgrade + /// batch itself. + /// + private async Task TryRunUpgradesAsync() + { + if (_pendingToggles.Count == 0) { return true; } + + var entriesByName = DatabaseService.Entries.ToDictionary( + entry => entry.FileName, + StringComparer.OrdinalIgnoreCase); + + var fileNamesNeedingUpgrade = _pendingToggles + .Where(pair => pair.Value + && entriesByName.TryGetValue(pair.Key, out var entry) + && entry.Status is DatabaseStatus.UpgradeRequired or DatabaseStatus.UpgradeFailed) + .Select(pair => pair.Key) + .ToList(); + + if (fileNamesNeedingUpgrade.Count == 0) { return true; } + + var confirmed = await AlertDialogService.ShowAlert( + "Upgrade Required", + fileNamesNeedingUpgrade.Count == 1 + ? "1 database requires an upgrade before it can be enabled. Upgrade now?" + : $"{fileNamesNeedingUpgrade.Count} databases require an upgrade before they can be enabled. Upgrade now?", + "Upgrade and enable", + "Cancel"); + + if (!confirmed) { return false; } + + foreach (var fileName in fileNamesNeedingUpgrade) + { + _pendingToggles.Remove(fileName); + } + + UpgradeBatchResult result; + + _isUpgradeInFlight = true; + + try + { + result = await DatabaseService.UpgradeBatchAsync( + fileNamesNeedingUpgrade, + UpgradeProgressScope.SettingsTriggered); + } + catch (Exception ex) + { + // Restore every entry whose toggle we removed so the user can retry or click Exit to discard. + // Without this restore, returning to OnSaveAsync would either commit a now-empty pending set + // or (with the previous return-true behavior) silently drop the user's enable intent. + foreach (var fileName in fileNamesNeedingUpgrade) + { + _pendingToggles[fileName] = true; + } + + await AlertDialogService.ShowAlert("Database Upgrade Failed", + $"An exception occurred while upgrading databases: {ex.Message}", + "OK"); + + return false; + } + finally + { + _isUpgradeInFlight = false; + } + + // Re-stage successes as pending so ApplyPendingToggles enables them as part of the normal Save commit. + // We do NOT call DatabaseService.Toggle here directly: per the modal contract, no toggle should take + // effect until the user's Save click reaches CompleteAsync — otherwise a subsequent Cancel/Exit would + // leave partial commits behind. + foreach (var fileName in result.Succeeded) + { + _pendingToggles[fileName] = true; + } + + foreach (var failure in result.Failed) + { + await AlertDialogService.ShowErrorAlert( + "Database Upgrade Failed", + $"Failed to upgrade '{failure.FileName}': {failure.Message}"); + } + + // Cancellation is the user's "abort" signal — restore the cancelled enable-toggles to pending so + // they re-appear next Save, and abort this save so the modal stays open. Without this restore, + // cancelled entries' toggles would be silently consumed and the user would have to re-toggle. + if (result.Cancelled.Count <= 0) { return true; } + + foreach (var fileName in result.Cancelled) + { + _pendingToggles[fileName] = true; + } + + return false; + } + private bool GetEffectiveEnabled(DatabaseEntry entry) => _pendingToggles.TryGetValue(entry.FileName, out var pending) ? pending : entry.IsEnabled; @@ -188,6 +317,8 @@ private void LoadFromSettings() _timeZoneId = Settings.TimeZoneId; } + private void OnBannerStateChanged() => _ = InvokeAsync(StateHasChanged); + private void OnDatabaseEntriesChanged(object? sender, EventArgs e) { _databaseStateChanged = true; From 4a914c1a046b77e04ca2456d7ceb930e395b1f93 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Sat, 2 May 2026 15:47:56 -0500 Subject: [PATCH 24/31] Default freshly-imported databases to disabled and auto-upgrade V3 entries --- .../Services/DatabaseServiceTests.cs | 312 ++++++++++++++++ .../Interfaces/IDatabaseService.cs | 120 ++++--- src/EventLogExpert.UI/Models/ImportResult.cs | 5 +- .../Services/DatabaseService.cs | 244 ++++++++++--- .../Shared/Components/SettingsModal.razor.cs | 333 +++++++++++------- 5 files changed, 777 insertions(+), 237 deletions(-) diff --git a/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs b/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs index 1db4c1ea..41a6394c 100644 --- a/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs +++ b/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs @@ -726,6 +726,318 @@ public void EntriesChanged_MultipleSubscribers_FirstThrows_ShouldStillInvokeRest Assert.Equal(1, secondSubscriberInvocations); } + [Fact] + public async Task EnumerateZipDbEntryNamesAsync_MalformedZip_ShouldReturnEmpty_NotThrow() + { + CreateDatabaseDirectory(); + var sourceDir = Path.Combine(_testDirectory, "source"); + Directory.CreateDirectory(sourceDir); + + var malformedZip = Path.Combine(sourceDir, "malformed.zip"); + File.WriteAllBytes(malformedZip, [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]); + + var service = CreateDatabaseService(); + + var names = await service.EnumerateZipDbEntryNamesAsync( + malformedZip, + TestContext.Current.CancellationToken); + + Assert.Empty(names); + } + + [Fact] + public async Task EnumerateZipDbEntryNamesAsync_ShouldReturnDbEntries_NotOtherFileTypes() + { + CreateDatabaseDirectory(); + var sourceDir = Path.Combine(_testDirectory, "source"); + Directory.CreateDirectory(sourceDir); + + var zipPath = Path.Combine(sourceDir, "import.zip"); + CreateZipWithEntries(zipPath, + [ + (Constants.TestDb1, "db1"), + ("readme.txt", "ignored"), + (Constants.TestDb2, "db2") + ]); + + var service = CreateDatabaseService(); + + var names = await service.EnumerateZipDbEntryNamesAsync(zipPath, TestContext.Current.CancellationToken); + + Assert.Equal(2, names.Count); + Assert.Contains(Constants.TestDb1, names); + Assert.Contains(Constants.TestDb2, names); + Assert.DoesNotContain("readme.txt", names); + } + + [Fact] + public async Task ImportAsync_FreshlyImportedV3Db_ShouldAutoUpgradeToReady_AndStayDisabled() + { + CreateDatabaseDirectory(); + var sourceDir = Path.Combine(_testDirectory, "source"); + Directory.CreateDirectory(sourceDir); + + var sourceFile = Path.Combine(sourceDir, Constants.TestDb1); + DatabaseSeedUtils.SeedV3Schema(sourceFile); + + var preferences = Substitute.For(); + preferences.DisabledDatabasesPreference.Returns([]); + + var service = CreateDatabaseService(preferences); + + var result = await service.ImportAsync([sourceFile], TestContext.Current.CancellationToken); + + Assert.Equal(1, result.Imported); + Assert.Empty(result.Failures); + Assert.Empty(result.UpgradeFailures); + + var entry = Assert.Single(service.Entries); + Assert.Equal(Constants.TestDb1, entry.FileName); + Assert.Equal(DatabaseStatus.Ready, entry.Status); + Assert.False(entry.IsEnabled); + Assert.False(entry.BackupExists); + } + + [Fact] + public async Task ImportAsync_FreshlyImportedV3Db_WithStaleBackupAtDestination_ShouldPopulateUpgradeFailures() + { + var databasePath = CreateDatabaseDirectory(); + + // Stale .upgrade.bak at destination (no main file present yet) — simulates a recovery + // scenario where a backup is sitting on disk waiting for the next upgrade attempt. + File.WriteAllText(Path.Combine(databasePath, Constants.TestDb1 + ".upgrade.bak"), "stale-backup"); + + var sourceDir = Path.Combine(_testDirectory, "source"); + Directory.CreateDirectory(sourceDir); + + var sourceFile = Path.Combine(sourceDir, Constants.TestDb1); + DatabaseSeedUtils.SeedV3Schema(sourceFile); + + var service = CreateDatabaseService(); + + var result = await service.ImportAsync([sourceFile], TestContext.Current.CancellationToken); + + Assert.Equal(1, result.Imported); + Assert.Empty(result.Failures); + + var failure = Assert.Single(result.UpgradeFailures); + Assert.Equal(Constants.TestDb1, failure.FileName); + Assert.Contains("Recovery required", failure.Reason, StringComparison.OrdinalIgnoreCase); + + var entry = Assert.Single(service.Entries); + Assert.Equal(DatabaseStatus.UpgradeRequired, entry.Status); + Assert.True(entry.BackupExists); + } + + [Fact] + public async Task ImportAsync_FreshlyImportedV4Db_ShouldDefaultDisabled_AndNotEnqueueUpgradeBatch() + { + CreateDatabaseDirectory(); + var sourceDir = Path.Combine(_testDirectory, "source"); + Directory.CreateDirectory(sourceDir); + + var sourceFile = Path.Combine(sourceDir, Constants.TestDb1); + DatabaseSeedUtils.SeedV4Schema(sourceFile); + + var preferences = Substitute.For(); + preferences.DisabledDatabasesPreference.Returns([]); + + var service = CreateDatabaseService(preferences); + + var batchStartedCount = 0; + service.UpgradeBatchStarted += (_, _) => Interlocked.Increment(ref batchStartedCount); + + var result = await service.ImportAsync([sourceFile], TestContext.Current.CancellationToken); + + Assert.Equal(1, result.Imported); + Assert.Empty(result.Failures); + Assert.Empty(result.UpgradeFailures); + Assert.Equal(0, batchStartedCount); + + var entry = Assert.Single(service.Entries); + Assert.Equal(Constants.TestDb1, entry.FileName); + Assert.False(entry.IsEnabled); + Assert.Equal(DatabaseStatus.Ready, entry.Status); + + preferences.Received().DisabledDatabasesPreference = + Arg.Is>(disabled => disabled.Contains(Constants.TestDb1, StringComparer.OrdinalIgnoreCase)); + } + + [Fact] + public async Task ImportAsync_ReimportedDb_NotOnSkipList_ShouldOverwriteAndPreserveEnabledState() + { + var databasePath = CreateDatabaseDirectory(); + var existingPath = Path.Combine(databasePath, Constants.TestDb1); + DatabaseSeedUtils.SeedV4Schema(existingPath); + + var preferences = Substitute.For(); + preferences.DisabledDatabasesPreference.Returns([]); + + var service = CreateDatabaseService(preferences); + Assert.True(service.Entries[0].IsEnabled); + + var sourceDir = Path.Combine(_testDirectory, "source"); + Directory.CreateDirectory(sourceDir); + var sourceFile = Path.Combine(sourceDir, Constants.TestDb1); + + const string overwriteContent = "fresh-overwrite-content"; + File.WriteAllText(sourceFile, overwriteContent); + + var result = await service.ImportAsync([sourceFile], TestContext.Current.CancellationToken); + + Assert.Equal(1, result.Imported); + Assert.Empty(result.Failures); + Assert.Empty(result.UpgradeFailures); + + SqliteConnection.ClearAllPools(); + Assert.Equal(overwriteContent, File.ReadAllText(existingPath)); + + var entry = Assert.Single(service.Entries); + Assert.True(entry.IsEnabled); + + preferences.DidNotReceive().DisabledDatabasesPreference = + Arg.Is>(disabled => disabled.Contains(Constants.TestDb1, StringComparer.OrdinalIgnoreCase)); + } + + [Fact] + public async Task ImportAsync_ReimportedDb_OnSkipList_ShouldPreserveExistingFileAndEnabledState() + { + var databasePath = CreateDatabaseDirectory(); + var existingPath = Path.Combine(databasePath, Constants.TestDb1); + DatabaseSeedUtils.SeedV4Schema(existingPath); + var existingLength = new FileInfo(existingPath).Length; + + var preferences = Substitute.For(); + preferences.DisabledDatabasesPreference.Returns([]); + + var service = CreateDatabaseService(preferences); + Assert.True(service.Entries[0].IsEnabled); + + var sourceDir = Path.Combine(_testDirectory, "source"); + Directory.CreateDirectory(sourceDir); + var sourceFile = Path.Combine(sourceDir, Constants.TestDb1); + File.WriteAllText(sourceFile, "would-overwrite-if-not-skipped"); + + var skipNames = new HashSet(StringComparer.OrdinalIgnoreCase) { Constants.TestDb1 }; + + var result = await service.ImportAsync([sourceFile], skipNames, TestContext.Current.CancellationToken); + + Assert.Equal(0, result.Imported); + Assert.Empty(result.Failures); + Assert.Empty(result.UpgradeFailures); + + SqliteConnection.ClearAllPools(); + Assert.Equal(existingLength, new FileInfo(existingPath).Length); + + var entry = Assert.Single(service.Entries); + Assert.True(entry.IsEnabled); + Assert.Equal(DatabaseStatus.Ready, entry.Status); + + preferences.DidNotReceive().DisabledDatabasesPreference = + Arg.Is>(disabled => disabled.Contains(Constants.TestDb1, StringComparer.OrdinalIgnoreCase)); + } + + [Fact] + public async Task ImportAsync_ReimportedV3DbOverV4_ShouldAutoUpgradeAndPreservePriorEnabledState() + { + var databasePath = CreateDatabaseDirectory(); + var existingPath = Path.Combine(databasePath, Constants.TestDb1); + DatabaseSeedUtils.SeedV4Schema(existingPath); + + var preferences = Substitute.For(); + preferences.DisabledDatabasesPreference.Returns([]); + + var service = CreateDatabaseService(preferences); + Assert.True(service.Entries[0].IsEnabled); + + var sourceDir = Path.Combine(_testDirectory, "source"); + Directory.CreateDirectory(sourceDir); + var sourceFile = Path.Combine(sourceDir, Constants.TestDb1); + DatabaseSeedUtils.SeedV3Schema(sourceFile); + + var result = await service.ImportAsync([sourceFile], TestContext.Current.CancellationToken); + + Assert.Equal(1, result.Imported); + Assert.Empty(result.Failures); + Assert.Empty(result.UpgradeFailures); + + var entry = Assert.Single(service.Entries); + Assert.True(entry.IsEnabled); + Assert.Equal(DatabaseStatus.Ready, entry.Status); + } + + [Fact] + public async Task ImportAsync_SkipFileNamesProvidedAsCaseSensitiveSet_ShouldStillSkipCaseInsensitively() + { + var databasePath = CreateDatabaseDirectory(); + var existingPath = Path.Combine(databasePath, Constants.TestDb1); + DatabaseSeedUtils.SeedV4Schema(existingPath); + var existingLength = new FileInfo(existingPath).Length; + + var preferences = Substitute.For(); + preferences.DisabledDatabasesPreference.Returns([]); + + var service = CreateDatabaseService(preferences); + + var sourceDir = Path.Combine(_testDirectory, "source"); + Directory.CreateDirectory(sourceDir); + var sourceFile = Path.Combine(sourceDir, Constants.TestDb1); + File.WriteAllText(sourceFile, "would-overwrite-if-comparer-mismatched"); + + // Caller uses an explicitly case-sensitive set with the upper-cased name. Service should + // honor its documented case-insensitive skip contract and not overwrite the existing file. + var caseSensitiveSkip = new HashSet(StringComparer.Ordinal) + { + Constants.TestDb1.ToUpperInvariant() + }; + + var result = await service.ImportAsync( + [sourceFile], + caseSensitiveSkip, + TestContext.Current.CancellationToken); + + Assert.Equal(0, result.Imported); + Assert.Empty(result.Failures); + Assert.Empty(result.UpgradeFailures); + + SqliteConnection.ClearAllPools(); + Assert.Equal(existingLength, new FileInfo(existingPath).Length); + } + + [Fact] + public async Task ImportAsync_SkipNamesIncludesZipEntry_ShouldNotExtractThatEntry_OthersExtracted() + { + var databasePath = CreateDatabaseDirectory(); + + // Pre-create the entry that we'll ask the import to skip. + var preExistingPath = Path.Combine(databasePath, Constants.TestDb1); + DatabaseSeedUtils.SeedV4Schema(preExistingPath); + var preExistingLength = new FileInfo(preExistingPath).Length; + + var sourceDir = Path.Combine(_testDirectory, "source"); + Directory.CreateDirectory(sourceDir); + var zipPath = Path.Combine(sourceDir, "import.zip"); + CreateZipWithEntries(zipPath, + [ + (Constants.TestDb1, "would-overwrite-if-not-skipped"), + (Constants.TestDb2, "fresh content") + ]); + + var service = CreateDatabaseService(); + + var skipNames = new HashSet(StringComparer.OrdinalIgnoreCase) { Constants.TestDb1 }; + + var result = await service.ImportAsync([zipPath], skipNames, TestContext.Current.CancellationToken); + + Assert.Equal(1, result.Imported); + Assert.Empty(result.Failures); + + SqliteConnection.ClearAllPools(); + Assert.Equal(preExistingLength, new FileInfo(preExistingPath).Length); + Assert.True(File.Exists(Path.Combine(databasePath, Constants.TestDb2))); + Assert.Equal(2, service.Entries.Count); + } + [Fact] public async Task ImportAsync_WhenDbFilesProvided_ShouldCopyAndRefresh() { diff --git a/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs b/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs index a363ad64..586045c9 100644 --- a/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs +++ b/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs @@ -10,27 +10,24 @@ public interface IDatabaseService : IAsyncDisposable event EventHandler? EntriesChanged; /// - /// Raised once per batch after every entry has either succeeded, been cancelled, or failed. - /// The companion in the args carries the per-entry outcome - /// plus any entries that were rejected up-front (e.g., recovery-required) and never enqueued - /// for actual upgrade work. + /// Raised once per batch after every entry has either succeeded, been cancelled, or failed. The companion + /// in the args carries the per-entry outcome plus any entries that were rejected + /// up-front (e.g., recovery-required) and never enqueued for actual upgrade work. /// event EventHandler? UpgradeBatchCompleted; /// - /// Raised at each phase transition for the entry currently being upgraded - /// ( → - /// ). Every invocation carries the batch identifier from - /// the matching . + /// Raised at each phase transition for the entry currently being upgraded ( + /// → ). Every invocation carries + /// the batch identifier from the matching . /// event EventHandler? UpgradeBatchProgress; /// - /// Raised when the consumer task starts processing a queued upgrade batch (after any - /// short-circuited / fully-rejected batches have already returned). Subscribers receive the - /// batch identifier (used to correlate with later progress and completion events) and a - /// hook to request cancellation. Always - /// followed by either + + /// Raised when the consumer task starts processing a queued upgrade batch (after any short-circuited / + /// fully-rejected batches have already returned). Subscribers receive the batch identifier (used to correlate with + /// later progress and completion events) and a hook to request + /// cancellation. Always followed by either + /// or just if every entry fails immediately. /// event EventHandler? UpgradeBatchStarted; @@ -38,41 +35,71 @@ public interface IDatabaseService : IAsyncDisposable IReadOnlyList Entries { get; } /// - /// Completes once the ctor-initiated pass finishes. - /// Guaranteed to never fault or cancel — failures (per-entry or infrastructure) are absorbed - /// and surface as on the affected entry. - /// Consumers (e.g., EventLogEffects.HandleOpenLog) await this on every log open; - /// a faulted task here would poison every subsequent open for the rest of app lifetime. + /// Completes once the ctor-initiated pass finishes. Guaranteed to never fault + /// or cancel — failures (per-entry or infrastructure) are absorbed and surface as + /// on the affected entry. Consumers (e.g., + /// EventLogEffects.HandleOpenLog) await this on every log open; a faulted task here would poison every + /// subsequent open for the rest of app lifetime. /// Task InitialClassificationTask { get; } /// - /// Snapshot count of upgrade batches accepted by that are - /// waiting in the queue and have not yet started processing. Excludes the batch (if any) - /// currently being processed by the consumer task. Updated atomically on enqueue/dequeue. + /// Snapshot count of upgrade batches accepted by that are waiting in the queue + /// and have not yet started processing. Excludes the batch (if any) currently being processed by the consumer task. + /// Updated atomically on enqueue/dequeue. /// int QueuedBatchCount { get; } /// /// Inspects each on disk (in read-only mode without auto-creating a schema) and - /// updates its from the on-disk schema version. Per-entry classification - /// failures (e.g., file locked, file missing) leave that entry at its current status and continue with the rest. - /// Raises exactly once when the pass completes. + /// updates its from the on-disk schema version. Per-entry classification failures + /// (e.g., file locked, file missing) leave that entry at its current status and continue with the rest. Raises + /// exactly once when the pass completes. /// Task ClassifyEntriesAsync(CancellationToken cancellationToken = default); /// - /// Deletes the -journal/-wal/-shm sidecars and any .upgrade.bak backup, - /// then deletes the main database file last (so a partial failure leaves the entry visible to - /// and retry-able), then removes the entry from . - /// Returns false on any IO failure (logged); the entry is left in place so the caller can - /// surface the error and retry. User-created .bak files (not the .upgrade.bak - /// marker) are never touched. + /// Deletes the -journal/-wal/-shm sidecars and any .upgrade.bak backup, then deletes + /// the main database file last (so a partial failure leaves the entry visible to and + /// retry-able), then removes the entry from . Returns false on any IO failure (logged); + /// the entry is left in place so the caller can surface the error and retry. User-created .bak files (not the + /// .upgrade.bak marker) are never touched. /// Task DeleteEntryWithBackupAsync(string fileName, CancellationToken cancellationToken = default); + /// + /// Reads a .zip archive without extracting and returns the names of contained .db entries (the same + /// entries that would extract). Used by callers + /// (e.g., the settings dialog) to pre-scan zip contents for filename conflicts before invoking the import. A malformed + /// or unreadable archive is logged and returns an empty list — the actual import call will surface the failure as an + /// in . + /// + Task> EnumerateZipDbEntryNamesAsync( + string sourceZipPath, + CancellationToken cancellationToken = default); + Task ImportAsync(IEnumerable sourceFilePaths, CancellationToken cancellationToken = default); + /// + /// Imports the supplied database files, skipping any whose file name appears in + /// (case-insensitive). Skipped files are not counted as imports, do not appear as failures, and do not affect existing + /// on-disk databases — the caller is expected to have already shown the user a conflict-resolution prompt and to have + /// populated with the user's "skip" decisions. Files NOT in the skip set are + /// copied/extracted with overwrite semantics (per-name reservations gate against concurrent upgrade/restore/remove on + /// the same name). Newly-added entries (file names not previously present in ) are persisted as + /// disabled before classification so the published snapshot is correct on the first + /// notification. Re-imports of existing names preserve the user's prior choice. + /// After classification any imported entries (new or overwritten) whose status is + /// are auto-upgraded via a single call + /// with . Per-entry upgrade failures are mapped to + /// ; cancelled upgrades roll back to + /// and are not surfaced as failures. + /// + Task ImportAsync( + IEnumerable sourceFilePaths, + IReadOnlySet skipFileNames, + CancellationToken cancellationToken = default); + void MarkStatus(string fileName, DatabaseStatus status); void Refresh(); @@ -80,31 +107,26 @@ public interface IDatabaseService : IAsyncDisposable void Remove(string fileName); /// - /// Deletes any stale -journal/-wal/-shm sidecars first (their presence - /// paired with a restored older main would let SQLite roll forward stale transactions into - /// the restored database on next open), then restores the main database file from its - /// .upgrade.bak backup, then deletes the backup. The backup is preserved if any sidecar - /// cleanup fails so the caller can retry. Re-classifies the entry so consumers observe the - /// post-restore state via . Returns false on any IO - /// failure (logged). + /// Deletes any stale -journal/-wal/-shm sidecars first (their presence paired with a + /// restored older main would let SQLite roll forward stale transactions into the restored database on next open), then + /// restores the main database file from its .upgrade.bak backup, then deletes the backup. The backup is + /// preserved if any sidecar cleanup fails so the caller can retry. Re-classifies the entry so consumers observe the + /// post-restore state via . Returns false on any IO failure (logged). /// Task RestoreFromBackupAsync(string fileName, CancellationToken cancellationToken = default); void Toggle(string fileName); /// - /// Enqueues an upgrade batch for the named entries and returns a task that completes when - /// the batch has been processed. Batches are processed sequentially in FIFO order by a single - /// consumer task; callers may safely invoke this concurrently from multiple threads. Entries - /// are filtered up-front: those whose status is not - /// or , - /// or whose is true (recovery required), or that are - /// unknown to the service, are returned in the result's - /// list with a descriptive reason and never reach the - /// consumer. If every entry is filtered out, the call returns immediately without raising any - /// events. Cancelling after the batch has been enqueued - /// causes the in-flight entry to roll back from its .upgrade.bak backup; previously - /// completed entries in the same batch keep their upgraded state. + /// Enqueues an upgrade batch for the named entries and returns a task that completes when the batch has been + /// processed. Batches are processed sequentially in FIFO order by a single consumer task; callers may safely invoke + /// this concurrently from multiple threads. Entries are filtered up-front: those whose status is not + /// or , or whose + /// is true (recovery required), or that are unknown to the service, are + /// returned in the result's list with a descriptive reason and never reach + /// the consumer. If every entry is filtered out, the call returns immediately without raising any events. Cancelling + /// after the batch has been enqueued causes the in-flight entry to roll back + /// from its .upgrade.bak backup; previously completed entries in the same batch keep their upgraded state. /// Task UpgradeBatchAsync( IReadOnlyList fileNames, diff --git a/src/EventLogExpert.UI/Models/ImportResult.cs b/src/EventLogExpert.UI/Models/ImportResult.cs index 8fb449c9..bdcf8ec8 100644 --- a/src/EventLogExpert.UI/Models/ImportResult.cs +++ b/src/EventLogExpert.UI/Models/ImportResult.cs @@ -3,4 +3,7 @@ namespace EventLogExpert.UI.Models; -public sealed record ImportResult(int Imported, IReadOnlyList Failures); +public sealed record ImportResult( + int Imported, + IReadOnlyList Failures, + IReadOnlyList UpgradeFailures); diff --git a/src/EventLogExpert.UI/Services/DatabaseService.cs b/src/EventLogExpert.UI/Services/DatabaseService.cs index 42dffba0..0c6fe317 100644 --- a/src/EventLogExpert.UI/Services/DatabaseService.cs +++ b/src/EventLogExpert.UI/Services/DatabaseService.cs @@ -22,13 +22,13 @@ public sealed partial class DatabaseService : IDatabaseService, IDatabaseCollect private readonly Task _consumerTask; private readonly CancellationTokenSource _disposeCts = new(); private readonly FileLocationOptions _fileLocationOptions; - private readonly HashSet _filesInOperation = new(StringComparer.OrdinalIgnoreCase); private readonly Lock _mutationLock = new(); private readonly IPreferencesProvider _preferences; private readonly ITraceLogger _traceLogger; private readonly Channel _upgradeQueue = Channel.CreateUnbounded( new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + private int _disposed; private ImmutableList _entries = []; private int _queuedBatchCount; @@ -87,7 +87,8 @@ public async Task ClassifyEntriesAsync(CancellationToken cancellationToken = def var statuses = await Task.Run( () => { - var perFile = new Dictionary(StringComparer.OrdinalIgnoreCase); + var perFile = + new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var entry in snapshot) { @@ -103,6 +104,7 @@ public async Task ClassifyEntriesAsync(CancellationToken cancellationToken = def if (!File.Exists(entry.FullPath) || new FileInfo(entry.FullPath).Length == 0) { perFile[entry.FileName] = (DatabaseStatus.UnrecognizedSchema, false); + continue; } @@ -230,16 +232,77 @@ public async ValueTask DisposeAsync() _disposeCts.Dispose(); } + public async Task> EnumerateZipDbEntryNamesAsync( + string sourceZipPath, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(sourceZipPath); + + ZipArchive archive; + + try + { + archive = await ZipFile.OpenReadAsync(sourceZipPath, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _traceLogger.Warn( + $"{nameof(DatabaseService)}.{nameof(EnumerateZipDbEntryNamesAsync)} failed to open '{sourceZipPath}': {ex}"); + + return []; + } + + await using (archive) + { + var names = new List(); + + foreach (var entry in archive.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (string.IsNullOrEmpty(entry.Name)) { continue; } + + if (!Path.GetExtension(entry.Name).Equals(".db", StringComparison.OrdinalIgnoreCase)) { continue; } + + names.Add(entry.Name); + } + + return names; + } + } + public async Task ImportAsync( IEnumerable sourceFilePaths, + CancellationToken cancellationToken = default) => + await ImportAsync(sourceFilePaths, ImmutableHashSet.Empty, cancellationToken) + .ConfigureAwait(false); + + public async Task ImportAsync( + IEnumerable sourceFilePaths, + IReadOnlySet skipFileNames, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(sourceFilePaths); + ArgumentNullException.ThrowIfNull(skipFileNames); + + // Enforce the documented case-insensitive skip semantics regardless of the comparer the + // caller chose for their set (defaults to case-sensitive Ordinal otherwise). + var skipSet = skipFileNames as HashSet is { Comparer: var comparer } && + comparer.Equals(StringComparer.OrdinalIgnoreCase) + ? skipFileNames + : skipFileNames.ToHashSet(StringComparer.OrdinalIgnoreCase); var importedCount = 0; var failures = new List(); + var importedNames = new List(); + var freshlyImportedNames = new List(); + Directory.CreateDirectory(_fileLocationOptions.DatabasePath); + var existingFileNames = _entries + .Select(entry => entry.FileName) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var sourceFilePath in sourceFilePaths) { cancellationToken.ThrowIfCancellationRequested(); @@ -252,19 +315,40 @@ public async Task ImportAsync( if (Path.GetExtension(fileName).Equals(".zip", StringComparison.OrdinalIgnoreCase)) { - var (zipImported, zipFailures) = await ImportZipAsync(sourceFilePath, fileName, cancellationToken) + var (zipImported, zipFailures, zipImportedNames) = await ImportZipAsync( + sourceFilePath, + fileName, + skipSet, + cancellationToken) .ConfigureAwait(false); importedCount += zipImported; failures.AddRange(zipFailures); + + foreach (var name in zipImportedNames) + { + importedNames.Add(name); + + if (!existingFileNames.Contains(name)) { freshlyImportedNames.Add(name); } + } } else { + if (skipSet.Contains(fileName)) { continue; } + try { var destinationPath = Path.Join(_fileLocationOptions.DatabasePath, fileName); - File.Copy(sourceFilePath, destinationPath, true); + + using (ReserveFileOperation(fileName, nameof(ImportAsync))) + { + File.Copy(sourceFilePath, destinationPath, true); + } + importedCount++; + importedNames.Add(fileName); + + if (!existingFileNames.Contains(fileName)) { freshlyImportedNames.Add(fileName); } } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -278,13 +362,34 @@ public async Task ImportAsync( if (importedCount <= 0) { - return new ImportResult(importedCount, failures); + return new ImportResult(importedCount, failures, []); + } + + // Persist freshly-imported names as disabled BEFORE Refresh so the next snapshot is correct + // first time and consumers don't observe a transient enabled→disabled flicker. + if (freshlyImportedNames.Count > 0) + { + lock (_mutationLock) + { + var disabledSet = _preferences.DisabledDatabasesPreference + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var added = false; + + foreach (var name in freshlyImportedNames) + { + if (disabledSet.Add(name)) { added = true; } + } + + if (added) + { + _preferences.DisabledDatabasesPreference = disabledSet.ToList(); + } + } } Refresh(); - // Classify so freshly-imported V3 databases surface in settings as UpgradeRequired - // (with the "restart required to upgrade" UX) rather than appearing as Ready. try { await ClassifyEntriesAsync(cancellationToken).ConfigureAwait(false); @@ -295,7 +400,31 @@ public async Task ImportAsync( $"{nameof(DatabaseService)}.{nameof(ImportAsync)} post-import classification failed: {ex}"); } - return new ImportResult(importedCount, failures); + IReadOnlyList upgradeFailures = []; + + var importedSet = importedNames.ToHashSet(StringComparer.OrdinalIgnoreCase); + + var upgradeNeeded = _entries + .Where(entry => importedSet.Contains(entry.FileName) && entry.Status == DatabaseStatus.UpgradeRequired) + .Select(entry => entry.FileName) + .ToList(); + + if (upgradeNeeded.Count <= 0) + { + return new ImportResult(importedCount, failures, upgradeFailures); + } + + var batchResult = await UpgradeBatchAsync( + upgradeNeeded, + UpgradeProgressScope.Background, + cancellationToken) + .ConfigureAwait(false); + + upgradeFailures = batchResult.Failed + .Select(failure => new ImportFailure(failure.FileName, failure.Message)) + .ToList(); + + return new ImportResult(importedCount, failures, upgradeFailures); } public void MarkStatus(string fileName, DatabaseStatus status) @@ -441,10 +570,7 @@ public async Task UpgradeBatchAsync( if (cancellationToken.IsCancellationRequested) { - return new UpgradeBatchResult( - Succeeded: [], - Cancelled: dedupedNames, - Failed: []); + return new UpgradeBatchResult([], dedupedNames, []); } // Awaited explicitly so callers that race the ctor get a meaningful classification before @@ -484,6 +610,7 @@ public async Task UpgradeBatchAsync( rejected.Add(new UpgradeFailure( fileName, "Recovery required — resolve via Settings or recovery prompt first")); + continue; } @@ -492,6 +619,7 @@ public async Task UpgradeBatchAsync( rejected.Add(new UpgradeFailure( fileName, $"Cannot upgrade entry in status '{entry.Status}'")); + continue; } @@ -501,7 +629,7 @@ public async Task UpgradeBatchAsync( if (acceptable.Count == 0) { - return new UpgradeBatchResult(Succeeded: [], Cancelled: [], Failed: rejected); + return new UpgradeBatchResult([], [], rejected); } var batchCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token); @@ -518,20 +646,17 @@ public async Task UpgradeBatchAsync( Interlocked.Increment(ref _queuedBatchCount); - if (!_upgradeQueue.Writer.TryWrite(batch)) + if (_upgradeQueue.Writer.TryWrite(batch)) { - // Channel already completed (disposal raced this enqueue). Surface the work as cancelled - // and roll back the counter increment so QueuedBatchCount reflects reality. - Interlocked.Decrement(ref _queuedBatchCount); - batchCts.Dispose(); - - return new UpgradeBatchResult( - Succeeded: [], - Cancelled: acceptable.Select(entry => entry.FileName).ToArray(), - Failed: rejected); + return await tcs.Task.ConfigureAwait(false); } - return await tcs.Task.ConfigureAwait(false); + // Channel already completed (disposal raced this enqueue). Surface the work as cancelled + // and roll back the counter increment so QueuedBatchCount reflects reality. + Interlocked.Decrement(ref _queuedBatchCount); + batchCts.Dispose(); + + return new UpgradeBatchResult([], acceptable.Select(entry => entry.FileName).ToArray(), rejected); } private static IReadOnlyList DedupePreservingOrder(IReadOnlyList source) @@ -724,13 +849,16 @@ private IEnumerable EnumerateDatabaseFileNames() } } - private async Task<(int Imported, IReadOnlyList Failures)> ImportZipAsync( - string sourceZipPath, - string zipFileName, - CancellationToken cancellationToken) + private async Task<(int Imported, IReadOnlyList Failures, IReadOnlyList ImportedNames)> + ImportZipAsync( + string sourceZipPath, + string zipFileName, + IReadOnlySet skipFileNames, + CancellationToken cancellationToken) { var imported = 0; var failures = new List(); + var importedNames = new List(); ZipArchive archive; @@ -738,14 +866,14 @@ private IEnumerable EnumerateDatabaseFileNames() { archive = await ZipFile.OpenReadAsync(sourceZipPath, cancellationToken); } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { failures.Add(new ImportFailure(zipFileName, $"Could not open archive: {ex.Message}")); _traceLogger.Warn( $"{nameof(DatabaseService)}.{nameof(ImportZipAsync)} failed to open '{zipFileName}': {ex}"); - return (imported, failures); + return (imported, failures, importedNames); } await using (archive) @@ -758,19 +886,25 @@ private IEnumerable EnumerateDatabaseFileNames() if (!Path.GetExtension(entry.Name).Equals(".db", StringComparison.OrdinalIgnoreCase)) { continue; } + if (skipFileNames.Contains(entry.Name)) { continue; } + var destinationPath = Path.Join(_fileLocationOptions.DatabasePath, entry.Name); try { - await using (var entryStream = await entry.OpenAsync(cancellationToken)) + using (ReserveFileOperation(entry.Name, nameof(ImportZipAsync))) { - await using (var fileStream = File.Create(destinationPath)) + await using (var entryStream = await entry.OpenAsync(cancellationToken)) { - await entryStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + await using (var fileStream = File.Create(destinationPath)) + { + await entryStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + } } } imported++; + importedNames.Add(entry.Name); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -795,7 +929,7 @@ private IEnumerable EnumerateDatabaseFileNames() } } - return (imported, failures); + return (imported, failures, importedNames); } private DatabaseEntry LookupEntryOrThrow(string fileName, string callerName) @@ -951,6 +1085,7 @@ private async Task ProcessBatchAsync(UpgradeBatch batch) { SafeLog(() => _traceLogger.Warn( $"{nameof(DatabaseService)}.{nameof(ProcessBatchAsync)}: unhandled batch error: {ex}")); + batch.Tcs.TrySetException(ex); } finally @@ -1086,7 +1221,8 @@ private bool TryDeleteFile(string path, string callerName) } catch (Exception ex) when (ex is not OperationCanceledException) { - SafeLog(() => _traceLogger.Warn($"{nameof(DatabaseService)}.{callerName}: delete failed for '{path}': {ex}")); + SafeLog(() => + _traceLogger.Warn($"{nameof(DatabaseService)}.{callerName}: delete failed for '{path}': {ex}")); return false; } @@ -1143,7 +1279,7 @@ private async Task UpgradeAsync(string fileName, int position, Guid batchId, Can { // Surface the disk truth on the entry so the recovery host can prompt without waiting // for the next classification pass. - UpdateEntryStatusAndBackup(fileName, entry.Status, backupExists: true); + UpdateEntryStatusAndBackup(fileName, entry.Status, true); throw new InvalidOperationException("Recovery required — .upgrade.bak already present"); } @@ -1169,7 +1305,7 @@ await Task.Run( try { - File.Copy(entry.FullPath, backupPath, overwrite: false); + File.Copy(entry.FullPath, backupPath, false); } catch (IOException ex) when (File.Exists(backupPath)) { @@ -1177,7 +1313,7 @@ await Task.Run( // Surface as recovery-required for consistency with the explicit precheck // rather than letting the raw IOException leak. Reflect the disk truth on // the entry so the recovery host can prompt immediately. - UpdateEntryStatusAndBackup(fileName, entry.Status, backupExists: true); + UpdateEntryStatusAndBackup(fileName, entry.Status, true); throw new InvalidOperationException( "Recovery required — .upgrade.bak appeared during backup", @@ -1195,11 +1331,7 @@ await Task.Run( migrationStarted = true; - using (var context = new EventProviderDbContext( - entry.FullPath, - readOnly: false, - ensureCreated: false, - _traceLogger)) + using (var context = new EventProviderDbContext(entry.FullPath, false, false, _traceLogger)) { context.PerformUpgradeIfNeeded(); } @@ -1225,12 +1357,13 @@ await Task.Run( { // Backup deletion failed but data is upgraded; leave .bak on disk so the // user can manually recover, and surface so the recovery host can prompt. - UpdateEntryStatusAndBackup(fileName, DatabaseStatus.Ready, backupExists: true); + UpdateEntryStatusAndBackup(fileName, DatabaseStatus.Ready, true); + throw new UpgradeCleanupFailedException( "Upgrade succeeded but backup cleanup failed; .upgrade.bak remains on disk"); } - UpdateEntryStatusAndBackup(fileName, DatabaseStatus.Ready, backupExists: false); + UpdateEntryStatusAndBackup(fileName, DatabaseStatus.Ready, false); } catch (UpgradeCleanupFailedException) { @@ -1245,11 +1378,12 @@ await Task.Run( if (RestoreFilesCore(entry)) { - UpdateEntryStatusAndBackup(fileName, DatabaseStatus.UpgradeRequired, backupExists: false); + UpdateEntryStatusAndBackup(fileName, DatabaseStatus.UpgradeRequired, false); } else { - UpdateEntryStatusAndBackup(fileName, DatabaseStatus.UpgradeFailed, backupExists: true); + UpdateEntryStatusAndBackup(fileName, DatabaseStatus.UpgradeFailed, true); + throw new UpgradeRollbackFailedException( $"Cancellation rollback failed for '{fileName}'; .upgrade.bak remains on disk"); } @@ -1262,12 +1396,13 @@ await Task.Run( { if (!RestoreFilesCore(entry)) { - UpdateEntryStatusAndBackup(fileName, DatabaseStatus.UpgradeFailed, backupExists: true); + UpdateEntryStatusAndBackup(fileName, DatabaseStatus.UpgradeFailed, true); + throw new UpgradeRollbackFailedException( $"Migration failed and rollback also failed for '{fileName}': {(ex is DatabaseUpgradeException dbEx ? dbEx.Reason : ex.Message)}"); } - UpdateEntryStatusAndBackup(fileName, DatabaseStatus.UpgradeFailed, backupExists: false); + UpdateEntryStatusAndBackup(fileName, DatabaseStatus.UpgradeFailed, false); } else if (migrationCompleted) { @@ -1276,12 +1411,13 @@ await Task.Run( // pre-upgrade state. if (!RestoreFilesCore(entry)) { - UpdateEntryStatusAndBackup(fileName, DatabaseStatus.UpgradeFailed, backupExists: true); + UpdateEntryStatusAndBackup(fileName, DatabaseStatus.UpgradeFailed, true); + throw new UpgradeRollbackFailedException( $"Verification or cleanup failed and rollback also failed for '{fileName}'"); } - UpdateEntryStatusAndBackup(fileName, DatabaseStatus.UpgradeFailed, backupExists: false); + UpdateEntryStatusAndBackup(fileName, DatabaseStatus.UpgradeFailed, false); } else if (backupCreated) { @@ -1301,11 +1437,7 @@ private bool VerifyEntryReady(string fullPath) { try { - using var context = new EventProviderDbContext( - fullPath, - readOnly: true, - ensureCreated: false, - _traceLogger); + using var context = new EventProviderDbContext(fullPath, true, false, _traceLogger); return context.IsUpgradeNeeded().CurrentVersion == ProviderDatabaseSchemaVersion.Current; } diff --git a/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs b/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs index adf88766..1a7e84f1 100644 --- a/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs +++ b/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs @@ -9,6 +9,7 @@ using Fluxor; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Logging; +using System.Text; using IDispatcher = Fluxor.IDispatcher; namespace EventLogExpert.Shared.Components; @@ -36,19 +37,18 @@ public sealed partial class SettingsModal : ModalBase [Inject] private IState EventLogState { get; init; } = null!; - [Inject] private ISettingsService Settings { get; init; } = null!; - /// - /// true while a settings-triggered upgrade has been initiated from this modal but not yet observed - /// to drain through the BannerService progress slot. Covers the queued-but-not-yet-started window - /// ( can sit behind another batch before - /// fires) where - /// alone would still be null. Composed with the banner - /// slot so once the consumer picks up the batch and the slot is populated, the predicate stays true through - /// completion. + /// true while a settings-triggered upgrade has been initiated from this modal but not yet observed to + /// drain through the BannerService progress slot. Covers the queued-but-not-yet-started window ( + /// can sit behind another batch before + /// fires) where + /// alone would still be null. Composed with the banner slot so once the consumer picks up the batch and the + /// slot is populated, the predicate stays true through completion. /// private bool IsCloseBlocked => _isUpgradeInFlight || BannerService.SettingsProgress is not null; + [Inject] private ISettingsService Settings { get; init; } = null!; + protected override async ValueTask DisposeAsyncCore(bool disposing) { if (disposing) @@ -60,20 +60,21 @@ protected override async ValueTask DisposeAsyncCore(bool disposing) await base.DisposeAsyncCore(disposing); } + protected override Task OnCancelAsync() => + IsCloseBlocked + ? Task.CompletedTask + : base.OnCancelAsync(); + protected override async Task OnClosingAsync() { if (_databaseStateChanged) { await ReloadOpenLogs(); + _databaseStateChanged = false; } } - protected override Task OnCancelAsync() => - IsCloseBlocked - ? Task.CompletedTask - : base.OnCancelAsync(); - protected override void OnInitialized() { LoadFromSettings(); @@ -102,13 +103,35 @@ protected override async Task OnSaveAsync() await CompleteAsync(true); } + private static void AppendImportFailureSection( + StringBuilder builder, + string heading, + IReadOnlyList entries) + { + builder.AppendLine(); + builder.AppendLine(); + builder.Append(heading); + + foreach (var entry in entries) + { + builder.AppendLine(); + builder.Append("\u2022 "); + builder.Append(entry.FileName); + builder.Append(": "); + builder.Append(entry.Reason); + } + } + private static (string Title, string Message) BuildImportSummary(ImportResult importResult) { var imported = importResult.Imported; var failures = importResult.Failures; + var upgradeFailures = importResult.UpgradeFailures; - if (failures.Count == 0) + if (failures.Count == 0 && upgradeFailures.Count == 0) { + if (imported == 0) { return ("Import Successful", "No databases were imported."); } + var successMessage = imported > 1 ? $"{imported} databases have successfully been imported" : "1 database has successfully been imported"; @@ -116,20 +139,22 @@ private static (string Title, string Message) BuildImportSummary(ImportResult im return ("Import Successful", successMessage); } - var failureLines = string.Join(Environment.NewLine, - failures.Select(failure => $"\u2022 {failure.FileName}: {failure.Reason}")); + var detail = new StringBuilder(); + + if (failures.Count > 0) { AppendImportFailureSection(detail, "Failed:", failures); } + + if (upgradeFailures.Count > 0) { AppendImportFailureSection(detail, "Upgrade failures:", upgradeFailures); } if (imported == 0) { - return ("Import Failed", $"No databases were imported.{Environment.NewLine}{Environment.NewLine}Failed:{Environment.NewLine}{failureLines}"); + return ("Import Failed", $"No databases were imported.{detail}"); } var partialMessage = imported > 1 ? $"{imported} databases imported successfully." : "1 database imported successfully."; - return ("Import Completed with Errors", - $"{partialMessage}{Environment.NewLine}{Environment.NewLine}Failed:{Environment.NewLine}{failureLines}"); + return ("Import Completed with Errors", $"{partialMessage}{detail}"); } private async Task ApplyPendingToggles() @@ -154,109 +179,6 @@ await AlertDialogService.ShowAlert("Failed to Update Database", } } - /// - /// Identify pending enable-toggles whose target entry needs an upgrade first, prompt for confirmation, run - /// the batch upgrade through the settings-scope progress slot, then re-stage the entries that succeeded so - /// they are committed by the normal Save flow (no immediate calls). - /// Surfaces per-entry failures via . Returns false - /// when the user declines the upgrade prompt, the upgrade throws, OR the user cancels the in-flight upgrade - /// — the caller should abort the save in any of those cases so the modal stays open and the user can either - /// retry or click Exit to discard. The principle: represents the - /// "commit" step and must only run as part of a successful Save, never as a side effect of the upgrade - /// batch itself. - /// - private async Task TryRunUpgradesAsync() - { - if (_pendingToggles.Count == 0) { return true; } - - var entriesByName = DatabaseService.Entries.ToDictionary( - entry => entry.FileName, - StringComparer.OrdinalIgnoreCase); - - var fileNamesNeedingUpgrade = _pendingToggles - .Where(pair => pair.Value - && entriesByName.TryGetValue(pair.Key, out var entry) - && entry.Status is DatabaseStatus.UpgradeRequired or DatabaseStatus.UpgradeFailed) - .Select(pair => pair.Key) - .ToList(); - - if (fileNamesNeedingUpgrade.Count == 0) { return true; } - - var confirmed = await AlertDialogService.ShowAlert( - "Upgrade Required", - fileNamesNeedingUpgrade.Count == 1 - ? "1 database requires an upgrade before it can be enabled. Upgrade now?" - : $"{fileNamesNeedingUpgrade.Count} databases require an upgrade before they can be enabled. Upgrade now?", - "Upgrade and enable", - "Cancel"); - - if (!confirmed) { return false; } - - foreach (var fileName in fileNamesNeedingUpgrade) - { - _pendingToggles.Remove(fileName); - } - - UpgradeBatchResult result; - - _isUpgradeInFlight = true; - - try - { - result = await DatabaseService.UpgradeBatchAsync( - fileNamesNeedingUpgrade, - UpgradeProgressScope.SettingsTriggered); - } - catch (Exception ex) - { - // Restore every entry whose toggle we removed so the user can retry or click Exit to discard. - // Without this restore, returning to OnSaveAsync would either commit a now-empty pending set - // or (with the previous return-true behavior) silently drop the user's enable intent. - foreach (var fileName in fileNamesNeedingUpgrade) - { - _pendingToggles[fileName] = true; - } - - await AlertDialogService.ShowAlert("Database Upgrade Failed", - $"An exception occurred while upgrading databases: {ex.Message}", - "OK"); - - return false; - } - finally - { - _isUpgradeInFlight = false; - } - - // Re-stage successes as pending so ApplyPendingToggles enables them as part of the normal Save commit. - // We do NOT call DatabaseService.Toggle here directly: per the modal contract, no toggle should take - // effect until the user's Save click reaches CompleteAsync — otherwise a subsequent Cancel/Exit would - // leave partial commits behind. - foreach (var fileName in result.Succeeded) - { - _pendingToggles[fileName] = true; - } - - foreach (var failure in result.Failed) - { - await AlertDialogService.ShowErrorAlert( - "Database Upgrade Failed", - $"Failed to upgrade '{failure.FileName}': {failure.Message}"); - } - - // Cancellation is the user's "abort" signal — restore the cancelled enable-toggles to pending so - // they re-appear next Save, and abort this save so the modal stays open. Without this restore, - // cancelled entries' toggles would be silently consumed and the user would have to re-toggle. - if (result.Cancelled.Count <= 0) { return true; } - - foreach (var fileName in result.Cancelled) - { - _pendingToggles[fileName] = true; - } - - return false; - } - private bool GetEffectiveEnabled(DatabaseEntry entry) => _pendingToggles.TryGetValue(entry.FileName, out var pending) ? pending : entry.IsEnabled; @@ -283,13 +205,9 @@ private async Task ImportDatabase() .Select(item => item!.FullPath) .ToList(); - var importResult = await DatabaseService.ImportAsync(sourcePaths); + var skipFileNames = await ResolveImportConflictsAsync(sourcePaths, CancellationToken.None); - if (importResult.Imported == 0 && importResult.Failures.Count == 0) - { - await AlertDialogService.ShowAlert("Import Failed", "No valid database files were selected.", "OK"); - return; - } + var importResult = await DatabaseService.ImportAsync(sourcePaths, skipFileNames, CancellationToken.None); var (title, message) = BuildImportSummary(importResult); await AlertDialogService.ShowAlert(title, message, "OK"); @@ -368,10 +286,60 @@ await AlertDialogService.ShowAlert("Failed to Remove Database", } } + private async Task> ResolveImportConflictsAsync( + IReadOnlyList sourcePaths, + CancellationToken cancellationToken) + { + var existingNames = DatabaseService.Entries + .Select(entry => entry.FileName) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (existingNames.Count == 0) { return new HashSet(StringComparer.OrdinalIgnoreCase); } + + var skip = new HashSet(StringComparer.OrdinalIgnoreCase); + var resolved = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var sourcePath in sourcePaths) + { + if (string.IsNullOrEmpty(sourcePath)) { continue; } + + IReadOnlyList candidateNames; + + if (Path.GetExtension(sourcePath).Equals(".zip", StringComparison.OrdinalIgnoreCase)) + { + candidateNames = await DatabaseService.EnumerateZipDbEntryNamesAsync(sourcePath, cancellationToken); + } + else + { + var name = Path.GetFileName(sourcePath); + candidateNames = string.IsNullOrEmpty(name) ? [] : [name]; + } + + foreach (var candidateName in candidateNames) + { + if (string.IsNullOrEmpty(candidateName)) { continue; } + + if (!existingNames.Contains(candidateName)) { continue; } + + if (!resolved.Add(candidateName)) { continue; } + + var overwrite = await AlertDialogService.ShowAlert( + "Database already exists", + $"{candidateName} already exists. Overwrite?", + "Overwrite", + "Skip"); + + if (!overwrite) { skip.Add(candidateName); } + } + } + + return skip; + } + private void ToggleDatabase(string fileName) { - var entry = DatabaseService.Entries.FirstOrDefault( - e => string.Equals(e.FileName, fileName, StringComparison.OrdinalIgnoreCase)); + var entry = DatabaseService.Entries.FirstOrDefault(e => + string.Equals(e.FileName, fileName, StringComparison.OrdinalIgnoreCase)); if (entry is null) { return; } @@ -386,4 +354,107 @@ private void ToggleDatabase(string fileName) _pendingToggles[fileName] = newValue; } } + + /// + /// Identify pending enable-toggles whose target entry needs an upgrade first, prompt for confirmation, run the + /// batch upgrade through the settings-scope progress slot, then re-stage the entries that succeeded so they are + /// committed by the normal Save flow (no immediate calls). Surfaces per-entry + /// failures via . Returns false when the user declines the + /// upgrade prompt, the upgrade throws, OR the user cancels the in-flight upgrade — the caller should abort the save in + /// any of those cases so the modal stays open and the user can either retry or click Exit to discard. The principle: + /// represents the "commit" step and must only run as part of a successful Save, + /// never as a side effect of the upgrade batch itself. + /// + private async Task TryRunUpgradesAsync() + { + if (_pendingToggles.Count == 0) { return true; } + + var entriesByName = DatabaseService.Entries.ToDictionary( + entry => entry.FileName, + StringComparer.OrdinalIgnoreCase); + + var fileNamesNeedingUpgrade = _pendingToggles + .Where(pair => + pair.Value && + entriesByName.TryGetValue(pair.Key, out var entry) && + entry.Status is DatabaseStatus.UpgradeRequired or DatabaseStatus.UpgradeFailed) + .Select(pair => pair.Key) + .ToList(); + + if (fileNamesNeedingUpgrade.Count == 0) { return true; } + + var confirmed = await AlertDialogService.ShowAlert( + "Upgrade Required", + fileNamesNeedingUpgrade.Count == 1 + ? "1 database requires an upgrade before it can be enabled. Upgrade now?" + : $"{fileNamesNeedingUpgrade.Count} databases require an upgrade before they can be enabled. Upgrade now?", + "Upgrade and enable", + "Cancel"); + + if (!confirmed) { return false; } + + foreach (var fileName in fileNamesNeedingUpgrade) + { + _pendingToggles.Remove(fileName); + } + + UpgradeBatchResult result; + + _isUpgradeInFlight = true; + + try + { + result = await DatabaseService.UpgradeBatchAsync( + fileNamesNeedingUpgrade, + UpgradeProgressScope.SettingsTriggered); + } + catch (Exception ex) + { + // Restore every entry whose toggle we removed so the user can retry or click Exit to discard. + // Without this restore, returning to OnSaveAsync would either commit a now-empty pending set + // or (with the previous return-true behavior) silently drop the user's enable intent. + foreach (var fileName in fileNamesNeedingUpgrade) + { + _pendingToggles[fileName] = true; + } + + await AlertDialogService.ShowAlert("Database Upgrade Failed", + $"An exception occurred while upgrading databases: {ex.Message}", + "OK"); + + return false; + } + finally + { + _isUpgradeInFlight = false; + } + + // Re-stage successes as pending so ApplyPendingToggles enables them as part of the normal Save commit. + // We do NOT call DatabaseService.Toggle here directly: per the modal contract, no toggle should take + // effect until the user's Save click reaches CompleteAsync — otherwise a subsequent Cancel/Exit would + // leave partial commits behind. + foreach (var fileName in result.Succeeded) + { + _pendingToggles[fileName] = true; + } + + foreach (var failure in result.Failed) + { + await AlertDialogService.ShowErrorAlert( + "Database Upgrade Failed", + $"Failed to upgrade '{failure.FileName}': {failure.Message}"); + } + + // Cancellation is the user's "abort" signal — restore the cancelled enable-toggles to pending so + // they re-appear next Save, and abort this save so the modal stays open. Without this restore, + // cancelled entries' toggles would be silently consumed and the user would have to re-toggle. + if (result.Cancelled.Count <= 0) { return true; } + + foreach (var fileName in result.Cancelled) + { + _pendingToggles[fileName] = true; + } + + return false; + } } From 3627f06b52b445247f0bedaf67e99795365ba4b1 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Sat, 2 May 2026 16:18:08 -0500 Subject: [PATCH 25/31] Guard HandleOpenLog with classification await and resolver try/catch --- .../Store/EventLog/EventLogEffectsTests.cs | 212 +++++++++++++++++- .../Store/EventLog/EventLogEffects.cs | 81 +++++-- 2 files changed, 272 insertions(+), 21 deletions(-) diff --git a/src/EventLogExpert.UI.Tests/Store/EventLog/EventLogEffectsTests.cs b/src/EventLogExpert.UI.Tests/Store/EventLog/EventLogEffectsTests.cs index af1991fc..7b2e1ff4 100644 --- a/src/EventLogExpert.UI.Tests/Store/EventLog/EventLogEffectsTests.cs +++ b/src/EventLogExpert.UI.Tests/Store/EventLog/EventLogEffectsTests.cs @@ -158,7 +158,9 @@ public async Task HandleCloseAll_ShouldClearAllResolvedXml() Substitute.For(), Substitute.For(), mockXmlResolver, - mockServiceScopeFactory); + mockServiceScopeFactory, + Substitute.For(), + Substitute.For()); var mockDispatcher = Substitute.For(); @@ -213,7 +215,9 @@ public async Task HandleCloseLog_ShouldClearResolvedXmlForLog() Substitute.For(), Substitute.For(), mockXmlResolver, - mockServiceScopeFactory); + mockServiceScopeFactory, + Substitute.For(), + Substitute.For()); var mockDispatcher = Substitute.For(); var action = new EventLogAction.CloseLog(logId, Constants.LogNameTestLog); @@ -400,6 +404,135 @@ public async Task HandleLoadNewEvents_WhenBufferSpansMultipleLogs_ShouldGroupInt Assert.Single(captured.EventsByLog[testLog.Id]); } + [Fact] + public async Task HandleOpenLog_AwaitsInitialClassificationTask_BeforeResolverConstruction() + { + // Arrange — block classification with a pending TCS so we can verify the resolver + // is NOT looked up until classification completes. Determinism comes from the await + // semantics: the IServiceProvider mock cannot be invoked while the await is parked + // on an incomplete task, so an immediate post-call assertion is sufficient. + var logData = new EventLogData(Constants.LogNameApplication, PathType.LogName, []); + var activeLogs = ImmutableDictionary.Empty.Add(Constants.LogNameApplication, logData); + + var classificationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var (effects, mockDispatcher, mockServiceProvider, _, mockDatabaseService) = + CreateEffectsForOpenLogGuards(activeLogs); + + mockDatabaseService.InitialClassificationTask.Returns(classificationTcs.Task); + + var action = new EventLogAction.OpenLog(Constants.LogNameApplication, PathType.LogName); + + // Act 1 — start the open; await yields back at InitialClassificationTask. + var openTask = effects.HandleOpenLog(action, mockDispatcher); + + // Assert 1 — resolver lookup MUST NOT have been touched yet. + mockServiceProvider.DidNotReceive().GetService(typeof(IEventResolver)); + + // Act 2 — release classification; let HandleOpenLog finish. + classificationTcs.SetResult(true); + await openTask; + + // Assert 2 — resolver lookup happens after classification. + mockServiceProvider.Received(1).GetService(typeof(IEventResolver)); + } + + [Fact] + public async Task HandleOpenLog_LogClosedDuringClassificationAwait_DoesNotDispatchAddTable() + { + // Arrange — simulate the user closing the log (HandleCloseLog dispatch already removed + // it from ActiveLogs and canceled its CTS) while HandleOpenLog is parked on the + // classification await. After the await releases, HandleOpenLog must bail BEFORE + // calling LoadLogAsync — otherwise LoadLogAsync's AddTable dispatch would resurrect + // a table entry the user already dismissed, leaving an orphan in EventTableState. + var logData = new EventLogData(Constants.LogNameApplication, PathType.LogName, []); + var initialActiveLogs = ImmutableDictionary.Empty.Add(Constants.LogNameApplication, logData); + + var classificationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Use a mutable IState so we can flip ActiveLogs to "log closed" partway through. + var mockEventLogState = Substitute.For>(); + var initialState = new EventLogState + { + ActiveLogs = initialActiveLogs, + AppliedFilter = new EventFilter(null, []) + }; + mockEventLogState.Value.Returns(initialState); + + var mockServiceScopeFactory = Substitute.For(); + var mockServiceScope = Substitute.For(); + var mockServiceProvider = Substitute.For(); + + mockServiceScopeFactory.CreateScope().Returns(mockServiceScope); + mockServiceScope.ServiceProvider.Returns(mockServiceProvider); + + var mockEventResolver = Substitute.For(); + mockServiceProvider.GetService(typeof(IEventResolver)).Returns(mockEventResolver); + + var mockDatabaseService = Substitute.For(); + mockDatabaseService.InitialClassificationTask.Returns(classificationTcs.Task); + + var effects = new EventLogEffects( + mockEventLogState, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + mockServiceScopeFactory, + mockDatabaseService, + Substitute.For()); + + var mockDispatcher = Substitute.For(); + var action = new EventLogAction.OpenLog(Constants.LogNameApplication, PathType.LogName); + + // Act 1 — start the open; await yields back at InitialClassificationTask. + var openTask = effects.HandleOpenLog(action, mockDispatcher); + + // Act 2 — simulate HandleCloseLog: remove the log from ActiveLogs. + mockEventLogState.Value.Returns(new EventLogState + { + ActiveLogs = ImmutableDictionary.Empty, + AppliedFilter = new EventFilter(null, []) + }); + + // Act 3 — release classification; HandleOpenLog should detect the missing log and bail. + classificationTcs.SetResult(true); + await openTask; + + // Assert — neither AddTable (would orphan in EventTableState) nor any resolver work + // happened. The bail-out path returns silently after the post-await identity check. + mockServiceProvider.DidNotReceive().GetService(typeof(IEventResolver)); + + mockDispatcher.DidNotReceive().Dispatch(Arg.Any()); + } + + [Fact] + public async Task HandleOpenLog_ResolverThrows_CallsReportCritical_DoesNotPropagate() + { + // Arrange — resolver factory throws (e.g., DI graph misconfiguration). HandleOpenLog + // must surface this as a Reload-tier banner via IBannerService.ReportCritical and + // return cleanly instead of letting the exception escape the effect. + var logData = new EventLogData(Constants.LogNameApplication, PathType.LogName, []); + var activeLogs = ImmutableDictionary.Empty.Add(Constants.LogNameApplication, logData); + + var (effects, mockDispatcher, mockServiceProvider, mockBannerService, _) = + CreateEffectsForOpenLogGuards(activeLogs); + + var thrown = new InvalidOperationException("resolver factory failed"); + mockServiceProvider.When(p => p.GetService(typeof(IEventResolver))).Do(_ => throw thrown); + + var action = new EventLogAction.OpenLog(Constants.LogNameApplication, PathType.LogName); + + // Act — must not throw. + await effects.HandleOpenLog(action, mockDispatcher); + + // Assert — exact exception forwarded to banner; no resolver-status dispatch fired. + mockBannerService.Received(1).ReportCritical(thrown); + + mockDispatcher.DidNotReceive().Dispatch(Arg.Any()); + } + [Fact] public async Task HandleOpenLog_WhenCancelled_ShouldDispatchCloseAndClearStatus() { @@ -932,7 +1065,9 @@ public async Task HandleSetFilters_WhenFilterRequiresXml_ShouldRestoreSelectionA Substitute.For(), Substitute.For(), Substitute.For(), - mockServiceScopeFactory); + mockServiceScopeFactory, + Substitute.For(), + Substitute.For()); var mockDispatcher = Substitute.For(); @@ -1033,6 +1168,9 @@ private static (EventLogEffects effects, IDispatcher mockDispatcher) CreateEffec mockServiceProvider.GetService(typeof(IEventResolver)).Returns((IEventResolver?)null); } + var mockDatabaseService = Substitute.For(); + mockDatabaseService.InitialClassificationTask.Returns(Task.CompletedTask); + var effects = new EventLogEffects( mockEventLogState, mockFilterService, @@ -1040,7 +1178,9 @@ private static (EventLogEffects effects, IDispatcher mockDispatcher) CreateEffec mockLogWatcherService, mockResolverCache, Substitute.For(), - mockServiceScopeFactory); + mockServiceScopeFactory, + mockDatabaseService, + Substitute.For()); var mockDispatcher = Substitute.For(); @@ -1073,6 +1213,9 @@ private static (EventLogEffects effects, mockServiceScopeFactory.CreateScope().Returns(mockServiceScope); mockServiceScope.ServiceProvider.Returns(mockServiceProvider); + var mockDatabaseService = Substitute.For(); + mockDatabaseService.InitialClassificationTask.Returns(Task.CompletedTask); + var effects = new EventLogEffects( mockEventLogState, mockFilterService, @@ -1080,13 +1223,65 @@ private static (EventLogEffects effects, mockLogWatcherService, mockResolverCache, Substitute.For(), - mockServiceScopeFactory); + mockServiceScopeFactory, + mockDatabaseService, + Substitute.For()); var mockDispatcher = Substitute.For(); return (effects, mockDispatcher, mockFilterService); } + private static (EventLogEffects effects, + IDispatcher mockDispatcher, + IServiceProvider mockServiceProvider, + IBannerService mockBannerService, + IDatabaseService mockDatabaseService) CreateEffectsForOpenLogGuards( + ImmutableDictionary activeLogs) + { + var mockEventLogState = Substitute.For>(); + + mockEventLogState.Value.Returns(new EventLogState + { + ActiveLogs = activeLogs, + AppliedFilter = new EventFilter(null, []) + }); + + var mockServiceScopeFactory = Substitute.For(); + var mockServiceScope = Substitute.For(); + var mockServiceProvider = Substitute.For(); + + mockServiceScopeFactory.CreateScope().Returns(mockServiceScope); + mockServiceScope.ServiceProvider.Returns(mockServiceProvider); + + var mockEventResolver = Substitute.For(); + + mockEventResolver.ResolveEvent(Arg.Any()) + .Returns(_ => EventUtils.CreateTestEvent(100)); + + mockServiceProvider.GetService(typeof(IEventResolver)).Returns(mockEventResolver); + + var mockDatabaseService = Substitute.For(); + mockDatabaseService.InitialClassificationTask.Returns(Task.CompletedTask); + + var mockBannerService = Substitute.For(); + + var effects = new EventLogEffects( + mockEventLogState, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + mockServiceScopeFactory, + mockDatabaseService, + mockBannerService); + + var mockDispatcher = Substitute.For(); + + return (effects, mockDispatcher, mockServiceProvider, mockBannerService, mockDatabaseService); + } + private static (EventLogEffects effects, IDispatcher mockDispatcher, ILogWatcherService mockLogWatcher, @@ -1125,6 +1320,9 @@ private static (EventLogEffects effects, mockServiceScopeFactory.CreateScope().Returns(mockServiceScope); mockServiceScope.ServiceProvider.Returns(mockServiceProvider); + var mockDatabaseService = Substitute.For(); + mockDatabaseService.InitialClassificationTask.Returns(Task.CompletedTask); + var effects = new EventLogEffects( mockEventLogState, mockFilterService, @@ -1132,7 +1330,9 @@ private static (EventLogEffects effects, mockLogWatcherService, mockResolverCache, Substitute.For(), - mockServiceScopeFactory); + mockServiceScopeFactory, + mockDatabaseService, + Substitute.For()); var mockDispatcher = Substitute.For(); diff --git a/src/EventLogExpert.UI/Store/EventLog/EventLogEffects.cs b/src/EventLogExpert.UI/Store/EventLog/EventLogEffects.cs index bc907894..f7f9b9a7 100644 --- a/src/EventLogExpert.UI/Store/EventLog/EventLogEffects.cs +++ b/src/EventLogExpert.UI/Store/EventLog/EventLogEffects.cs @@ -27,11 +27,15 @@ public sealed class EventLogEffects( ILogWatcherService logWatcherService, IEventResolverCache resolverCache, IEventXmlResolver xmlResolver, - IServiceScopeFactory serviceScopeFactory) + IServiceScopeFactory serviceScopeFactory, + IDatabaseService databaseService, + IBannerService bannerService) { private static readonly int s_maxGlobalConcurrency = Math.Max(1, Environment.ProcessorCount - 1); private static readonly SemaphoreSlim s_resolutionThrottle = new(s_maxGlobalConcurrency, s_maxGlobalConcurrency); + private readonly IBannerService _bannerService = bannerService; + private readonly IDatabaseService _databaseService = databaseService; private readonly IState _eventLogState = eventLogState; private readonly IFilterService _filterService = filterService; private readonly Lock _globalCtsLock = new(); @@ -209,22 +213,14 @@ public Task HandleLoadNewEvents(IDispatcher dispatcher) [EffectMethod] public async Task HandleOpenLog(EventLogAction.OpenLog action, IDispatcher dispatcher) { - // Snapshot the cancel generation before any state checks. If CancelAllLoads - // increments this before we finish linking, we know our load must be canceled - // (it may have linked to the post-swap uncanceled global CTS). + // Snapshot the cancel generation FIRST. If CancelAllLoads fires between this + // snapshot and the CTS link below — including the new window introduced by + // the InitialClassificationTask await — the self-cancel check below will + // observe the generation drift and cancel our load. long startGeneration = Volatile.Read(ref _cancelGeneration); - using var serviceScope = _serviceScopeFactory.CreateScope(); - - var eventResolver = serviceScope.ServiceProvider.GetService(); - - if (eventResolver is null) - { - dispatcher.Dispatch(new StatusBarAction.SetResolverStatus("Error: No event resolver available")); - - return; - } - + // Cheap exit when the log is no longer in ActiveLogs (caller raced this open + // against a CloseLog) — skip scope creation and classification waits entirely. if (!_eventLogState.Value.ActiveLogs.TryGetValue(action.LogName, out var logData)) { dispatcher.Dispatch(new StatusBarAction.SetResolverStatus($"Error: Failed to open {action.LogName}")); @@ -258,6 +254,61 @@ public async Task HandleOpenLog(EventLogAction.OpenLog action, IDispatcher dispa try { + // Wait for the initial database classification scan so resolvers are + // built against verified-Ready entries (rather than the NotClassified + // default). The task is contractually non-faulting per IDatabaseService; + // the catch is defense-in-depth so an unexpected fault never poisons opens. + try + { + await _databaseService.InitialClassificationTask; + } + catch (Exception ex) + { + _logger.Trace($"InitialClassificationTask faulted unexpectedly during HandleOpenLog: {ex}"); + } + + // After the classification await, the user may have closed this log + // (HandleCloseLog removes it from ActiveLogs) or all logs (HandleCloseAll + // dispatches EventTableAction.CloseAll), or even closed-and-reopened the + // same name under a new EventLogId. Bail out without reaching LoadLogAsync — + // its AddTable dispatch would otherwise resurrect an entry the user already + // dismissed, leaving an orphan in EventTableState. + // + // We deliberately do NOT bail on token-cancellation alone: a pre-canceled + // caller token (or a CancelAllLoads that didn't dispatch CloseAll) leaves the + // log in ActiveLogs, so we must still proceed into LoadLogAsync where the + // existing OCE handler dispatches the user-visible CloseLog + ClearStatus + // cleanup. Identity-based bail covers exactly the orphan-table scenario. + if (!_eventLogState.Value.ActiveLogs.TryGetValue(action.LogName, out var current) + || current.Id != logData.Id) + { + return; + } + + using var serviceScope = _serviceScopeFactory.CreateScope(); + + IEventResolver? eventResolver; + + try + { + eventResolver = serviceScope.ServiceProvider.GetService(); + } + catch (Exception ex) + { + // Resolver factory threw during construction — surface as a Reload-tier + // banner so the user can recover instead of silently failing the open. + _bannerService.ReportCritical(ex); + + return; + } + + if (eventResolver is null) + { + dispatcher.Dispatch(new StatusBarAction.SetResolverStatus("Error: No event resolver available")); + + return; + } + await LoadLogAsync(action, logData, eventResolver, serviceScope, dispatcher, token); } finally From cf04995af5b9782eaeb494be7636c68dafb750fe Mon Sep 17 00:00:00 2001 From: jschick04 Date: Sat, 2 May 2026 18:00:28 -0500 Subject: [PATCH 26/31] Add classification-pending UX in SettingsModal and tighten WCAG AA contrast on status fills --- .../BannerHost.razor.css | 5 +- .../DatabaseStatusLabelsTests.cs | 71 +++++++++++++++++++ src/EventLogExpert.UI/DatabaseStatusLabels.cs | 24 +++++++ .../Shared/Components/BooleanSelect.razor | 4 +- .../Shared/Components/BooleanSelect.razor.cs | 2 + .../Shared/Components/BooleanSelect.razor.css | 10 ++- .../Shared/Components/SettingsModal.razor | 15 +++- .../Shared/Components/SettingsModal.razor.cs | 55 ++++++++++++++ .../Shared/Components/SettingsModal.razor.css | 49 +++++++++++++ src/EventLogExpert/wwwroot/css/app.css | 13 +++- 10 files changed, 238 insertions(+), 10 deletions(-) create mode 100644 src/EventLogExpert.UI.Tests/DatabaseStatusLabelsTests.cs create mode 100644 src/EventLogExpert.UI/DatabaseStatusLabels.cs diff --git a/src/EventLogExpert.Components/BannerHost.razor.css b/src/EventLogExpert.Components/BannerHost.razor.css index e7b6308c..93437170 100644 --- a/src/EventLogExpert.Components/BannerHost.razor.css +++ b/src/EventLogExpert.Components/BannerHost.razor.css @@ -17,6 +17,7 @@ .banner-error, .banner-critical { background-color: var(--clr-red); + color: var(--clr-on-status-fill); } .banner-critical { @@ -25,7 +26,7 @@ .banner-warning { background-color: var(--clr-yellow); - color: var(--clr-on-light-highlight); + color: var(--clr-on-status-fill); } .banner-info { @@ -35,7 +36,7 @@ .banner-attention { background-color: var(--clr-yellow); - color: var(--clr-on-light-highlight); + color: var(--clr-on-status-fill); } .banner-upgrade-progress { diff --git a/src/EventLogExpert.UI.Tests/DatabaseStatusLabelsTests.cs b/src/EventLogExpert.UI.Tests/DatabaseStatusLabelsTests.cs new file mode 100644 index 00000000..c3fdb6d2 --- /dev/null +++ b/src/EventLogExpert.UI.Tests/DatabaseStatusLabelsTests.cs @@ -0,0 +1,71 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.UI; + +namespace EventLogExpert.UI.Tests; + +public sealed class DatabaseStatusLabelsTests +{ + [Fact] + public void GetDisplayLabel_ClassificationFailed_ReturnsClassificationFailed() + { + Assert.Equal("Classification failed", DatabaseStatusLabels.GetDisplayLabel(DatabaseStatus.ClassificationFailed)); + } + + [Fact] + public void GetDisplayLabel_EveryEnumValue_ReturnsNonEmptyString() + { + foreach (var value in Enum.GetValues()) + { + var label = DatabaseStatusLabels.GetDisplayLabel(value); + + Assert.False(string.IsNullOrEmpty(label), + $"DatabaseStatus.{value} should map to a non-empty label."); + } + } + + [Fact] + public void GetDisplayLabel_NotClassified_ReturnsClassifyingWithEllipsis() + { + Assert.Equal("Classifying\u2026", DatabaseStatusLabels.GetDisplayLabel(DatabaseStatus.NotClassified)); + } + + [Fact] + public void GetDisplayLabel_ObsoleteSchema_ReturnsObsolete() + { + Assert.Equal("Obsolete", DatabaseStatusLabels.GetDisplayLabel(DatabaseStatus.ObsoleteSchema)); + } + + [Fact] + public void GetDisplayLabel_Ready_ReturnsReady() + { + Assert.Equal("Ready", DatabaseStatusLabels.GetDisplayLabel(DatabaseStatus.Ready)); + } + + [Fact] + public void GetDisplayLabel_UndefinedEnumValue_ReturnsEnumString() + { + const DatabaseStatus undefined = (DatabaseStatus)999; + + Assert.Equal(undefined.ToString(), DatabaseStatusLabels.GetDisplayLabel(undefined)); + } + + [Fact] + public void GetDisplayLabel_UnrecognizedSchema_ReturnsUnrecognized() + { + Assert.Equal("Unrecognized", DatabaseStatusLabels.GetDisplayLabel(DatabaseStatus.UnrecognizedSchema)); + } + + [Fact] + public void GetDisplayLabel_UpgradeFailed_ReturnsUpgradeFailed() + { + Assert.Equal("Upgrade failed", DatabaseStatusLabels.GetDisplayLabel(DatabaseStatus.UpgradeFailed)); + } + + [Fact] + public void GetDisplayLabel_UpgradeRequired_ReturnsUpgradeRequired() + { + Assert.Equal("Upgrade required", DatabaseStatusLabels.GetDisplayLabel(DatabaseStatus.UpgradeRequired)); + } +} diff --git a/src/EventLogExpert.UI/DatabaseStatusLabels.cs b/src/EventLogExpert.UI/DatabaseStatusLabels.cs new file mode 100644 index 00000000..999c3465 --- /dev/null +++ b/src/EventLogExpert.UI/DatabaseStatusLabels.cs @@ -0,0 +1,24 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.UI; + +/// +/// Maps a to the user-facing label shown next to a database entry. Returns a label +/// for every member (including ); whether to render the badge for a given +/// status is a per-screen suppression decision left to the caller. +/// +public static class DatabaseStatusLabels +{ + public static string GetDisplayLabel(DatabaseStatus status) => status switch + { + DatabaseStatus.Ready => "Ready", + DatabaseStatus.NotClassified => "Classifying\u2026", + DatabaseStatus.UpgradeRequired => "Upgrade required", + DatabaseStatus.UpgradeFailed => "Upgrade failed", + DatabaseStatus.UnrecognizedSchema => "Unrecognized", + DatabaseStatus.ObsoleteSchema => "Obsolete", + DatabaseStatus.ClassificationFailed => "Classification failed", + _ => status.ToString() + }; +} diff --git a/src/EventLogExpert/Shared/Components/BooleanSelect.razor b/src/EventLogExpert/Shared/Components/BooleanSelect.razor index 47a01bc3..9d70e52b 100644 --- a/src/EventLogExpert/Shared/Components/BooleanSelect.razor +++ b/src/EventLogExpert/Shared/Components/BooleanSelect.razor @@ -2,9 +2,9 @@ @inherits InputComponent
- + - +
diff --git a/src/EventLogExpert/Shared/Components/BooleanSelect.razor.cs b/src/EventLogExpert/Shared/Components/BooleanSelect.razor.cs index d30e4e8f..5078d660 100644 --- a/src/EventLogExpert/Shared/Components/BooleanSelect.razor.cs +++ b/src/EventLogExpert/Shared/Components/BooleanSelect.razor.cs @@ -10,6 +10,8 @@ public sealed partial class BooleanSelect : InputComponent { [Parameter] public string AriaLabel { get; set; } = string.Empty; + [Parameter] public bool Disabled { get; set; } + [Parameter] public string DisabledString { get; set; } = "Disabled"; [Parameter] public string EnabledString { get; set; } = "Enabled"; diff --git a/src/EventLogExpert/Shared/Components/BooleanSelect.razor.css b/src/EventLogExpert/Shared/Components/BooleanSelect.razor.css index 65a0858a..f27e2937 100644 --- a/src/EventLogExpert/Shared/Components/BooleanSelect.razor.css +++ b/src/EventLogExpert/Shared/Components/BooleanSelect.razor.css @@ -40,15 +40,21 @@ .toggle input:checked + label[data-use-status-colors="true"]:first-of-type { background-color: var(--clr-red); - color: var(--clr-white); + color: var(--clr-on-status-fill); } .toggle input:checked + label[data-use-status-colors="true"]:last-of-type { background-color: var(--clr-green); - color: var(--clr-white); + color: var(--clr-on-status-fill); } .toggle input:focus-visible + label { outline: 2px solid var(--clr-statusbar); outline-offset: -2px; } + +.toggle input:disabled + label { + cursor: not-allowed; + opacity: 0.5; + pointer-events: none; +} diff --git a/src/EventLogExpert/Shared/Components/SettingsModal.razor b/src/EventLogExpert/Shared/Components/SettingsModal.razor index 5e440eab..3f3fe96b 100644 --- a/src/EventLogExpert/Shared/Components/SettingsModal.razor +++ b/src/EventLogExpert/Shared/Components/SettingsModal.razor @@ -38,6 +38,14 @@
+ @if (IsClassificationPending) + { +
+ + Classifying databases… +
+ } +
@if (DatabaseService.Entries.Count > 0) { @@ -47,6 +55,11 @@
@entry.FileName + @if (entry.Status != DatabaseStatus.Ready) + { + @DatabaseStatusLabels.GetDisplayLabel(entry.Status) + } + @if (!entry.IsEnabled || entry.Status != DatabaseStatus.Ready) {
- +
} } diff --git a/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs b/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs index 1a7e84f1..6c810d6c 100644 --- a/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs +++ b/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs @@ -18,8 +18,10 @@ public sealed partial class SettingsModal : ModalBase { private readonly Dictionary _pendingToggles = new(StringComparer.OrdinalIgnoreCase); + private CancellationTokenSource? _classificationCts; private CopyType _copyType; private bool _databaseStateChanged; + private volatile bool _disposed; private bool _isPreReleaseEnabled; private bool _isUpgradeInFlight; private LogLevel _logLevel; @@ -37,6 +39,13 @@ public sealed partial class SettingsModal : ModalBase [Inject] private IState EventLogState { get; init; } = null!; + /// + /// true while has not completed. Drives the + /// "Classifying databases…" header indicator and disables every per-entry toggle so the user cannot trigger an + /// upgrade-required prompt against a status snapshot that is still being computed. + /// + private bool IsClassificationPending => !DatabaseService.InitialClassificationTask.IsCompleted; + /// /// true while a settings-triggered upgrade has been initiated from this modal but not yet observed to /// drain through the BannerService progress slot. Covers the queued-but-not-yet-started window ( @@ -53,6 +62,10 @@ protected override async ValueTask DisposeAsyncCore(bool disposing) { if (disposing) { + _disposed = true; + _classificationCts?.Cancel(); + _classificationCts?.Dispose(); + _classificationCts = null; DatabaseService.EntriesChanged -= OnDatabaseEntriesChanged; BannerService.StateChanged -= OnBannerStateChanged; } @@ -82,6 +95,19 @@ protected override void OnInitialized() DatabaseService.EntriesChanged += OnDatabaseEntriesChanged; BannerService.StateChanged += OnBannerStateChanged; + if (!DatabaseService.InitialClassificationTask.IsCompleted) + { + // EntriesChanged fires synchronously inside ClassifyEntriesAsync (DatabaseService.cs:163-165), + // before StartInitialClassificationAsync returns — so the entries-changed handler observes + // InitialClassificationTask.IsCompleted == false. We need a separate continuation that runs + // after the task object itself completes to flip IsClassificationPending in the UI. + // The CTS lets us cancel the WaitAsync proxy on dispose so the captured `this` reference + // is released even if the underlying classification task is still running, preventing the + // modal from leaking across open/close cycles during a long classification. + _classificationCts = new CancellationTokenSource(); + _ = ObserveClassificationCompletionAsync(_classificationCts.Token); + } + base.OnInitialized(); } @@ -235,6 +261,35 @@ private void LoadFromSettings() _timeZoneId = Settings.TimeZoneId; } + private async Task ObserveClassificationCompletionAsync(CancellationToken cancellationToken) + { + try + { + await DatabaseService.InitialClassificationTask.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Modal was disposed before classification completed; drop the captured `this` reference. + return; + } + catch + { + // InitialClassificationTask is contractually never-faulting (DatabaseService.cs:1200-1212); + // defensive try/catch so a future contract drift cannot orphan this fire-and-forget task. + } + + if (_disposed) { return; } + + try + { + await InvokeAsync(StateHasChanged); + } + catch (ObjectDisposedException) + { + // Modal was torn down between the _disposed check and the dispatcher call; safe to ignore. + } + } + private void OnBannerStateChanged() => _ = InvokeAsync(StateHasChanged); private void OnDatabaseEntriesChanged(object? sender, EventArgs e) diff --git a/src/EventLogExpert/Shared/Components/SettingsModal.razor.css b/src/EventLogExpert/Shared/Components/SettingsModal.razor.css index 26126037..7589334a 100644 --- a/src/EventLogExpert/Shared/Components/SettingsModal.razor.css +++ b/src/EventLogExpert/Shared/Components/SettingsModal.razor.css @@ -9,3 +9,52 @@ .tz-row { margin-bottom: .5rem; } .fixed-width { width: 50px; } + +.classification-pending { + display: flex; + align-items: center; + gap: .5rem; + padding: .25rem .5rem; + + color: var(--clr-lightblue); + font-style: italic; +} + +.classification-pending i { + flex: 0 0 auto; + animation: classification-spin 1.5s linear infinite; +} + +@keyframes classification-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.entry-status-badge { + display: inline-block; + margin-left: .5rem; + padding: .1rem .4rem; + + font-size: .85em; + background-color: var(--background-darkgray); + color: var(--clr-lightblue); + border-radius: .25rem; + vertical-align: middle; +} + +.entry-status-badge[data-status="UpgradeRequired"] { + background-color: var(--clr-yellow); + color: var(--clr-on-status-fill); +} + +.entry-status-badge[data-status="UpgradeFailed"], +.entry-status-badge[data-status="UnrecognizedSchema"], +.entry-status-badge[data-status="ObsoleteSchema"], +.entry-status-badge[data-status="ClassificationFailed"] { + background-color: var(--clr-red); + color: var(--clr-on-status-fill); +} + +.entry-status-badge[data-status="NotClassified"] { + font-style: italic; +} diff --git a/src/EventLogExpert/wwwroot/css/app.css b/src/EventLogExpert/wwwroot/css/app.css index 89a7ccab..14051d0c 100644 --- a/src/EventLogExpert/wwwroot/css/app.css +++ b/src/EventLogExpert/wwwroot/css/app.css @@ -41,6 +41,13 @@ /* Inverted text for badges / scrollbar thumbs that always appear on the statusbar (blue) background. */ --text-on-statusbar: var(--clr-white); + /* Foreground for filled status surfaces (warning/error/status-toggle banners, + badges, and toggle fills) where the fill is theme-aware + (--clr-yellow / --clr-red / --clr-green). White on the dark light-mode fills, + near-black on the bright dark-mode fills — meets WCAG AA in both themes + against all three. Distinct from --clr-on-light-highlight, which is dark + in both themes and only safe over the always-bright --highlight-light* tints. */ + --clr-on-status-fill: light-dark(var(--clr-white), #1A1A1A); /* Muted/secondary text (disabled menu items, shortcut hints, etc.). */ --text-secondary: light-dark(#555, var(--clr-lightgray)); @@ -82,15 +89,15 @@ [data-highlight="darkred"] { --hl-bg: #73190E; --hl-fg: var(--clr-white); } [data-highlight="lightorange"] { --hl-bg: #FEB95E; --hl-fg: var(--clr-on-light-highlight); } [data-highlight="orange"] { --hl-bg: #FE9100; --hl-fg: var(--clr-on-light-highlight); } -[data-highlight="darkorange"] { --hl-bg: #BE660F; --hl-fg: var(--clr-on-light-highlight); } +[data-highlight="darkorange"] { --hl-bg: #B05E00; --hl-fg: var(--clr-white); } [data-highlight="lightyellow"] { --hl-bg: #FCE393; --hl-fg: var(--clr-on-light-highlight); } [data-highlight="yellow"] { --hl-bg: #F8C84B; --hl-fg: var(--clr-on-light-highlight); } [data-highlight="darkyellow"] { --hl-bg: #F29A3B; --hl-fg: var(--clr-on-light-highlight); } [data-highlight="lightgreen"] { --hl-bg: #A5E1AC; --hl-fg: var(--clr-on-light-highlight); } [data-highlight="green"] { --hl-bg: #5AC358; --hl-fg: var(--clr-on-light-highlight); } -[data-highlight="darkgreen"] { --hl-bg: #3C8331; --hl-fg: var(--clr-on-light-highlight); } +[data-highlight="darkgreen"] { --hl-bg: #3C8331; --hl-fg: var(--clr-white); } [data-highlight="lightteal"] { --hl-bg: #5DC2B4; --hl-fg: var(--clr-on-light-highlight); } -[data-highlight="teal"] { --hl-bg: #018C76; --hl-fg: var(--clr-on-light-highlight); } +[data-highlight="teal"] { --hl-bg: #017866; --hl-fg: var(--clr-white); } [data-highlight="darkteal"] { --hl-bg: #01594C; --hl-fg: var(--clr-white); } [data-highlight="lightblue"] { --hl-bg: #73B8FD; --hl-fg: var(--clr-on-light-highlight); } [data-highlight="blue"] { --hl-bg: #066EE2; --hl-fg: var(--clr-white); } From ad7298d67016b51d286f90c5ee91a3cf2988a567 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Sat, 2 May 2026 21:43:49 -0500 Subject: [PATCH 27/31] Restructure SettingsModal database row UX with per-status primary actions --- .../DatabaseEntryRowTests.cs | 363 ++++++++++++++++++ .../DatabaseRecoveryDialogTests.cs | 91 +++-- .../DatabaseRecoveryHostTests.cs | 62 ++- .../SettingsUpgradeProgressBannerTests.cs | 44 ++- .../BannerHost.razor.cs | 51 +-- .../Base/FooterPreset.cs | 5 - .../Base/InputComponent.cs | 6 +- .../Base/ModalChrome.razor.cs | 26 -- .../Base/ModalChrome.razor.css | 18 - .../BooleanSelect.razor | 2 +- .../BooleanSelect.razor.cs | 4 +- .../BooleanSelect.razor.css | 0 .../DatabaseEntryRow.razor | 57 +++ .../DatabaseEntryRow.razor.cs | 67 ++++ .../DatabaseEntryRow.razor.css | 87 +++++ .../DatabaseRecoveryDialog.razor.cs | 44 +-- .../DatabaseRecoveryDialog.razor.css | 3 - .../DatabaseRecoveryHost.razor.cs | 45 +-- .../SettingsUpgradeProgressBanner.razor.cs | 10 +- .../DiffDatabaseCommand.cs | 10 - .../MergeDatabaseCommand.cs | 12 - .../ProviderSource.cs | 18 - .../Providers/ProviderDetailsTests.cs | 22 +- .../EventProviderDbContext.cs | 32 -- .../ProviderDatabaseSchemaVersion.cs | 6 - .../EventResolvers/EventResolver.cs | 3 +- .../Providers/ProviderDetails.cs | 14 - .../DatabaseStatusLabelsTests.cs | 50 +++ .../Services/BannerViewSelectorTests.cs | 29 +- .../TestUtils/DatabaseSeedUtils.cs | 18 - src/EventLogExpert.UI/DatabaseStatusLabels.cs | 11 +- .../Interfaces/IAlertDialogService.cs | 20 - .../Interfaces/IBannerService.cs | 57 --- .../Interfaces/IDatabaseService.cs | 82 ---- .../Interfaces/IMenuActionService.cs | 5 - .../Models/UpgradeBatchProgressEventArgs.cs | 1 - .../Models/UpgradeBatchStartedEventArgs.cs | 6 - .../Services/BannerService.cs | 25 +- .../Services/BannerViewSelector.cs | 22 -- .../Services/DatabaseService.cs | 91 +---- .../Services/ModalAlertDialogService.cs | 8 - .../Store/EventLog/EventLogEffects.cs | 60 +-- .../Components/DetailsPane.razor.cs | 7 +- .../Components/EventTable.razor.cs | 6 +- src/EventLogExpert/MainPage.xaml.cs | 4 - src/EventLogExpert/MauiProgram.cs | 4 - .../Services/KeyboardShortcutService.cs | 4 +- src/EventLogExpert/Shared/Base/ModalBase.cs | 2 - .../Shared/Components/SettingsModal.razor | 28 +- .../Shared/Components/SettingsModal.razor.cs | 179 ++++----- .../Shared/Components/SettingsModal.razor.css | 29 -- .../Shared/Components/TextInput.razor | 2 +- .../Shared/Components/TextInput.razor.cs | 2 +- .../Shared/Components/ValueSelect.razor | 1 - .../Shared/Components/ValueSelect.razor.cs | 2 +- src/EventLogExpert/wwwroot/css/app.css | 6 - 56 files changed, 954 insertions(+), 909 deletions(-) create mode 100644 src/EventLogExpert.Components.Tests/DatabaseEntryRowTests.cs rename src/{EventLogExpert/Shared => EventLogExpert.Components}/Base/InputComponent.cs (84%) rename src/{EventLogExpert/Shared/Components => EventLogExpert.Components}/BooleanSelect.razor (94%) rename src/{EventLogExpert/Shared/Components => EventLogExpert.Components}/BooleanSelect.razor.cs (91%) rename src/{EventLogExpert/Shared/Components => EventLogExpert.Components}/BooleanSelect.razor.css (100%) create mode 100644 src/EventLogExpert.Components/DatabaseEntryRow.razor create mode 100644 src/EventLogExpert.Components/DatabaseEntryRow.razor.cs create mode 100644 src/EventLogExpert.Components/DatabaseEntryRow.razor.css diff --git a/src/EventLogExpert.Components.Tests/DatabaseEntryRowTests.cs b/src/EventLogExpert.Components.Tests/DatabaseEntryRowTests.cs new file mode 100644 index 00000000..ce794b3b --- /dev/null +++ b/src/EventLogExpert.Components.Tests/DatabaseEntryRowTests.cs @@ -0,0 +1,363 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Bunit; +using EventLogExpert.UI; +using EventLogExpert.UI.Models; + +namespace EventLogExpert.Components.Tests; + +public sealed class DatabaseEntryRowTests : BunitContext +{ + public DatabaseEntryRowTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + } + + [Fact] + public async Task RemoveButtonClick_InvokesOnRemove() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.Ready); + int invocationCount = 0; + var component = Render(parameters => parameters + .Add(p => p.Entry, entry) + .Add(p => p.OnRemove, () => invocationCount++)); + + // Act + await component.Find(".db-entry-remove-btn").ClickAsync(new()); + + // Assert + Assert.Equal(1, invocationCount); + } + + [Fact] + public void Render_AllRows_ShowTrashButton() + { + foreach (var status in Enum.GetValues()) + { + // Arrange + var entry = MakeEntry(status); + + // Act + var component = RenderRow(entry); + + // Assert + Assert.Single(component.FindAll(".db-entry-remove-btn")); + } + } + + [Fact] + public void Render_BackupExistsAndIsUpgrading_StillShowsRecoveryBadge() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.Ready, backupExists: true); + + // Act + var component = RenderRow(entry, isUpgrading: true); + + // Assert + var badge = component.Find(".db-entry-badge"); + Assert.Equal("Recovery required", badge.TextContent); + Assert.Equal("Recovery", badge.GetAttribute("data-badge")); + + Assert.Empty(component.FindAll(".db-entry-upgrading")); + Assert.Empty(component.FindAll(".db-entry-upgrade-btn")); + Assert.Empty(component.FindAll(".toggle")); + } + + [Fact] + public void Render_BackupExistsEntry_OverridesReadyStatus() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.Ready, backupExists: true); + + // Act + var component = RenderRow(entry); + + // Assert + var badge = component.Find(".db-entry-badge"); + Assert.Equal("Recovery required", badge.TextContent); + Assert.Empty(component.FindAll(".toggle")); + } + + [Fact] + public void Render_BackupExistsEntry_ShowsRecoveryRequiredBadge_AndOnlyTrash() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.UpgradeRequired, backupExists: true); + + // Act + var component = RenderRow(entry); + + // Assert + var badge = component.Find(".db-entry-badge"); + Assert.Equal("Recovery required", badge.TextContent); + Assert.Equal("Recovery", badge.GetAttribute("data-badge")); + + Assert.Empty(component.FindAll(".db-entry-upgrade-btn")); + Assert.Empty(component.FindAll(".toggle")); + Assert.Empty(component.FindAll(".db-entry-upgrading")); + + Assert.Single(component.FindAll(".db-entry-remove-btn")); + } + + [Fact] + public void Render_FileName_AppearsInRow() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.Ready, fileName: "MyProvider.db"); + + // Act + var component = RenderRow(entry); + + // Assert + Assert.Equal("MyProvider.db", component.Find(".db-entry-name").TextContent); + } + + [Fact] + public void Render_IsUpgradeBlocked_DisablesRetryButton() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.UpgradeFailed); + + // Act + var component = RenderRow(entry, isUpgradeBlocked: true); + + // Assert + var button = component.Find(".db-entry-upgrade-btn"); + Assert.True(button.HasAttribute("disabled")); + } + + [Fact] + public void Render_IsUpgradeBlocked_DisablesUpgradeButton() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.UpgradeRequired); + + // Act + var component = RenderRow(entry, isUpgradeBlocked: true); + + // Assert + var button = component.Find(".db-entry-upgrade-btn"); + Assert.True(button.HasAttribute("disabled")); + } + + [Fact] + public void Render_IsUpgrading_ShowsSpinner_AndHidesBadge() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.UpgradeRequired); + + // Act + var component = RenderRow(entry, isUpgrading: true); + + // Assert + var upgrading = component.Find(".db-entry-upgrading"); + Assert.Contains("Upgrading", upgrading.TextContent); + Assert.Single(component.FindAll(".db-entry-upgrading .db-entry-spinner")); + + Assert.Empty(component.FindAll(".db-entry-upgrade-btn")); + Assert.Empty(component.FindAll(".db-entry-badge")); + } + + [Fact] + public void Render_NotClassifiedEntry_ShowsDisabledToggle_AndClassifyingBadge() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.NotClassified); + + // Act + var component = RenderRow(entry); + + // Assert + var radios = component.FindAll(".toggle input[type='radio']"); + Assert.NotEmpty(radios); + Assert.All(radios, r => Assert.True(r.HasAttribute("disabled"))); + + var badge = component.Find(".db-entry-badge"); + Assert.Equal("Classifying\u2026", badge.TextContent); + Assert.Equal("NotClassified", badge.GetAttribute("data-badge")); + } + + [Fact] + public void Render_ReadyEntry_ShowsToggle_AndNoBadge() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.Ready); + + // Act + var component = RenderRow(entry); + + // Assert + Assert.Single(component.FindAll(".toggle")); + Assert.Empty(component.FindAll(".db-entry-badge")); + Assert.Empty(component.FindAll(".db-entry-upgrade-btn")); + Assert.Empty(component.FindAll(".db-entry-upgrading")); + } + + [Fact] + public void Render_ReadyEntryWithClassificationPending_ShowsDisabledToggle() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.Ready); + + // Act + var component = RenderRow(entry, isClassificationPending: true); + + // Assert + var radios = component.FindAll(".toggle input[type='radio']"); + Assert.NotEmpty(radios); + Assert.All(radios, r => Assert.True(r.HasAttribute("disabled"))); + } + + [Fact] + public void Render_RemoveButton_HasAriaLabel() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.Ready, fileName: "MyProvider.db"); + + // Act + var component = RenderRow(entry); + + // Assert + var button = component.Find(".db-entry-remove-btn"); + Assert.Equal("Remove database MyProvider.db", button.GetAttribute("aria-label")); + } + + [Theory] + [InlineData(DatabaseStatus.UnrecognizedSchema, "Unrecognized")] + [InlineData(DatabaseStatus.ObsoleteSchema, "Obsolete")] + [InlineData(DatabaseStatus.ClassificationFailed, "Classification failed")] + public void Render_TerminalStatus_ShowsBadge_AndOnlyTrash(DatabaseStatus status, string expectedLabel) + { + // Arrange + var entry = MakeEntry(status); + + // Act + var component = RenderRow(entry); + + // Assert + var badge = component.Find(".db-entry-badge"); + Assert.Equal(expectedLabel, badge.TextContent); + Assert.Equal(status.ToString(), badge.GetAttribute("data-badge")); + + Assert.Empty(component.FindAll(".db-entry-upgrade-btn")); + Assert.Empty(component.FindAll(".toggle")); + Assert.Single(component.FindAll(".db-entry-remove-btn")); + } + + [Fact] + public void Render_UpgradeFailedEntry_ShowsRetryButton_AndRedBadge() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.UpgradeFailed); + + // Act + var component = RenderRow(entry); + + // Assert + var button = component.Find(".db-entry-upgrade-btn"); + Assert.Equal("Retry Upgrade", button.TextContent.Trim()); + Assert.Contains("button-red", button.GetAttribute("class") ?? string.Empty); + + var badge = component.Find(".db-entry-badge"); + Assert.Equal("Upgrade failed", badge.TextContent); + Assert.Equal("UpgradeFailed", badge.GetAttribute("data-badge")); + } + + [Fact] + public void Render_UpgradeRequiredEntry_ShowsUpgradeButton_AndYellowBadge() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.UpgradeRequired); + + // Act + var component = RenderRow(entry); + + // Assert + var button = component.Find(".db-entry-upgrade-btn"); + Assert.Equal("Upgrade", button.TextContent.Trim()); + Assert.False(button.HasAttribute("disabled")); + Assert.DoesNotContain("button-red", button.GetAttribute("class") ?? string.Empty); + + var badge = component.Find(".db-entry-badge"); + Assert.Equal("Upgrade required", badge.TextContent); + Assert.Equal("UpgradeRequired", badge.GetAttribute("data-badge")); + + Assert.Empty(component.FindAll(".toggle")); + } + + [Fact] + public async Task RetryButtonClick_InvokesOnUpgrade() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.UpgradeFailed); + int invocationCount = 0; + var component = Render(parameters => parameters + .Add(p => p.Entry, entry) + .Add(p => p.OnUpgrade, () => invocationCount++)); + + // Act + await component.Find(".db-entry-upgrade-btn").ClickAsync(new()); + + // Assert + Assert.Equal(1, invocationCount); + } + + [Fact] + public async Task TogglingTrueRadio_InvokesOnToggle_OnReadyEntry() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.Ready); + int invocationCount = 0; + var component = Render(parameters => parameters + .Add(p => p.Entry, entry) + .Add(p => p.EffectiveEnabled, false) + .Add(p => p.OnToggle, () => invocationCount++)); + + // Act + var enableRadio = component.FindAll(".toggle input[type='radio']")[1]; + await enableRadio.ChangeAsync(new() { Value = "true" }); + + // Assert + Assert.Equal(1, invocationCount); + } + + [Fact] + public async Task UpgradeButtonClick_InvokesOnUpgrade() + { + // Arrange + var entry = MakeEntry(DatabaseStatus.UpgradeRequired); + int invocationCount = 0; + var component = Render(parameters => parameters + .Add(p => p.Entry, entry) + .Add(p => p.OnUpgrade, () => invocationCount++)); + + // Act + await component.Find(".db-entry-upgrade-btn").ClickAsync(new()); + + // Assert + Assert.Equal(1, invocationCount); + } + + private static DatabaseEntry MakeEntry( + DatabaseStatus status, + string fileName = "a.db", + bool isEnabled = false, + bool backupExists = false) => + new(fileName, $@"C:\dbs\{fileName}", isEnabled, status, backupExists); + + private IRenderedComponent RenderRow( + DatabaseEntry entry, + bool isClassificationPending = false, + bool isUpgrading = false, + bool isUpgradeBlocked = false, + bool effectiveEnabled = false) => + Render(parameters => parameters + .Add(p => p.Entry, entry) + .Add(p => p.IsClassificationPending, isClassificationPending) + .Add(p => p.IsUpgrading, isUpgrading) + .Add(p => p.IsUpgradeBlocked, isUpgradeBlocked) + .Add(p => p.EffectiveEnabled, effectiveEnabled)); +} diff --git a/src/EventLogExpert.Components.Tests/DatabaseRecoveryDialogTests.cs b/src/EventLogExpert.Components.Tests/DatabaseRecoveryDialogTests.cs index 920e8d46..5ce63f28 100644 --- a/src/EventLogExpert.Components.Tests/DatabaseRecoveryDialogTests.cs +++ b/src/EventLogExpert.Components.Tests/DatabaseRecoveryDialogTests.cs @@ -33,6 +33,7 @@ public DatabaseRecoveryDialogTests() [Fact] public async Task DatabaseRecoveryDialog_AllRowsSucceed_AutoDismisses() { + // Arrange var entriesBefore = new DatabaseEntry[] { BuildEntry("a.db", backupExists: true), @@ -41,20 +42,19 @@ public async Task DatabaseRecoveryDialog_AllRowsSucceed_AutoDismisses() _databaseService.Entries.Returns(entriesBefore); - // After Apply succeeds, the service raises EntriesChanged with both backups gone. Simulate - // by flipping Entries to an empty list before raising the event from inside the call. _databaseService.RestoreFromBackupAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); var component = Render(parameters => parameters .Add(p => p.OnDismissed, () => Interlocked.Increment(ref _onDismissedCallCount))); + // Act await component.Find("button:contains('Apply')").ClickAsync(new()); - // Now reflect the service raising EntriesChanged after the last successful Restore. _databaseService.Entries.Returns([]); _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + // Assert await component.WaitForAssertionAsync(() => Assert.Equal(1, _onDismissedCallCount)); await _databaseService.Received(1).RestoreFromBackupAsync("a.db", Arg.Any()); await _databaseService.Received(1).RestoreFromBackupAsync("b.db", Arg.Any()); @@ -63,6 +63,7 @@ public async Task DatabaseRecoveryDialog_AllRowsSucceed_AutoDismisses() [Fact] public async Task DatabaseRecoveryDialog_ApplyDeleteReturnsFalse_SurfacesErrorBannerAndMarksRowFailed() { + // Arrange _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); _databaseService.DeleteEntryWithBackupAsync("a.db", Arg.Any()) .Returns(Task.FromResult(false)); @@ -70,9 +71,11 @@ public async Task DatabaseRecoveryDialog_ApplyDeleteReturnsFalse_SurfacesErrorBa var component = Render(parameters => parameters .Add(p => p.OnDismissed, () => Interlocked.Increment(ref _onDismissedCallCount))); + // Act await component.Find("button.button:contains('Delete all')").ClickAsync(new()); await component.Find("button:contains('Apply')").ClickAsync(new()); + // Assert _bannerService.Received(1).ReportError( "Database recovery failed", "Failed to delete 'a.db'."); @@ -85,6 +88,7 @@ public async Task DatabaseRecoveryDialog_ApplyDeleteReturnsFalse_SurfacesErrorBa [Fact] public async Task DatabaseRecoveryDialog_ApplyDisablesAllControls_WhilePending() { + // Arrange _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); var pendingRestore = new TaskCompletionSource(); _databaseService.RestoreFromBackupAsync("a.db", Arg.Any()) @@ -92,9 +96,10 @@ public async Task DatabaseRecoveryDialog_ApplyDisablesAllControls_WhilePending() var component = Render(); + // Act var applyClick = component.Find("button:contains('Apply')").ClickAsync(new()); - // While pending, every interactive control should be disabled. + // Assert Assert.True(((AngleSharp.Html.Dom.IHtmlButtonElement)component.Find("button:contains('Apply')")).IsDisabled); Assert.True(((AngleSharp.Html.Dom.IHtmlButtonElement)component.Find("button:contains('Cancel')")).IsDisabled); Assert.True(((AngleSharp.Html.Dom.IHtmlButtonElement)component.Find("button.button:contains('Restore all')")).IsDisabled); @@ -112,6 +117,7 @@ public async Task DatabaseRecoveryDialog_ApplyDisablesAllControls_WhilePending() [Fact] public async Task DatabaseRecoveryDialog_ApplyMixed_CallsBothMethodsForRespectiveRows() { + // Arrange _databaseService.Entries.Returns( [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); _databaseService.RestoreFromBackupAsync("a.db", Arg.Any()) @@ -121,13 +127,14 @@ public async Task DatabaseRecoveryDialog_ApplyMixed_CallsBothMethodsForRespectiv var component = Render(); - // Row 1 stays Restore (default); flip row 2 to Delete by clicking its Delete radio. + // Act var bRow = component.FindAll("li.recovery-row")[1]; var bDeleteRadio = bRow.QuerySelectorAll("input[type=radio]")[1]; await bDeleteRadio.ChangeAsync(new() { Value = "on" }); await component.Find("button:contains('Apply')").ClickAsync(new()); + // Assert await _databaseService.Received(1).RestoreFromBackupAsync("a.db", Arg.Any()); await _databaseService.Received(1).DeleteEntryWithBackupAsync("b.db", Arg.Any()); await _databaseService.DidNotReceive().RestoreFromBackupAsync("b.db", Arg.Any()); @@ -137,6 +144,7 @@ public async Task DatabaseRecoveryDialog_ApplyMixed_CallsBothMethodsForRespectiv [Fact] public async Task DatabaseRecoveryDialog_ApplyRestoreReturnsFalse_SurfacesErrorBannerAndMarksRowFailed() { + // Arrange _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); _databaseService.RestoreFromBackupAsync("a.db", Arg.Any()) .Returns(Task.FromResult(false)); @@ -144,8 +152,10 @@ public async Task DatabaseRecoveryDialog_ApplyRestoreReturnsFalse_SurfacesErrorB var component = Render(parameters => parameters .Add(p => p.OnDismissed, () => Interlocked.Increment(ref _onDismissedCallCount))); + // Act await component.Find("button:contains('Apply')").ClickAsync(new()); + // Assert _bannerService.Received(1).ReportError( "Database recovery failed", "Failed to restore 'a.db' from backup."); @@ -158,17 +168,17 @@ public async Task DatabaseRecoveryDialog_ApplyRestoreReturnsFalse_SurfacesErrorB [Fact] public async Task DatabaseRecoveryDialog_ApplyThrowsInvalidOperation_TreatsAsBenignSkipNoErrorBanner() { - // DatabaseService raises InvalidOperationException for "entry not found" / "operation in - // progress". Both mean the world changed underneath us; the dialog must NOT surface a - // recovery-failed banner because the user didn't actually lose anything. + // Arrange _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); _databaseService.RestoreFromBackupAsync("a.db", Arg.Any()) .Returns(Task.FromException(new InvalidOperationException("entry not found"))); var component = Render(); + // Act await component.Find("button:contains('Apply')").ClickAsync(new()); + // Assert _bannerService.DidNotReceive().ReportError( Arg.Any(), Arg.Any(), @@ -182,14 +192,17 @@ public async Task DatabaseRecoveryDialog_ApplyThrowsInvalidOperation_TreatsAsBen [Fact] public async Task DatabaseRecoveryDialog_ApplyThrowsUnexpected_SurfacesErrorBannerAndMarksRowFailed() { + // Arrange _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); _databaseService.RestoreFromBackupAsync("a.db", Arg.Any()) .Returns(Task.FromException(new IOException("disk gone"))); var component = Render(); + // Act await component.Find("button:contains('Apply')").ClickAsync(new()); + // Assert _bannerService.Received(1).ReportError( "Database recovery failed", "Failed to restore 'a.db' from backup."); @@ -201,15 +214,18 @@ public async Task DatabaseRecoveryDialog_ApplyThrowsUnexpected_SurfacesErrorBann [Fact] public async Task DatabaseRecoveryDialog_ApplyWithDelete_CallsDeleteEntryWithBackupAsyncWithFileName() { + // Arrange _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); _databaseService.DeleteEntryWithBackupAsync("a.db", Arg.Any()) .Returns(Task.FromResult(true)); var component = Render(); + // Act await component.Find("button.button:contains('Delete all')").ClickAsync(new()); await component.Find("button:contains('Apply')").ClickAsync(new()); + // Assert await _databaseService.Received(1).DeleteEntryWithBackupAsync( Arg.Is(name => name == "a.db"), Arg.Any()); @@ -221,14 +237,17 @@ await _databaseService.DidNotReceive().RestoreFromBackupAsync( [Fact] public async Task DatabaseRecoveryDialog_ApplyWithRestore_CallsRestoreFromBackupAsyncWithFileName() { + // Arrange _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); _databaseService.RestoreFromBackupAsync("a.db", Arg.Any()) .Returns(Task.FromResult(true)); var component = Render(); + // Act await component.Find("button:contains('Apply')").ClickAsync(new()); + // Assert await _databaseService.Received(1).RestoreFromBackupAsync( Arg.Is(name => name == "a.db"), Arg.Any()); @@ -240,13 +259,16 @@ await _databaseService.DidNotReceive().DeleteEntryWithBackupAsync( [Fact] public async Task DatabaseRecoveryDialog_CancelClicked_RaisesOnDismissedDoesNotCallDatabaseService() { + // Arrange _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); var component = Render(parameters => parameters .Add(p => p.OnDismissed, () => Interlocked.Increment(ref _onDismissedCallCount))); + // Act await component.Find("button:contains('Cancel')").ClickAsync(new()); + // Assert Assert.Equal(1, _onDismissedCallCount); await _databaseService.DidNotReceive().RestoreFromBackupAsync( Arg.Any(), @@ -259,11 +281,14 @@ await _databaseService.DidNotReceive().DeleteEntryWithBackupAsync( [Fact] public void DatabaseRecoveryDialog_DefaultsEachRowToRestore() { + // Arrange _databaseService.Entries.Returns( [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + // Act var component = Render(); + // Assert foreach (var row in component.FindAll("li.recovery-row")) { var radios = row.QuerySelectorAll("input[type=radio]"); @@ -276,13 +301,16 @@ public void DatabaseRecoveryDialog_DefaultsEachRowToRestore() [Fact] public async Task DatabaseRecoveryDialog_DeleteAllClicked_SetsAllRowsToDelete() { + // Arrange _databaseService.Entries.Returns( [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); var component = Render(); + // Act await component.Find("button.button:contains('Delete all')").ClickAsync(new()); + // Assert foreach (var row in component.FindAll("li.recovery-row")) { var radios = row.QuerySelectorAll("input[type=radio]"); @@ -294,32 +322,36 @@ public async Task DatabaseRecoveryDialog_DeleteAllClicked_SetsAllRowsToDelete() [Fact] public async Task DatabaseRecoveryDialog_EntriesChangedAllResolved_AutoDismisses() { + // Arrange _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); var component = Render(parameters => parameters .Add(p => p.OnDismissed, () => Interlocked.Increment(ref _onDismissedCallCount))); - // Flip the backing entries to empty before raising EntriesChanged. + // Act _databaseService.Entries.Returns([]); _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + // Assert await component.WaitForAssertionAsync(() => Assert.Equal(1, _onDismissedCallCount)); } [Fact] public async Task DatabaseRecoveryDialog_EntriesChangedNewBackupExistsEntry_AddsRowWithRestoreDefault() { + // Arrange _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); var component = Render(); - // A new entry appears (e.g. another database's upgrade just got interrupted). + // Act _databaseService.Entries.Returns( [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); component.WaitForState(() => component.FindAll("li.recovery-row").Count == 2); + // Assert var newRow = component.FindAll("li.recovery-row")[1]; Assert.Contains("b.db", newRow.TextContent); @@ -333,24 +365,24 @@ public async Task DatabaseRecoveryDialog_EntriesChangedNewBackupExistsEntry_Adds [Fact] public async Task DatabaseRecoveryDialog_EntriesChangedSubsetResolved_RemovesResolvedRowsKeepsDialogOpen() { + // Arrange _databaseService.Entries.Returns( [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); var component = Render(parameters => parameters .Add(p => p.OnDismissed, () => Interlocked.Increment(ref _onDismissedCallCount))); - // Flip "b.db" selection to Delete so we can prove the surviving "a.db" Restore selection is - // preserved across the EntriesChanged refresh. var bRow = component.FindAll("li.recovery-row")[1]; var bDeleteRadio = bRow.QuerySelectorAll("input[type=radio]")[1]; await bDeleteRadio.ChangeAsync(new() { Value = "on" }); - // External resolution removes "b.db". + // Act _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); component.WaitForState(() => component.FindAll("li.recovery-row").Count == 1); + // Assert var remainingRow = component.Find("li.recovery-row"); Assert.Contains("a.db", remainingRow.TextContent); @@ -363,6 +395,7 @@ public async Task DatabaseRecoveryDialog_EntriesChangedSubsetResolved_RemovesRes [Fact] public async Task DatabaseRecoveryDialog_EscDuringApply_DoesNotDismiss() { + // Arrange _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); var pendingRestore = new TaskCompletionSource(); _databaseService.RestoreFromBackupAsync("a.db", Arg.Any()) @@ -371,13 +404,12 @@ public async Task DatabaseRecoveryDialog_EscDuringApply_DoesNotDismiss() var component = Render(parameters => parameters .Add(p => p.OnDismissed, () => Interlocked.Increment(ref _onDismissedCallCount))); + // Act var applyTask = component.Find("button:contains('Apply')").ClickAsync(new()); - // Esc on a fires `cancel`. ModalChrome's @oncancel handler routes through - // OnDialogClosedByUser, which we map to the same _isApplying-aware handler as the Cancel - // button. So mid-Apply Esc must NOT invoke OnDismissed. await component.Find("dialog").TriggerEventAsync("oncancel", EventArgs.Empty); + // Assert Assert.Equal(0, _onDismissedCallCount); pendingRestore.SetResult(true); @@ -387,7 +419,7 @@ public async Task DatabaseRecoveryDialog_EscDuringApply_DoesNotDismiss() [Fact] public async Task DatabaseRecoveryDialog_FailedRow_LosesFailureMarkOnNextApplySuccessWhileOthersStayFailed() { - // Two rows both fail on first Apply. + // Arrange _databaseService.Entries.Returns( [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); _databaseService.RestoreFromBackupAsync("a.db", Arg.Any()) @@ -406,13 +438,13 @@ public async Task DatabaseRecoveryDialog_FailedRow_LosesFailureMarkOnNextApplySu "recovery-row-failed", FindRowByFileName(component, "b.db").GetAttribute("class") ?? string.Empty); - // Second Apply: a now succeeds, b still fails. Per-row failure-mark scoping means a loses - // its mark but b keeps its mark. + // Act _databaseService.RestoreFromBackupAsync("a.db", Arg.Any()) .Returns(Task.FromResult(true)); await component.Find("button:contains('Apply')").ClickAsync(new()); + // Assert Assert.DoesNotContain( "recovery-row-failed", FindRowByFileName(component, "a.db").GetAttribute("class") ?? string.Empty); @@ -424,20 +456,21 @@ public async Task DatabaseRecoveryDialog_FailedRow_LosesFailureMarkOnNextApplySu [Fact] public async Task DatabaseRecoveryDialog_InitialEmptySet_AutoDismissesWithoutShowingModal() { - // The dialog can be mounted after the parent decided recovery was needed but before our - // first render — by which point every BackupExists entry may have already been resolved - // (e.g. by another concurrent recovery path). + // Arrange _databaseService.Entries.Returns([]); + // Act var component = Render(parameters => parameters .Add(p => p.OnDismissed, () => Interlocked.Increment(ref _onDismissedCallCount))); + // Assert await component.WaitForAssertionAsync(() => Assert.Equal(1, _onDismissedCallCount)); } [Fact] public void DatabaseRecoveryDialog_RendersOneRowPerBackupExistsEntry() { + // Arrange _databaseService.Entries.Returns( [ BuildEntry("a.db", backupExists: true), @@ -445,8 +478,10 @@ public void DatabaseRecoveryDialog_RendersOneRowPerBackupExistsEntry() BuildEntry("c.db", backupExists: false) ]); + // Act var component = Render(); + // Assert var rows = component.FindAll("li.recovery-row"); Assert.Equal(2, rows.Count); Assert.Contains("a.db", rows[0].TextContent); @@ -456,13 +491,12 @@ public void DatabaseRecoveryDialog_RendersOneRowPerBackupExistsEntry() [Fact] public async Task DatabaseRecoveryDialog_RestoreAllClicked_SetsAllRowsToRestore() { + // Arrange _databaseService.Entries.Returns( [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); var component = Render(); - // Flip both rows to Delete first so RestoreAll has something to flip back. Re-query inside - // the loop because each ChangeAsync re-renders and invalidates prior element handles. var rowCount = component.FindAll("li.recovery-row").Count; for (var rowIndex = 0; rowIndex < rowCount; rowIndex++) { @@ -471,8 +505,10 @@ public async Task DatabaseRecoveryDialog_RestoreAllClicked_SetsAllRowsToRestore( await deleteRadio.ChangeAsync(new() { Value = "on" }); } + // Act await component.Find("button.button:contains('Restore all')").ClickAsync(new()); + // Assert foreach (var row in component.FindAll("li.recovery-row")) { var radios = row.QuerySelectorAll("input[type=radio]"); @@ -484,9 +520,7 @@ public async Task DatabaseRecoveryDialog_RestoreAllClicked_SetsAllRowsToRestore( [Fact] public async Task DatabaseRecoveryDialog_RowResolvedExternallyMidLoop_DoesNotCallServiceForResolvedRow() { - // Two rows in the snapshot the dialog originally rendered. Between OnInitialized and - // ApplyAsync, an external code path resolves "b.db" — its BackupExists flips to false. The - // dialog must re-check live state per row before calling the service, and silently skip "b.db". + // Arrange var entriesBefore = new DatabaseEntry[] { BuildEntry("a.db", backupExists: true), @@ -505,11 +539,12 @@ public async Task DatabaseRecoveryDialog_RowResolvedExternallyMidLoop_DoesNotCal var component = Render(); - // Simulate the external resolution between render and Apply. _databaseService.Entries.Returns(entriesAfter); + // Act await component.Find("button:contains('Apply')").ClickAsync(new()); + // Assert await _databaseService.Received(1).RestoreFromBackupAsync("a.db", Arg.Any()); await _databaseService.DidNotReceive().RestoreFromBackupAsync("b.db", Arg.Any()); _bannerService.DidNotReceive().ReportError( diff --git a/src/EventLogExpert.Components.Tests/DatabaseRecoveryHostTests.cs b/src/EventLogExpert.Components.Tests/DatabaseRecoveryHostTests.cs index 7ccdd200..32821edd 100644 --- a/src/EventLogExpert.Components.Tests/DatabaseRecoveryHostTests.cs +++ b/src/EventLogExpert.Components.Tests/DatabaseRecoveryHostTests.cs @@ -25,9 +25,6 @@ public DatabaseRecoveryHostTests() _databaseService.Entries.Returns([]); _bannerService.ErrorBanners.Returns([]); - // Capture the Resolve action callback as a side-effect of any ReportError call so dialog- - // open tests can invoke it without having to re-discover it. _nextBannerId is read inside - // the Returns lambda so each test can stage a distinct id per call. _bannerService .ReportError( Arg.Any(), @@ -46,6 +43,7 @@ public DatabaseRecoveryHostTests() [Fact] public void DatabaseRecoveryHost_BannerDismissedExternally_DoesNotRepromptForSameSet() { + // Arrange var initialId = Guid.NewGuid(); _nextBannerId = initialId; _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); @@ -56,13 +54,13 @@ [new ErrorBannerEntry(initialId, "Database upgrade recovery", "...", "Resolve", Render(); _bannerService.ClearReceivedCalls(); - // User clicks the banner's X button; ErrorBanners no longer contains our id. + // Act _bannerService.ErrorBanners.Returns([]); _bannerService.StateChanged += Raise.Event(); - // Subsequent EntriesChanged tick with the SAME set must not re-prompt. _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + // Assert _bannerService.DidNotReceive().ReportError( Arg.Any(), Arg.Any(), @@ -73,6 +71,7 @@ [new ErrorBannerEntry(initialId, "Database upgrade recovery", "...", "Resolve", [Fact] public void DatabaseRecoveryHost_BannerDismissedExternally_NewBackupEntryAppears_RepromptsWithNewCount() { + // Arrange var initialId = Guid.NewGuid(); _nextBannerId = initialId; _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); @@ -83,17 +82,17 @@ [new ErrorBannerEntry(initialId, "Database upgrade recovery", "...", "Resolve", Render(); _bannerService.ClearReceivedCalls(); - // External dismissal. _bannerService.ErrorBanners.Returns([]); _bannerService.StateChanged += Raise.Event(); - // A new backup-exists entry appears later. + // Act var newId = Guid.NewGuid(); _nextBannerId = newId; _databaseService.Entries.Returns( [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + // Assert _bannerService.Received(1).ReportError( "Database upgrade recovery", "2 databases need recovery from interrupted upgrade.", @@ -104,6 +103,7 @@ [new ErrorBannerEntry(initialId, "Database upgrade recovery", "...", "Resolve", [Fact] public async Task DatabaseRecoveryHost_DialogDismissed_BannerStillVisible_DoesNotDismissBanner() { + // Arrange var initialId = Guid.NewGuid(); _nextBannerId = initialId; _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); @@ -113,17 +113,19 @@ public async Task DatabaseRecoveryHost_DialogDismissed_BannerStillVisible_DoesNo await component.InvokeAsync(() => _capturedRecoveryAction!()); + // Act await component.Find("button:contains('Cancel')").ClickAsync(new()); + // Assert Assert.Empty(component.FindAll("dialog")); - // The banner stays — host did NOT call DismissError as part of dialog dismissal. _bannerService.DidNotReceive().DismissError(Arg.Any()); } [Fact] public async Task DatabaseRecoveryHost_DialogOpen_NewBackupEntryAppears_RefreshesBannerKeepsDialogOpen() { + // Arrange var initialId = Guid.NewGuid(); _nextBannerId = initialId; _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); @@ -133,12 +135,14 @@ public async Task DatabaseRecoveryHost_DialogOpen_NewBackupEntryAppears_Refreshe Assert.Single(component.FindAll("dialog")); + // Act var newId = Guid.NewGuid(); _nextBannerId = newId; _databaseService.Entries.Returns( [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + // Assert _bannerService.Received(1).DismissError(initialId); _bannerService.Received(1).ReportError( "Database upgrade recovery", @@ -152,24 +156,24 @@ public async Task DatabaseRecoveryHost_DialogOpen_NewBackupEntryAppears_Refreshe [Fact] public void DatabaseRecoveryHost_Disposed_DismissesOwnedBanner() { + // Arrange var initialId = Guid.NewGuid(); _nextBannerId = initialId; _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); var component = Render(); + // Act component.Instance.Dispose(); - // Without this, a crash that triggers UnhandledExceptionHandler.OnErrorAsync would dispose - // the host but leave the recovery banner alive in BannerService.ErrorBanners — the new host - // mounted after Recover() would then post a second banner alongside the stale one whose - // Resolve action targets this dead instance. + // Assert _bannerService.Received(1).DismissError(initialId); } [Fact] public void DatabaseRecoveryHost_Disposed_NoLongerRespondsToEntriesChanged() { + // Arrange _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); var component = Render(); @@ -177,10 +181,12 @@ public void DatabaseRecoveryHost_Disposed_NoLongerRespondsToEntriesChanged() component.Instance.Dispose(); _bannerService.ClearReceivedCalls(); + // Act _databaseService.Entries.Returns( [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + // Assert _bannerService.DidNotReceive().DismissError(Arg.Any()); _bannerService.DidNotReceive().ReportError( Arg.Any(), @@ -192,33 +198,40 @@ public void DatabaseRecoveryHost_Disposed_NoLongerRespondsToEntriesChanged() [Fact] public void DatabaseRecoveryHost_Disposed_TwiceIsIdempotent() { + // Arrange var initialId = Guid.NewGuid(); _nextBannerId = initialId; _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); var component = Render(); + // Act component.Instance.Dispose(); component.Instance.Dispose(); + // Assert _bannerService.Received(1).DismissError(initialId); } [Fact] public void DatabaseRecoveryHost_Disposed_WithNoOwnedBanner_DoesNotCallDismiss() { + // Arrange _databaseService.Entries.Returns([]); var component = Render(); + // Act component.Instance.Dispose(); + // Assert _bannerService.DidNotReceive().DismissError(Arg.Any()); } [Fact] public void DatabaseRecoveryHost_EntriesChanged_AllRecovered_DismissesBannerAndDoesNotReprompt() { + // Arrange var initialId = Guid.NewGuid(); _nextBannerId = initialId; _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); @@ -226,9 +239,11 @@ public void DatabaseRecoveryHost_EntriesChanged_AllRecovered_DismissesBannerAndD Render(); _bannerService.ClearReceivedCalls(); + // Act _databaseService.Entries.Returns([]); _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + // Assert _bannerService.Received(1).DismissError(initialId); _bannerService.DidNotReceive().ReportError( Arg.Any(), @@ -240,18 +255,21 @@ public void DatabaseRecoveryHost_EntriesChanged_AllRecovered_DismissesBannerAndD [Fact] public void DatabaseRecoveryHost_EntriesChanged_NewBackupExistsEntry_DismissesOldBannerAndRaisesNewWithUpdatedCount() { + // Arrange var initialId = Guid.NewGuid(); _nextBannerId = initialId; _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); Render(); + // Act var newId = Guid.NewGuid(); _nextBannerId = newId; _databaseService.Entries.Returns( [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + // Assert _bannerService.Received(1).DismissError(initialId); _bannerService.Received(1).ReportError( "Database upgrade recovery", @@ -263,14 +281,16 @@ public void DatabaseRecoveryHost_EntriesChanged_NewBackupExistsEntry_DismissesOl [Fact] public void DatabaseRecoveryHost_EntriesChanged_SameBackupSet_DoesNotDismissOrReprompt() { + // Arrange _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); Render(); _bannerService.ClearReceivedCalls(); - // Same set, fresh EntriesChanged tick (e.g., unrelated entry's IsEnabled toggled). + // Act _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + // Assert _bannerService.DidNotReceive().DismissError(Arg.Any()); _bannerService.DidNotReceive().ReportError( Arg.Any(), @@ -282,6 +302,7 @@ public void DatabaseRecoveryHost_EntriesChanged_SameBackupSet_DoesNotDismissOrRe [Fact] public void DatabaseRecoveryHost_EntriesChanged_ShrinkButStillNonEmpty_DismissesOldBannerAndRaisesNewWithUpdatedCount() { + // Arrange var initialId = Guid.NewGuid(); _nextBannerId = initialId; _databaseService.Entries.Returns( @@ -289,12 +310,13 @@ public void DatabaseRecoveryHost_EntriesChanged_ShrinkButStillNonEmpty_Dismisses Render(); - // a.db gets resolved externally; b.db remains. + // Act var newId = Guid.NewGuid(); _nextBannerId = newId; _databaseService.Entries.Returns([BuildEntry("b.db", backupExists: true)]); _databaseService.EntriesChanged += Raise.Event(_databaseService, EventArgs.Empty); + // Assert _bannerService.Received(1).DismissError(initialId); _bannerService.Received(1).ReportError( "Database upgrade recovery", @@ -306,11 +328,14 @@ public void DatabaseRecoveryHost_EntriesChanged_ShrinkButStillNonEmpty_Dismisses [Fact] public void DatabaseRecoveryHost_OnInit_MultipleEntries_UsesPluralLabel() { + // Arrange _databaseService.Entries.Returns( [BuildEntry("a.db", backupExists: true), BuildEntry("b.db", backupExists: true)]); + // Act Render(); + // Assert _bannerService.Received(1).ReportError( "Database upgrade recovery", "2 databases need recovery from interrupted upgrade.", @@ -321,10 +346,13 @@ public void DatabaseRecoveryHost_OnInit_MultipleEntries_UsesPluralLabel() [Fact] public void DatabaseRecoveryHost_OnInit_WithBackupExistsEntries_RaisesErrorBanner() { + // Arrange _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); + // Act Render(); + // Assert _bannerService.Received(1).ReportError( "Database upgrade recovery", "1 database needs recovery from interrupted upgrade.", @@ -335,10 +363,13 @@ public void DatabaseRecoveryHost_OnInit_WithBackupExistsEntries_RaisesErrorBanne [Fact] public void DatabaseRecoveryHost_OnInit_WithNoBackupExistsEntries_DoesNotRaiseBanner() { + // Arrange _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: false)]); + // Act Render(); + // Assert _bannerService.DidNotReceive().ReportError( Arg.Any(), Arg.Any(), @@ -349,6 +380,7 @@ public void DatabaseRecoveryHost_OnInit_WithNoBackupExistsEntries_DoesNotRaiseBa [Fact] public async Task DatabaseRecoveryHost_ResolveActionClicked_OpensDialog() { + // Arrange _databaseService.Entries.Returns([BuildEntry("a.db", backupExists: true)]); var component = Render(); @@ -356,8 +388,10 @@ public async Task DatabaseRecoveryHost_ResolveActionClicked_OpensDialog() Assert.NotNull(_capturedRecoveryAction); Assert.Empty(component.FindAll("dialog")); + // Act await component.InvokeAsync(() => _capturedRecoveryAction!()); + // Assert Assert.Single(component.FindAll("dialog")); } diff --git a/src/EventLogExpert.Components.Tests/SettingsUpgradeProgressBannerTests.cs b/src/EventLogExpert.Components.Tests/SettingsUpgradeProgressBannerTests.cs index 54ae8ba1..f8d9fd68 100644 --- a/src/EventLogExpert.Components.Tests/SettingsUpgradeProgressBannerTests.cs +++ b/src/EventLogExpert.Components.Tests/SettingsUpgradeProgressBannerTests.cs @@ -29,36 +29,43 @@ public SettingsUpgradeProgressBannerTests() [Fact] public void SettingsUpgradeProgressBanner_BackgroundProgressOnly_RendersNothing() { - // The inline banner observes SettingsProgress only. A background-scope batch must not surface here - // (top-level BannerHost owns that slot). + // Arrange _bannerService.BackgroundProgress.Returns(BuildProgress(UpgradeProgressScope.Background)); + // Act var component = Render(); + // Assert Assert.Empty(component.FindAll("aside.settings-upgrade-banner")); } [Fact] public async Task SettingsUpgradeProgressBanner_CancelClicked_InvokesProgressCancelDelegate() { + // Arrange int cancelInvocationCount = 0; _bannerService.SettingsProgress.Returns(BuildProgress(cancel: () => cancelInvocationCount++)); + // Act var component = Render(); await component.Find("aside.settings-upgrade-banner button.banner-action").ClickAsync(new()); + // Assert Assert.Equal(1, cancelInvocationCount); } [Fact] public async Task SettingsUpgradeProgressBanner_CancelThrows_LogsViaTraceLoggerAndDoesNotPropagate() { + // Arrange _bannerService.SettingsProgress.Returns( BuildProgress(cancel: () => throw new InvalidOperationException("cts disposed"))); + // Act var component = Render(); await component.Find("aside.settings-upgrade-banner button.banner-action").ClickAsync(new()); + // Assert Assert.Single(component.FindAll("aside.settings-upgrade-banner")); _traceLogger.Received(1).Error(Arg.Is(h => @@ -69,40 +76,45 @@ public async Task SettingsUpgradeProgressBanner_CancelThrows_LogsViaTraceLoggerA [Fact] public void SettingsUpgradeProgressBanner_DisposeIsIdempotent() { + // Arrange var component = Render(); var instance = component.Instance; + // Act + Assert instance.Dispose(); instance.Dispose(); - // Two disposals must not unsubscribe twice (handler delta would go negative). - // Verified by the fact that no exception bubbles out and the component stays alive. } [Fact] public async Task SettingsUpgradeProgressBanner_DisposeUnsubscribesFromStateChanged() { + // Arrange _bannerService.SettingsProgress.Returns((BannerProgressEntry?)null); var component = Render(); component.Instance.Dispose(); - // After Dispose, raising StateChanged with a now-non-null SettingsProgress must not re-render. + // Act _bannerService.SettingsProgress.Returns(BuildProgress()); _bannerService.StateChanged += Raise.Event(); await Task.Yield(); + // Assert Assert.Empty(component.FindAll("aside.settings-upgrade-banner")); } [Fact] public void SettingsUpgradeProgressBanner_QueuedBatchesAfterOne_UsesSingularBatchLabel() { + // Arrange _bannerService.SettingsProgress.Returns(BuildProgress(queuedBatchesAfter: 1)); + // Act var component = Render(); + // Assert var subtitle = component.Find("aside.settings-upgrade-banner .banner-subtitle"); Assert.Contains("+1 batch queued", subtitle.TextContent); Assert.DoesNotContain("batches", subtitle.TextContent); @@ -111,10 +123,13 @@ public void SettingsUpgradeProgressBanner_QueuedBatchesAfterOne_UsesSingularBatc [Fact] public void SettingsUpgradeProgressBanner_QueuedBatchesAfterTwo_UsesPluralBatchesLabel() { + // Arrange _bannerService.SettingsProgress.Returns(BuildProgress(queuedBatchesAfter: 2)); + // Act var component = Render(); + // Assert var subtitle = component.Find("aside.settings-upgrade-banner .banner-subtitle"); Assert.Contains("+2 batches queued", subtitle.TextContent); } @@ -122,32 +137,40 @@ public void SettingsUpgradeProgressBanner_QueuedBatchesAfterTwo_UsesPluralBatche [Fact] public void SettingsUpgradeProgressBanner_QueuedBatchesAfterZero_DoesNotRenderSubtitle() { + // Arrange _bannerService.SettingsProgress.Returns(BuildProgress(queuedBatchesAfter: 0)); + // Act var component = Render(); + // Assert Assert.Empty(component.FindAll("aside.settings-upgrade-banner .banner-subtitle")); } [Fact] public void SettingsUpgradeProgressBanner_SettingsProgressNull_RendersNothing() { + // Arrange _bannerService.SettingsProgress.Returns((BannerProgressEntry?)null); + // Act var component = Render(); + // Assert Assert.Empty(component.FindAll("aside.settings-upgrade-banner")); } [Fact] public void SettingsUpgradeProgressBanner_SettingsProgressWithBatchSizeOne_UsesSingularDatabaseLabel() { - // Pre-first-tick rendering: empty CurrentEntryName triggers the "Preparing..." string. + // Arrange _bannerService.SettingsProgress.Returns( BuildProgress(currentBatchSize: 1, currentEntryName: string.Empty)); + // Act var component = Render(); + // Assert var banner = component.Find("aside.settings-upgrade-banner"); Assert.Contains("Preparing upgrade of 1 database", banner.TextContent); Assert.DoesNotContain("databases", banner.TextContent); @@ -156,11 +179,14 @@ public void SettingsUpgradeProgressBanner_SettingsProgressWithBatchSizeOne_UsesS [Fact] public void SettingsUpgradeProgressBanner_SettingsProgressWithEmptyEntryName_RendersPreparingMessage() { + // Arrange _bannerService.SettingsProgress.Returns( BuildProgress(currentBatchSize: 3, currentEntryName: string.Empty)); + // Act var component = Render(); + // Assert var banner = component.Find("aside.settings-upgrade-banner"); Assert.Contains("Preparing upgrade of 3 databases", banner.TextContent); Assert.DoesNotContain("Upgrading database 0", banner.TextContent); @@ -170,14 +196,17 @@ public void SettingsUpgradeProgressBanner_SettingsProgressWithEmptyEntryName_Ren public void SettingsUpgradeProgressBanner_SettingsProgressWithEntryName_RendersUpgradeProgressBannerWithCancelButton() { + // Arrange _bannerService.SettingsProgress.Returns(BuildProgress( currentBatchPosition: 2, currentBatchSize: 5, currentEntryName: "MyDb.evtx", currentPhase: UpgradePhase.MigratingSchema)); + // Act var component = Render(); + // Assert var banner = component.Find("aside.settings-upgrade-banner"); Assert.Contains("Upgrading database 2 of 5", banner.TextContent); Assert.Contains("MyDb.evtx", banner.TextContent); @@ -189,13 +218,16 @@ public void [Fact] public async Task SettingsUpgradeProgressBanner_StateChangedRaised_RerendersWithNewProgress() { + // Arrange _bannerService.SettingsProgress.Returns((BannerProgressEntry?)null); var component = Render(); Assert.Empty(component.FindAll("aside.settings-upgrade-banner")); + // Act _bannerService.SettingsProgress.Returns(BuildProgress(currentEntryName: "x.evtx")); _bannerService.StateChanged += Raise.Event(); + // Assert await component.WaitForAssertionAsync(() => Assert.Single(component.FindAll("aside.settings-upgrade-banner"))); } diff --git a/src/EventLogExpert.Components/BannerHost.razor.cs b/src/EventLogExpert.Components/BannerHost.razor.cs index ac03f1a7..4caade4c 100644 --- a/src/EventLogExpert.Components/BannerHost.razor.cs +++ b/src/EventLogExpert.Components/BannerHost.razor.cs @@ -50,8 +50,8 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { await _reloadButtonRef.FocusAsync(); } - catch (JSDisconnectedException) { } - catch (TaskCanceledException) { } + catch (JSDisconnectedException) { /* Circuit gone — nothing to focus. */ } + catch (TaskCanceledException) { /* Focus cancelled mid-render; harmless. */ } } _previousView = _currentView; @@ -72,8 +72,6 @@ private static bool ItemMatches(BannerCycleItem selected, BannerCycleItem candid return false; } - // EntryId is the stable identity for multi-entry slices (Error/Info). For singleton slices - // (Attention/UpgradeProgress/Critical) both carry null and the View match above is sufficient. return selected.EntryId == candidate.EntryId; } @@ -85,10 +83,7 @@ private async Task OnCancelUpgradeClickedAsync(BannerProgressEntry entry) } catch (Exception ex) { - // Cancel delegates close over a CancellationTokenSource; defensive catch so a disposed CTS or other - // unexpected throw does not bubble into ErrorBoundary and escalate the visible banner to Critical. - TraceLogger.Error( - $"{nameof(BannerHost)}.{nameof(OnCancelUpgradeClickedAsync)}: cancel threw: {ex}"); + TraceLogger.Error($"{nameof(BannerHost)}.{nameof(OnCancelUpgradeClickedAsync)}: cancel threw: {ex}"); } await Task.CompletedTask; @@ -122,7 +117,7 @@ private async Task OnCopyDetailsClickedAsync(Exception ex) StateHasChanged(); } } - catch (TaskCanceledException) { } + catch (TaskCanceledException) { /* Feedback cycle cancelled by next copy or dispose. */ } } private void OnCycleNext() @@ -157,8 +152,6 @@ private async Task OnErrorActionClickedAsync(Func action) } catch (Exception ex) { - // Caller-provided actions own their own error handling; we log and swallow here so a failed action - // does not bubble to ErrorBoundary and escalate the visible banner from Error to Critical. TraceLogger.Error($"{nameof(BannerHost)}.{nameof(OnErrorActionClickedAsync)}: action threw: {ex}"); } } @@ -173,7 +166,6 @@ private async Task OnOpenSettingsClickedAsync() } catch (JSDisconnectedException) { - // Circuit gone — nothing to render an error into anyway. return; } catch (TaskCanceledException) @@ -182,38 +174,22 @@ private async Task OnOpenSettingsClickedAsync() } catch (Exception ex) { - // Defensive: the IMenuActionService contract says OpenSettingsAsync catches internally and returns - // bool, but a synchronous throw before the await would still bubble. Swallow so we do not escalate - // to Critical via ErrorBoundary, and surface a recoverable error so the user knows the click did - // something. Leave the attention banner up — the underlying databases still need attention. TraceLogger.Error($"{nameof(BannerHost)}.{nameof(OnOpenSettingsClickedAsync)}: open settings threw: {ex}"); Guid errorId = BannerService.ReportError("Settings", $"Failed to open settings: {ex.Message}"); - // Steer selection to the new error so the user actually SEES the failure message instead of being - // left on the stale Attention selection (ItemMatches preserves Attention by (View, null) otherwise). - // IndexWithinSlice is a placeholder — RebuildItemsAndPickSelected refreshes it from the new snapshot. _selectedItem = new BannerCycleItem(BannerView.Error, 0, errorId); return; } if (success) { - // Dismiss attention only AFTER the modal opened. The FileName-ratchet in DismissAttention - // suppresses re-raising the banner for the SAME databases when settings closes (typical happy path: - // user enabled or removed something); NEW databases entering the attention bucket later re-raise. BannerService.DismissAttention(); } else { - // OpenSettingsAsync caught internally and returned false. Keep attention banner up so the - // underlying state stays visible, and report a recoverable error so the user knows the click - // was received but the modal failed to open. TraceLogger.Error($"{nameof(BannerHost)}.{nameof(OnOpenSettingsClickedAsync)}: open settings returned false"); Guid errorId =BannerService.ReportError("Settings", "Failed to open settings; try again from the menu."); - // Steer selection to the new error so the user actually SEES the failure message instead of being - // left on the stale Attention selection (ItemMatches preserves Attention by (View, null) otherwise). - // IndexWithinSlice is a placeholder — RebuildItemsAndPickSelected refreshes it from the new snapshot. _selectedItem = new BannerCycleItem(BannerView.Error, 0, errorId); } } @@ -253,18 +229,6 @@ private async Task OnReloadClickedAsync() private void OnStateChanged() => _ = InvokeAsync(StateHasChanged); - /// - /// Recompute the cycle list from the latest banner state and pick the displayed index. Preserves the user's - /// selection across rebuilds by matching + - /// (NOT record equality, which would also compare the volatile - /// and silently lose the selection or land on the wrong entry whenever a preceding error/info is dismissed); - /// falls back to clamping the previous index when the saved item is no longer active. Always called - /// synchronously inside the render path. The caller passes in the snapshots it captured at the top of the - /// render block so this method and the razor template index into the SAME snapshot — re-reading - /// properties here would create a window in which the cycle item's - /// could refer to a different entry than the razor's - /// captured snapshot, producing a wrong-entry render. - /// private (BannerCycleItem? Selected, BannerView View) RebuildItemsAndPickSelected( Exception? currentCritical, IReadOnlyList errors, @@ -290,11 +254,6 @@ private async Task OnReloadClickedAsync() return (null, BannerView.None); } - // Preserve the user's selection across rebuilds when possible so a state tick (e.g., upgrade progress) - // does not jolt their position. Match by (View, EntryId) so dismissals of preceding errors/infos do not - // shift the user onto a different entry whose IndexWithinSlice happens to match. If the previously - // selected item is no longer in the list, clamp to the last valid index so dismissals near the end of the - // cycle do not silently jump to the start. if (_selectedItem is not null) { for (int i = 0; i < items.Count; i++) @@ -302,8 +261,6 @@ private async Task OnReloadClickedAsync() if (!ItemMatches(_selectedItem, items[i])) { continue; } _displayedIndex = i; - // Refresh _selectedItem from the new snapshot so its IndexWithinSlice reflects the current - // slice position (callers index into the captured snapshot using IndexWithinSlice). _selectedItem = items[i]; return (_selectedItem, _selectedItem.View); diff --git a/src/EventLogExpert.Components/Base/FooterPreset.cs b/src/EventLogExpert.Components/Base/FooterPreset.cs index ab6035bc..80120c86 100644 --- a/src/EventLogExpert.Components/Base/FooterPreset.cs +++ b/src/EventLogExpert.Components/Base/FooterPreset.cs @@ -3,16 +3,11 @@ namespace EventLogExpert.Components.Base; -/// Predefined footer button layouts for . public enum FooterPreset { CloseOnly, SaveCancel, ImportExportClose, - - /// Single accept-style button — caller-provided label. Dismiss, - - /// Two buttons (accept/cancel) — caller-provided labels. AcceptCancel } diff --git a/src/EventLogExpert/Shared/Base/InputComponent.cs b/src/EventLogExpert.Components/Base/InputComponent.cs similarity index 84% rename from src/EventLogExpert/Shared/Base/InputComponent.cs rename to src/EventLogExpert.Components/Base/InputComponent.cs index 3ed82b84..a18a882b 100644 --- a/src/EventLogExpert/Shared/Base/InputComponent.cs +++ b/src/EventLogExpert.Components/Base/InputComponent.cs @@ -4,7 +4,7 @@ using EventLogExpert.UI.Models; using Microsoft.AspNetCore.Components; -namespace EventLogExpert.Shared.Base; +namespace EventLogExpert.Components.Base; public abstract class InputComponent : ComponentBase { @@ -16,9 +16,9 @@ public abstract class InputComponent : ComponentBase public DisplayConverter? DisplayConverter { get; protected set; } [Parameter] -#pragma warning disable BL0007 // Component parameters should be auto properties +#pragma warning disable BL0007 public Func ToStringFunc -#pragma warning restore BL0007 // Component parameters should be auto properties +#pragma warning restore BL0007 { get => _toStringFunc; set diff --git a/src/EventLogExpert.Components/Base/ModalChrome.razor.cs b/src/EventLogExpert.Components/Base/ModalChrome.razor.cs index b20196d3..b4896427 100644 --- a/src/EventLogExpert.Components/Base/ModalChrome.razor.cs +++ b/src/EventLogExpert.Components/Base/ModalChrome.razor.cs @@ -7,11 +7,6 @@ namespace EventLogExpert.Components.Base; -/// -/// Renders the shared <dialog> chrome (header, body, footer) for a modal. Owned by -/// via @ref. Esc/native cancel is intercepted with -/// @oncancel:preventDefault so close ordering is driven from C#. -/// public sealed partial class ModalChrome : ComponentBase, IAsyncDisposable { private readonly string _inlineAlertMessageId = $"modal-inline-alert-message-{Guid.NewGuid():N}"; @@ -30,15 +25,12 @@ public sealed partial class ModalChrome : ComponentBase, IAsyncDisposable [Parameter] public string AcceptLabel { get; set; } = "OK"; - /// Fallback accessible name when is not set. [Parameter] public string? AriaLabel { get; set; } [Parameter] public string CancelLabel { get; set; } = "Cancel"; [Parameter] public RenderFragment? ChildContent { get; set; } - /// Accessible name for the header (X) close button. Independent of so - /// localized footer labels (e.g. "Exit") don't bleed into the icon button's announcement. [Parameter] public string CloseButtonAriaLabel { get; set; } = "Close"; [Parameter] public string CloseLabel { get; set; } = "Close"; @@ -51,23 +43,16 @@ public sealed partial class ModalChrome : ComponentBase, IAsyncDisposable [Parameter] public FooterPreset Footer { get; set; } = FooterPreset.CloseOnly; - /// When true, all preset footer buttons render disabled. Useful while a long-running - /// operation triggered by the modal is in flight so the user can't re-trigger or dismiss - /// mid-action. Independent of inline-alert disable semantics. [Parameter] public bool FooterDisabled { get; set; } - /// Optional height (e.g. "60%", "40rem") exposed as var(--modal-height). [Parameter] public string? Height { get; set; } [Parameter] public string ImportLabel { get; set; } = "Import"; - /// When set, renders as a banner over an inert body/footer and Esc dismisses the alert (not the host modal). [Parameter] public InlineAlertRequest? InlineAlert { get; set; } - /// Optional max width exposed as var(--modal-max-width). [Parameter] public string? MaxWidth { get; set; } - /// Optional min width exposed as var(--modal-min-width). [Parameter] public string? MinWidth { get; set; } [Parameter] public EventCallback OnAccept { get; set; } @@ -76,14 +61,12 @@ public sealed partial class ModalChrome : ComponentBase, IAsyncDisposable [Parameter] public EventCallback OnClose { get; set; } - /// Invoked when the dialog is closed by Esc or native cancel (not a footer button). [Parameter] public EventCallback OnDialogClosedByUser { get; set; } [Parameter] public EventCallback OnExport { get; set; } [Parameter] public EventCallback OnImport { get; set; } - /// Invoked when the user resolves the active . [Parameter] public EventCallback OnInlineAlertResolved { get; set; } [Parameter] public EventCallback OnSave { get; set; } @@ -114,7 +97,6 @@ private string? DialogInlineStyle [Inject] private IJSRuntime JSRuntime { get; init; } = null!; - /// Imperatively close the dialog. Idempotent and best-effort. public async Task CloseAsync() { if (_isClosed) { return; } @@ -147,7 +129,6 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } } - // Move focus into a newly-appeared inline alert; reference-compare to avoid re-focusing. if (!ReferenceEquals(_previouslyRenderedInlineAlert, InlineAlert)) { _previouslyRenderedInlineAlert = InlineAlert; @@ -181,7 +162,6 @@ protected override async Task OnAfterRenderAsync(bool firstRender) protected override void OnParametersSet() { - // Initialize prompt value before first render so the input paints with the correct value. if (!ReferenceEquals(_inlineAlertInitializedFor, InlineAlert)) { _inlineAlertInitializedFor = InlineAlert; @@ -198,14 +178,10 @@ protected override void OnParametersSet() private Task HandleCancelButtonAsync() => OnCancel.InvokeAsync(); - // Esc fires 'cancel'. preventDefault keeps close ordering driven from C# so the - // parent's OnClosingAsync runs before the dialog is actually closed. _isClosingByCancel - // debounces repeated Esc presses while the user callback (which may surface an alert) runs. private async Task HandleCancelEventAsync() { if (_isClosed) { return; } - // Inline alert intercepts Esc; the host modal stays open. if (HasInlineAlert) { await HandleInlineAlertCancelAsync(); @@ -222,8 +198,6 @@ private async Task HandleCancelEventAsync() } finally { - // Only keep the latch while actually closing; allow Esc again if the callback bailed - // without closing (e.g. surfaced a confirmation alert). if (!_isClosed) { _isClosingByCancel = false; diff --git a/src/EventLogExpert.Components/Base/ModalChrome.razor.css b/src/EventLogExpert.Components/Base/ModalChrome.razor.css index f518a022..6d1fa111 100644 --- a/src/EventLogExpert.Components/Base/ModalChrome.razor.css +++ b/src/EventLogExpert.Components/Base/ModalChrome.razor.css @@ -1,10 +1,4 @@ -/* Shared chrome for all modal dialogs. Size is parameterized via CSS custom properties set by - the ModalChrome component (--modal-height, --modal-min-width, --modal-max-width) so each - consumer modal can override without re-theming. */ - dialog { - /* :modal default is `fit-content`. Overriding to `auto` would make the dialog stretch to - the viewport because the UA stylesheet sets `inset-block-start/end: 0` on :modal. */ height: var(--modal-height, fit-content); min-width: var(--modal-min-width, auto); max-width: var(--modal-max-width, none); @@ -14,10 +8,6 @@ dialog { background-color: var(--background-dark); } -/* Establish a containing block for the absolutely-positioned inline-alert overlay only when the - dialog is non-modal. The UA `dialog:modal` rule sets `position: fixed`, which already creates - a containing block; an author rule of `position: relative` on the bare `dialog` selector wins - over the UA rule (author origin beats UA origin) and would break default modal positioning. */ dialog:not(:modal) { position: relative; } @@ -55,20 +45,12 @@ dialog:not(:modal) { } .dialog-body { - /* Flex column so ChildContent siblings (e.g., a `.flex-column-scroll` list followed by a - Create button) lay out the way they did pre-refactor when content sat directly inside - `.dialog-group`. The body itself does not scroll — child elements opt in via - `.flex-column-scroll` or their own `overflow`. */ display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; } -/* Inline alert overlay — centered modal-in-modal. The overlay div covers the dialog surface - with a semi-transparent backdrop; the alert card is positioned above it in the center. - Host dialog content is marked `inert` by the parent component so the overlay is also the - interactive root while active. */ .inline-alert-overlay { position: absolute; inset: 0; diff --git a/src/EventLogExpert/Shared/Components/BooleanSelect.razor b/src/EventLogExpert.Components/BooleanSelect.razor similarity index 94% rename from src/EventLogExpert/Shared/Components/BooleanSelect.razor rename to src/EventLogExpert.Components/BooleanSelect.razor index 9d70e52b..bf39997a 100644 --- a/src/EventLogExpert/Shared/Components/BooleanSelect.razor +++ b/src/EventLogExpert.Components/BooleanSelect.razor @@ -1,4 +1,4 @@ -@using EventLogExpert.Shared.Base +@using EventLogExpert.Components.Base @inherits InputComponent
diff --git a/src/EventLogExpert/Shared/Components/BooleanSelect.razor.cs b/src/EventLogExpert.Components/BooleanSelect.razor.cs similarity index 91% rename from src/EventLogExpert/Shared/Components/BooleanSelect.razor.cs rename to src/EventLogExpert.Components/BooleanSelect.razor.cs index 5078d660..9972a6c0 100644 --- a/src/EventLogExpert/Shared/Components/BooleanSelect.razor.cs +++ b/src/EventLogExpert.Components/BooleanSelect.razor.cs @@ -1,10 +1,10 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. -using EventLogExpert.Shared.Base; +using EventLogExpert.Components.Base; using Microsoft.AspNetCore.Components; -namespace EventLogExpert.Shared.Components; +namespace EventLogExpert.Components; public sealed partial class BooleanSelect : InputComponent { diff --git a/src/EventLogExpert/Shared/Components/BooleanSelect.razor.css b/src/EventLogExpert.Components/BooleanSelect.razor.css similarity index 100% rename from src/EventLogExpert/Shared/Components/BooleanSelect.razor.css rename to src/EventLogExpert.Components/BooleanSelect.razor.css diff --git a/src/EventLogExpert.Components/DatabaseEntryRow.razor b/src/EventLogExpert.Components/DatabaseEntryRow.razor new file mode 100644 index 00000000..60a9219c --- /dev/null +++ b/src/EventLogExpert.Components/DatabaseEntryRow.razor @@ -0,0 +1,57 @@ +
+ + +
+ @switch (PrimaryAction) + { + case ActionKind.Toggle: + case ActionKind.DisabledToggle: + + + break; + + case ActionKind.Spinner: + + + Upgrading… + + break; + + case ActionKind.Upgrade: + + break; + + case ActionKind.Retry: + + break; + } + + +
+
diff --git a/src/EventLogExpert.Components/DatabaseEntryRow.razor.cs b/src/EventLogExpert.Components/DatabaseEntryRow.razor.cs new file mode 100644 index 00000000..b48ac2a3 --- /dev/null +++ b/src/EventLogExpert.Components/DatabaseEntryRow.razor.cs @@ -0,0 +1,67 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.UI; +using EventLogExpert.UI.Models; +using Microsoft.AspNetCore.Components; + +namespace EventLogExpert.Components; + +public sealed partial class DatabaseEntryRow : ComponentBase +{ + private enum ActionKind + { + None, + Toggle, + DisabledToggle, + Upgrade, + Retry, + Spinner + } + + [Parameter] public bool EffectiveEnabled { get; set; } + + [Parameter] public required DatabaseEntry Entry { get; set; } + + [Parameter] public bool IsClassificationPending { get; set; } + + [Parameter] public bool IsUpgradeBlocked { get; set; } + + [Parameter] public bool IsUpgrading { get; set; } + + [Parameter] public EventCallback OnRemove { get; set; } + + [Parameter] public EventCallback OnToggle { get; set; } + + [Parameter] public EventCallback OnUpgrade { get; set; } + + private string BadgeKind => Entry.BackupExists ? "Recovery" : Entry.Status.ToString(); + + private string BadgeLabel => DatabaseStatusLabels.GetRowBadgeLabel(Entry); + + private ActionKind PrimaryAction + { + get + { + if (Entry.BackupExists) { return ActionKind.None; } + + if (IsUpgrading) { return ActionKind.Spinner; } + + return Entry.Status switch + { + DatabaseStatus.Ready => + IsClassificationPending ? ActionKind.DisabledToggle : ActionKind.Toggle, + DatabaseStatus.NotClassified => ActionKind.DisabledToggle, + DatabaseStatus.UpgradeRequired => ActionKind.Upgrade, + DatabaseStatus.UpgradeFailed => ActionKind.Retry, + DatabaseStatus.UnrecognizedSchema => ActionKind.None, + DatabaseStatus.ObsoleteSchema => ActionKind.None, + DatabaseStatus.ClassificationFailed => ActionKind.None, + _ => ActionKind.None + }; + } + } + + private bool ShouldShowBadge => + Entry.BackupExists || (!IsUpgrading && Entry.Status != DatabaseStatus.Ready); +} diff --git a/src/EventLogExpert.Components/DatabaseEntryRow.razor.css b/src/EventLogExpert.Components/DatabaseEntryRow.razor.css new file mode 100644 index 00000000..bca08e46 --- /dev/null +++ b/src/EventLogExpert.Components/DatabaseEntryRow.razor.css @@ -0,0 +1,87 @@ +.db-entry-row { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 1rem; + + padding: .35rem .5rem; + border-bottom: 1px solid var(--clr-statusbar); +} + +.db-entry-row:last-child { + border-bottom: 0; +} + +.db-entry-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.db-entry-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.db-entry-badge { + align-self: flex-start; + margin-top: .15rem; + padding: .1rem .4rem; + + font-size: .85em; + background-color: var(--background-darkgray); + color: var(--clr-lightblue); + border-radius: .25rem; +} + +.db-entry-badge[data-badge="UpgradeRequired"] { + background-color: var(--clr-yellow); + color: var(--clr-on-status-fill); +} + +.db-entry-badge[data-badge="UpgradeFailed"], +.db-entry-badge[data-badge="UnrecognizedSchema"], +.db-entry-badge[data-badge="ObsoleteSchema"], +.db-entry-badge[data-badge="ClassificationFailed"], +.db-entry-badge[data-badge="Recovery"] { + background-color: var(--clr-red); + color: var(--clr-on-status-fill); +} + +.db-entry-badge[data-badge="NotClassified"] { + font-style: italic; +} + +.db-entry-actions { + display: flex; + align-items: center; + gap: .5rem; +} + +.db-entry-upgrade-btn { + white-space: nowrap; +} + +.db-entry-upgrading { + display: inline-flex; + align-items: center; + gap: .35rem; + + color: var(--clr-lightblue); + font-style: italic; +} + +.db-entry-spinner { + flex: 0 0 auto; + animation: db-entry-spin 1.5s linear infinite; +} + +.db-entry-remove-btn { + flex: 0 0 auto; +} + +@keyframes db-entry-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/src/EventLogExpert.Components/DatabaseRecoveryDialog.razor.cs b/src/EventLogExpert.Components/DatabaseRecoveryDialog.razor.cs index 7b2aac88..0865e4cc 100644 --- a/src/EventLogExpert.Components/DatabaseRecoveryDialog.razor.cs +++ b/src/EventLogExpert.Components/DatabaseRecoveryDialog.razor.cs @@ -9,20 +9,10 @@ namespace EventLogExpert.Components; -/// -/// Lets the user restore-from-backup or permanently delete each database whose upgrade was -/// interrupted (i.e., still has a .upgrade.bak sidecar on disk). The dialog chrome -/// () owns <dialog> open/close and Esc handling; this -/// component owns the per-row state and the EntriesChanged subscription so rows resolved -/// externally (or by this dialog itself) disappear live and the dialog auto-dismisses when the -/// affected set becomes empty. -/// public sealed partial class DatabaseRecoveryDialog : ComponentBase, IAsyncDisposable { - private readonly HashSet _failedFileNames = - new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _selectedActions = - new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _failedFileNames = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _selectedActions = new(StringComparer.OrdinalIgnoreCase); private ModalChrome? _chrome; private IReadOnlyList _entries = []; @@ -53,10 +43,6 @@ protected override Task OnAfterRenderAsync(bool firstRender) { if (firstRender && _entries.Count == 0) { - // The recovery set was already resolved (e.g. concurrently between the parent's mount - // decision and our first render). The conditional in the .razor never rendered the - // ModalChrome; defer the dismiss past the current render lifecycle so the parent's - // OnDismissed handler doesn't run inside our own OnAfterRenderAsync. _ = InvokeAsync(async () => { try @@ -76,8 +62,6 @@ protected override Task OnAfterRenderAsync(bool firstRender) protected override void OnInitialized() { - // Subscribe BEFORE snapshotting so any EntriesChanged that fires between the snapshot and the - // subscribe is observed (the symmetric race that BannerService also avoids). DatabaseService.EntriesChanged += OnEntriesChanged; RefreshEntriesFromService(); @@ -91,8 +75,6 @@ private async Task ApplyAsync() _isApplying = true; StateHasChanged(); - // Snapshot to iterate a stable list even if EntriesChanged fires (and mutates _entries) mid-loop - // from one of our own successful Restore/Delete calls. var rowsToProcess = _entries.ToArray(); try @@ -103,19 +85,12 @@ private async Task ApplyAsync() if (!_selectedActions.TryGetValue(fileName, out var action)) { continue; } - // Re-check current service state — concurrent recovery (e.g. another open dialog or a - // separate code path) could have resolved this row between our snapshot and now. Skip - // silently rather than calling the service and getting back a misleading - // InvalidOperationException ("entry not found"). var liveEntry = DatabaseService.Entries.FirstOrDefault(entry => string.Equals(entry.FileName, fileName, StringComparison.OrdinalIgnoreCase) && entry.BackupExists); if (liveEntry is null) { continue; } - // Clear any prior failure mark for this row before attempting; we'll re-add only if - // this attempt itself fails. Per-row scoping (instead of a global pre-clear) preserves - // failure marks for rows we don't end up attempting in this Apply. _failedFileNames.Remove(fileName); bool success; @@ -131,9 +106,6 @@ private async Task ApplyAsync() } catch (InvalidOperationException invalidOperation) { - // DatabaseService raises InvalidOperationException for "entry not found" and - // "another operation in progress". Both mean the world changed underneath us; - // treat as benign skip rather than a user-visible recovery failure. TraceLogger.Warn( $"DatabaseRecoveryDialog: skipped '{fileName}' ({action}) — {invalidOperation.Message}"); continue; @@ -161,8 +133,6 @@ private async Task ApplyAsync() { _isApplying = false; StateHasChanged(); - // EntriesChanged fired by successful Restore/Delete calls will refilter _entries; if the - // affected set ends up empty, OnEntriesChanged → DismissAsync auto-closes the dialog. } } @@ -182,10 +152,6 @@ private async Task DismissAsync() try { - // Best-effort early close so the dialog disappears immediately rather than waiting for - // the parent's re-render to unmount us. ModalChrome.CloseAsync is idempotent and never - // throws; a missing _chrome (e.g. empty-set path that never rendered the chrome) is - // also fine. if (_chrome is not null) { await _chrome.CloseAsync(); @@ -193,9 +159,6 @@ private async Task DismissAsync() } finally { - // Always notify the parent so it can remove us from the tree, even if CloseAsync threw - // unexpectedly. The conditional render then unmounts ModalChrome, whose DisposeAsync - // makes a final idempotent JS closeModal call. await OnDismissed.InvokeAsync(); } } @@ -239,14 +202,11 @@ private void RefreshEntriesFromService() snapshot.Select(entry => entry.FileName), StringComparer.OrdinalIgnoreCase); - // Default Restore for any newly-appeared entry; preserve prior selection for surviving entries. foreach (var entry in snapshot) { _selectedActions.TryAdd(entry.FileName, RecoveryAction.Restore); } - // Drop selections + failure marks for entries no longer present (they were resolved externally - // or by a previous Apply iteration). var staleSelections = _selectedActions.Keys .Where(name => !presentFileNames.Contains(name)) .ToArray(); diff --git a/src/EventLogExpert.Components/DatabaseRecoveryDialog.razor.css b/src/EventLogExpert.Components/DatabaseRecoveryDialog.razor.css index 47ffe078..07b78f2a 100644 --- a/src/EventLogExpert.Components/DatabaseRecoveryDialog.razor.css +++ b/src/EventLogExpert.Components/DatabaseRecoveryDialog.razor.css @@ -1,6 +1,3 @@ -/* Body content styling. Dialog chrome (header, footer, sizing, dialog box) is rendered by - ModalChrome — see EventLogExpert.Components.Base.ModalChrome. */ - .recovery-description { margin: 0; diff --git a/src/EventLogExpert.Components/DatabaseRecoveryHost.razor.cs b/src/EventLogExpert.Components/DatabaseRecoveryHost.razor.cs index 23acb59d..1820569d 100644 --- a/src/EventLogExpert.Components/DatabaseRecoveryHost.razor.cs +++ b/src/EventLogExpert.Components/DatabaseRecoveryHost.razor.cs @@ -8,14 +8,6 @@ namespace EventLogExpert.Components; -/// -/// App-root host that watches for entries with -/// set (i.e., interrupted-upgrade backups still on -/// disk), surfaces an error banner with a "Resolve" action, and opens the -/// when the user clicks the action. Mounted inside -/// UnhandledExceptionHandler so any crash in the host or the dialog routes through the -/// critical-error path instead of bypassing it. -/// public sealed partial class DatabaseRecoveryHost : ComponentBase, IDisposable { private bool _dialogOpen; @@ -37,22 +29,14 @@ public void Dispose() DatabaseService.EntriesChanged -= OnEntriesChanged; BannerService.StateChanged -= OnBannerStateChanged; - // Dismiss the banner we own so the host's lifetime — not the BannerService's queue — bounds - // the recovery prompt. Without this, a crash that disposes us via UnhandledExceptionHandler - // would leave a stale banner whose Resolve action targets this dead instance; after Recover - // the new host would post a second banner alongside it. - if (_recoveryBannerId is { } id) - { - BannerService.DismissError(id); - _recoveryBannerId = null; - } + if (_recoveryBannerId is not { } id) { return; } + + BannerService.DismissError(id); + _recoveryBannerId = null; } protected override void OnInitialized() { - // Subscribe BEFORE pulling initial state so an EntriesChanged tick that races with our - // OnInitialized can't be dropped between the initial Entries read and the subscription - // taking effect. DatabaseService.EntriesChanged += OnEntriesChanged; BannerService.StateChanged += OnBannerStateChanged; @@ -77,23 +61,22 @@ private void EvaluateState() } _promptedFor.Clear(); + return; } if (_recoveryBannerId is { } visibleId) { - // Banner currently visible — refresh count whenever the set changes (grow OR shrink), - // otherwise leave the banner alone so we don't churn the UI. if (currentBackupSet.SetEquals(_promptedFor)) { return; } BannerService.DismissError(visibleId); + _recoveryBannerId = ReportRecoveryBanner(currentBackupSet.Count); _promptedFor = currentBackupSet; + return; } - // No active banner: either initial state or user dismissed previously. Re-prompt only if - // the set differs from the one the user dismissed. if (currentBackupSet.SetEquals(_promptedFor)) { return; } _recoveryBannerId = ReportRecoveryBanner(currentBackupSet.Count); @@ -106,10 +89,6 @@ private void HandleBannerStateChanged() if (_recoveryBannerId is { } id && BannerService.ErrorBanners.All(banner => banner.Id != id)) { - // The banner with our recorded id is no longer in the queue, so the user dismissed it - // via the banner card's X button (the only path that mutates ErrorBanners without our - // involvement). Clear the id but KEEP _promptedFor so we don't immediately re-prompt - // for the same set on the next EntriesChanged tick. _recoveryBannerId = null; } } @@ -121,6 +100,7 @@ private void OnDialogDismissed() if (_disposed) { return; } _dialogOpen = false; + StateHasChanged(); } @@ -128,9 +108,6 @@ private void OnDialogDismissed() private Task OpenRecoveryDialogAsync() { - // BannerHost currently invokes the action callback on the renderer dispatcher, but route - // through InvokeAsync defensively so the host stays correct if a future caller invokes the - // action from a non-UI context. return InvokeAsync(() => { if (_disposed) { return; } @@ -147,10 +124,6 @@ private Guid ReportRecoveryBanner(int count) ? "1 database needs recovery from interrupted upgrade." : $"{count} databases need recovery from interrupted upgrade."; - return BannerService.ReportError( - "Database upgrade recovery", - message, - "Resolve", - OpenRecoveryDialogAsync); + return BannerService.ReportError("Database upgrade recovery", message, "Resolve", OpenRecoveryDialogAsync); } } diff --git a/src/EventLogExpert.Components/SettingsUpgradeProgressBanner.razor.cs b/src/EventLogExpert.Components/SettingsUpgradeProgressBanner.razor.cs index e9b48d25..51fb2a8d 100644 --- a/src/EventLogExpert.Components/SettingsUpgradeProgressBanner.razor.cs +++ b/src/EventLogExpert.Components/SettingsUpgradeProgressBanner.razor.cs @@ -8,11 +8,6 @@ namespace EventLogExpert.Components; -/// -/// Inline banner rendered inside SettingsModal that mirrors the top-level upgrade-progress card but -/// observes instead of the background slot. Renders nothing -/// when no settings-scope upgrade batch is in flight. -/// public sealed partial class SettingsUpgradeProgressBanner : ComponentBase, IDisposable { private bool _disposed; @@ -26,12 +21,14 @@ public void Dispose() if (_disposed) { return; } _disposed = true; + BannerService.StateChanged -= OnStateChanged; } protected override void OnInitialized() { BannerService.StateChanged += OnStateChanged; + base.OnInitialized(); } @@ -43,8 +40,7 @@ private async Task OnCancelClickedAsync(BannerProgressEntry entry) } catch (Exception ex) { - TraceLogger.Error( - $"{nameof(SettingsUpgradeProgressBanner)}.{nameof(OnCancelClickedAsync)}: cancel threw: {ex}"); + TraceLogger.Error($"{nameof(SettingsUpgradeProgressBanner)}.{nameof(OnCancelClickedAsync)}: cancel threw: {ex}"); } await Task.CompletedTask; diff --git a/src/EventLogExpert.EventDbTool/DiffDatabaseCommand.cs b/src/EventLogExpert.EventDbTool/DiffDatabaseCommand.cs index 9ce0d97a..7e572bc6 100644 --- a/src/EventLogExpert.EventDbTool/DiffDatabaseCommand.cs +++ b/src/EventLogExpert.EventDbTool/DiffDatabaseCommand.cs @@ -62,12 +62,6 @@ internal void DiffDatabase(string firstSource, string secondSource, string newDb if (!ProviderSource.TryValidate(secondSource, Logger)) { return; } - // Diff treats the first source as the authoritative skip baseline for the second source. - // If any .db file in either source is at an unrecognized or stale schema, ProviderSource - // would log the error and silently yield zero providers from that file, which would cause - // every provider in the second source to be (incorrectly) flagged as "missing from first" - // and copied to the new diff db. Validate up front so a single corrupt or obsolete .db - // anywhere in either source aborts the whole operation rather than producing a wrong result. if (!ProviderSource.ValidateSourceSchemas(firstSource, Logger)) { return; } if (!ProviderSource.ValidateSourceSchemas(secondSource, Logger)) { return; } @@ -108,10 +102,6 @@ internal void DiffDatabase(string firstSource, string secondSource, string newDb newDbContext ??= new EventProviderDbContext(newDb, false, Logger); - // ProviderSource yields fresh, non-tracked entities — Add(details) is safe and - // avoids hand-projecting fields (which silently dropped ResolvedFromOwningPublisher - // and would silently drop any future ProviderDetails member added without updating - // this projection). Matches the pattern in MergeDatabaseCommand. newDbContext.ProviderDetails.Add(details); providersCopied.Add(details); diff --git a/src/EventLogExpert.EventDbTool/MergeDatabaseCommand.cs b/src/EventLogExpert.EventDbTool/MergeDatabaseCommand.cs index d66a303e..9e1101c7 100644 --- a/src/EventLogExpert.EventDbTool/MergeDatabaseCommand.cs +++ b/src/EventLogExpert.EventDbTool/MergeDatabaseCommand.cs @@ -80,10 +80,6 @@ private void MergeDatabase(string source, string targetFile, bool overwriteProvi using var targetContext = new EventProviderDbContext(targetFile, false, Logger); - // Reject pre-V4 target databases up front. Without this gate the merge would happily - // run against an obsolete schema and either silently mis-handle case-insensitive PK - // matching or fail at the SaveChanges step. The user is expected to run the - // `upgrade` command first. var schemaState = targetContext.IsUpgradeNeeded(); if (schemaState.CurrentVersion == ProviderDatabaseSchemaVersion.Unknown) @@ -111,8 +107,6 @@ private void MergeDatabase(string source, string targetFile, bool overwriteProvi .Take(ProviderSource.MaxInClauseParameters) .ToList(); - // The ProviderName column uses NOCASE collation as of schema v4, so a plain - // IN-clause matches case-insensitively while still using the PK index. targetMatchingNames.AddRange( targetContext.ProviderDetails .AsNoTracking() @@ -165,10 +159,6 @@ private void MergeDatabase(string source, string targetFile, bool overwriteProvi // skip set is passed so all source providers are loaded and re-inserted. var skipForLoad = overwriteProviders ? null : providerNamesInTarget; - // Compute the expected copy set up front from cheap string sets so we can size the log - // header BEFORE streaming. This avoids retaining the full ProviderDetails objects in a - // summary list — those objects can be sizable for .evtx+MTA sources, defeating the - // ChangeTracker.Clear batching below. var expectedCopiedNames = skipForLoad is null ? sourceNames.ToList() : sourceNames.Where(n => !skipForLoad.Contains(n)).ToList(); @@ -186,8 +176,6 @@ private void MergeDatabase(string source, string targetFile, bool overwriteProvi foreach (var provider in ProviderSource.LoadProviders(source, Logger, filter: null, skipProviderNames: skipForLoad)) { - // ProviderSource yields fresh, non-tracked entities — Add(provider) is safe and avoids - // the redundant per-property projection that the previous implementation used. targetContext.ProviderDetails.Add(provider); pendingBatch.Add(provider); diff --git a/src/EventLogExpert.EventDbTool/ProviderSource.cs b/src/EventLogExpert.EventDbTool/ProviderSource.cs index 0da76bfa..b3cba2a4 100644 --- a/src/EventLogExpert.EventDbTool/ProviderSource.cs +++ b/src/EventLogExpert.EventDbTool/ProviderSource.cs @@ -112,16 +112,6 @@ public static bool TryValidate(string path, ITraceLogger logger) return false; } - /// - /// Returns true when every .db file enumerated from is at the current - /// schema. When any file is at an unrecognized or stale schema, an actionable error is logged for that - /// file and the method returns false. Use this before workflows where a schema-rejected source file - /// silently producing zero providers would corrupt the result (e.g., diff, where the first source - /// defines the baseline of what to skip from the second source). Single-file workflows that skip - /// individual unreadable sources can rely on the per-file gate inside - /// and friends, which logs and yields no rows for the failing file. .evtx files have no schema - /// and are ignored here. - /// public static bool ValidateSourceSchemas(string path, ITraceLogger logger) { var allOk = true; @@ -303,14 +293,6 @@ private static IEnumerable LoadNamesFromFile(string file, ITraceLogger l return []; } - /// - /// Returns true if points to a database at the current schema - /// version. Otherwise logs an explicit error and returns false. Without this gate, EF would - /// query the source with the V4 model against a V3 schema and either throw a confusing - /// DbException for the missing column or — in the names-only path — silently discover - /// names that subsequently fail to materialize as full provider rows. Distinguishes the - /// "unrecognized schema" case from the "known but stale" case so the error message is actionable. - /// private static bool IsSourceSchemaCurrent(EventProviderDbContext context, string file, ITraceLogger logger) { var state = context.IsUpgradeNeeded(); diff --git a/src/EventLogExpert.Eventing.Tests/Providers/ProviderDetailsTests.cs b/src/EventLogExpert.Eventing.Tests/Providers/ProviderDetailsTests.cs index de965e8d..1e788da0 100644 --- a/src/EventLogExpert.Eventing.Tests/Providers/ProviderDetailsTests.cs +++ b/src/EventLogExpert.Eventing.Tests/Providers/ProviderDetailsTests.cs @@ -11,98 +11,108 @@ public sealed class ProviderDetailsTests [Fact] public void IsEmpty_WhenAllCollectionsEmptyAndNoFallback_ReturnsTrue() { + // Arrange var details = new ProviderDetails { ProviderName = "Provider-Name" }; + // Act + Assert Assert.True(details.IsEmpty); } [Fact] public void IsEmpty_WhenEventsPopulated_ReturnsFalse() { + // Arrange var details = new ProviderDetails { ProviderName = "Provider-Name", Events = [new EventModel { Id = 1, LogName = "Application", Keywords = [] }] }; + // Act + Assert Assert.False(details.IsEmpty); } [Fact] public void IsEmpty_WhenKeywordsPopulated_ReturnsFalse() { + // Arrange var details = new ProviderDetails { ProviderName = "Provider-Name", Keywords = new Dictionary { { 1, "kw" } } }; + // Act + Assert Assert.False(details.IsEmpty); } [Fact] public void IsEmpty_WhenMessagesPopulated_ReturnsFalse() { + // Arrange var details = new ProviderDetails { ProviderName = "Provider-Name", Messages = [new MessageModel { ShortId = 1, RawId = 1, Text = "x" }] }; + // Act + Assert Assert.False(details.IsEmpty); } [Fact] public void IsEmpty_WhenOpcodesPopulated_ReturnsFalse() { + // Arrange var details = new ProviderDetails { ProviderName = "Provider-Name", Opcodes = new Dictionary { { 1, "op" } } }; + // Act + Assert Assert.False(details.IsEmpty); } [Fact] public void IsEmpty_WhenParametersPopulated_ReturnsFalse() { + // Arrange var details = new ProviderDetails { ProviderName = "Provider-Name", Parameters = [new MessageModel { ShortId = 1, RawId = 1, Text = "p" }] }; + // Act + Assert Assert.False(details.IsEmpty); } [Fact] public void IsEmpty_WhenResolvedFromOwningPublisherSet_ReturnsFalse() { - // A non-null ResolvedFromOwningPublisher records that a channel-owner fallback - // succeeded for this row. Even when the collections are empty, the row is NOT - // empty in the "provider not found" sense — fallback resolution did locate a - // publisher. This guards the resolver from re-attempting fallback on a row that - // has already been fallback-resolved (avoids redundant lookups and keeps the - // diagnostic field's semantics consistent with IsEmpty). + // Arrange var details = new ProviderDetails { ProviderName = "Channel/Operational", ResolvedFromOwningPublisher = "Microsoft-Windows-OwningPublisher" }; + // Act + Assert Assert.False(details.IsEmpty); } [Fact] public void IsEmpty_WhenTasksPopulated_ReturnsFalse() { + // Arrange var details = new ProviderDetails { ProviderName = "Provider-Name", Tasks = new Dictionary { { 1, "task" } } }; + // Act + Assert Assert.False(details.IsEmpty); } } diff --git a/src/EventLogExpert.Eventing/EventProviderDatabase/EventProviderDbContext.cs b/src/EventLogExpert.Eventing/EventProviderDatabase/EventProviderDbContext.cs index b444cf29..1d50c2c5 100644 --- a/src/EventLogExpert.Eventing/EventProviderDatabase/EventProviderDbContext.cs +++ b/src/EventLogExpert.Eventing/EventProviderDatabase/EventProviderDbContext.cs @@ -43,8 +43,6 @@ public EventProviderDbContext(string path, bool readOnly, bool ensureCreated, IT public ProviderDatabaseSchemaState IsUpgradeNeeded() { - // Inspect the on-disk schema via PRAGMA table_info / index_xinfo so we are robust to - // EF / SQLite text variations across versions and across upgrade levels (V1/V2/V3/V4). var connection = Database.GetDbConnection(); Database.OpenConnection(); @@ -110,19 +108,10 @@ public ProviderDatabaseSchemaState IsUpgradeNeeded() if (!hasAnyColumn) { - // No ProviderDetails table at all — could be an empty SQLite file or an unrelated - // database. Either way it is not one of our recognized schemas. Report Unknown so - // PerformUpgradeIfNeeded fails closed instead of pretending to be Current and - // letting EventResolver later crash on `ProviderDetails.FirstOrDefault(...)` - // with "no such table". currentVersion = ProviderDatabaseSchemaVersion.Unknown; } else { - // V1 and V2 stored every payload column as TEXT JSON; V3 and V4 store them all - // as compressed BLOB. A mixture of TEXT and BLOB payload columns is not any - // recognized schema and is reported as Unknown so the read path can surface a - // distinct error instead of crashing on a cast in ReadCompressedRow. var payloadColumnsAllText = IsType(messagesType, "TEXT") && IsType(eventsType, "TEXT") && IsType(keywordsType, "TEXT") && @@ -148,9 +137,6 @@ public ProviderDatabaseSchemaState IsUpgradeNeeded() } else { - // Unknown column shape — payload columns are not uniformly TEXT or BLOB, or - // Parameters disagrees with the rest. Report the Unknown sentinel so - // PerformUpgradeIfNeeded can surface a distinct "unrecognized schema" error. currentVersion = ProviderDatabaseSchemaVersion.Unknown; } @@ -178,12 +164,6 @@ public void PerformUpgradeIfNeeded() if (!state.NeedsUpgrade) { return; } - // Hard-fail before any destructive step (DROP TABLE) so the on-disk data is preserved - // when the upgrade cannot proceed. Two distinct failure modes: - // * Unknown shape — file is not a recognizable ProviderDetails database, possibly - // corrupt or from a future / incompatible version. - // * V1/V2 — pre-V3 legacy schemas are no longer supported by this build; the user - // must upgrade through an older release that supported V3 first, or delete the file. if (state.CurrentVersion == ProviderDatabaseSchemaVersion.Unknown) { throw new DatabaseUpgradeException( @@ -226,8 +206,6 @@ public void PerformUpgradeIfNeeded() } } - // Pre-DROP merge: detect case-insensitive duplicates and either merge or hard-fail. - // Throwing here (before DROP) preserves the original database contents on conflict. var merged = ProviderDetailsMerger.MergeCaseInsensitiveDuplicates(allProviderDetails, Path); using (var dropCommand = connection.CreateCommand()) @@ -314,15 +292,6 @@ private static ProviderDetails ReadCompressedRow(IDataReader reader, string prov private static bool TryDetectPrimaryKeyNoCaseCollation(DbConnection connection) { - // SQLite stores per-column collation on each index entry rather than on the column itself. - // Find the auto-generated PK index for ProviderDetails, then read the collation for the - // ProviderName entry from PRAGMA index_xinfo. - // - // Returning false on any "could not detect" branch (missing PK index, missing column entry, - // null `coll`) is intentional and safe: the caller treats false as "not V4", which triggers - // the V3->V4 upgrade path. The upgrade unconditionally DROPs and recreates the table via - // EnsureCreated, so a corrupt-or-unrecognized PK index self-heals on the next launch — there - // is no infinite-upgrade-loop risk. string? pkIndexName = null; using (var indexListCommand = connection.CreateCommand()) @@ -385,7 +354,6 @@ private static bool TryDetectPrimaryKeyNoCaseCollation(DbConnection connection) return reader.IsDBNull(i) ? null : reader.GetString(i); } - // Column not present (V3 schema) — leave null. _ = providerName; return null; diff --git a/src/EventLogExpert.Eventing/EventProviderDatabase/ProviderDatabaseSchemaVersion.cs b/src/EventLogExpert.Eventing/EventProviderDatabase/ProviderDatabaseSchemaVersion.cs index 1d8f5667..6c1e14dd 100644 --- a/src/EventLogExpert.Eventing/EventProviderDatabase/ProviderDatabaseSchemaVersion.cs +++ b/src/EventLogExpert.Eventing/EventProviderDatabase/ProviderDatabaseSchemaVersion.cs @@ -6,11 +6,5 @@ namespace EventLogExpert.Eventing.EventProviderDatabase; public static class ProviderDatabaseSchemaVersion { public const int Current = 4; - - /// - /// Sentinel used when on-disk schema detection cannot identify any known - /// ProviderDetails shape. Distinguishes "unknown / possibly corrupt" from a - /// known-but-legacy version so callers can surface a more accurate error. - /// public const int Unknown = 0; } diff --git a/src/EventLogExpert.Eventing/EventResolvers/EventResolver.cs b/src/EventLogExpert.Eventing/EventResolvers/EventResolver.cs index b500776b..ab23ea58 100644 --- a/src/EventLogExpert.Eventing/EventResolvers/EventResolver.cs +++ b/src/EventLogExpert.Eventing/EventResolvers/EventResolver.cs @@ -73,9 +73,8 @@ public void ResolveProviderDetails(EventRecord eventRecord) } catch { - // Don't poison the provider for the resolver's lifetime — clear the gate so a later - // call can retry. Use compare-remove so we don't yank a newer gate created mid-retry. RemoveGateIfSame(providerName, gate); + throw; } diff --git a/src/EventLogExpert.Eventing/Providers/ProviderDetails.cs b/src/EventLogExpert.Eventing/Providers/ProviderDetails.cs index 76c4115f..234b18ef 100644 --- a/src/EventLogExpert.Eventing/Providers/ProviderDetails.cs +++ b/src/EventLogExpert.Eventing/Providers/ProviderDetails.cs @@ -23,11 +23,6 @@ public IReadOnlyList Events } } - /// True when no provider metadata of any kind is loaded (legacy or modern) AND - /// no channel-owner fallback has populated this row. Used by resolvers to distinguish - /// "provider not found" from "provider known but no matching message". A non-null - /// means a channel-owner fallback succeeded - /// (even if the metadata collections happen to be empty), so this row is NOT empty. public bool IsEmpty => Events.Count == 0 && Messages.Count == 0 && @@ -57,15 +52,6 @@ public IReadOnlyList Messages public string ProviderName { get; set; } = string.Empty; - /// - /// When the provider name on the event was actually a channel path that the resolver - /// followed back to its owning publisher, this records the publisher name that the - /// metadata/messages were ultimately loaded from. Null when no channel-owner fallback - /// was used. Does not participate in any cache key or equality check, but does - /// contribute to so a fallback-resolved row is treated as - /// non-empty even if its metadata collections happen to be empty. - /// Persisted as of database schema V4. - /// public string? ResolvedFromOwningPublisher { get; set; } public IDictionary Tasks { get; set; } = new Dictionary(); diff --git a/src/EventLogExpert.UI.Tests/DatabaseStatusLabelsTests.cs b/src/EventLogExpert.UI.Tests/DatabaseStatusLabelsTests.cs index c3fdb6d2..46bdcdbe 100644 --- a/src/EventLogExpert.UI.Tests/DatabaseStatusLabelsTests.cs +++ b/src/EventLogExpert.UI.Tests/DatabaseStatusLabelsTests.cs @@ -2,6 +2,7 @@ // // Licensed under the MIT License. using EventLogExpert.UI; +using EventLogExpert.UI.Models; namespace EventLogExpert.UI.Tests; @@ -10,6 +11,7 @@ public sealed class DatabaseStatusLabelsTests [Fact] public void GetDisplayLabel_ClassificationFailed_ReturnsClassificationFailed() { + // Act + Assert Assert.Equal("Classification failed", DatabaseStatusLabels.GetDisplayLabel(DatabaseStatus.ClassificationFailed)); } @@ -18,8 +20,10 @@ public void GetDisplayLabel_EveryEnumValue_ReturnsNonEmptyString() { foreach (var value in Enum.GetValues()) { + // Act var label = DatabaseStatusLabels.GetDisplayLabel(value); + // Assert Assert.False(string.IsNullOrEmpty(label), $"DatabaseStatus.{value} should map to a non-empty label."); } @@ -28,44 +32,90 @@ public void GetDisplayLabel_EveryEnumValue_ReturnsNonEmptyString() [Fact] public void GetDisplayLabel_NotClassified_ReturnsClassifyingWithEllipsis() { + // Act + Assert Assert.Equal("Classifying\u2026", DatabaseStatusLabels.GetDisplayLabel(DatabaseStatus.NotClassified)); } [Fact] public void GetDisplayLabel_ObsoleteSchema_ReturnsObsolete() { + // Act + Assert Assert.Equal("Obsolete", DatabaseStatusLabels.GetDisplayLabel(DatabaseStatus.ObsoleteSchema)); } [Fact] public void GetDisplayLabel_Ready_ReturnsReady() { + // Act + Assert Assert.Equal("Ready", DatabaseStatusLabels.GetDisplayLabel(DatabaseStatus.Ready)); } [Fact] public void GetDisplayLabel_UndefinedEnumValue_ReturnsEnumString() { + // Arrange const DatabaseStatus undefined = (DatabaseStatus)999; + // Act + Assert Assert.Equal(undefined.ToString(), DatabaseStatusLabels.GetDisplayLabel(undefined)); } [Fact] public void GetDisplayLabel_UnrecognizedSchema_ReturnsUnrecognized() { + // Act + Assert Assert.Equal("Unrecognized", DatabaseStatusLabels.GetDisplayLabel(DatabaseStatus.UnrecognizedSchema)); } [Fact] public void GetDisplayLabel_UpgradeFailed_ReturnsUpgradeFailed() { + // Act + Assert Assert.Equal("Upgrade failed", DatabaseStatusLabels.GetDisplayLabel(DatabaseStatus.UpgradeFailed)); } [Fact] public void GetDisplayLabel_UpgradeRequired_ReturnsUpgradeRequired() { + // Act + Assert Assert.Equal("Upgrade required", DatabaseStatusLabels.GetDisplayLabel(DatabaseStatus.UpgradeRequired)); } + + [Fact] + public void GetRowBadgeLabel_BackupExistsFalse_DelegatesToGetDisplayLabel() + { + foreach (var status in Enum.GetValues()) + { + // Arrange + var entry = new DatabaseEntry("a.db", @"C:\dbs\a.db", IsEnabled: false, status); + + // Act + Assert + Assert.Equal( + DatabaseStatusLabels.GetDisplayLabel(status), + DatabaseStatusLabels.GetRowBadgeLabel(entry)); + } + } + + [Fact] + public void GetRowBadgeLabel_BackupExistsTrue_ReturnsRecoveryRequired_RegardlessOfStatus() + { + foreach (var status in Enum.GetValues()) + { + // Arrange + var entry = new DatabaseEntry("a.db", @"C:\dbs\a.db", IsEnabled: false, status, BackupExists: true); + + // Act + Assert + Assert.Equal("Recovery required", DatabaseStatusLabels.GetRowBadgeLabel(entry)); + } + } + + [Fact] + public void GetRowBadgeLabel_ReadyAndBackupExists_PreferRecoveryRequired_OverReady() + { + // Arrange + var entry = new DatabaseEntry("a.db", @"C:\dbs\a.db", IsEnabled: false, DatabaseStatus.Ready, BackupExists: true); + + // Act + Assert + Assert.Equal("Recovery required", DatabaseStatusLabels.GetRowBadgeLabel(entry)); + } } diff --git a/src/EventLogExpert.UI.Tests/Services/BannerViewSelectorTests.cs b/src/EventLogExpert.UI.Tests/Services/BannerViewSelectorTests.cs index b1e07b99..fbb94443 100644 --- a/src/EventLogExpert.UI.Tests/Services/BannerViewSelectorTests.cs +++ b/src/EventLogExpert.UI.Tests/Services/BannerViewSelectorTests.cs @@ -13,6 +13,7 @@ public sealed class BannerViewSelectorTests [Fact] public void BuildCycle_AllEmpty_ReturnsEmpty() { + // Act IReadOnlyList result = BannerViewSelector.BuildCycle( currentCritical: null, errorBanners: [], @@ -21,18 +22,21 @@ public void BuildCycle_AllEmpty_ReturnsEmpty() backgroundProgress: null, infoBanners: []); + // Assert Assert.Empty(result); } [Fact] public void BuildCycle_AllSlicesActive_OrdersErrorsThenAttentionThenUpgradeProgressThenInfos() { + // Arrange ErrorBannerEntry e0 = BuildError(); ErrorBannerEntry e1 = BuildError(); BannerInfoEntry i0 = BuildInfo(); BannerInfoEntry i1 = BuildInfo(); BannerInfoEntry i2 = BuildInfo(); + // Act IReadOnlyList result = BannerViewSelector.BuildCycle( currentCritical: null, errorBanners: [e0, e1], @@ -41,6 +45,7 @@ public void BuildCycle_AllSlicesActive_OrdersErrorsThenAttentionThenUpgradeProgr backgroundProgress: BuildProgress(), infoBanners: [i0, i1, i2]); + // Assert Assert.Equal(7, result.Count); Assert.Equal(new BannerCycleItem(BannerView.Error, 0, e0.Id), result[0]); Assert.Equal(new BannerCycleItem(BannerView.Error, 1, e1.Id), result[1]); @@ -54,6 +59,7 @@ public void BuildCycle_AllSlicesActive_OrdersErrorsThenAttentionThenUpgradeProgr [Fact] public void BuildCycle_AttentionDismissed_AttentionExcluded() { + // Act IReadOnlyList result = BannerViewSelector.BuildCycle( currentCritical: null, errorBanners: [], @@ -62,12 +68,14 @@ public void BuildCycle_AttentionDismissed_AttentionExcluded() backgroundProgress: null, infoBanners: []); + // Assert Assert.Empty(result); } [Fact] public void BuildCycle_AttentionEntriesEmpty_AttentionExcluded_RegardlessOfDismissedFlag() { + // Act IReadOnlyList result = BannerViewSelector.BuildCycle( currentCritical: null, errorBanners: [], @@ -76,12 +84,14 @@ public void BuildCycle_AttentionEntriesEmpty_AttentionExcluded_RegardlessOfDismi backgroundProgress: null, infoBanners: []); + // Assert Assert.Empty(result); } [Fact] public void BuildCycle_AttentionEntriesNull_Throws() { + // Act + Assert Assert.Throws( () => BannerViewSelector.BuildCycle( currentCritical: null, @@ -95,6 +105,7 @@ public void BuildCycle_AttentionEntriesNull_Throws() [Fact] public void BuildCycle_CriticalPresent_ReturnsSingleCriticalItem_RegardlessOfOtherSlices() { + // Act IReadOnlyList result = BannerViewSelector.BuildCycle( currentCritical: new InvalidOperationException("boom"), errorBanners: [BuildError(), BuildError()], @@ -103,6 +114,7 @@ public void BuildCycle_CriticalPresent_ReturnsSingleCriticalItem_RegardlessOfOth backgroundProgress: BuildProgress(), infoBanners: [BuildInfo()]); + // Assert Assert.Single(result); Assert.Equal(BannerView.Critical, result[0].View); Assert.Equal(0, result[0].IndexWithinSlice); @@ -112,6 +124,7 @@ public void BuildCycle_CriticalPresent_ReturnsSingleCriticalItem_RegardlessOfOth [Fact] public void BuildCycle_ErrorBannersNull_Throws() { + // Act + Assert Assert.Throws( () => BannerViewSelector.BuildCycle( currentCritical: null, @@ -125,6 +138,7 @@ public void BuildCycle_ErrorBannersNull_Throws() [Fact] public void BuildCycle_InfoBannersNull_Throws() { + // Act + Assert Assert.Throws( () => BannerViewSelector.BuildCycle( currentCritical: null, @@ -138,6 +152,7 @@ public void BuildCycle_InfoBannersNull_Throws() [Fact] public void BuildCycle_OnlyAttention_ReturnsSingleAttentionItem() { + // Act IReadOnlyList result = BannerViewSelector.BuildCycle( currentCritical: null, errorBanners: [], @@ -146,6 +161,7 @@ public void BuildCycle_OnlyAttention_ReturnsSingleAttentionItem() backgroundProgress: null, infoBanners: []); + // Assert Assert.Single(result); Assert.Equal(new BannerCycleItem(BannerView.Attention, 0, null), result[0]); } @@ -153,10 +169,12 @@ public void BuildCycle_OnlyAttention_ReturnsSingleAttentionItem() [Fact] public void BuildCycle_OnlyErrors_OneItemPerError_StableOrder() { + // Arrange ErrorBannerEntry e0 = BuildError(); ErrorBannerEntry e1 = BuildError(); ErrorBannerEntry e2 = BuildError(); + // Act IReadOnlyList result = BannerViewSelector.BuildCycle( currentCritical: null, errorBanners: [e0, e1, e2], @@ -165,6 +183,7 @@ public void BuildCycle_OnlyErrors_OneItemPerError_StableOrder() backgroundProgress: null, infoBanners: []); + // Assert Assert.Equal(3, result.Count); Assert.Equal(new BannerCycleItem(BannerView.Error, 0, e0.Id), result[0]); Assert.Equal(new BannerCycleItem(BannerView.Error, 1, e1.Id), result[1]); @@ -174,9 +193,11 @@ public void BuildCycle_OnlyErrors_OneItemPerError_StableOrder() [Fact] public void BuildCycle_OnlyInfos_OneItemPerInfo_StableOrder() { + // Arrange BannerInfoEntry i0 = BuildInfo(); BannerInfoEntry i1 = BuildInfo(); + // Act IReadOnlyList result = BannerViewSelector.BuildCycle( currentCritical: null, errorBanners: [], @@ -185,6 +206,7 @@ public void BuildCycle_OnlyInfos_OneItemPerInfo_StableOrder() backgroundProgress: null, infoBanners: [i0, i1]); + // Assert Assert.Equal(2, result.Count); Assert.Equal(new BannerCycleItem(BannerView.Info, 0, i0.Id), result[0]); Assert.Equal(new BannerCycleItem(BannerView.Info, 1, i1.Id), result[1]); @@ -193,6 +215,7 @@ public void BuildCycle_OnlyInfos_OneItemPerInfo_StableOrder() [Fact] public void BuildCycle_OnlyUpgradeProgress_ReturnsSingleUpgradeProgressItem() { + // Act IReadOnlyList result = BannerViewSelector.BuildCycle( currentCritical: null, errorBanners: [], @@ -201,6 +224,7 @@ public void BuildCycle_OnlyUpgradeProgress_ReturnsSingleUpgradeProgressItem() backgroundProgress: BuildProgress(), infoBanners: []); + // Assert Assert.Single(result); Assert.Equal(new BannerCycleItem(BannerView.UpgradeProgress, 0, null), result[0]); } @@ -208,10 +232,12 @@ public void BuildCycle_OnlyUpgradeProgress_ReturnsSingleUpgradeProgressItem() [Fact] public void BuildCycle_RebuildAfterErrorDismissed_PreservesEntryIdOnSurvivingError() { + // Arrange ErrorBannerEntry e0 = BuildError(); ErrorBannerEntry e1 = BuildError(); ErrorBannerEntry e2 = BuildError(); + // Act IReadOnlyList before = BannerViewSelector.BuildCycle( currentCritical: null, errorBanners: [e0, e1, e2], @@ -228,10 +254,9 @@ public void BuildCycle_RebuildAfterErrorDismissed_PreservesEntryIdOnSurvivingErr backgroundProgress: null, infoBanners: []); + // Assert Assert.Equal(3, before.Count); Assert.Equal(2, after.Count); - // After dismissing e0, e1's IndexWithinSlice shifts from 1 to 0 but its EntryId stays e1.Id — which is - // exactly what BannerHost relies on to keep a user pinned to the same logical error across rebuilds. Assert.Equal(new BannerCycleItem(BannerView.Error, 0, e1.Id), after[0]); Assert.Equal(new BannerCycleItem(BannerView.Error, 1, e2.Id), after[1]); } diff --git a/src/EventLogExpert.UI.Tests/TestUtils/DatabaseSeedUtils.cs b/src/EventLogExpert.UI.Tests/TestUtils/DatabaseSeedUtils.cs index afcdc347..e3dcd295 100644 --- a/src/EventLogExpert.UI.Tests/TestUtils/DatabaseSeedUtils.cs +++ b/src/EventLogExpert.UI.Tests/TestUtils/DatabaseSeedUtils.cs @@ -9,8 +9,6 @@ namespace EventLogExpert.UI.Tests.TestUtils; internal static class DatabaseSeedUtils { - /// Seeds the V1 ProviderDetails schema (TEXT payload columns, no Parameters column). - /// V1 is the "non-upgradable" leg — it should classify as ObsoleteSchema, NOT UpgradeRequired. internal static void SeedV1Schema(string dbPath) { using (var connection = new SqliteConnection($"Data Source={dbPath}")) @@ -32,10 +30,6 @@ internal static void SeedV1Schema(string dbPath) SqliteConnection.ClearAllPools(); } - /// Seeds the V2 ProviderDetails schema (TEXT payload columns, TEXT Parameters column). - /// V2 added Parameters as TEXT JSON before the V3 BLOB rewrite. Should also classify as - /// ObsoleteSchema per Phase B policy. Layout matches EventProviderDbContext.IsUpgradeNeeded - /// detection logic: `payloadColumnsAllText && IsType(parametersType, "TEXT")` → version 2. internal static void SeedV2Schema(string dbPath) { using (var connection = new SqliteConnection($"Data Source={dbPath}")) @@ -58,11 +52,6 @@ internal static void SeedV2Schema(string dbPath) SqliteConnection.ClearAllPools(); } - /// Seeds an empty file with the V3 ProviderDetails schema (BLOB payload columns, - /// no ResolvedFromOwningPublisher column, default BINARY collation on the PK). Built by raw - /// SQL so a subsequent EnsureCreated() sees the table as already-existing and skips its V4 - /// schema generation, exposing the legacy V3 shape to detection / upgrade paths. Mirrors - /// EventProviderDbContextTests.SeedV3Schema. internal static void SeedV3Schema(string dbPath) { using (var connection = new SqliteConnection($"Data Source={dbPath}")) @@ -82,16 +71,9 @@ internal static void SeedV3Schema(string dbPath) cmd.ExecuteNonQuery(); } - // Seed methods are used by tests that subsequently lock the file, copy/restore it, or - // delete it; SqliteConnection pooling keeps the OS handle alive after the using-block - // disposes the managed wrapper. Drop the pool here so callers can manipulate the file - // immediately. SqliteConnection.ClearAllPools(); } - /// Creates a fully-formed V4 schema by letting EnsureCreated build it on a brand-new - /// file. Returns immediately after the context is disposed — the resulting .db reflects the - /// current production schema. internal static void SeedV4Schema(string dbPath, ITraceLogger? logger = null) { using (var context = new EventProviderDbContext(dbPath, readOnly: false, ensureCreated: true, logger)) diff --git a/src/EventLogExpert.UI/DatabaseStatusLabels.cs b/src/EventLogExpert.UI/DatabaseStatusLabels.cs index 999c3465..12a9f59b 100644 --- a/src/EventLogExpert.UI/DatabaseStatusLabels.cs +++ b/src/EventLogExpert.UI/DatabaseStatusLabels.cs @@ -1,13 +1,10 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. +using EventLogExpert.UI.Models; + namespace EventLogExpert.UI; -/// -/// Maps a to the user-facing label shown next to a database entry. Returns a label -/// for every member (including ); whether to render the badge for a given -/// status is a per-screen suppression decision left to the caller. -/// public static class DatabaseStatusLabels { public static string GetDisplayLabel(DatabaseStatus status) => status switch @@ -21,4 +18,8 @@ public static class DatabaseStatusLabels DatabaseStatus.ClassificationFailed => "Classification failed", _ => status.ToString() }; + + public static string GetRowBadgeLabel(DatabaseEntry entry) => entry.BackupExists + ? "Recovery required" + : GetDisplayLabel(entry.Status); } diff --git a/src/EventLogExpert.UI/Interfaces/IAlertDialogService.cs b/src/EventLogExpert.UI/Interfaces/IAlertDialogService.cs index d15dcf41..b2650f1d 100644 --- a/src/EventLogExpert.UI/Interfaces/IAlertDialogService.cs +++ b/src/EventLogExpert.UI/Interfaces/IAlertDialogService.cs @@ -5,39 +5,19 @@ namespace EventLogExpert.UI.Interfaces; -/// -/// 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 DisplayPrompt(string title, string message); Task DisplayPrompt(string title, string message, string initialValue); - /// One-button informational alert. Uses presentation. Task ShowAlert(string title, string message, string cancel); - /// One-button informational alert with explicit presentation control. Task ShowAlert(string title, string message, string cancel, AlertPresentation presentation); - /// Two-button confirmation alert. Uses presentation. Task ShowAlert(string title, string message, string accept, string cancel); - /// - /// 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 an error 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. and - /// are passed through to the banner; both must be provided together or both omitted (see - /// for validation rules). Callers needing the new banner's id (e.g., to - /// self-dismiss from within the action) should call directly. - /// Task ShowErrorAlert(string title, string message, string? actionLabel = null, Func? action = null); } diff --git a/src/EventLogExpert.UI/Interfaces/IBannerService.cs b/src/EventLogExpert.UI/Interfaces/IBannerService.cs index da96fce5..0b53c7fc 100644 --- a/src/EventLogExpert.UI/Interfaces/IBannerService.cs +++ b/src/EventLogExpert.UI/Interfaces/IBannerService.cs @@ -5,40 +5,14 @@ namespace EventLogExpert.UI.Interfaces; -/// -/// Singleton aggregator for the app-level banner surface. Holds the current critical exception (highest priority, -/// non-dismissible), the queue of error banners (FIFO, individually dismissible), and the queue of info banners -/// (FIFO, individually dismissible). The banner host renders one card at a time by priority: critical > error -/// > 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; - /// - /// true when the user has explicitly dismissed the attention banner this session. Resets - /// automatically the next time a file enters with a name that - /// wasn't present at the last call (FileName-ratchet) so newly - /// introduced problems re-surface the banner. Resets unconditionally on app restart. - /// bool AttentionDismissed { get; } - /// - /// Snapshot of database entries whose status indicates the user must take action: not yet - /// upgraded, last upgrade failed, schema unrecognized or obsolete, or classification failed. - /// Recomputed when fires; never returns stale - /// instances even when set composition is unchanged. - /// IReadOnlyList AttentionEntries { get; } - /// - /// Snapshot of the currently-running background-scope upgrade batch (e.g., import-triggered auto-upgrades), or - /// null when no background batch is in flight. At most one of and - /// is non-null at any time because the database service processes batches - /// sequentially, but the two slots are kept separate so the top-level banner host and the inline settings banner - /// can each query their own slot without needing scope-discrimination logic. - /// BannerProgressEntry? BackgroundProgress { get; } Exception? CurrentCritical { get; } @@ -47,55 +21,24 @@ public interface IBannerService IReadOnlyList InfoBanners { get; } - /// - /// Snapshot of the currently-running settings-scope upgrade batch (triggered from the SettingsModal toggle - /// confirmation), or null when no settings batch is in flight. See for - /// scope-routing rationale. - /// BannerProgressEntry? SettingsProgress { get; } - /// Clear the current critical exception and raise . void ClearCritical(); - /// - /// Mark the attention banner dismissed and raise if the state changed. The dismissal is - /// ratcheted by file name: any future attention entry with a file name not present at this call un-dismisses the - /// banner so newly introduced problems re-surface. No-op when already dismissed. - /// void DismissAttention(); - /// Remove an error banner by id and raise . No-op if the id is not present. void DismissError(Guid id); /// Remove an info banner by id and raise . No-op if the id is not present. void DismissInfoBanner(Guid id); - /// - /// Register a callback that invokes before clearing the critical exception. 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); - /// Replace the current critical exception and raise . void ReportCritical(Exception ex); - /// - /// Append an error banner to the queue and raise . Returns the new entry's id so callers - /// can later call when the underlying issue is resolved. - /// and are optional; when both are provided the banner renders an action button between - /// the message and the dismiss button. Providing exactly one (or providing an action with a whitespace label) - /// throws — partial action state is a caller bug. The action callback owns its - /// own cleanup (e.g., dismissing the banner after the user resolves the issue). - /// Guid ReportError(string title, string message, string? actionLabel = null, Func? action = null); - /// Append an info banner to the queue and raise . void ReportInfoBanner(string title, string message, BannerSeverity severity); - /// - /// Invoke the registered recovery callback (if any), then clear the critical exception. If no callback is - /// registered, just clears the exception. - /// Task TryRecoverAsync(); } diff --git a/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs b/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs index 586045c9..66c8fddc 100644 --- a/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs +++ b/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs @@ -9,92 +9,28 @@ public interface IDatabaseService : IAsyncDisposable { event EventHandler? EntriesChanged; - /// - /// Raised once per batch after every entry has either succeeded, been cancelled, or failed. The companion - /// in the args carries the per-entry outcome plus any entries that were rejected - /// up-front (e.g., recovery-required) and never enqueued for actual upgrade work. - /// event EventHandler? UpgradeBatchCompleted; - /// - /// Raised at each phase transition for the entry currently being upgraded ( - /// → ). Every invocation carries - /// the batch identifier from the matching . - /// event EventHandler? UpgradeBatchProgress; - /// - /// Raised when the consumer task starts processing a queued upgrade batch (after any short-circuited / - /// fully-rejected batches have already returned). Subscribers receive the batch identifier (used to correlate with - /// later progress and completion events) and a hook to request - /// cancellation. Always followed by either + - /// or just if every entry fails immediately. - /// event EventHandler? UpgradeBatchStarted; IReadOnlyList Entries { get; } - /// - /// Completes once the ctor-initiated pass finishes. Guaranteed to never fault - /// or cancel — failures (per-entry or infrastructure) are absorbed and surface as - /// on the affected entry. Consumers (e.g., - /// EventLogEffects.HandleOpenLog) await this on every log open; a faulted task here would poison every - /// subsequent open for the rest of app lifetime. - /// Task InitialClassificationTask { get; } - /// - /// Snapshot count of upgrade batches accepted by that are waiting in the queue - /// and have not yet started processing. Excludes the batch (if any) currently being processed by the consumer task. - /// Updated atomically on enqueue/dequeue. - /// int QueuedBatchCount { get; } - /// - /// Inspects each on disk (in read-only mode without auto-creating a schema) and - /// updates its from the on-disk schema version. Per-entry classification failures - /// (e.g., file locked, file missing) leave that entry at its current status and continue with the rest. Raises - /// exactly once when the pass completes. - /// Task ClassifyEntriesAsync(CancellationToken cancellationToken = default); - /// - /// Deletes the -journal/-wal/-shm sidecars and any .upgrade.bak backup, then deletes - /// the main database file last (so a partial failure leaves the entry visible to and - /// retry-able), then removes the entry from . Returns false on any IO failure (logged); - /// the entry is left in place so the caller can surface the error and retry. User-created .bak files (not the - /// .upgrade.bak marker) are never touched. - /// Task DeleteEntryWithBackupAsync(string fileName, CancellationToken cancellationToken = default); - /// - /// Reads a .zip archive without extracting and returns the names of contained .db entries (the same - /// entries that would extract). Used by callers - /// (e.g., the settings dialog) to pre-scan zip contents for filename conflicts before invoking the import. A malformed - /// or unreadable archive is logged and returns an empty list — the actual import call will surface the failure as an - /// in . - /// Task> EnumerateZipDbEntryNamesAsync( string sourceZipPath, CancellationToken cancellationToken = default); Task ImportAsync(IEnumerable sourceFilePaths, CancellationToken cancellationToken = default); - /// - /// Imports the supplied database files, skipping any whose file name appears in - /// (case-insensitive). Skipped files are not counted as imports, do not appear as failures, and do not affect existing - /// on-disk databases — the caller is expected to have already shown the user a conflict-resolution prompt and to have - /// populated with the user's "skip" decisions. Files NOT in the skip set are - /// copied/extracted with overwrite semantics (per-name reservations gate against concurrent upgrade/restore/remove on - /// the same name). Newly-added entries (file names not previously present in ) are persisted as - /// disabled before classification so the published snapshot is correct on the first - /// notification. Re-imports of existing names preserve the user's prior choice. - /// After classification any imported entries (new or overwritten) whose status is - /// are auto-upgraded via a single call - /// with . Per-entry upgrade failures are mapped to - /// ; cancelled upgrades roll back to - /// and are not surfaced as failures. - /// Task ImportAsync( IEnumerable sourceFilePaths, IReadOnlySet skipFileNames, @@ -106,28 +42,10 @@ Task ImportAsync( void Remove(string fileName); - /// - /// Deletes any stale -journal/-wal/-shm sidecars first (their presence paired with a - /// restored older main would let SQLite roll forward stale transactions into the restored database on next open), then - /// restores the main database file from its .upgrade.bak backup, then deletes the backup. The backup is - /// preserved if any sidecar cleanup fails so the caller can retry. Re-classifies the entry so consumers observe the - /// post-restore state via . Returns false on any IO failure (logged). - /// Task RestoreFromBackupAsync(string fileName, CancellationToken cancellationToken = default); void Toggle(string fileName); - /// - /// Enqueues an upgrade batch for the named entries and returns a task that completes when the batch has been - /// processed. Batches are processed sequentially in FIFO order by a single consumer task; callers may safely invoke - /// this concurrently from multiple threads. Entries are filtered up-front: those whose status is not - /// or , or whose - /// is true (recovery required), or that are unknown to the service, are - /// returned in the result's list with a descriptive reason and never reach - /// the consumer. If every entry is filtered out, the call returns immediately without raising any events. Cancelling - /// after the batch has been enqueued causes the in-flight entry to roll back - /// from its .upgrade.bak backup; previously completed entries in the same batch keep their upgraded state. - /// Task UpgradeBatchAsync( IReadOnlyList fileNames, UpgradeProgressScope scope, diff --git a/src/EventLogExpert.UI/Interfaces/IMenuActionService.cs b/src/EventLogExpert.UI/Interfaces/IMenuActionService.cs index 12e64b38..e8c32532 100644 --- a/src/EventLogExpert.UI/Interfaces/IMenuActionService.cs +++ b/src/EventLogExpert.UI/Interfaces/IMenuActionService.cs @@ -3,11 +3,6 @@ namespace EventLogExpert.UI.Interfaces; -/// -/// Platform-neutral surface invoked by the Blazor menu bar and keyboard shortcut handler. -/// Returns only primitives — never MAUI/WinUI/MenuItem types — so the menu UI can be unit-tested -/// and the platform implementation can evolve independently. -/// public interface IMenuActionService { Task CheckForUpdatesAsync(); diff --git a/src/EventLogExpert.UI/Models/UpgradeBatchProgressEventArgs.cs b/src/EventLogExpert.UI/Models/UpgradeBatchProgressEventArgs.cs index 26abc26e..b6d8452e 100644 --- a/src/EventLogExpert.UI/Models/UpgradeBatchProgressEventArgs.cs +++ b/src/EventLogExpert.UI/Models/UpgradeBatchProgressEventArgs.cs @@ -11,6 +11,5 @@ public sealed class UpgradeBatchProgressEventArgs(Guid batchId, int position, st public UpgradePhase Phase { get; } = phase; - /// One-based index of within its batch. public int Position { get; } = position; } diff --git a/src/EventLogExpert.UI/Models/UpgradeBatchStartedEventArgs.cs b/src/EventLogExpert.UI/Models/UpgradeBatchStartedEventArgs.cs index 1f2979ef..721a86c6 100644 --- a/src/EventLogExpert.UI/Models/UpgradeBatchStartedEventArgs.cs +++ b/src/EventLogExpert.UI/Models/UpgradeBatchStartedEventArgs.cs @@ -17,12 +17,6 @@ public sealed class UpgradeBatchStartedEventArgs( public UpgradeProgressScope Scope { get; } = scope; - /// - /// Cancels the batch by signaling the underlying . Safe to call after the - /// batch has completed: the underlying source may already be disposed by the consumer task, in which case the - /// resulting is swallowed because cancellation arriving after completion is a - /// no-op. - /// public void Cancel() { try diff --git a/src/EventLogExpert.UI/Services/BannerService.cs b/src/EventLogExpert.UI/Services/BannerService.cs index 6d0285ea..8d64632b 100644 --- a/src/EventLogExpert.UI/Services/BannerService.cs +++ b/src/EventLogExpert.UI/Services/BannerService.cs @@ -36,10 +36,6 @@ public BannerService(IDatabaseService databaseService, ITraceLogger traceLogger) _databaseService.UpgradeBatchProgress += OnUpgradeBatchProgress; _databaseService.UpgradeBatchCompleted += OnUpgradeBatchCompleted; - // Subscribe to EntriesChanged FIRST, then invoke the handler manually for the initial pull. - // Pull-then-subscribe leaves a race where an EntriesChanged firing between pull and - // subscribe would be missed. Subscribe-then-invoke is idempotent: if a real EntriesChanged - // races with the manual call, both serialize on _stateLock and converge to the latest state. _databaseService.EntriesChanged += OnEntriesChanged; OnEntriesChanged(this, EventArgs.Empty); } @@ -101,9 +97,6 @@ public void DismissAttention() if (changed) { _attentionDismissed = true; - // Snapshot the file names that were in attention at dismissal time. The handler - // un-dismisses if any future attention entry has a file name not in this snapshot - // (FileName-ratchet) so that newly-introduced problems re-surface the banner. _dismissedAttentionFileNames = _attentionEntries .Select(entry => entry.FileName) .ToImmutableHashSet(); @@ -234,8 +227,6 @@ public async Task TryRecoverAsync() lock (_stateLock) { - // Only clear if the critical exception is still the one we set out to recover. If a newer one - // was reported while the callback was running, leave it visible so the user sees the new state. if (ReferenceEquals(_currentCritical, snapshotCritical)) { _currentCritical = null; @@ -252,7 +243,7 @@ public async Task TryRecoverAsync() private static void SafeLog(Action log) { try { log(); } - catch { /* Ignore */ } + catch { /* Logger faults must not propagate from defensive logging sites. */ } } private void AssignProgressSlot(UpgradeProgressScope scope, BannerProgressEntry entry) @@ -276,13 +267,6 @@ private void OnEntriesChanged(object? sender, EventArgs args) lock (_stateLock) { - // Read AND filter under the lock so that two handlers running concurrently can never - // produce out-of-order assignments. Without the lock here, an older handler (still - // computing newAttention from a stale Entries snapshot) could acquire the lock AFTER - // a newer handler had already applied the latest snapshot, overwriting the newer - // state with the older one. _databaseService.Entries is an atomic ref read of an - // immutable list and the filter is small and cheap (entries count is bounded by the - // user's database list), so doing this work under the lock is safe and necessary. ImmutableList newAttention = _databaseService.Entries .Where(entry => entry.Status is DatabaseStatus.UpgradeRequired or DatabaseStatus.UpgradeFailed @@ -291,8 +275,6 @@ or DatabaseStatus.ObsoleteSchema or DatabaseStatus.ClassificationFailed) .ToImmutableList(); - // Always assign the latest list so AttentionEntries never returns stale DatabaseEntry - // instances, even when the set composition (by file name) is unchanged. stateChanged = !_attentionEntries.SequenceEqual(newAttention); _attentionEntries = newAttention; @@ -305,9 +287,6 @@ or DatabaseStatus.ObsoleteSchema continue; } - // FileName-ratchet: a previously-unseen attention entry means new info - // for the user, so un-dismiss and clear the snapshot. Subsequent - // DismissAttention() calls re-snapshot from the then-current set. _attentionDismissed = false; _dismissedAttentionFileNames = ImmutableHashSet.Empty; stateChanged = true; @@ -411,8 +390,6 @@ private void RaiseStateChanged() if (handler is null) { return; } - // Iterate the invocation list so one throwing subscriber does not block subsequent ones — - // a direct multicast Invoke aborts the chain on first exception. foreach (var subscriber in handler.GetInvocationList()) { try diff --git a/src/EventLogExpert.UI/Services/BannerViewSelector.cs b/src/EventLogExpert.UI/Services/BannerViewSelector.cs index 0e503d4b..dd67d16f 100644 --- a/src/EventLogExpert.UI/Services/BannerViewSelector.cs +++ b/src/EventLogExpert.UI/Services/BannerViewSelector.cs @@ -15,32 +15,10 @@ public enum BannerView Info } -/// -/// One step in the BannerHost's flat cycle. identifies the rendered card type, -/// identifies which entry within a multi-entry slice (Error/Info) is shown, -/// and carries the underlying entry's stable identifier for Error/Info items so the -/// host can preserve a user's selection across rebuilds even when preceding entries are dismissed (which -/// would otherwise shift beneath them). Singleton slices (Critical, -/// Attention, UpgradeProgress) carry IndexWithinSlice = 0 and EntryId = null because the -/// alone is a stable identity for them. -/// public sealed record BannerCycleItem(BannerView View, int IndexWithinSlice, Guid? EntryId); public static class BannerViewSelector { - /// - /// Builds the flat ordered list of items the user can cycle through in BannerHost. Each error and each info - /// contributes its own item; Attention and UpgradeProgress contribute one item each when active. Critical - /// LOCKS the cycle to a single-item list so the user cannot dismiss-by-cycling past an unresolved critical; - /// other slices never combine with Critical. - /// - /// - /// Order within the cycle: errors -> attention -> upgrade-progress -> infos. Critical, when present, - /// pre-empts the entire list. The order is stable so the BannerHost can preserve the user's selection across - /// rebuilds by matching the / pair - /// (record equality is intentionally NOT used for that match because - /// legitimately shifts when preceding entries are dismissed). - /// public static IReadOnlyList BuildCycle( Exception? currentCritical, IReadOnlyList errorBanners, diff --git a/src/EventLogExpert.UI/Services/DatabaseService.cs b/src/EventLogExpert.UI/Services/DatabaseService.cs index 0c6fe317..90f6da79 100644 --- a/src/EventLogExpert.UI/Services/DatabaseService.cs +++ b/src/EventLogExpert.UI/Services/DatabaseService.cs @@ -71,8 +71,6 @@ public DatabaseService( public async Task ClassifyEntriesAsync(CancellationToken cancellationToken = default) { - // Snapshot under the lock so concurrent Refresh/Toggle/Remove calls cannot trigger an - // index-out-of-range during the IO pass below. ImmutableList snapshot; lock (_mutationLock) @@ -82,8 +80,6 @@ public async Task ClassifyEntriesAsync(CancellationToken cancellationToken = def if (snapshot.Count == 0) { return; } - // Whole pass runs on a worker thread because IsUpgradeNeeded performs synchronous SQLite IO - // (PRAGMA queries via blocking ADO.NET). Driving it from the UI thread would block render. var statuses = await Task.Run( () => { @@ -96,11 +92,6 @@ public async Task ClassifyEntriesAsync(CancellationToken cancellationToken = def try { - // Empty/missing files would otherwise classify as Ready (IsUpgradeNeeded sees - // no columns → currentVersion=Current → no upgrade needed). EventResolver - // would then call EnsureCreated on first access and silently rewrite the - // file with a V4 schema. Force these into UnrecognizedSchema so they are - // quarantined and surfaced in Settings instead of being mutated invisibly. if (!File.Exists(entry.FullPath) || new FileInfo(entry.FullPath).Length == 0) { perFile[entry.FileName] = (DatabaseStatus.UnrecognizedSchema, false); @@ -108,9 +99,11 @@ public async Task ClassifyEntriesAsync(CancellationToken cancellationToken = def continue; } - // ensureCreated:false so a missing/empty file is NOT auto-populated with the V4 schema - // during inspection — that would silently mask the very thing classification is for. - using var context = new EventProviderDbContext(entry.FullPath, false, false, _traceLogger); + using var context = new EventProviderDbContext( + entry.FullPath, + readOnly: false, + ensureCreated: false, + logger: _traceLogger); var state = context.IsUpgradeNeeded(); var status = MapSchemaVersionToStatus(state.CurrentVersion); @@ -137,8 +130,6 @@ public async Task ClassifyEntriesAsync(CancellationToken cancellationToken = def lock (_mutationLock) { - // Re-resolve indexes against the current snapshot — Refresh/Remove may have run while - // classification was on the worker thread. var builder = _entries.ToBuilder(); for (var i = 0; i < builder.Count; i++) @@ -185,8 +176,6 @@ public async Task DeleteEntryWithBackupAsync(string fileName, Cancellation var index = FindEntryIndex(_entries, fileName); ImmutableList nextSnapshot = index >= 0 ? _entries.RemoveAt(index) : _entries; - // Always re-persist even if a concurrent Refresh already removed the entry — otherwise a - // stale fileName lingers in DisabledDatabasesPreference and a re-import comes back disabled. PersistDisabled(nextSnapshot); removed = index >= 0; @@ -285,8 +274,6 @@ public async Task ImportAsync( ArgumentNullException.ThrowIfNull(sourceFilePaths); ArgumentNullException.ThrowIfNull(skipFileNames); - // Enforce the documented case-insensitive skip semantics regardless of the comparer the - // caller chose for their set (defaults to case-sensitive Ordinal otherwise). var skipSet = skipFileNames as HashSet is { Comparer: var comparer } && comparer.Equals(StringComparer.OrdinalIgnoreCase) ? skipFileNames @@ -365,8 +352,6 @@ public async Task ImportAsync( return new ImportResult(importedCount, failures, []); } - // Persist freshly-imported names as disabled BEFORE Refresh so the next snapshot is correct - // first time and consumers don't observe a transient enabled→disabled flicker. if (freshlyImportedNames.Count > 0) { lock (_mutationLock) @@ -526,8 +511,6 @@ public async Task RestoreFromBackupAsync(string fileName, CancellationToke } catch (Exception ex) { - // Restore already succeeded on disk; absorb any post-classify failure (incl. cancellation) - // so the caller's success signal is preserved. The next entries access re-reads disk anyway. SafeLog(() => _traceLogger.Warn( $"{nameof(DatabaseService)}.{nameof(RestoreFromBackupAsync)} post-restore classification failed: {ex}")); } @@ -573,8 +556,6 @@ public async Task UpgradeBatchAsync( return new UpgradeBatchResult([], dedupedNames, []); } - // Awaited explicitly so callers that race the ctor get a meaningful classification before - // partitioning, rather than a batch full of false "NotClassified" rejections. try { await InitialClassificationTask.ConfigureAwait(false); @@ -651,8 +632,6 @@ public async Task UpgradeBatchAsync( return await tcs.Task.ConfigureAwait(false); } - // Channel already completed (disposal raced this enqueue). Surface the work as cancelled - // and roll back the counter increment so QueuedBatchCount reflects reality. Interlocked.Decrement(ref _queuedBatchCount); batchCts.Dispose(); @@ -692,7 +671,7 @@ private static DatabaseStatus MapSchemaVersionToStatus(int currentVersion) => private static void SafeLog(Action log) { try { log(); } - catch { /* Ignore */ } + catch { /* Logger faults must not propagate from defensive logging sites. */ } } private static IEnumerable SortDatabases(IEnumerable databases) @@ -704,10 +683,6 @@ private static IEnumerable SortDatabases(IEnumerable databases) return databases .Select(name => { - // Strip the .db extension before applying the version regex so that "Server 20.db" - // splits into ("Server ", "20") instead of ("Server ", "20.db") — otherwise the - // version capture never parses as a number and numeric sort falls back to - // lexicographic ("Server 2.db" < "Server 20.db" but "Server 20.db" > "Server 10.db"). var nameWithoutExt = Path.GetFileNameWithoutExtension(name); var match = splitter.Match(nameWithoutExt); @@ -757,8 +732,6 @@ private static void WalCheckpoint(string dbPath) cmd.ExecuteNonQuery(); } - // Drop the pool so the OS handle is released BEFORE File.Copy reads the file. Otherwise on - // Windows the copy can race with the pooled connection still holding the handle. SqliteConnection.ClearAllPools(); } @@ -808,7 +781,6 @@ private bool DeleteFilesCore(DatabaseEntry entry) if (!TryDeleteFile(mainPath + "-shm", nameof(DeleteEntryWithBackupAsync))) { return false; } return TryDeleteFile(mainPath + UpgradeBackupSuffix, nameof(DeleteEntryWithBackupAsync)) && - // Delete main last so a partial failure leaves the entry visible to Refresh and retry-able. TryDeleteFile(mainPath, nameof(DeleteEntryWithBackupAsync)); } @@ -819,7 +791,7 @@ private void DrainPendingBatches() Interlocked.Decrement(ref _queuedBatchCount); try { batch.BatchCts.Cancel(); } - catch (Exception) { /* CTS may already be disposed; cancellation moot */ } + catch (Exception) { /* CTS may already be disposed; cancellation moot. */ } try { batch.BatchCts.Dispose(); } catch (Exception) { /* Already disposed; ignore. */ } @@ -966,8 +938,6 @@ private bool ProbeOrCleanupBackup(DatabaseEntry entry, DatabaseStatus status) } catch (Exception ex) when (ex is not OperationCanceledException) { - // Conservative on probe failure: surface a possibly-spurious recovery prompt - // rather than silently denying the user a chance to restore from .upgrade.bak. SafeLog(() => _traceLogger.Warn( $"{nameof(DatabaseService)}.{nameof(ProbeOrCleanupBackup)} probe failed for '{entry.FileName}': {ex}")); @@ -977,8 +947,6 @@ private bool ProbeOrCleanupBackup(DatabaseEntry entry, DatabaseStatus status) if (status == DatabaseStatus.Ready) { - // V4 is the canonical post-upgrade state; an existing .upgrade.bak is stale - // (we crashed between rename and cleanup). File.Delete is a no-op if absent. try { File.Delete(backupPath); @@ -1101,8 +1069,6 @@ private void RaiseEntriesChanged() if (handler is null) { return; } - // Iterate the invocation list so one throwing subscriber does not block subsequent ones — - // a direct multicast Invoke aborts the chain on first exception. foreach (var subscriber in handler.GetInvocationList()) { try @@ -1152,9 +1118,6 @@ private bool RestoreFilesCore(DatabaseEntry entry) return false; } - // Sidecars must be removed before main is overwritten. Otherwise a partial failure leaves a - // restored V3 main paired with stale V4 sidecars, which SQLite can roll forward into the - // restored database on next open. if (!TryDeleteFile(mainPath + "-journal", nameof(RestoreFromBackupAsync))) { return false; } if (!TryDeleteFile(mainPath + "-wal", nameof(RestoreFromBackupAsync))) { return false; } @@ -1181,8 +1144,6 @@ private void SafeRaise(EventHandler? handler, TArgs args, string e { if (handler is null) { return; } - // Iterate the invocation list so one throwing subscriber does not block subsequent ones — - // a direct multicast Invoke aborts the chain on first exception. foreach (var subscriber in handler.GetInvocationList()) { try @@ -1205,7 +1166,6 @@ private async Task StartInitialClassificationAsync() } catch (Exception ex) { - // Honor InitialClassificationTask never-fault contract; absorbs subscriber throws. SafeLog(() => _traceLogger.Warn( $"{nameof(DatabaseService)}.{nameof(StartInitialClassificationAsync)}: initial classification failed: {ex}")); } @@ -1255,8 +1215,6 @@ private async Task UpgradeAsync(string fileName, int position, Guid batchId, Can { cancellationToken.ThrowIfCancellationRequested(); - // Reserve before any file checks so concurrent Remove/RestoreFromBackup/DeleteEntryWithBackup - // calls observe an in-flight operation and cannot race the precheck → Task.Run window. using var reservation = ReserveFileOperation(fileName, nameof(UpgradeAsync)); var entry = LookupEntryOrThrow(fileName, nameof(UpgradeAsync)); @@ -1273,12 +1231,8 @@ private async Task UpgradeAsync(string fileName, int position, Guid batchId, Can var backupPath = entry.FullPath + UpgradeBackupSuffix; - // TOCTOU re-check immediately before backup; the explicit check distinguishes the - // "backup already on disk" case from a generic IO failure later in File.Copy. if (File.Exists(backupPath)) { - // Surface the disk truth on the entry so the recovery host can prompt without waiting - // for the next classification pass. UpdateEntryStatusAndBackup(fileName, entry.Status, true); throw new InvalidOperationException("Recovery required — .upgrade.bak already present"); @@ -1309,10 +1263,6 @@ await Task.Run( } catch (IOException ex) when (File.Exists(backupPath)) { - // TOCTOU: backup file appeared between the precheck above and File.Copy. - // Surface as recovery-required for consistency with the explicit precheck - // rather than letting the raw IOException leak. Reflect the disk truth on - // the entry so the recovery host can prompt immediately. UpdateEntryStatusAndBackup(fileName, entry.Status, true); throw new InvalidOperationException( @@ -1331,7 +1281,11 @@ await Task.Run( migrationStarted = true; - using (var context = new EventProviderDbContext(entry.FullPath, false, false, _traceLogger)) + using (var context = new EventProviderDbContext( + entry.FullPath, + readOnly: false, + ensureCreated: false, + logger: _traceLogger)) { context.PerformUpgradeIfNeeded(); } @@ -1339,10 +1293,6 @@ await Task.Run( migrationCompleted = true; SqliteConnection.ClearAllPools(); - // Late-cancellation cutoff: once migration completes (destructive work done), - // do NOT honor cancellation for THIS entry — finish verify+cleanup so the - // successful schema rewrite is not wasted on a rollback. Remaining entries in - // the batch will still observe cancellation in ProcessBatchAsync. SafeRaise( UpgradeBatchProgress, new UpgradeBatchProgressEventArgs(batchId, position, fileName, UpgradePhase.Verifying), @@ -1355,8 +1305,6 @@ await Task.Run( if (!TryDeleteFile(backupPath, nameof(UpgradeAsync))) { - // Backup deletion failed but data is upgraded; leave .bak on disk so the - // user can manually recover, and surface so the recovery host can prompt. UpdateEntryStatusAndBackup(fileName, DatabaseStatus.Ready, true); throw new UpgradeCleanupFailedException( @@ -1367,9 +1315,6 @@ await Task.Run( } catch (UpgradeCleanupFailedException) { - // Entry is already in the correct state (Ready + BackupExists=true). Skip the - // generic catch (Exception) below that would treat this as a post-migration - // failure and roll back the successful upgrade by restoring from .upgrade.bak. throw; } catch (OperationCanceledException) @@ -1406,9 +1351,6 @@ await Task.Run( } else if (migrationCompleted) { - // Verify or post-migration cleanup failed; main file IS in a different - // shape than the backup represents, so rollback puts the disk back in a - // pre-upgrade state. if (!RestoreFilesCore(entry)) { UpdateEntryStatusAndBackup(fileName, DatabaseStatus.UpgradeFailed, true); @@ -1421,9 +1363,6 @@ await Task.Run( } else if (backupCreated) { - // Failure happened before migration touched main; backup is the only - // residue. Best-effort delete leaves status untouched so the entry - // remains retry-able as UpgradeRequired. TryDeleteFile(backupPath, nameof(UpgradeAsync)); } @@ -1437,7 +1376,11 @@ private bool VerifyEntryReady(string fullPath) { try { - using var context = new EventProviderDbContext(fullPath, true, false, _traceLogger); + using var context = new EventProviderDbContext( + fullPath, + readOnly: true, + ensureCreated: false, + logger: _traceLogger); return context.IsUpgradeNeeded().CurrentVersion == ProviderDatabaseSchemaVersion.Current; } diff --git a/src/EventLogExpert.UI/Services/ModalAlertDialogService.cs b/src/EventLogExpert.UI/Services/ModalAlertDialogService.cs index 55d916ef..f1a8b228 100644 --- a/src/EventLogExpert.UI/Services/ModalAlertDialogService.cs +++ b/src/EventLogExpert.UI/Services/ModalAlertDialogService.cs @@ -6,14 +6,6 @@ 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. 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, diff --git a/src/EventLogExpert.UI/Store/EventLog/EventLogEffects.cs b/src/EventLogExpert.UI/Store/EventLog/EventLogEffects.cs index f7f9b9a7..9b3fa388 100644 --- a/src/EventLogExpert.UI/Store/EventLog/EventLogEffects.cs +++ b/src/EventLogExpert.UI/Store/EventLog/EventLogEffects.cs @@ -127,13 +127,10 @@ public Task HandleCloseAll(IDispatcher dispatcher) [EffectMethod] public Task HandleCloseLog(EventLogAction.CloseLog action, IDispatcher dispatcher) { - // Only cancel — don't remove or dispose. HandleOpenLog's finally block - // owns CTS disposal after the load has fully unwound, avoiding a race - // where disposal here causes ObjectDisposedException in the running load. if (_logCts.TryGetValue(action.LogId, out var cts)) { try { cts.Cancel(); } - catch (ObjectDisposedException) { } + catch (ObjectDisposedException) { /* CTS already disposed; cancel is moot. */ } } _logWatcherService.RemoveLog(action.LogName); @@ -174,10 +171,6 @@ public Task HandleLoadEvents(EventLogAction.LoadEvents action, IDispatcher dispa .Where(e => e.RecordId.HasValue && pending.SelectedIds.Contains(e.RecordId.Value)) .ToList(); - // SelectedEvent (focus cursor) is tracked independently of SelectedEvents, - // so resolve the focused row from action.Events directly — it may not be - // a member of restored (e.g., user Ctrl+clicked to toggle a row off but - // kept it as the focus cursor). DisplayEventModel? selectedRestored = pending.SelectedId.HasValue ? action.Events.FirstOrDefault(e => e.RecordId == pending.SelectedId.Value) : null; @@ -213,14 +206,8 @@ public Task HandleLoadNewEvents(IDispatcher dispatcher) [EffectMethod] public async Task HandleOpenLog(EventLogAction.OpenLog action, IDispatcher dispatcher) { - // Snapshot the cancel generation FIRST. If CancelAllLoads fires between this - // snapshot and the CTS link below — including the new window introduced by - // the InitialClassificationTask await — the self-cancel check below will - // observe the generation drift and cancel our load. long startGeneration = Volatile.Read(ref _cancelGeneration); - // Cheap exit when the log is no longer in ActiveLogs (caller raced this open - // against a CloseLog) — skip scope creation and classification waits entirely. if (!_eventLogState.Value.ActiveLogs.TryGetValue(action.LogName, out var logData)) { dispatcher.Dispatch(new StatusBarAction.SetResolverStatus($"Error: Failed to open {action.LogName}")); @@ -228,10 +215,6 @@ public async Task HandleOpenLog(EventLogAction.OpenLog action, IDispatcher dispa return; } - // Create a per-load CTS linked to the global CTS and the caller's token so that - // either individual HandleCloseLog, a global cancel (CloseAll), or the caller can - // stop this load. The lock ensures we always link to the current global CTS. - // Not using `using` — disposal is handled by the finally block below. CancellationTokenSource perLoadCts; using (_globalCtsLock.EnterScope()) @@ -247,17 +230,13 @@ public async Task HandleOpenLog(EventLogAction.OpenLog action, IDispatcher dispa if (Volatile.Read(ref _cancelGeneration) != startGeneration) { try { perLoadCts.Cancel(); } - catch (ObjectDisposedException) { } + catch (ObjectDisposedException) { /* CTS already disposed; cancel is moot. */ } } var token = perLoadCts.Token; try { - // Wait for the initial database classification scan so resolvers are - // built against verified-Ready entries (rather than the NotClassified - // default). The task is contractually non-faulting per IDatabaseService; - // the catch is defense-in-depth so an unexpected fault never poisons opens. try { await _databaseService.InitialClassificationTask; @@ -267,18 +246,6 @@ public async Task HandleOpenLog(EventLogAction.OpenLog action, IDispatcher dispa _logger.Trace($"InitialClassificationTask faulted unexpectedly during HandleOpenLog: {ex}"); } - // After the classification await, the user may have closed this log - // (HandleCloseLog removes it from ActiveLogs) or all logs (HandleCloseAll - // dispatches EventTableAction.CloseAll), or even closed-and-reopened the - // same name under a new EventLogId. Bail out without reaching LoadLogAsync — - // its AddTable dispatch would otherwise resurrect an entry the user already - // dismissed, leaving an orphan in EventTableState. - // - // We deliberately do NOT bail on token-cancellation alone: a pre-canceled - // caller token (or a CancelAllLoads that didn't dispatch CloseAll) leaves the - // log in ActiveLogs, so we must still proceed into LoadLogAsync where the - // existing OCE handler dispatches the user-visible CloseLog + ClearStatus - // cleanup. Identity-based bail covers exactly the orphan-table scenario. if (!_eventLogState.Value.ActiveLogs.TryGetValue(action.LogName, out var current) || current.Id != logData.Id) { @@ -295,8 +262,6 @@ public async Task HandleOpenLog(EventLogAction.OpenLog action, IDispatcher dispa } catch (Exception ex) { - // Resolver factory threw during construction — surface as a Reload-tier - // banner so the user can recover instead of silently failing the open. _bannerService.ReportCritical(ex); return; @@ -313,9 +278,6 @@ public async Task HandleOpenLog(EventLogAction.OpenLog action, IDispatcher dispa } finally { - // This finally block is the sole owner of per-load CTS removal and - // disposal. Cancel/close paths (HandleCloseLog, CancelAllLoads) only - // request cancellation — they never remove or dispose entries. if (_logCts.TryRemove(logData.Id, out var removedCts)) { removedCts.Dispose(); @@ -523,7 +485,7 @@ private static ImmutableDictionary DistributeEventsToManyL private static async Task StopProducerAsync(Task producerTask) { try { await producerTask; } - catch { /* Intentionally swallowed — caller handles error reporting */ } + catch { /* Intentionally swallowed — caller handles error reporting. */ } } private void CancelAllLoads() @@ -544,21 +506,13 @@ private void CancelAllLoads() oldGlobalCts.Cancel(); - // Don't dispose oldGlobalCts here — per-load linked tokens still - // reference it. It will be collected by GC after all linked tokens - // are disposed by their HandleOpenLog finally blocks. - // Cancel per-load tokens for immediate effect (redundant when they are - // linked to oldGlobalCts, but ensures cancellation for edge cases). - // Don't remove or dispose — HandleOpenLog's finally block owns cleanup - // after each load has fully unwound, avoiding a race where disposal - // here causes ObjectDisposedException in running async operations. foreach (var key in _logCts.Keys) { if (_logCts.TryGetValue(key, out var cts)) { try { cts.Cancel(); } - catch (ObjectDisposedException) { } + catch (ObjectDisposedException) { /* CTS already disposed; cancel is moot. */ } } } } @@ -571,8 +525,6 @@ private async Task LoadLogAsync( IDispatcher dispatcher, CancellationToken token) { - // Detect locale metadata files for exported logs. - // Filesystem probing is best-effort — failures must not abort opening the log. if (action.PathType == PathType.FilePath) { try @@ -743,8 +695,6 @@ await Parallel.ForEachAsync( _pendingSelectionRestore.TryRemove(logData.Name, out _); - // Only close the log if it still exists with the same ID — - // prevents stale cancellation from closing a newly reopened log. if (_eventLogState.Value.ActiveLogs.TryGetValue(logData.Name, out var currentLog) && currentLog.Id == logData.Id) { @@ -778,8 +728,6 @@ await Parallel.ForEachAsync( events.Sort((a, b) => Comparer.Default.Compare(b.RecordId, a.RecordId)); - // Re-check cancellation and log identity before committing results — - // a close may have arrived after the producer/consumer loop completed. token.ThrowIfCancellationRequested(); if (!_eventLogState.Value.ActiveLogs.TryGetValue(logData.Name, out var activeLog) diff --git a/src/EventLogExpert/Components/DetailsPane.razor.cs b/src/EventLogExpert/Components/DetailsPane.razor.cs index 0003aac3..9e723855 100644 --- a/src/EventLogExpert/Components/DetailsPane.razor.cs +++ b/src/EventLogExpert/Components/DetailsPane.razor.cs @@ -59,14 +59,15 @@ protected override async ValueTask DisposeAsyncCore(bool disposing) { SelectedEvent.SelectedValueChanged -= OnSelectedEventChanged; - try { _xmlResolveCts?.Cancel(); } catch (ObjectDisposedException) { } + try { _xmlResolveCts?.Cancel(); } catch (ObjectDisposedException) { /* CTS already disposed; cancel is moot. */ } + _xmlResolveCts?.Dispose(); try { await JSRuntime.InvokeVoidAsync("disposeDetailsPaneResizer"); } - catch (JSDisconnectedException) { } + catch (JSDisconnectedException) { /* Circuit gone — JS resource already torn down. */ } _dotNetRef?.Dispose(); } @@ -150,7 +151,7 @@ private async void OnSelectedEventChanged(object? sender, DisplayEventModel? sel // Cancel any in-flight resolution from a prior selection so a stale fetch // can't overwrite the resolved XML for the now-current selection. - try { _xmlResolveCts?.Cancel(); } catch (ObjectDisposedException) { } + try { _xmlResolveCts?.Cancel(); } catch (ObjectDisposedException) { /* CTS already disposed; cancel is moot. */ } _xmlResolveCts?.Dispose(); _xmlResolveCts = null; diff --git a/src/EventLogExpert/Components/EventTable.razor.cs b/src/EventLogExpert/Components/EventTable.razor.cs index 56313421..c08a4cc7 100644 --- a/src/EventLogExpert/Components/EventTable.razor.cs +++ b/src/EventLogExpert/Components/EventTable.razor.cs @@ -116,7 +116,7 @@ protected override async ValueTask DisposeAsyncCore(bool disposing) { await JSRuntime.InvokeVoidAsync("disposeTableEvents"); } - catch (JSDisconnectedException) { } + catch (JSDisconnectedException) { /* Circuit gone — JS resource already torn down. */ } _dotNetRef?.Dispose(); } @@ -150,7 +150,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (measured > 0) { _pageSize = measured; } } - catch (JSDisconnectedException) { } + catch (JSDisconnectedException) { /* Circuit gone — fall back to default page size. */ } catch (Exception e) { TraceLogger.Warn($"Failed to measure table page size, using default {DefaultPageSize}: {e}"); @@ -413,7 +413,7 @@ private async Task FocusActiveRow() { await JSRuntime.InvokeVoidAsync("focusEventTableRow", index); } - catch (JSDisconnectedException) { } + catch (JSDisconnectedException) { /* Circuit gone — focus best-effort during teardown. */ } catch (Exception e) { TraceLogger.Warn($"Failed to focus active table row: {e}"); diff --git a/src/EventLogExpert/MainPage.xaml.cs b/src/EventLogExpert/MainPage.xaml.cs index 93b72a56..e3c82ed5 100644 --- a/src/EventLogExpert/MainPage.xaml.cs +++ b/src/EventLogExpert/MainPage.xaml.cs @@ -136,10 +136,6 @@ private async void DropGestureRecognizer_OnDrop(object? sender, DropEventArgs e) private void MainWebView_BlazorWebViewInitialized(object? sender, BlazorWebViewInitializedEventArgs e) { - // Inject the saved theme synchronously into every document the WebView2 creates so the page - // renders with the correct palette on first paint (avoids the dark→light flash while - // Blazor boots and calls setTheme). Also align WebView2's prefers-color-scheme with the - // saved choice so the System theme path lights up the right CSS branch immediately. _coreWebView = e.WebView.CoreWebView2; if (_coreWebView is null) { return; } diff --git a/src/EventLogExpert/MauiProgram.cs b/src/EventLogExpert/MauiProgram.cs index b3bbdb37..db90fa74 100644 --- a/src/EventLogExpert/MauiProgram.cs +++ b/src/EventLogExpert/MauiProgram.cs @@ -122,10 +122,6 @@ public static MauiApp CreateMauiApp() var mauiApp = builder.Build(); - // Eagerly resolve BannerService so it subscribes to DatabaseService upgrade events before any - // user-driven action (importing a database, opening Settings) can call UpgradeBatchAsync. Without - // this, BannerService would not be constructed until BannerHost first renders, and any UpgradeBatch* - // events raised before that point would be missed. mauiApp.Services.GetRequiredService(); return mauiApp; diff --git a/src/EventLogExpert/Services/KeyboardShortcutService.cs b/src/EventLogExpert/Services/KeyboardShortcutService.cs index bc3969a9..6960081f 100644 --- a/src/EventLogExpert/Services/KeyboardShortcutService.cs +++ b/src/EventLogExpert/Services/KeyboardShortcutService.cs @@ -37,8 +37,8 @@ public async ValueTask UnregisterAsync() { await _jsRuntime.InvokeVoidAsync("unregisterKeyboardShortcuts"); } - catch (JSDisconnectedException) { } - catch (TaskCanceledException) { } + catch (JSDisconnectedException) { /* Circuit gone — listener already detached. */ } + catch (TaskCanceledException) { /* Teardown cancellation; nothing to do. */ } } _selfRef.Dispose(); diff --git a/src/EventLogExpert/Shared/Base/ModalBase.cs b/src/EventLogExpert/Shared/Base/ModalBase.cs index dcd65199..bd4c1fca 100644 --- a/src/EventLogExpert/Shared/Base/ModalBase.cs +++ b/src/EventLogExpert/Shared/Base/ModalBase.cs @@ -135,8 +135,6 @@ protected override void OnInitialized() protected virtual Task OnSaveAsync() => CompleteAsync(default); - // When `expected` is non-null, only clears if it matches the active entry — guards against - // a cancellation callback clobbering a successor alert. private void TryClearInlineAlert(InlineAlertEntry? expected, InlineAlertResult? result, bool cancel) { InlineAlertEntry? cleared; diff --git a/src/EventLogExpert/Shared/Components/SettingsModal.razor b/src/EventLogExpert/Shared/Components/SettingsModal.razor index 3f3fe96b..21789498 100644 --- a/src/EventLogExpert/Shared/Components/SettingsModal.razor +++ b/src/EventLogExpert/Shared/Components/SettingsModal.razor @@ -51,26 +51,14 @@ { @foreach (var entry in DatabaseService.Entries) { -
-
- @entry.FileName - - @if (entry.Status != DatabaseStatus.Ready) - { - @DatabaseStatusLabels.GetDisplayLabel(entry.Status) - } - - @if (!entry.IsEnabled || entry.Status != DatabaseStatus.Ready) - { - - } -
- -
+ } } else diff --git a/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs b/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs index 6c810d6c..7076d2d7 100644 --- a/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs +++ b/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs @@ -17,13 +17,13 @@ namespace EventLogExpert.Shared.Components; public sealed partial class SettingsModal : ModalBase { private readonly Dictionary _pendingToggles = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _entriesUpgrading = new(StringComparer.OrdinalIgnoreCase); private CancellationTokenSource? _classificationCts; private CopyType _copyType; private bool _databaseStateChanged; private volatile bool _disposed; private bool _isPreReleaseEnabled; - private bool _isUpgradeInFlight; private LogLevel _logLevel; private bool _showDisplayPaneOnSelectionChange; private Theme _theme; @@ -39,22 +39,11 @@ public sealed partial class SettingsModal : ModalBase [Inject] private IState EventLogState { get; init; } = null!; - /// - /// true while has not completed. Drives the - /// "Classifying databases…" header indicator and disables every per-entry toggle so the user cannot trigger an - /// upgrade-required prompt against a status snapshot that is still being computed. - /// private bool IsClassificationPending => !DatabaseService.InitialClassificationTask.IsCompleted; - /// - /// true while a settings-triggered upgrade has been initiated from this modal but not yet observed to - /// drain through the BannerService progress slot. Covers the queued-but-not-yet-started window ( - /// can sit behind another batch before - /// fires) where - /// alone would still be null. Composed with the banner slot so once the consumer picks up the batch and the - /// slot is populated, the predicate stays true through completion. - /// - private bool IsCloseBlocked => _isUpgradeInFlight || BannerService.SettingsProgress is not null; + private bool IsAnyUpgradeInFlight => _entriesUpgrading.Count > 0 || BannerService.SettingsProgress is not null; + + private bool IsCloseBlocked => IsAnyUpgradeInFlight; [Inject] private ISettingsService Settings { get; init; } = null!; @@ -97,13 +86,6 @@ protected override void OnInitialized() if (!DatabaseService.InitialClassificationTask.IsCompleted) { - // EntriesChanged fires synchronously inside ClassifyEntriesAsync (DatabaseService.cs:163-165), - // before StartInitialClassificationAsync returns — so the entries-changed handler observes - // InitialClassificationTask.IsCompleted == false. We need a separate continuation that runs - // after the task object itself completes to flip IsClassificationPending in the UI. - // The CTS lets us cancel the WaitAsync proxy on dispose so the captured `this` reference - // is released even if the underlying classification task is still running, preventing the - // modal from leaking across open/close cycles during a long classification. _classificationCts = new CancellationTokenSource(); _ = ObserveClassificationCompletionAsync(_classificationCts.Token); } @@ -115,8 +97,6 @@ protected override async Task OnSaveAsync() { if (IsCloseBlocked) { return; } - if (!await TryRunUpgradesAsync()) { return; } - await ApplyPendingToggles(); Settings.CopyType = _copyType; @@ -269,12 +249,11 @@ private async Task ObserveClassificationCompletionAsync(CancellationToken cancel } catch (OperationCanceledException) { - // Modal was disposed before classification completed; drop the captured `this` reference. return; } catch { - // InitialClassificationTask is contractually never-faulting (DatabaseService.cs:1200-1212); + // InitialClassificationTask is contractually never-faulting (DatabaseService.cs); // defensive try/catch so a future contract drift cannot orphan this fire-and-forget task. } @@ -319,10 +298,17 @@ private async Task ReloadOpenLogs() } } - private async Task RemoveDatabase(string fileName) + private async Task RemoveDatabase(DatabaseEntry entry) { + var fileName = entry.FileName; + var isReadyAndEnabled = entry is { IsEnabled: true, Status: DatabaseStatus.Ready }; + + var message = isReadyAndEnabled + ? $"{fileName} is currently enabled. Removing will affect open log views. Are you sure?" + : $"Are you sure you want to remove {fileName}?"; + var confirmed = await AlertDialogService.ShowAlert("Remove Database", - $"Are you sure you want to remove {fileName}?", + message, "Remove", "Cancel"); @@ -410,106 +396,71 @@ private void ToggleDatabase(string fileName) } } - /// - /// Identify pending enable-toggles whose target entry needs an upgrade first, prompt for confirmation, run the - /// batch upgrade through the settings-scope progress slot, then re-stage the entries that succeeded so they are - /// committed by the normal Save flow (no immediate calls). Surfaces per-entry - /// failures via . Returns false when the user declines the - /// upgrade prompt, the upgrade throws, OR the user cancels the in-flight upgrade — the caller should abort the save in - /// any of those cases so the modal stays open and the user can either retry or click Exit to discard. The principle: - /// represents the "commit" step and must only run as part of a successful Save, - /// never as a side effect of the upgrade batch itself. - /// - private async Task TryRunUpgradesAsync() + private async Task UpgradeEntry(string fileName) { - if (_pendingToggles.Count == 0) { return true; } - - var entriesByName = DatabaseService.Entries.ToDictionary( - entry => entry.FileName, - StringComparer.OrdinalIgnoreCase); - - var fileNamesNeedingUpgrade = _pendingToggles - .Where(pair => - pair.Value && - entriesByName.TryGetValue(pair.Key, out var entry) && - entry.Status is DatabaseStatus.UpgradeRequired or DatabaseStatus.UpgradeFailed) - .Select(pair => pair.Key) - .ToList(); - - if (fileNamesNeedingUpgrade.Count == 0) { return true; } - - var confirmed = await AlertDialogService.ShowAlert( - "Upgrade Required", - fileNamesNeedingUpgrade.Count == 1 - ? "1 database requires an upgrade before it can be enabled. Upgrade now?" - : $"{fileNamesNeedingUpgrade.Count} databases require an upgrade before they can be enabled. Upgrade now?", - "Upgrade and enable", - "Cancel"); + if (IsAnyUpgradeInFlight && !_entriesUpgrading.Contains(fileName)) { return; } - if (!confirmed) { return false; } + if (!_entriesUpgrading.Add(fileName)) { return; } - foreach (var fileName in fileNamesNeedingUpgrade) + try { - _pendingToggles.Remove(fileName); - } + UpgradeBatchResult result; - UpgradeBatchResult result; + try + { + result = await DatabaseService.UpgradeBatchAsync( + [fileName], + UpgradeProgressScope.SettingsTriggered); + } + catch (Exception ex) + { + if (_disposed) { return; } - _isUpgradeInFlight = true; + try + { + await AlertDialogService.ShowAlert("Database Upgrade Failed", + $"An exception occurred while upgrading '{fileName}': {ex.Message}", + "OK"); + } + catch (ObjectDisposedException) + { + // Alert dialog service was torn down with the modal; nothing to surface to. + } - try - { - result = await DatabaseService.UpgradeBatchAsync( - fileNamesNeedingUpgrade, - UpgradeProgressScope.SettingsTriggered); - } - catch (Exception ex) - { - // Restore every entry whose toggle we removed so the user can retry or click Exit to discard. - // Without this restore, returning to OnSaveAsync would either commit a now-empty pending set - // or (with the previous return-true behavior) silently drop the user's enable intent. - foreach (var fileName in fileNamesNeedingUpgrade) - { - _pendingToggles[fileName] = true; + return; } - await AlertDialogService.ShowAlert("Database Upgrade Failed", - $"An exception occurred while upgrading databases: {ex.Message}", - "OK"); + if (_disposed) { return; } - return false; + foreach (var failure in result.Failed) + { + try + { + await AlertDialogService.ShowErrorAlert( + "Database Upgrade Failed", + $"Failed to upgrade '{failure.FileName}': {failure.Message}"); + } + catch (ObjectDisposedException) + { + return; + } + } } finally { - _isUpgradeInFlight = false; - } - - // Re-stage successes as pending so ApplyPendingToggles enables them as part of the normal Save commit. - // We do NOT call DatabaseService.Toggle here directly: per the modal contract, no toggle should take - // effect until the user's Save click reaches CompleteAsync — otherwise a subsequent Cancel/Exit would - // leave partial commits behind. - foreach (var fileName in result.Succeeded) - { - _pendingToggles[fileName] = true; - } - - foreach (var failure in result.Failed) - { - await AlertDialogService.ShowErrorAlert( - "Database Upgrade Failed", - $"Failed to upgrade '{failure.FileName}': {failure.Message}"); - } + _entriesUpgrading.Remove(fileName); - // Cancellation is the user's "abort" signal — restore the cancelled enable-toggles to pending so - // they re-appear next Save, and abort this save so the modal stays open. Without this restore, - // cancelled entries' toggles would be silently consumed and the user would have to re-toggle. - if (result.Cancelled.Count <= 0) { return true; } - - foreach (var fileName in result.Cancelled) - { - _pendingToggles[fileName] = true; + if (!_disposed) + { + try + { + await InvokeAsync(StateHasChanged); + } + catch (ObjectDisposedException) + { + // Modal was torn down between the _disposed check and the dispatcher call; safe to ignore. + } + } } - - return false; } } diff --git a/src/EventLogExpert/Shared/Components/SettingsModal.razor.css b/src/EventLogExpert/Shared/Components/SettingsModal.razor.css index 7589334a..cd6e17fa 100644 --- a/src/EventLogExpert/Shared/Components/SettingsModal.razor.css +++ b/src/EventLogExpert/Shared/Components/SettingsModal.razor.css @@ -29,32 +29,3 @@ from { transform: rotate(0deg); } to { transform: rotate(360deg); } } - -.entry-status-badge { - display: inline-block; - margin-left: .5rem; - padding: .1rem .4rem; - - font-size: .85em; - background-color: var(--background-darkgray); - color: var(--clr-lightblue); - border-radius: .25rem; - vertical-align: middle; -} - -.entry-status-badge[data-status="UpgradeRequired"] { - background-color: var(--clr-yellow); - color: var(--clr-on-status-fill); -} - -.entry-status-badge[data-status="UpgradeFailed"], -.entry-status-badge[data-status="UnrecognizedSchema"], -.entry-status-badge[data-status="ObsoleteSchema"], -.entry-status-badge[data-status="ClassificationFailed"] { - background-color: var(--clr-red); - color: var(--clr-on-status-fill); -} - -.entry-status-badge[data-status="NotClassified"] { - font-style: italic; -} diff --git a/src/EventLogExpert/Shared/Components/TextInput.razor b/src/EventLogExpert/Shared/Components/TextInput.razor index 64f62370..633acb9a 100644 --- a/src/EventLogExpert/Shared/Components/TextInput.razor +++ b/src/EventLogExpert/Shared/Components/TextInput.razor @@ -1,3 +1,3 @@ -@inherits Base.InputComponent +@inherits InputComponent diff --git a/src/EventLogExpert/Shared/Components/TextInput.razor.cs b/src/EventLogExpert/Shared/Components/TextInput.razor.cs index 2b7b08e1..2bcd5a90 100644 --- a/src/EventLogExpert/Shared/Components/TextInput.razor.cs +++ b/src/EventLogExpert/Shared/Components/TextInput.razor.cs @@ -1,7 +1,7 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. -using EventLogExpert.Shared.Base; +using EventLogExpert.Components.Base; using Microsoft.AspNetCore.Components; namespace EventLogExpert.Shared.Components; diff --git a/src/EventLogExpert/Shared/Components/ValueSelect.razor b/src/EventLogExpert/Shared/Components/ValueSelect.razor index 6778546f..f590ff66 100644 --- a/src/EventLogExpert/Shared/Components/ValueSelect.razor +++ b/src/EventLogExpert/Shared/Components/ValueSelect.razor @@ -1,5 +1,4 @@ @typeparam T -@using EventLogExpert.Shared.Base @inherits InputComponent
diff --git a/src/EventLogExpert.Components/DatabaseEntryRow.razor.cs b/src/EventLogExpert.Components/DatabaseEntryRow.razor.cs index b48ac2a3..5eff180c 100644 --- a/src/EventLogExpert.Components/DatabaseEntryRow.razor.cs +++ b/src/EventLogExpert.Components/DatabaseEntryRow.razor.cs @@ -39,6 +39,15 @@ private enum ActionKind private string BadgeLabel => DatabaseStatusLabels.GetRowBadgeLabel(Entry); + /// Removal is hidden for entries the user must resolve some other way first: + /// an in-flight upgrade (would race the per-file reservation), a pending recovery + /// (would orphan the .upgrade.bak), or a V3 database awaiting upgrade (steers the user + /// to either Upgrade or — once UpgradeFailed — Retry/Remove). UpgradeFailed remains + /// removable so a user who has tried and given up can still clean the entry up. + private bool CanRemove => !IsUpgrading + && !Entry.BackupExists + && Entry.Status != DatabaseStatus.UpgradeRequired; + private ActionKind PrimaryAction { get @@ -62,6 +71,8 @@ private ActionKind PrimaryAction } } - private bool ShouldShowBadge => - Entry.BackupExists || (!IsUpgrading && Entry.Status != DatabaseStatus.Ready); + private bool ShouldShowBadge => Entry.BackupExists || + (!IsUpgrading && + Entry.Status != DatabaseStatus.Ready && + Entry.Status != DatabaseStatus.UpgradeRequired); } diff --git a/src/EventLogExpert.Components/DatabaseEntryRow.razor.css b/src/EventLogExpert.Components/DatabaseEntryRow.razor.css index bca08e46..fc3c04f2 100644 --- a/src/EventLogExpert.Components/DatabaseEntryRow.razor.css +++ b/src/EventLogExpert.Components/DatabaseEntryRow.razor.css @@ -4,29 +4,27 @@ align-items: center; gap: 1rem; - padding: .35rem .5rem; - border-bottom: 1px solid var(--clr-statusbar); -} - -.db-entry-row:last-child { - border-bottom: 0; + padding: .15rem .5rem; } .db-entry-info { display: flex; - flex-direction: column; + align-items: center; + gap: .5rem; min-width: 0; } .db-entry-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .db-entry-badge { - align-self: flex-start; - margin-top: .15rem; + flex: 0 0 auto; padding: .1rem .4rem; font-size: .85em; @@ -35,11 +33,6 @@ border-radius: .25rem; } -.db-entry-badge[data-badge="UpgradeRequired"] { - background-color: var(--clr-yellow); - color: var(--clr-on-status-fill); -} - .db-entry-badge[data-badge="UpgradeFailed"], .db-entry-badge[data-badge="UnrecognizedSchema"], .db-entry-badge[data-badge="ObsoleteSchema"], @@ -79,6 +72,29 @@ .db-entry-remove-btn { flex: 0 0 auto; + + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease; +} + +.db-entry-row:hover .db-entry-remove-btn, +.db-entry-row:focus-within .db-entry-remove-btn { + opacity: 1; + pointer-events: auto; +} + +@media (hover: none) { + .db-entry-row .db-entry-remove-btn { + opacity: 1; + pointer-events: auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .db-entry-remove-btn { + transition: none; + } } @keyframes db-entry-spin { diff --git a/src/EventLogExpert.Components/DatabaseRecoveryHost.razor.cs b/src/EventLogExpert.Components/DatabaseRecoveryHost.razor.cs index 1820569d..fd5003ac 100644 --- a/src/EventLogExpert.Components/DatabaseRecoveryHost.razor.cs +++ b/src/EventLogExpert.Components/DatabaseRecoveryHost.razor.cs @@ -54,43 +54,37 @@ private void EvaluateState() if (currentBackupSet.Count == 0) { - if (_recoveryBannerId is { } activeId) - { - BannerService.DismissError(activeId); - _recoveryBannerId = null; - } - + DismissCurrentBannerIfAny(); _promptedFor.Clear(); return; } - if (_recoveryBannerId is { } visibleId) - { - if (currentBackupSet.SetEquals(_promptedFor)) { return; } - - BannerService.DismissError(visibleId); - - _recoveryBannerId = ReportRecoveryBanner(currentBackupSet.Count); - _promptedFor = currentBackupSet; - - return; - } - if (currentBackupSet.SetEquals(_promptedFor)) { return; } + DismissCurrentBannerIfAny(); + _recoveryBannerId = ReportRecoveryBanner(currentBackupSet.Count); _promptedFor = currentBackupSet; } + private void DismissCurrentBannerIfAny() + { + if (_recoveryBannerId is not { } activeId) { return; } + + BannerService.DismissError(activeId); + _recoveryBannerId = null; + } + private void HandleBannerStateChanged() { if (_disposed) { return; } - if (_recoveryBannerId is { } id && BannerService.ErrorBanners.All(banner => banner.Id != id)) - { - _recoveryBannerId = null; - } + if (_recoveryBannerId is not { } id) { return; } + + if (BannerService.ErrorBanners.Any(banner => banner.Id == id)) { return; } + + _recoveryBannerId = null; } private void OnBannerStateChanged() => _ = InvokeAsync(HandleBannerStateChanged); diff --git a/src/EventLogExpert.Eventing/Readers/EventLogWatcher.cs b/src/EventLogExpert.Eventing/Readers/EventLogWatcher.cs index 5bde366b..beb4ae03 100644 --- a/src/EventLogExpert.Eventing/Readers/EventLogWatcher.cs +++ b/src/EventLogExpert.Eventing/Readers/EventLogWatcher.cs @@ -189,7 +189,16 @@ private void Unsubscribe() if (_waitHandle is not null) { - _waitHandle.Unregister(null); + // Pass a wait object (not null) so Unregister blocks until all in-flight + // ProcessNewEvents callbacks have completed. Per the .NET docs on + // RegisteredWaitHandle.Unregister, passing null does NOT wait — and our + // callback creates per-event resolver scopes that hold pooled SQLite + // connections, so callers (e.g., DatabaseService delete) need a hard + // guarantee that no callback can fire after Unregister returns. + using var unregisterSignal = new ManualResetEvent(false); + + _waitHandle.Unregister(unregisterSignal); + unregisterSignal.WaitOne(); _waitHandle = null; } diff --git a/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs b/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs index 41a6394c..48333fc6 100644 --- a/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs +++ b/src/EventLogExpert.UI.Tests/Services/DatabaseServiceTests.cs @@ -1469,8 +1469,9 @@ public void Refresh_WhenNewFilesAppear_ShouldPickThemUp() } [Fact] - public void Remove_DoesNotTouchUserCreatedDotBakFiles() + public async Task RemoveAsync_DoesNotTouchUserCreatedDotBakFiles() { + // Arrange var databasePath = CreateDatabaseDirectory(); var dbPath = Path.Combine(databasePath, Constants.TestDb1); CreateDatabaseFile(databasePath, Constants.TestDb1); @@ -1481,16 +1482,19 @@ public void Remove_DoesNotTouchUserCreatedDotBakFiles() var service = CreateDatabaseService(); - service.Remove(Constants.TestDb1); + // Act + await service.RemoveAsync(Constants.TestDb1, cancellationToken: TestContext.Current.CancellationToken); + // Assert Assert.False(File.Exists(dbPath)); Assert.True(File.Exists(userBakPath)); Assert.Equal(userBackupContent, File.ReadAllText(userBakPath)); } [Fact] - public async Task Remove_DuringInFlightUpgrade_ShouldThrowInvalidOperationException() + public async Task RemoveAsync_DuringInFlightUpgrade_ShouldThrowInvalidOperationException() { + // Arrange var databasePath = CreateDatabaseDirectory(); DatabaseSeedUtils.SeedV3Schema(Path.Combine(databasePath, Constants.TestDb1)); @@ -1515,7 +1519,9 @@ public async Task Remove_DuringInFlightUpgrade_ShouldThrowInvalidOperationExcept Assert.True(inFlight.Wait(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)); - var ex = Assert.Throws(() => service.Remove(Constants.TestDb1)); + // Act + Assert + var ex = await Assert.ThrowsAsync( + () => service.RemoveAsync(Constants.TestDb1, cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains("another operation is in progress", ex.Message, StringComparison.OrdinalIgnoreCase); release.Set(); @@ -1523,7 +1529,28 @@ public async Task Remove_DuringInFlightUpgrade_ShouldThrowInvalidOperationExcept } [Fact] - public void Remove_WhenCalled_ShouldDeleteDatabaseAndSidecars() + public async Task RemoveAsync_WhenAlreadyCancelledCallerCt_ThrowsOperationCanceled_BeforeAnyPhase() + { + // Arrange + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); + var service = CreateDatabaseService(); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + + // Act + Assert + await Assert.ThrowsAsync( + () => service.RemoveAsync(Constants.TestDb1, cancellationToken: cts.Token)); + + Assert.True(File.Exists(dbPath)); + Assert.Single(service.Entries); + } + + [Fact] + public async Task RemoveAsync_WhenCalled_ShouldDeleteDatabaseAndSidecars() { // Arrange var databasePath = CreateDatabaseDirectory(); @@ -1534,7 +1561,7 @@ public void Remove_WhenCalled_ShouldDeleteDatabaseAndSidecars() var service = CreateDatabaseService(); // Act - service.Remove(Constants.TestDb1); + await service.RemoveAsync(Constants.TestDb1, cancellationToken: TestContext.Current.CancellationToken); // Assert Assert.False(File.Exists(Path.Combine(databasePath, Constants.TestDb1))); @@ -1544,7 +1571,7 @@ public void Remove_WhenCalled_ShouldDeleteDatabaseAndSidecars() } [Fact] - public void Remove_WhenCalled_ShouldRaiseEntriesChanged() + public async Task RemoveAsync_WhenCalled_ShouldRaiseEntriesChanged() { // Arrange var databasePath = CreateDatabaseDirectory(); @@ -1555,21 +1582,154 @@ public void Remove_WhenCalled_ShouldRaiseEntriesChanged() service.EntriesChanged += (_, _) => raised = true; // Act - service.Remove(Constants.TestDb1); + await service.RemoveAsync(Constants.TestDb1, cancellationToken: TestContext.Current.CancellationToken); // Assert Assert.True(raised); } [Fact] - public void Remove_WhenFileNameUnknown_ShouldThrow() + public async Task RemoveAsync_WhenEntryDisabled_StillInvokesPrepareCallback() + { + // Arrange — a previously-disabled entry can still be referenced by IEventResolver + // instances constructed before the disable (the user disabled it and declined the + // modal-close reload prompt). The coordinator must still close any open log views + // so SqliteConnection.ClearAllPools + File.Delete don't race with in-flight scopes. + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); + + var prefs = Substitute.For(); + prefs.DisabledDatabasesPreference.Returns([Constants.TestDb1]); + var service = CreateDatabaseService(prefs); + + var disabledEntry = Assert.Single(service.Entries); + Assert.False(disabledEntry.IsEnabled); + + var prepareInvoked = false; + Task PrepareCallback(CancellationToken ct) + { + prepareInvoked = true; + return Task.CompletedTask; + } + + // Act + await service.RemoveAsync( + Constants.TestDb1, + prepareForDeletionAsync: PrepareCallback, + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.True(prepareInvoked); + Assert.Empty(service.Entries); + } + + [Fact] + public async Task RemoveAsync_WhenEntryEnabled_AwaitsPrepareCallback_AfterDisable_BeforeFileDelete() + { + // Arrange — verifies the 4-phase ordering: disable → prepare-callback → file delete. + // Track phase order via a shared list. The callback observes IsEnabled (must already + // be false) and confirms the file still exists (Phase 3 hasn't run yet). + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); + + var prefs = Substitute.For(); + prefs.DisabledDatabasesPreference.Returns([]); + var service = CreateDatabaseService(prefs); + + // Default IsEnabled is true when the file is not in the disabled-preference list, + // so the entry is already enabled — no toggle needed. + var enabledEntry = Assert.Single(service.Entries); + Assert.True(enabledEntry.IsEnabled); + + var observations = new List<(string Phase, bool IsEnabled, bool FileExists)>(); + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + + Task PrepareCallback(CancellationToken ct) + { + var current = service.Entries.SingleOrDefault(e => + string.Equals(e.FileName, Constants.TestDb1, StringComparison.OrdinalIgnoreCase)); + observations.Add(("prepare", current?.IsEnabled ?? false, File.Exists(dbPath))); + return Task.CompletedTask; + } + + // Act + await service.RemoveAsync( + Constants.TestDb1, + prepareForDeletionAsync: PrepareCallback, + cancellationToken: TestContext.Current.CancellationToken); + + // Assert — prepare callback observed IsEnabled=false (Phase 1 ran first) and the + // file still present (Phase 3 had not yet run). + var observation = Assert.Single(observations); + Assert.False(observation.IsEnabled); + Assert.True(observation.FileExists); + + // After RemoveAsync returned, the file is gone and the entry is removed. + Assert.False(File.Exists(dbPath)); + Assert.Empty(service.Entries); + } + + [Fact] + public async Task RemoveAsync_WhenEntryEnabled_RaisesEntriesChangedTwice_OncePerPhaseMutation() + { + // Arrange — Phase 1 (disable) and Phase 4 (RemoveAt) should each fire EntriesChanged. + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); + var service = CreateDatabaseService(); + + Assert.True(service.Entries.Single().IsEnabled); + + var raiseCount = 0; + service.EntriesChanged += (_, _) => Interlocked.Increment(ref raiseCount); + + // Act + await service.RemoveAsync(Constants.TestDb1, cancellationToken: TestContext.Current.CancellationToken); + + // Assert — exactly two raises: one for the disable mutation, one for the remove. + Assert.Equal(2, raiseCount); + Assert.Empty(service.Entries); + } + + [Fact] + public async Task RemoveAsync_WhenFileNameUnknown_ShouldThrow() { // Arrange CreateDatabaseDirectory(); var service = CreateDatabaseService(); // Act + Assert - Assert.Throws(() => service.Remove("does-not-exist.db")); + await Assert.ThrowsAsync( + () => service.RemoveAsync("does-not-exist.db", cancellationToken: TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task RemoveAsync_WhenPrepareCallbackThrows_RestoresIsEnabled_AndRethrows() + { + // Arrange — Phase 2 failure must roll back Phase 1. The entry should remain in + // Entries with IsEnabled restored to true so the caller can retry. + var databasePath = CreateDatabaseDirectory(); + CreateDatabaseFile(databasePath, Constants.TestDb1); + + var prefs = Substitute.For(); + prefs.DisabledDatabasesPreference.Returns([]); + var service = CreateDatabaseService(prefs); + + Assert.True(service.Entries.Single().IsEnabled); + + var dbPath = Path.Combine(databasePath, Constants.TestDb1); + + // Act + var ex = await Assert.ThrowsAsync( + () => service.RemoveAsync( + Constants.TestDb1, + prepareForDeletionAsync: _ => throw new InvalidOperationException("prepare boom"), + cancellationToken: TestContext.Current.CancellationToken)); + + // Assert + Assert.Equal("prepare boom", ex.Message); + Assert.True(File.Exists(dbPath)); + var rolledBack = Assert.Single(service.Entries); + Assert.True(rolledBack.IsEnabled); } [Fact] diff --git a/src/EventLogExpert.UI.Tests/Store/EventLog/EventLogEffectsTests.cs b/src/EventLogExpert.UI.Tests/Store/EventLog/EventLogEffectsTests.cs index 7b2e1ff4..265779ae 100644 --- a/src/EventLogExpert.UI.Tests/Store/EventLog/EventLogEffectsTests.cs +++ b/src/EventLogExpert.UI.Tests/Store/EventLog/EventLogEffectsTests.cs @@ -160,7 +160,8 @@ public async Task HandleCloseAll_ShouldClearAllResolvedXml() mockXmlResolver, mockServiceScopeFactory, Substitute.For(), - Substitute.For()); + Substitute.For(), + Substitute.For()); var mockDispatcher = Substitute.For(); @@ -181,12 +182,44 @@ public async Task HandleCloseAll_ShouldRemoveAllLogsAndClearCache() await effects.HandleCloseAll(mockDispatcher); // Assert - mockLogWatcher.Received(1).RemoveAll(); + await mockLogWatcher.Received(1).RemoveAllAsync(); mockResolverCache.Received(1).ClearAll(); mockDispatcher.Received(1).Dispatch(Arg.Any()); mockDispatcher.Received(1).Dispatch(Arg.Any()); } + [Fact] + public async Task HandleCloseLog_AwaitsWatcherShutdown_BeforeSignalingCloseCompletion() + { + // Arrange — RemoveLogAsync returns a TCS we control; HandleCloseLog must NOT + // signal its close-completion TCS until that TCS finishes. Verifies the B2 + // ordering fix: per-event resolver scopes (and their pooled SQLite handles) must + // drain before the coordinator believes the log is closed. + var (effects, mockDispatcher, mockLogWatcher, _, _) = CreateEffectsWithServices(); + + var watcherTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + mockLogWatcher.RemoveLogAsync(Arg.Any()).Returns(watcherTcs.Task); + + var logId = EventLogId.Create(); + var action = new EventLogAction.CloseLog(logId, Constants.LogNameTestLog); + + // Act + var closeTask = effects.HandleCloseLog(action, mockDispatcher); + + // Give HandleCloseLog a chance to advance to the await. + await Task.Delay(50, TestContext.Current.CancellationToken); + + // Assert — close has not completed because the watcher hasn't released yet. + Assert.False(closeTask.IsCompleted, "HandleCloseLog should be blocked on RemoveLogAsync."); + + // Now release the watcher; HandleCloseLog should complete promptly. + watcherTcs.SetResult(); + + await closeTask.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.True(closeTask.IsCompletedSuccessfully); + } + [Fact] public async Task HandleCloseLog_ShouldClearResolvedXmlForLog() { @@ -217,7 +250,8 @@ public async Task HandleCloseLog_ShouldClearResolvedXmlForLog() mockXmlResolver, mockServiceScopeFactory, Substitute.For(), - Substitute.For()); + Substitute.For(), + Substitute.For()); var mockDispatcher = Substitute.For(); var action = new EventLogAction.CloseLog(logId, Constants.LogNameTestLog); @@ -241,7 +275,7 @@ public async Task HandleCloseLog_ShouldRemoveLogAndDispatchCloseAction() await effects.HandleCloseLog(action, mockDispatcher); // Assert - mockLogWatcher.Received(1).RemoveLog(Constants.LogNameTestLog); + await mockLogWatcher.Received(1).RemoveLogAsync(Constants.LogNameTestLog); mockDispatcher.Received(1).Dispatch(Arg.Is(a => a.LogId == logId)); @@ -481,7 +515,8 @@ public async Task HandleOpenLog_LogClosedDuringClassificationAwait_DoesNotDispat Substitute.For(), mockServiceScopeFactory, mockDatabaseService, - Substitute.For()); + Substitute.For(), + Substitute.For()); var mockDispatcher = Substitute.For(); var action = new EventLogAction.OpenLog(Constants.LogNameApplication, PathType.LogName); @@ -1026,6 +1061,58 @@ public async Task HandleSetFilters_WhenFilterDoesNotRequireXml_ShouldNotReloadLo mockDispatcher.Received(1).Dispatch(Arg.Any()); } + [Fact] + public async Task HandleSetFilters_WhenFilterRequiresXml_AwaitsCloseCompletionBeforeReturning() + { + // Arrange — RemoveLogAsync is gated by a controlled TCS so HandleCloseLog cannot + // complete until the test signals it. HandleCloseLog clears _pendingSelectionRestore + // for the log name AFTER awaiting the watcher, then signals the close-completion + // TCS in its finally block. HandleSetFilters must await that close-completion TCS + // before populating _pendingSelectionRestore — otherwise the in-flight close wipes + // the freshly-written entry. This test pins down the ordering invariant. + var logData = new EventLogData(Constants.LogNameTestLog, PathType.LogName, []); + var activeLogs = ImmutableDictionary.Empty.Add(Constants.LogNameTestLog, logData); + + var (effects, mockDispatcher, mockLogWatcher, _, _) = CreateEffectsWithServices(activeLogs: activeLogs); + + var watcherCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + mockLogWatcher.RemoveLogAsync(Arg.Any()).Returns(watcherCompletion.Task); + + // Capture each routed close so any fault in HandleCloseLog surfaces before the test + // ends (the discarded `_ = effects.HandleCloseLog(...)` would otherwise let a fault + // be silently observed by the finalizer). + var closeTasks = new List(); + mockDispatcher + .When(d => d.Dispatch(Arg.Any())) + .Do(callInfo => + { + closeTasks.Add(effects.HandleCloseLog(callInfo.Arg(), mockDispatcher)); + }); + + var xmlFilter = FilterUtils.CreateTestFilter(Constants.FilterXmlContainsData, isEnabled: true); + var eventFilter = new EventFilter(null, [xmlFilter]); + + // Act — start HandleSetFilters; it must remain pending until watcherCompletion fires. + var setFiltersTask = effects.HandleSetFilters(new EventLogAction.SetFilters(eventFilter), mockDispatcher); + + // Assert — task is blocked because HandleCloseLog can't finish until RemoveLogAsync + // returns. Without the close-completion await in HandleSetFilters, the task would + // already be completed (it would have written the restore map and returned). + Assert.False(setFiltersTask.IsCompleted, "HandleSetFilters must wait for HandleCloseLog before populating the restore map."); + + // Release HandleCloseLog → finally block signals the close-completion TCS. + watcherCompletion.SetResult(); + + await setFiltersTask.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + // OpenLog should now have been dispatched (only happens after the close await). + mockDispatcher.Received(1).Dispatch(Arg.Is(a => + a.LogName == Constants.LogNameTestLog && a.PathType == PathType.LogName)); + + // Surface any HandleCloseLog faults before exiting the test. + await Task.WhenAll(closeTasks); + } + [Fact] public async Task HandleSetFilters_WhenFilterRequiresXml_ShouldRestoreSelectionAfterReload() { @@ -1067,10 +1154,25 @@ public async Task HandleSetFilters_WhenFilterRequiresXml_ShouldRestoreSelectionA Substitute.For(), mockServiceScopeFactory, Substitute.For(), - Substitute.For()); + Substitute.For(), + Substitute.For()); var mockDispatcher = Substitute.For(); + // Route CloseLog dispatches into HandleCloseLog so the close-completion TCSes + // get signaled. HandleCloseLog clears _pendingSelectionRestore for the log name as + // part of its async cleanup; HandleSetFilters must await the close before writing + // the restore map. Without this routing the await would hit LogCloseTimeout (30s). + // Capture the routed tasks so any fault in HandleCloseLog surfaces at the end of + // the test instead of being swallowed by the discard. + var closeTasks = new List(); + mockDispatcher + .When(d => d.Dispatch(Arg.Any())) + .Do(callInfo => + { + closeTasks.Add(effects.HandleCloseLog(callInfo.Arg(), mockDispatcher)); + }); + var xmlFilter = FilterUtils.CreateTestFilter(Constants.FilterXmlContainsData, isEnabled: true); var eventFilter = new EventFilter(null, [xmlFilter]); @@ -1088,6 +1190,9 @@ public async Task HandleSetFilters_WhenFilterRequiresXml_ShouldRestoreSelectionA // Assert — SetSelectedEvents dispatched with exactly the restored event (RecordId=42). mockDispatcher.Received(1).Dispatch(Arg.Is(a => a.SelectedEvents.Count() == 1 && a.SelectedEvents.First().RecordId == 42)); + + // Surface any HandleCloseLog faults before exiting the test. + await Task.WhenAll(closeTasks); } [Fact] @@ -1099,6 +1204,18 @@ public async Task HandleSetFilters_WhenFilterRequiresXmlAndLogLacksXml_ShouldClo var (effects, mockDispatcher, _, _, _) = CreateEffectsWithServices(activeLogs: activeLogs); + // Route CloseLog → HandleCloseLog so HandleSetFilters' await on the close-completion + // TCS resolves quickly (otherwise it hits LogCloseTimeout, 30s). Capture the routed + // tasks so any fault in HandleCloseLog surfaces at the end of the test instead of + // being swallowed by the discard. + var closeTasks = new List(); + mockDispatcher + .When(d => d.Dispatch(Arg.Any())) + .Do(callInfo => + { + closeTasks.Add(effects.HandleCloseLog(callInfo.Arg(), mockDispatcher)); + }); + var xmlFilter = FilterUtils.CreateTestFilter(Constants.FilterXmlContainsData, isEnabled: true); var eventFilter = new EventFilter(null, [xmlFilter]); var action = new EventLogAction.SetFilters(eventFilter); @@ -1117,6 +1234,115 @@ public async Task HandleSetFilters_WhenFilterRequiresXmlAndLogLacksXml_ShouldClo // Reload path returns early — no UpdateDisplayedEvents until LoadEvents fires. mockDispatcher.DidNotReceive().Dispatch(Arg.Any()); + + // Surface any HandleCloseLog faults before exiting the test. + await Task.WhenAll(closeTasks); + } + + [Fact] + public async Task PrepareForDatabaseRemovalAsync_DispatchesCloseLogPerActiveLog_PopulatesSnapshotInCloseOrder() + { + // Arrange — two active logs; route the dispatcher's CloseLog actions back into + // HandleCloseLog so the close-completion TCSes get signaled. This proves the sink + // pattern (snapshot is populated incrementally) rather than relying on the live + // Fluxor pipeline. + var log1 = new EventLogData(Constants.LogNameLog1, PathType.LogName, []); + var log2 = new EventLogData(Constants.LogNameLog2, PathType.LogName, []); + var log1Id = log1.Id; + var log2Id = log2.Id; + + var activeLogs = ImmutableDictionary.Empty + .Add(Constants.LogNameLog1, log1) + .Add(Constants.LogNameLog2, log2); + + var (effects, mockDispatcher, _, _, _) = CreateEffectsWithServices(activeLogs: activeLogs); + + // Route CloseLog dispatches back into HandleCloseLog so the close-completion TCSes + // get signaled. Without this the coordinator would block forever waiting on the TCSes. + mockDispatcher + .When(d => d.Dispatch(Arg.Any())) + .Do(callInfo => + { + var action = callInfo.Arg(); + _ = effects.HandleCloseLog(action, mockDispatcher); + }); + + var snapshot = new LogReopenSnapshot(); + var coordinator = (ILogReloadCoordinator)effects; + + // Act + await coordinator.PrepareForDatabaseRemovalAsync(snapshot, TestContext.Current.CancellationToken); + + // Assert — snapshot has both logs (order matches the ActiveLogs iteration). + Assert.Equal(2, snapshot.Items.Count); + Assert.Contains(snapshot.Items, item => item.Name == Constants.LogNameLog1 && item.Type == PathType.LogName); + Assert.Contains(snapshot.Items, item => item.Name == Constants.LogNameLog2 && item.Type == PathType.LogName); + + // Both CloseLog actions should have been dispatched. + mockDispatcher.Received(1).Dispatch(Arg.Is(a => a.LogId == log1Id)); + mockDispatcher.Received(1).Dispatch(Arg.Is(a => a.LogId == log2Id)); + } + + [Fact] + public async Task PrepareForDatabaseRemovalAsync_WhenCancellationTokenCancelsBeforeClose_ThrowsAndDoesNotPopulateSnapshot() + { + // Arrange — one active log, dispatcher does NOT route back. The close-completion + // TCS will never be signaled, so the await with a cancelled token should throw and + // the sink should remain empty. + var log = new EventLogData(Constants.LogNameLog1, PathType.LogName, []); + var activeLogs = ImmutableDictionary.Empty.Add(Constants.LogNameLog1, log); + + var (effects, _, _, _, _) = CreateEffectsWithServices(activeLogs: activeLogs); + var snapshot = new LogReopenSnapshot(); + var coordinator = (ILogReloadCoordinator)effects; + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act + Assert + await Assert.ThrowsAnyAsync( + () => coordinator.PrepareForDatabaseRemovalAsync(snapshot, cts.Token)); + + Assert.Empty(snapshot.Items); + } + + [Fact] + public async Task PrepareForDatabaseRemovalAsync_WhenNoActiveLogs_ReturnsImmediatelyAndDispatchesNothing() + { + // Arrange — empty ActiveLogs is the "user removed a disabled entry" path. + var (effects, _, _, _, _) = CreateEffectsWithServices(); + var snapshot = new LogReopenSnapshot(); + + var coordinator = (ILogReloadCoordinator)effects; + + // Act + await coordinator.PrepareForDatabaseRemovalAsync(snapshot, TestContext.Current.CancellationToken); + + // Assert + Assert.Empty(snapshot.Items); + } + + [Fact] + public void ReopenAfterDatabaseRemoval_DispatchesOpenLogPerSnapshotEntry() + { + // Arrange + var (effects, mockDispatcher, _, _, _) = CreateEffectsWithServices(); + var coordinator = (ILogReloadCoordinator)effects; + + var snapshot = new[] + { + new LogReopenInfo(Constants.LogNameLog1, PathType.LogName), + new LogReopenInfo(Constants.LogNameLog2, PathType.FilePath) + }; + + // Act + coordinator.ReopenAfterDatabaseRemoval(snapshot); + + // Assert + mockDispatcher.Received(1).Dispatch(Arg.Is(a => + a.LogName == Constants.LogNameLog1 && a.PathType == PathType.LogName)); + mockDispatcher.Received(1).Dispatch(Arg.Is(a => + a.LogName == Constants.LogNameLog2 && a.PathType == PathType.FilePath)); } private static (EventLogEffects effects, IDispatcher mockDispatcher) CreateEffects( @@ -1145,6 +1371,8 @@ private static (EventLogEffects effects, IDispatcher mockDispatcher) CreateEffec var mockLogger = Substitute.For(); var mockLogWatcherService = Substitute.For(); + mockLogWatcherService.RemoveLogAsync(Arg.Any()).Returns(Task.CompletedTask); + mockLogWatcherService.RemoveAllAsync().Returns(Task.CompletedTask); var mockResolverCache = Substitute.For(); var mockServiceScopeFactory = Substitute.For(); @@ -1171,6 +1399,8 @@ private static (EventLogEffects effects, IDispatcher mockDispatcher) CreateEffec var mockDatabaseService = Substitute.For(); mockDatabaseService.InitialClassificationTask.Returns(Task.CompletedTask); + var mockDispatcher = Substitute.For(); + var effects = new EventLogEffects( mockEventLogState, mockFilterService, @@ -1180,9 +1410,8 @@ private static (EventLogEffects effects, IDispatcher mockDispatcher) CreateEffec Substitute.For(), mockServiceScopeFactory, mockDatabaseService, - Substitute.For()); - - var mockDispatcher = Substitute.For(); + Substitute.For(), + mockDispatcher); return (effects, mockDispatcher); } @@ -1266,6 +1495,8 @@ private static (EventLogEffects effects, var mockBannerService = Substitute.For(); + var mockDispatcher = Substitute.For(); + var effects = new EventLogEffects( mockEventLogState, Substitute.For(), @@ -1275,9 +1506,8 @@ private static (EventLogEffects effects, Substitute.For(), mockServiceScopeFactory, mockDatabaseService, - mockBannerService); - - var mockDispatcher = Substitute.For(); + mockBannerService, + mockDispatcher); return (effects, mockDispatcher, mockServiceProvider, mockBannerService, mockDatabaseService); } @@ -1311,6 +1541,8 @@ private static (EventLogEffects effects, var mockLogger = Substitute.For(); var mockLogWatcherService = Substitute.For(); + mockLogWatcherService.RemoveLogAsync(Arg.Any()).Returns(Task.CompletedTask); + mockLogWatcherService.RemoveAllAsync().Returns(Task.CompletedTask); var mockResolverCache = Substitute.For(); var mockServiceScopeFactory = Substitute.For(); @@ -1323,6 +1555,8 @@ private static (EventLogEffects effects, var mockDatabaseService = Substitute.For(); mockDatabaseService.InitialClassificationTask.Returns(Task.CompletedTask); + var mockDispatcher = Substitute.For(); + var effects = new EventLogEffects( mockEventLogState, mockFilterService, @@ -1332,9 +1566,8 @@ private static (EventLogEffects effects, Substitute.For(), mockServiceScopeFactory, mockDatabaseService, - Substitute.For()); - - var mockDispatcher = Substitute.For(); + Substitute.For(), + mockDispatcher); return (effects, mockDispatcher, mockLogWatcherService, mockResolverCache, mockFilterService); } diff --git a/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs b/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs index 66c8fddc..e745ea0d 100644 --- a/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs +++ b/src/EventLogExpert.UI/Interfaces/IDatabaseService.cs @@ -40,7 +40,10 @@ Task ImportAsync( void Refresh(); - void Remove(string fileName); + Task RemoveAsync( + string fileName, + Func? prepareForDeletionAsync = null, + CancellationToken cancellationToken = default); Task RestoreFromBackupAsync(string fileName, CancellationToken cancellationToken = default); diff --git a/src/EventLogExpert.UI/Services/DatabaseService.cs b/src/EventLogExpert.UI/Services/DatabaseService.cs index 90f6da79..246dda2d 100644 --- a/src/EventLogExpert.UI/Services/DatabaseService.cs +++ b/src/EventLogExpert.UI/Services/DatabaseService.cs @@ -28,7 +28,7 @@ public sealed partial class DatabaseService : IDatabaseService, IDatabaseCollect private readonly ITraceLogger _traceLogger; private readonly Channel _upgradeQueue = Channel.CreateUnbounded( new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); - + private int _disposed; private ImmutableList _entries = []; private int _queuedBatchCount; @@ -469,9 +469,34 @@ public void Refresh() RaiseEntriesChanged(); } - public void Remove(string fileName) + public async Task RemoveAsync( + string fileName, + Func? prepareForDeletionAsync = null, + CancellationToken cancellationToken = default) { - using var reservation = ReserveFileOperation(fileName, nameof(Remove)); + cancellationToken.ThrowIfCancellationRequested(); + + using var reservation = ReserveFileOperation(fileName, nameof(RemoveAsync)); + + // Wait for initial classification so callers don't race with the background scan + // (which can mutate IsEnabled / Status / BackupExists for this entry mid-flight). + try + { + await InitialClassificationTask.ConfigureAwait(false); + } + catch (Exception ex) + { + SafeLog(() => _traceLogger.Trace( + $"{nameof(DatabaseService)}.{nameof(RemoveAsync)}: InitialClassificationTask faulted unexpectedly: {ex}")); + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Phase 1: disable the entry under the mutation lock so EventResolver can no longer + // pick this database up on its next construction. Capture wasEnabled so we can + // restore on rollback if a later phase fails. RaiseEntriesChanged outside the lock. + bool wasEnabled; + bool entryFound; lock (_mutationLock) { @@ -480,17 +505,82 @@ public void Remove(string fileName) if (index < 0) { throw new InvalidOperationException( - $"{nameof(DatabaseService)}.{nameof(Remove)}: no entry found with file name '{fileName}'."); + $"{nameof(DatabaseService)}.{nameof(RemoveAsync)}: no entry found with file name '{fileName}'."); + } + + var current = _entries[index]; + wasEnabled = current.IsEnabled; + entryFound = true; + + if (wasEnabled) + { + _entries = _entries.SetItem(index, current with { IsEnabled = false }); + PersistDisabled(_entries); + } + } + + if (entryFound && wasEnabled) { RaiseEntriesChanged(); } + + // Phase 2: let the caller close any open log views before we touch the file. + // Always invoked when a callback is supplied — a previously-disabled entry can still + // be referenced by IEventResolver instances constructed before the disable (e.g., the + // user disabled this DB and declined the modal-close reload prompt). Those resolvers + // hold DbContexts whose connections are NOT in the pool until the per-event service + // scope disposes, so ClearAllPools below cannot release them and File.Delete races. + if (prepareForDeletionAsync is not null) + { + try + { + await prepareForDeletionAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + SafeLog(() => _traceLogger.Warn( + $"{nameof(DatabaseService)}.{nameof(RemoveAsync)}: prepare-for-deletion callback failed: {ex}")); + + RestoreEnabledIfChanged(fileName, wasEnabled); + + throw; } + } + + // Phase 3: clear the SQLite connection pool and delete the files. Pool clear is + // required so the OS file handles backing the pooled connections are closed; the + // closed-but-pooled connections still hold the file handle without + // FILE_SHARE_DELETE on Windows. + try + { + SqliteConnection.ClearAllPools(); DeleteDatabaseFiles(fileName); + } + catch (Exception ex) + { + SafeLog(() => _traceLogger.Warn( + $"{nameof(DatabaseService)}.{nameof(RemoveAsync)}: deleting files for '{fileName}' failed: {ex}")); - ImmutableList nextSnapshot = _entries.RemoveAt(index); - PersistDisabled(nextSnapshot); - _entries = nextSnapshot; + RestoreEnabledIfChanged(fileName, wasEnabled); + + throw; } - RaiseEntriesChanged(); + // Phase 4: drop the entry from the snapshot. Re-find by fileName under the lock — + // the snapshot reference captured in Phase 1 is stale because Phase 1 mutated it. + bool removed = false; + + lock (_mutationLock) + { + var index = FindEntryIndex(_entries, fileName); + + if (index >= 0) + { + _entries = _entries.RemoveAt(index); + PersistDisabled(_entries); + removed = true; + } + } + + if (removed) { RaiseEntriesChanged(); } } public async Task RestoreFromBackupAsync(string fileName, CancellationToken cancellationToken = default) @@ -520,22 +610,43 @@ public async Task RestoreFromBackupAsync(string fileName, CancellationToke public void Toggle(string fileName) { - lock (_mutationLock) + // Defense-in-depth: the UI disables toggle while a remove/import/upgrade is in + // flight, but a stale click could still reach here. ReserveFileOperation throws + // if another op holds the reservation; catch + log so the UI sees a no-op rather + // than an unhandled exception. + FileOperationReservation reservation; + + try { - var index = FindEntryIndex(_entries, fileName); + reservation = ReserveFileOperation(fileName, nameof(Toggle)); + } + catch (InvalidOperationException ex) + { + SafeLog(() => _traceLogger.Trace( + $"{nameof(DatabaseService)}.{nameof(Toggle)}: skipping toggle of '{fileName}' because another operation is in progress: {ex.Message}")); - if (index < 0) + return; + } + + using (reservation) + { + lock (_mutationLock) { - throw new InvalidOperationException( - $"{nameof(DatabaseService)}.{nameof(Toggle)}: no entry found with file name '{fileName}'."); - } + var index = FindEntryIndex(_entries, fileName); - var current = _entries[index]; - var updated = current with { IsEnabled = !current.IsEnabled }; - ImmutableList nextSnapshot = _entries.SetItem(index, updated); + if (index < 0) + { + throw new InvalidOperationException( + $"{nameof(DatabaseService)}.{nameof(Toggle)}: no entry found with file name '{fileName}'."); + } - PersistDisabled(nextSnapshot); - _entries = nextSnapshot; + var current = _entries[index]; + var updated = current with { IsEnabled = !current.IsEnabled }; + ImmutableList nextSnapshot = _entries.SetItem(index, updated); + + PersistDisabled(nextSnapshot); + _entries = nextSnapshot; + } } RaiseEntriesChanged(); @@ -1063,8 +1174,7 @@ private async Task ProcessBatchAsync(UpgradeBatch batch) } } - private void RaiseEntriesChanged() - { + private void RaiseEntriesChanged() { var handler = EntriesChanged; if (handler is null) { return; } @@ -1105,6 +1215,31 @@ private FileOperationReservation ReserveFileOperation(string fileName, string ca return new FileOperationReservation(this, fileName); } + private void RestoreEnabledIfChanged(string fileName, bool wasEnabled) + { + bool changed = false; + + // Re-find by fileName under the lock — the Phase 1 snapshot is stale by now. + // Only mutate the IsEnabled field on whatever entry matches; status / backup / + // other fields may have been updated by classification or another path. + lock (_mutationLock) + { + var index = FindEntryIndex(_entries, fileName); + + if (index < 0) { return; } + + var current = _entries[index]; + + if (current.IsEnabled == wasEnabled) { return; } + + _entries = _entries.SetItem(index, current with { IsEnabled = wasEnabled }); + PersistDisabled(_entries); + changed = true; + } + + if (changed) { RaiseEntriesChanged(); } + } + private bool RestoreFilesCore(DatabaseEntry entry) { var mainPath = entry.FullPath; diff --git a/src/EventLogExpert.UI/Store/EventLog/EventLogEffects.cs b/src/EventLogExpert.UI/Store/EventLog/EventLogEffects.cs index 9b3fa388..2f2da333 100644 --- a/src/EventLogExpert.UI/Store/EventLog/EventLogEffects.cs +++ b/src/EventLogExpert.UI/Store/EventLog/EventLogEffects.cs @@ -29,19 +29,47 @@ public sealed class EventLogEffects( IEventXmlResolver xmlResolver, IServiceScopeFactory serviceScopeFactory, IDatabaseService databaseService, - IBannerService bannerService) + IBannerService bannerService, + IDispatcher dispatcher) : ILogReloadCoordinator { + /// Defensive timeout when awaiting a log close — both for the load task to + /// unwind (so the LoadLogAsync service scope disposes and releases its event resolver) + /// and for the watcher callbacks to drain (so per-event resolver scopes finish releasing + /// their pooled SQLite handles). 30 seconds is generous; in practice each completes in + /// milliseconds once cancellation is propagated. + public static readonly TimeSpan LogCloseTimeout = TimeSpan.FromSeconds(30); + private static readonly int s_maxGlobalConcurrency = Math.Max(1, Environment.ProcessorCount - 1); private static readonly SemaphoreSlim s_resolutionThrottle = new(s_maxGlobalConcurrency, s_maxGlobalConcurrency); private readonly IBannerService _bannerService = bannerService; private readonly IDatabaseService _databaseService = databaseService; + private readonly IDispatcher _dispatcher = dispatcher; private readonly IState _eventLogState = eventLogState; private readonly IFilterService _filterService = filterService; private readonly Lock _globalCtsLock = new(); + private readonly ConcurrentDictionary _logCloseCompletions = new(); + + /// Serializes 's XML-reload path with + /// . Both write into + /// with raw assignment; concurrent overlap on the same log id would orphan the first caller's + /// TCS (HandleCloseLog signals whichever is currently in the dict, the orphaned awaiter then + /// hits the 30s timeout). The lock ensures only one coordinator pre-registers, dispatches, + /// and awaits at a time. does NOT take this lock — it only + /// signals/removes existing entries and would deadlock against a coordinator holding the lock + /// and awaiting it. + private readonly SemaphoreSlim _logCloseCoordinatorLock = new(1, 1); + private readonly ConcurrentDictionary _logCts = new(); private readonly ITraceLogger _logger = logger; + /// Tracks per-log load completion so can wait for + /// the in-flight service scope to dispose before draining the + /// watcher. Without this, cts.Cancel() merely requests cancellation — LoadLogAsync's + /// service scope (which owns the IEventResolver and its SQLite handles) only disposes + /// once HandleOpenLog's outer using/finally runs. + private readonly ConcurrentDictionary _logLoadCompletions = new(); + /// Tracks which currently-open logs (by ) were loaded /// with renderXml=true. A reload-on-transition only re-opens logs that lack XML; logs that /// already have it are left alone. Removing or disabling an XML filter never triggers a @@ -107,11 +135,11 @@ public Task HandleAddEvent(EventLogAction.AddEvent action, IDispatcher dispatche } [EffectMethod(typeof(EventLogAction.CloseAll))] - public Task HandleCloseAll(IDispatcher dispatcher) + public async Task HandleCloseAll(IDispatcher dispatcher) { CancelAllLoads(); - _logWatcherService.RemoveAll(); + await _logWatcherService.RemoveAllAsync(); dispatcher.Dispatch(new EventTableAction.CloseAll()); dispatcher.Dispatch(new StatusBarAction.CloseAll()); @@ -120,35 +148,65 @@ public Task HandleCloseAll(IDispatcher dispatcher) _xmlResolver.ClearAll(); _logsLoadedWithXml.Clear(); _pendingSelectionRestore.Clear(); - - return Task.CompletedTask; } [EffectMethod] - public Task HandleCloseLog(EventLogAction.CloseLog action, IDispatcher dispatcher) + public async Task HandleCloseLog(EventLogAction.CloseLog action, IDispatcher dispatcher) { - if (_logCts.TryGetValue(action.LogId, out var cts)) + try { - try { cts.Cancel(); } - catch (ObjectDisposedException) { /* CTS already disposed; cancel is moot. */ } - } + if (_logCts.TryGetValue(action.LogId, out var cts)) + { + try { cts.Cancel(); } + catch (ObjectDisposedException) { /* CTS already disposed; cancel is moot. */ } + } + + // Wait for the load task to fully unwind. Cancellation only requests stop; + // LoadLogAsync's service scope (which owns the IEventResolver and its SQLite + // handles) is not disposed until HandleOpenLog's outer using/finally runs. + // Defensive timeout so a wedged load can't deadlock close forever. + if (_logLoadCompletions.TryGetValue(action.LogId, out var loadCompletion)) + { + try + { + await loadCompletion.Task.WaitAsync(LogCloseTimeout); + } + catch (TimeoutException) + { + _logger.Trace($"{nameof(HandleCloseLog)}: load task for '{action.LogName}' did not unwind within {LogCloseTimeout}."); + } + } - _logWatcherService.RemoveLog(action.LogName); - _logsLoadedWithXml.TryRemove(action.LogId, out _); - _pendingSelectionRestore.TryRemove(action.LogName, out _); + // Drain watcher callbacks. RemoveLogAsync's Task only completes after + // EventLogWatcher.Unsubscribe blocks for all in-flight ProcessNewEvents + // callbacks → all per-event resolver scopes disposed → all DbContexts + // disposed → connections returned to the SQLite pool. + await _logWatcherService.RemoveLogAsync(action.LogName); - // Drop any cached XML for this log; if the same name reopens later (e.g., post-rotation) - // a fresh resolve must occur instead of returning stale text from a different file. - _xmlResolver.ClearLog(action.LogName); + _logsLoadedWithXml.TryRemove(action.LogId, out _); + _pendingSelectionRestore.TryRemove(action.LogName, out _); - dispatcher.Dispatch(new EventTableAction.CloseLog(action.LogId)); + // Drop any cached XML for this log; if the same name reopens later (e.g., post-rotation) + // a fresh resolve must occur instead of returning stale text from a different file. + _xmlResolver.ClearLog(action.LogName); - if (_eventLogState.Value.ActiveLogs.IsEmpty) + dispatcher.Dispatch(new EventTableAction.CloseLog(action.LogId)); + + if (_eventLogState.Value.ActiveLogs.IsEmpty) + { + _resolverCache.ClearAll(); + } + } + finally { - _resolverCache.ClearAll(); + // Signal the coordinator (if it pre-registered for this log id) that all close + // work — load-await + watcher-drain + side effects — is done. Removing keeps + // the dictionary from leaking entries from non-coordinator close paths. + if (_logCloseCompletions.TryRemove(action.LogId, out var closeCompletion)) + { + closeCompletion.TrySetResult(); + } } - - return Task.CompletedTask; } [EffectMethod] @@ -223,6 +281,7 @@ public async Task HandleOpenLog(EventLogAction.OpenLog action, IDispatcher dispa } _logCts[logData.Id] = perLoadCts; + _logLoadCompletions[logData.Id] = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // If CancelAllLoads ran between our generation snapshot and now, we may have // linked to the new (uncanceled) global CTS after the swap. CancelAllLoads's @@ -282,6 +341,15 @@ public async Task HandleOpenLog(EventLogAction.OpenLog action, IDispatcher dispa { removedCts.Dispose(); } + + // Signal load completion AFTER the using/finally above has disposed the + // service scope. HandleCloseLog awaits this TCS so it can guarantee the + // resolver and its SQLite handles are released before the watcher is + // drained and the file is deleted. + if (_logLoadCompletions.TryRemove(logData.Id, out var loadCompletion)) + { + loadCompletion.TrySetResult(); + } } } @@ -338,23 +406,71 @@ public async Task HandleSetFilters(EventLogAction.SetFilters action, IDispatcher } // Close + reopen only the logs that lack XML. Other open logs keep their state. - // CloseLog is dispatched first (and clears any prior _pendingSelectionRestore entry - // for that log name), then we populate the restore map, then OpenLog kicks off the - // new load which will consume the restore entry in HandleLoadEvents. - foreach (var (id, name, _) in logsNeedingReload) - { - dispatcher.Dispatch(new EventLogAction.CloseLog(id, name)); - } + // HandleCloseLog clears _pendingSelectionRestore for the log name as part of its + // async cleanup (after awaiting watcher shutdown). Pre-register a close-completion + // TCS for each log, dispatch the closes, then AWAIT each completion before + // populating the restore map — otherwise an in-flight HandleCloseLog can wipe the + // freshly-written entry and silently lose the user's selection. + // + // The semaphore serializes this path with PrepareForDatabaseRemovalAsync: both + // write into _logCloseCompletions with raw assignment, so concurrent overlap on + // the same log id would orphan the first caller's TCS (HandleCloseLog signals + // whichever entry happens to be in the dict at the time). + await _logCloseCoordinatorLock.WaitAsync(); - foreach (var (name, ids) in selectionByLog) + try { - long? selectedIdForLog = string.Equals(name, selectedLogName, StringComparison.Ordinal) ? selectedRecordId : null; - _pendingSelectionRestore[name] = new PendingSelectionRestore(ids, selectedIdForLog); - } + var closeWaiters = new List<(EventLogId Id, string Name, Task Task)>(logsNeedingReload.Count); + + foreach (var (id, name, _) in logsNeedingReload) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _logCloseCompletions[id] = tcs; + closeWaiters.Add((id, name, tcs.Task)); + } + + foreach (var (id, name, _) in logsNeedingReload) + { + dispatcher.Dispatch(new EventLogAction.CloseLog(id, name)); + } + + var timedOutLogs = new HashSet(StringComparer.Ordinal); + + foreach (var (id, name, task) in closeWaiters) + { + try + { + await task.WaitAsync(LogCloseTimeout); + } + catch (TimeoutException) + { + _logCloseCompletions.TryRemove(id, out _); + timedOutLogs.Add(name); + _logger.Trace($"{nameof(HandleSetFilters)}: close for log '{name}' did not complete within {LogCloseTimeout}; selection will not be restored to avoid race with the delayed close wiping the entry."); + } + } + + foreach (var (name, ids) in selectionByLog) + { + if (timedOutLogs.Contains(name)) + { + // The delayed HandleCloseLog will eventually call _pendingSelectionRestore + // .TryRemove(name) — writing here would be silently wiped. Skip. + continue; + } + + long? selectedIdForLog = string.Equals(name, selectedLogName, StringComparison.Ordinal) ? selectedRecordId : null; + _pendingSelectionRestore[name] = new PendingSelectionRestore(ids, selectedIdForLog); + } - foreach (var (_, name, type) in logsNeedingReload) + foreach (var (_, name, type) in logsNeedingReload) + { + dispatcher.Dispatch(new EventLogAction.OpenLog(name, type)); + } + } + finally { - dispatcher.Dispatch(new EventLogAction.OpenLog(name, type)); + _logCloseCoordinatorLock.Release(); } return; @@ -435,6 +551,108 @@ public async Task HandleSetFilters(EventLogAction.SetFilters action, IDispatcher } } + public async Task PrepareForDatabaseRemovalAsync(LogReopenSnapshot snapshot, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(snapshot); + + var activeLogs = _eventLogState.Value.ActiveLogs.Values.ToList(); + + if (activeLogs.Count == 0) { return; } + + // Serialize with HandleSetFilters' XML-reload path (see _logCloseCoordinatorLock + // doc-comment). Without this lock a concurrent reload-on-XML-filter could overwrite + // our pre-registered TCSes (or vice versa), orphaning awaiters until they hit the + // 30s timeout. + await _logCloseCoordinatorLock.WaitAsync(cancellationToken); + + try + { + // Snapshot selection state BEFORE dispatching any CloseLog action — ReduceCloseLog + // synchronously wipes SelectedEvents/SelectedEvent from state, so reading them after + // the dispatch loses everything. + var reloadNames = activeLogs.Select(l => l.Name).ToHashSet(StringComparer.Ordinal); + + var selectionByLog = _eventLogState.Value.SelectedEvents + .Where(e => e.RecordId.HasValue && reloadNames.Contains(e.OwningLog)) + .GroupBy(e => e.OwningLog) + .ToDictionary(g => g.Key, g => (IReadOnlySet)g.Select(e => e.RecordId!.Value).ToHashSet()); + + var selectedRecordId = _eventLogState.Value.SelectedEvent?.RecordId; + var selectedLogName = _eventLogState.Value.SelectedEvent?.OwningLog; + + if (selectedRecordId.HasValue + && !string.IsNullOrEmpty(selectedLogName) + && reloadNames.Contains(selectedLogName) + && !selectionByLog.ContainsKey(selectedLogName)) + { + selectionByLog[selectedLogName] = new HashSet(); + } + + // Pre-register the close-completion TCSes BEFORE dispatching, so HandleCloseLog + // is guaranteed to find an entry to signal (the dispatcher might queue HandleCloseLog + // before this method's next statement runs). + var waiters = new List<(EventLogId Id, string Name, PathType Type, Task Task)>(activeLogs.Count); + + foreach (var log in activeLogs) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _logCloseCompletions[log.Id] = tcs; + waiters.Add((log.Id, log.Name, log.Type, tcs.Task)); + } + + foreach (var (id, name, _, _) in waiters) + { + _dispatcher.Dispatch(new EventLogAction.CloseLog(id, name)); + } + + // Await completion per-log so we can populate the snapshot incrementally. If a later + // log times out or cancels, the caller still gets the prefix that closed cleanly and + // can reopen them in the finally block. + foreach (var (id, name, type, task) in waiters) + { + try + { + await task.WaitAsync(LogCloseTimeout, cancellationToken); + + snapshot.Add(new LogReopenInfo(name, type)); + } + catch (Exception ex) when (ex is TimeoutException or OperationCanceledException) + { + // The TCS will never be signaled now (HandleCloseLog already removed itself + // from the dict if it ran, or won't find us if it didn't). Drop the stranded + // entry so the dictionary doesn't leak. + _logCloseCompletions.TryRemove(id, out _); + _logger.Trace($"{nameof(PrepareForDatabaseRemovalAsync)}: close for log '{name}' did not complete: {ex.GetType().Name}"); + + throw; + } + } + + // NOW write pending selection restores. HandleCloseLog wiped any prior entries + // for these log names during its TryRemove(_pendingSelectionRestore) call, so we + // know we're not racing with stale state. + foreach (var (name, ids) in selectionByLog) + { + long? selectedIdForLog = string.Equals(name, selectedLogName, StringComparison.Ordinal) ? selectedRecordId : null; + _pendingSelectionRestore[name] = new PendingSelectionRestore(ids, selectedIdForLog); + } + } + finally + { + _logCloseCoordinatorLock.Release(); + } + } + + public void ReopenAfterDatabaseRemoval(IReadOnlyList snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + + foreach (var entry in snapshot) + { + _dispatcher.Dispatch(new EventLogAction.OpenLog(entry.Name, entry.Type)); + } + } + /// Adds new events to the currently opened log private static EventLogData AddEventsToOneLog(EventLogData logData, List eventsToAdd) { diff --git a/src/EventLogExpert.UI/Store/EventLog/ILogReloadCoordinator.cs b/src/EventLogExpert.UI/Store/EventLog/ILogReloadCoordinator.cs new file mode 100644 index 00000000..5acacd4b --- /dev/null +++ b/src/EventLogExpert.UI/Store/EventLog/ILogReloadCoordinator.cs @@ -0,0 +1,40 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Helpers; + +namespace EventLogExpert.UI.Store.EventLog; + +/// +/// Bridges DatabaseService delete operations and the EventLogEffects log lifecycle. Lets the database layer ask +/// the log layer to close every active log (so SQLite handles are released) and later reopen exactly the logs that +/// closed cleanly. Implemented by EventLogEffects so close/open dispatches share the same TCS dictionaries that +/// already track per-log load and close completion. +/// +public interface ILogReloadCoordinator +{ + Task PrepareForDatabaseRemovalAsync(LogReopenSnapshot snapshot, CancellationToken cancellationToken = default); + + void ReopenAfterDatabaseRemoval(IReadOnlyList snapshot); +} + +public sealed record LogReopenInfo(string Name, PathType Type); + +/// +/// Mutable container that the coordinator populates as each active log finishes closing. Callers pass an empty +/// snapshot in to and then unconditionally pass +/// to in their finally +/// block — that way logs that closed cleanly always get reopened, even when a later phase (file delete, reservation, +/// etc.) throws before all logs were processed. +/// +public sealed class LogReopenSnapshot +{ + private readonly List _items = []; + + public IReadOnlyList Items => _items; + + internal void Add(LogReopenInfo info) + { + _items.Add(info); + } +} diff --git a/src/EventLogExpert.UI/Store/EventLog/LiveLogWatcherService.cs b/src/EventLogExpert.UI/Store/EventLog/LiveLogWatcherService.cs index ec87b581..08b7bdfa 100644 --- a/src/EventLogExpert.UI/Store/EventLog/LiveLogWatcherService.cs +++ b/src/EventLogExpert.UI/Store/EventLog/LiveLogWatcherService.cs @@ -13,9 +13,9 @@ public interface ILogWatcherService { void AddLog(string logName, string? bookmark, bool renderXml = false); - void RemoveAll(); + Task RemoveAllAsync(); - void RemoveLog(string logName); + Task RemoveLogAsync(string logName); } public sealed class LiveLogWatcherService : ILogWatcherService @@ -45,7 +45,10 @@ public LiveLogWatcherService( { if (isFull) { - StopWatching(); + // Buffer-full is a state change, not a coordinated delete: fire-and-forget + // is fine because nothing downstream is waiting for the old per-event + // resolver scopes to finish disposing. + _ = StopAllWatchersAsync(); } else { @@ -71,7 +74,7 @@ public void AddLog(string logName, string? bookmark, bool renderXml = false) // If this is the first log added, or if we're already watching // other logs, then we need to start watching this one. // - // If we have _logsToWatch but no watchers, that means StopWatching() + // If we have _logsToWatch but no watchers, that means StopAllWatchersAsync() // was called due to a full buffer. In that case we do not want to // start watching the new log. if (_logsToWatch.Count == 1 || IsWatching()) @@ -80,25 +83,51 @@ public void AddLog(string logName, string? bookmark, bool renderXml = false) } } - public void RemoveAll() + public Task RemoveAllAsync() { - using var scope = _watchersLock.EnterScope(); + List logNames; + + using (_watchersLock.EnterScope()) + { + // Snapshot the names before iterating so RemoveLogAsync can mutate + // _logsToWatch without invalidating our enumerator. + logNames = [.. _logsToWatch]; + } + + var tasks = new List(logNames.Count); - while (_logsToWatch.Count > 0) + foreach (var logName in logNames) { - RemoveLog(_logsToWatch[0]); + tasks.Add(RemoveLogAsync(logName)); } + + return Task.WhenAll(tasks); } - public void RemoveLog(string logName) + public Task RemoveLogAsync(string logName) { - using var scope = _watchersLock.EnterScope(); + EventLogWatcher? watcher; + + using (_watchersLock.EnterScope()) + { + _logsToWatch.Remove(logName); + _bookmarks.Remove(logName); + _renderXmlByLog.Remove(logName); - _logsToWatch.Remove(logName); - _bookmarks.Remove(logName); - _renderXmlByLog.Remove(logName); + if (!_watchers.Remove(logName, out watcher)) { return Task.CompletedTask; } + } + + // Always dispose on a background thread to avoid a deadlock if this is + // called on the UI thread. EventLogWatcher.Unsubscribe blocks until all + // in-flight ProcessNewEvents callbacks have completed (so the per-event + // service scopes — and the SQLite handles they hold — are released + // before this Task completes). + return Task.Run(() => + { + watcher.Dispose(); - StopWatching(logName); + _debugLogger.Info($"{nameof(LiveLogWatcherService)} disposed the old watcher for log {logName}."); + }); } private bool IsWatching() @@ -175,36 +204,40 @@ private void StartWatching(string logName) }); } - private void StopWatching() + private Task StopAllWatchersAsync() { - using var scope = _watchersLock.EnterScope(); + List logNames; + + using (_watchersLock.EnterScope()) + { + // Snapshot keys before iterating; StopWatchingAsync mutates the dict. + logNames = [.. _watchers.Keys]; + } + + var tasks = new List(logNames.Count); - foreach (var logName in _watchers.Keys) + foreach (var logName in logNames) { - StopWatching(logName); + tasks.Add(StopWatchingAsync(logName)); } + + return Task.WhenAll(tasks); } - private void StopWatching(string logName) + private Task StopWatchingAsync(string logName) { - using var scope = _watchersLock.EnterScope(); + EventLogWatcher? watcher; - if (!_watchers.Remove(logName, out var watcher)) + using (_watchersLock.EnterScope()) { - return; + if (!_watchers.Remove(logName, out watcher)) { return Task.CompletedTask; } } - // Always do this on a background thread to avoid a deadlock - // if this is called on the UI thread. EventLogWatcher.Enabled = false - // will cause the thread to block until all outstanding callbacks - // have completed. - Task.Run(() => + return Task.Run(() => { watcher.Dispose(); _debugLogger.Info($"{nameof(LiveLogWatcherService)} disposed the old watcher for log {logName}."); }); - - _debugLogger.Debug($"{nameof(LiveLogWatcherService)} dispatched a task to stop the watcher for log {logName}."); } } diff --git a/src/EventLogExpert/MauiProgram.cs b/src/EventLogExpert/MauiProgram.cs index db90fa74..71bee1cb 100644 --- a/src/EventLogExpert/MauiProgram.cs +++ b/src/EventLogExpert/MauiProgram.cs @@ -42,6 +42,13 @@ public static MauiApp CreateMauiApp() options.AddMiddleware(); }); + // EventLogEffects implements ILogReloadCoordinator. Fluxor registers Effects as singletons + // by assembly scan; resolve the same instance through the coordinator interface so callers + // (SettingsModal) get the single per-app instance with its dictionaries of in-flight loads + // and close completions. + builder.Services.AddSingleton(sp => + sp.GetRequiredService()); + // Core Services builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs b/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs index 7076d2d7..3ab75b56 100644 --- a/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs +++ b/src/EventLogExpert/Shared/Components/SettingsModal.razor.cs @@ -39,6 +39,8 @@ public sealed partial class SettingsModal : ModalBase [Inject] private IState EventLogState { get; init; } = null!; + [Inject] private ILogReloadCoordinator LogReloadCoordinator { get; init; } = null!; + private bool IsClassificationPending => !DatabaseService.InitialClassificationTask.IsCompleted; private bool IsAnyUpgradeInFlight => _entriesUpgrading.Count > 0 || BannerService.SettingsProgress is not null; @@ -304,7 +306,7 @@ private async Task RemoveDatabase(DatabaseEntry entry) var isReadyAndEnabled = entry is { IsEnabled: true, Status: DatabaseStatus.Ready }; var message = isReadyAndEnabled - ? $"{fileName} is currently enabled. Removing will affect open log views. Are you sure?" + ? $"{fileName} is currently enabled. Removing will close and reopen any affected log views. Are you sure?" : $"Are you sure you want to remove {fileName}?"; var confirmed = await AlertDialogService.ShowAlert("Remove Database", @@ -314,10 +316,17 @@ private async Task RemoveDatabase(DatabaseEntry entry) if (!confirmed) { return; } + // Sink populated incrementally as the coordinator closes each log; the finally + // block reopens every log that successfully closed regardless of whether the + // delete succeeded or threw downstream. + var snapshot = new LogReopenSnapshot(); + try { - DatabaseService.Remove(fileName); - _pendingToggles.Remove(fileName); + await DatabaseService.RemoveAsync( + fileName, + prepareForDeletionAsync: ct => + LogReloadCoordinator.PrepareForDatabaseRemovalAsync(snapshot, ct)); } catch (Exception ex) { @@ -325,6 +334,23 @@ await AlertDialogService.ShowAlert("Failed to Remove Database", $"An exception occurred while removing provider databases: {ex.Message}", "OK"); } + finally + { + if (snapshot.Items.Count > 0) + { + LogReloadCoordinator.ReopenAfterDatabaseRemoval(snapshot.Items); + + // Coordinator just rebuilt the resolver scopes for these logs against the + // current enabled-database set, which subsumes any prior dirty state from + // earlier modal actions. Suppress the modal-close global reload prompt so + // the user isn't asked twice. When no logs were reopened we leave the flag + // alone so unrelated prior mutations (e.g., a toggle earlier in the same + // modal session) still trigger their pending reload on close. + _databaseStateChanged = false; + } + + _pendingToggles.Remove(fileName); + } } private async Task> ResolveImportConflictsAsync( From c1c6e06f29eb7b9b39099a17294028badcfa3a53 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Sun, 3 May 2026 10:41:06 -0500 Subject: [PATCH 29/31] Reveal database row trash via name click with recessed left strip --- .../DatabaseEntryRowTests.cs | 42 ++++--- .../DatabaseEntryRow.razor | 104 +++++++++--------- .../DatabaseEntryRow.razor.cs | 21 ++-- .../DatabaseEntryRow.razor.css | 80 +++++++++++--- .../Shared/Components/SettingsModal.razor | 3 +- .../Shared/Components/SettingsModal.razor.cs | 3 +- src/EventLogExpert/wwwroot/css/app.css | 6 + 7 files changed, 159 insertions(+), 100 deletions(-) diff --git a/src/EventLogExpert.Components.Tests/DatabaseEntryRowTests.cs b/src/EventLogExpert.Components.Tests/DatabaseEntryRowTests.cs index 236f75a7..b38abd62 100644 --- a/src/EventLogExpert.Components.Tests/DatabaseEntryRowTests.cs +++ b/src/EventLogExpert.Components.Tests/DatabaseEntryRowTests.cs @@ -49,9 +49,9 @@ public void Render_BackupExistsAndIsUpgrading_StillShowsRecoveryBadge() Assert.Empty(component.FindAll(".db-entry-upgrade-btn")); Assert.Empty(component.FindAll(".toggle")); - // BackupExists hides trash so the user is steered to the recovery dialog instead - // of triggering a delete that would leave the .upgrade.bak orphaned. - Assert.Empty(component.FindAll(".db-entry-remove-btn")); + // Trash is unconditionally rendered now; visibility is governed by the CSS + // hover/focus reveal animation, not by markup. + Assert.Single(component.FindAll(".db-entry-remove-btn")); } [Fact] @@ -67,14 +67,15 @@ public void Render_BackupExistsEntry_OverridesReadyStatus() var badge = component.Find(".db-entry-badge"); Assert.Equal("Recovery required", badge.TextContent); Assert.Empty(component.FindAll(".toggle")); - Assert.Empty(component.FindAll(".db-entry-remove-btn")); + Assert.Single(component.FindAll(".db-entry-remove-btn")); } [Fact] - public void Render_BackupExistsEntry_ShowsRecoveryRequiredBadge_AndHidesTrash() + public void Render_BackupExistsEntry_ShowsRecoveryRequiredBadge_AndShowsTrash() { - // Arrange — BackupExists routes the user to the recovery dialog, so the row hides - // trash to prevent a delete that would orphan the .upgrade.bak file. + // Arrange — BackupExists routes the user to the recovery dialog as the primary + // action, but the trash is still rendered (revealed by hover/focus) so a user + // who wants to abandon the entry entirely can do so. var entry = MakeEntry(DatabaseStatus.UpgradeRequired, backupExists: true); // Act @@ -89,7 +90,7 @@ public void Render_BackupExistsEntry_ShowsRecoveryRequiredBadge_AndHidesTrash() Assert.Empty(component.FindAll(".toggle")); Assert.Empty(component.FindAll(".db-entry-upgrading")); - Assert.Empty(component.FindAll(".db-entry-remove-btn")); + Assert.Single(component.FindAll(".db-entry-remove-btn")); } [Fact] @@ -97,18 +98,14 @@ public void Render_DisabledEntries_ShowTrashButton() { foreach (var status in Enum.GetValues()) { - // UpgradeRequired hides trash to steer the user to Upgrade first; it has its - // own dedicated test below. - if (status == DatabaseStatus.UpgradeRequired) { continue; } - // Arrange var entry = MakeEntry(status); // Act var component = RenderRow(entry); - // Assert — non-BackupExists, non-Upgrading entries always render trash - // (visibility is governed by CSS hover/focus reveal, not by markup). + // Assert — every status renders the trash; visibility is governed by the + // CSS hover/focus reveal animation, not by markup. Assert.Single(component.FindAll(".db-entry-remove-btn")); } } @@ -171,9 +168,9 @@ public void Render_IsUpgrading_ShowsSpinner_AndHidesBadge() Assert.Empty(component.FindAll(".db-entry-upgrade-btn")); Assert.Empty(component.FindAll(".db-entry-badge")); - // IsUpgrading hides trash so a delete cannot race with the in-flight upgrade - // (which is sitting on a per-file reservation inside DatabaseService). - Assert.Empty(component.FindAll(".db-entry-remove-btn")); + // Trash is rendered even during an in-flight upgrade; RemoveAsync coordinates + // with the per-file reservation so the user-initiated delete is safe to expose. + Assert.Single(component.FindAll(".db-entry-remove-btn")); } [Fact] @@ -325,19 +322,18 @@ public void Render_UpgradeFailedEntry_ShowsRetryButton_AndRedBadge() } [Fact] - public void Render_UpgradeRequiredEntry_HidesTrashButton() + public void Render_UpgradeRequiredEntry_ShowsTrashButton() { - // Arrange — UpgradeRequired hides trash so the user is steered to either Upgrade - // (resolving the V3 → V4 transition) or — once they give up and the entry transitions - // to UpgradeFailed — Remove. Allowing direct removal of an UpgradeRequired entry would - // skip past the explicit upgrade decision the row UI is designed to surface. + // Arrange — UpgradeRequired now renders the trash like every other status; the + // user is no longer forced to either Upgrade or transition through UpgradeFailed + // before being able to remove the entry. var entry = MakeEntry(DatabaseStatus.UpgradeRequired); // Act var component = RenderRow(entry); // Assert - Assert.Empty(component.FindAll(".db-entry-remove-btn")); + Assert.Single(component.FindAll(".db-entry-remove-btn")); } [Fact] diff --git a/src/EventLogExpert.Components/DatabaseEntryRow.razor b/src/EventLogExpert.Components/DatabaseEntryRow.razor index 9df036b8..e6177a31 100644 --- a/src/EventLogExpert.Components/DatabaseEntryRow.razor +++ b/src/EventLogExpert.Components/DatabaseEntryRow.razor @@ -1,60 +1,60 @@ -
- +
+ -
- @switch (PrimaryAction) - { - case ActionKind.Toggle: - case ActionKind.DisabledToggle: - +
+ - break; +
+ @switch (PrimaryAction) + { + case ActionKind.Toggle: + case ActionKind.DisabledToggle: + - case ActionKind.Spinner: - - - Upgrading… - - break; + break; - case ActionKind.Upgrade: - - break; + case ActionKind.Spinner: + + + Upgrading… + + break; - case ActionKind.Retry: - - break; - } + case ActionKind.Upgrade: + + break; - @if (CanRemove) - { - - } + case ActionKind.Retry: + + break; + } +
diff --git a/src/EventLogExpert.Components/DatabaseEntryRow.razor.cs b/src/EventLogExpert.Components/DatabaseEntryRow.razor.cs index 5eff180c..6d168f96 100644 --- a/src/EventLogExpert.Components/DatabaseEntryRow.razor.cs +++ b/src/EventLogExpert.Components/DatabaseEntryRow.razor.cs @@ -9,6 +9,14 @@ namespace EventLogExpert.Components; public sealed partial class DatabaseEntryRow : ComponentBase { + /// + /// Click-driven reveal flag for the trash strip on the left of the row. Set true when the + /// name button is clicked, cleared when the cursor leaves the row -- so re-entering the row + /// without re-clicking does not re-open the slide, even though the name button may still + /// hold DOM focus. Keyboard navigation drives the reveal via :focus-visible in CSS instead. + /// + private bool _isMouseRevealed; + private enum ActionKind { None, @@ -39,15 +47,6 @@ private enum ActionKind private string BadgeLabel => DatabaseStatusLabels.GetRowBadgeLabel(Entry); - /// Removal is hidden for entries the user must resolve some other way first: - /// an in-flight upgrade (would race the per-file reservation), a pending recovery - /// (would orphan the .upgrade.bak), or a V3 database awaiting upgrade (steers the user - /// to either Upgrade or — once UpgradeFailed — Retry/Remove). UpgradeFailed remains - /// removable so a user who has tried and given up can still clean the entry up. - private bool CanRemove => !IsUpgrading - && !Entry.BackupExists - && Entry.Status != DatabaseStatus.UpgradeRequired; - private ActionKind PrimaryAction { get @@ -75,4 +74,8 @@ private ActionKind PrimaryAction (!IsUpgrading && Entry.Status != DatabaseStatus.Ready && Entry.Status != DatabaseStatus.UpgradeRequired); + + private void HandleNameClick() => _isMouseRevealed = true; + + private void HandleRowMouseLeave() => _isMouseRevealed = false; } diff --git a/src/EventLogExpert.Components/DatabaseEntryRow.razor.css b/src/EventLogExpert.Components/DatabaseEntryRow.razor.css index fc3c04f2..5c6ca977 100644 --- a/src/EventLogExpert.Components/DatabaseEntryRow.razor.css +++ b/src/EventLogExpert.Components/DatabaseEntryRow.razor.css @@ -1,10 +1,46 @@ .db-entry-row { + --db-entry-trash-reveal: 2.5rem; + + position: relative; + overflow: hidden; +} + +.db-entry-row-content { + position: relative; + display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 1rem; - padding: .15rem .5rem; + /* No left padding: the visible left indent lives on .db-entry-info so the + content layer's left edge butts up against the trash strip when slid. + Background matches the modal chrome (var(--background-dark)) so rows + blend visually but the opaque fill is still required to cover the + absolutely-positioned trash button underneath at rest. The trash + button is rendered earlier in the DOM than this element, so this + element paints above it via source order without needing z-index -- + which also keeps rows below dialog-level overlays (e.g. inline alerts). */ + padding: .15rem .5rem .15rem 0; + background-color: var(--background-dark); + + transition: transform 200ms ease; +} + +/* Reveal logic: + - Mouse: clicking the name button sets the .db-entry-row--revealed flag in + code-behind, which keeps the slide open until the cursor leaves the row + (@onmouseleave clears the flag). Re-entering the row without re-clicking + does NOT re-open the slide, even if the name button still has DOM focus. + - Keyboard: :focus-visible on the name button (matched only when focus + came from a key press, not a mouse click) opens the slide independently + of hover/state, so keyboard users can see what's there. + - Trash :focus keeps the strip open during the click (mouse focus or + keyboard focus), so the click reliably lands. */ +.db-entry-row.db-entry-row--revealed .db-entry-row-content, +.db-entry-row:has(.db-entry-name:focus-visible) .db-entry-row-content, +.db-entry-row:has(.db-entry-remove-btn:focus) .db-entry-row-content { + transform: translateX(var(--db-entry-trash-reveal)); } .db-entry-info { @@ -12,12 +48,25 @@ align-items: center; gap: .5rem; min-width: 0; + + padding-left: .5rem; } .db-entry-name { flex: 1 1 auto; min-width: 0; + /* Reset the