diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index 45597b4327..53ba13ba2c 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -55,7 +55,7 @@ namespace Exceptionless.Core; public class Bootstrapper { - public static void RegisterServices(IServiceCollection services, AppOptions appOptions) + public static void RegisterServices(IServiceCollection services, AppOptions appOptions, bool runDataSeedStartupAction = true) { // Register System.Text.Json options with Exceptionless defaults (snake_case, null handling) services.AddSingleton(_ => new JsonSerializerOptions().ConfigureExceptionlessDefaults()); @@ -81,7 +81,8 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddSingleton(); - services.AddStartupAction(); + if (runDataSeedStartupAction) + services.AddStartupAction(); services.AddStartupAction("Create Sample Data", CreateSampleDataAsync); diff --git a/src/Exceptionless.Core/Jobs/Elastic/MigrationJob.cs b/src/Exceptionless.Core/Jobs/Elastic/MigrationJob.cs index 5ed7a5aad2..2d6c56370a 100644 --- a/src/Exceptionless.Core/Jobs/Elastic/MigrationJob.cs +++ b/src/Exceptionless.Core/Jobs/Elastic/MigrationJob.cs @@ -1,4 +1,5 @@ using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Seed; using Foundatio.Jobs; using Foundatio.Repositories.Elasticsearch.Configuration; using Foundatio.Repositories.Migrations; @@ -12,10 +13,12 @@ public class MigrationJob : JobBase { private readonly MigrationManager _migrationManager; private readonly ExceptionlessElasticConfiguration _configuration; + private readonly DataSeedService _dataSeedService; public MigrationJob( MigrationManager migrationManager, ExceptionlessElasticConfiguration configuration, + DataSeedService dataSeedService, TimeProvider timeProvider, IResiliencePolicyProvider resiliencePolicyProvider, ILoggerFactory loggerFactory @@ -23,12 +26,14 @@ ILoggerFactory loggerFactory { _migrationManager = migrationManager; _configuration = configuration; + _dataSeedService = dataSeedService; } protected override async Task RunInternalAsync(JobContext context) { await _configuration.ConfigureIndexesAsync(null, false); await _migrationManager.RunMigrationsAsync(); + await _dataSeedService.SeedAsync(context.CancellationToken); var tasks = _configuration.Indexes.OfType().Select(ReindexIfNecessary); await Task.WhenAll(tasks); diff --git a/src/Exceptionless.Core/Migrations/004_MigrateSavedViewColumns.cs b/src/Exceptionless.Core/Migrations/004_MigrateSavedViewColumns.cs new file mode 100644 index 0000000000..8b29c8e2e1 --- /dev/null +++ b/src/Exceptionless.Core/Migrations/004_MigrateSavedViewColumns.cs @@ -0,0 +1,261 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Elastic.Clients.Elasticsearch; +using Elastic.Clients.Elasticsearch.Core.Search; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Seed; +using Foundatio.Repositories.Elasticsearch.Extensions; +using Foundatio.Repositories.Migrations; +using Foundatio.Serializer; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Migrations; + +public sealed class MigrateSavedViewColumns : MigrationBase +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly ElasticsearchClient _client; + private readonly ExceptionlessElasticConfiguration _configuration; + private readonly ITextSerializer _serializer; + + public MigrateSavedViewColumns( + ExceptionlessElasticConfiguration configuration, + ITextSerializer serializer, + ILoggerFactory loggerFactory) : base(loggerFactory) + { + _client = configuration.Client; + _configuration = configuration; + _serializer = serializer; + + MigrationType = MigrationType.VersionedAndResumable; + Version = 4; + } + + public override async Task RunAsync(MigrationContext context) + { + const int pageSize = 500; + var pointInTime = await _client.OpenPointInTimeAsync( + _configuration.SavedViews.VersionedName, + request => request.KeepAlive("2m"), + context.CancellationToken); + _logger.LogRequest(pointInTime); + + if (!pointInTime.IsValidResponse || String.IsNullOrWhiteSpace(pointInTime.Id)) + throw new InvalidOperationException("Unable to open a point in time for the saved view migration."); + + FieldValue[]? searchAfter = null; + int migrated = 0; + + try + { + do + { + var response = await _client.SearchAsync(request => + { + request + .Pit(pit => pit.Id(pointInTime.Id).KeepAlive("2m")) + .SeqNoPrimaryTerm() + .Size(pageSize) + .Sort(sort => sort.Field("_shard_doc")); + + if (searchAfter is not null) + request.SearchAfter(searchAfter); + }, context.CancellationToken); + _logger.LogRequest(response); + + if (!response.IsValidResponse) + throw new InvalidOperationException("Unable to read saved views during the column migration."); + + foreach (var hit in response.Hits) + { + if (hit.Source.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + continue; + + var source = JsonNode.Parse(hit.Source.GetRawText())?.AsObject(); + if (source is null) + continue; + + if (hit.SeqNo is null || hit.PrimaryTerm is null) + throw new InvalidOperationException($"Unable to read concurrency metadata for saved view '{hit.Id}'."); + + string? legacyContentHash = GetLegacyContentHash(source); + if (!TryMigrate(source)) + continue; + + UpdatePredefinedContentHash(source, legacyContentHash); + + var indexResponse = await IndexMigratedDocumentAsync( + source, + hit.Id, + hit.SeqNo.Value, + hit.PrimaryTerm.Value, + context.CancellationToken); + _logger.LogRequest(indexResponse); + + if (!indexResponse.IsValidResponse) + { + string reason = indexResponse.ApiCallDetails.HttpStatusCode == 409 + ? " because it changed while the migration was running" + : String.Empty; + throw new InvalidOperationException($"Unable to migrate saved view '{hit.Id}'{reason}. The resumable migration can be run again safely."); + } + + migrated++; + } + + searchAfter = response.Hits.Count == pageSize + ? response.Hits.Last().Sort!.ToArray() + : null; + + await context.Lock.RenewAsync(); + } while (searchAfter is not null && !context.CancellationToken.IsCancellationRequested); + } + finally + { + var closeResponse = await _client.ClosePointInTimeAsync( + request => request.Id(pointInTime.Id), + CancellationToken.None); + _logger.LogRequest(closeResponse); + } + + _logger.LogInformation("Migrated {SavedViewCount} saved view column configurations", migrated); + } + + internal Task IndexMigratedDocumentAsync( + JsonObject source, + string id, + long sequenceNumber, + long primaryTerm, + CancellationToken cancellationToken) + { + return _client.IndexAsync( + source, + request => request + .Index(_configuration.SavedViews.VersionedName) + .Id(id) + .IfSeqNo(sequenceNumber) + .IfPrimaryTerm(primaryTerm), + cancellationToken); + } + + internal static bool TryMigrate(JsonObject source) + { + var columns = source["columns"] as JsonObject; + var columnOrder = source["column_order"] as JsonArray; + bool hasLegacyColumns = columns?.Any(entry => entry.Value is JsonValue value && value.TryGetValue(out _)) == true; + + if (!hasLegacyColumns && columnOrder is null) + return false; + + var migratedColumns = new JsonObject(); + if (columns is not null) + { + foreach (var (columnId, value) in columns) + { + if (value is JsonValue jsonValue && jsonValue.TryGetValue(out bool visible)) + { + migratedColumns[columnId] = new JsonObject + { + ["visible"] = visible + }; + } + else if (value is JsonObject settings) + { + migratedColumns[columnId] = settings.DeepClone(); + } + } + } + + if (columnOrder is not null) + { + for (int position = 0; position < columnOrder.Count; position++) + { + string? columnId = columnOrder[position]?.GetValue(); + if (String.IsNullOrWhiteSpace(columnId)) + continue; + + if (migratedColumns[columnId] is not JsonObject settings) + { + settings = new JsonObject(); + migratedColumns[columnId] = settings; + } + + settings["position"] = position; + } + } + + source["columns"] = migratedColumns.Count > 0 ? migratedColumns : null; + source.Remove("column_order"); + return true; + } + + private void UpdatePredefinedContentHash(JsonObject source, string? legacyContentHash) + { + string? predefinedContentHash = source["predefined_content_hash"]?.GetValue(); + if (String.IsNullOrWhiteSpace(predefinedContentHash)) + return; + + if (!String.Equals(predefinedContentHash, legacyContentHash, StringComparison.Ordinal)) + return; + + var savedView = _serializer.Deserialize(source.ToJsonString(JsonOptions)); + if (savedView is null) + throw new InvalidOperationException("Unable to deserialize a migrated saved view."); + + source["predefined_content_hash"] = PredefinedSavedViewContentHasher.GetContentHash(savedView); + } + + internal static string? GetLegacyContentHash(JsonObject source) + { + var columns = source["columns"] as JsonObject; + if (columns?.Any(entry => entry.Value is not JsonValue value || !value.TryGetValue(out _)) == true) + return null; + + var legacyColumns = columns?.ToDictionary( + entry => entry.Key, + entry => entry.Value!.GetValue()); + var columnOrder = (source["column_order"] as JsonArray)? + .Select(value => value?.GetValue()) + .Where(value => value is not null) + .Cast() + .ToList(); + + var content = new + { + name = source["name"]?.GetValue(), + slug = source["slug"]?.GetValue(), + viewType = source["view_type"]?.GetValue(), + filter = source["filter"]?.GetValue(), + time = source["time"]?.GetValue(), + sort = source["sort"]?.GetValue(), + filterDefinitions = CanonicalizeFilterDefinitions(source["filter_definitions"]?.GetValue()), + Columns = legacyColumns?.OrderBy(column => column.Key, StringComparer.Ordinal), + columnOrder, + showStats = source["show_stats"]?.GetValue(), + showChart = source["show_chart"]?.GetValue() + }; + + return JsonSerializer.Serialize(content).ToSHA256(); + } + + private static string? CanonicalizeFilterDefinitions(string? filterDefinitions) + { + if (String.IsNullOrWhiteSpace(filterDefinitions)) + return filterDefinitions; + + try + { + return JsonNode.Parse(filterDefinitions)?.ToJsonString() ?? filterDefinitions; + } + catch (JsonException) + { + return filterDefinitions; + } + } +} diff --git a/src/Exceptionless.Core/Models/SavedView.cs b/src/Exceptionless.Core/Models/SavedView.cs index 1c3bcc9393..7c0b7c9a77 100644 --- a/src/Exceptionless.Core/Models/SavedView.cs +++ b/src/Exceptionless.Core/Models/SavedView.cs @@ -45,11 +45,8 @@ public partial record SavedView : IOwnedByOrganizationWithIdentity, IHaveDates [MaxLength(MaxFilterDefinitionsLength)] public string? FilterDefinitions { get; set; } - /// Column visibility state per dashboard table, keyed by column id. - public Dictionary? Columns { get; set; } - - /// Column display order per dashboard table, excluding utility columns. - public List? ColumnOrder { get; set; } + /// Extensible display settings keyed by dashboard column id. + public Dictionary? Columns { get; set; } /// Whether dashboard statistic cards are shown for this view. Null means use the default. public bool? ShowStats { get; set; } diff --git a/src/Exceptionless.Core/Models/SavedViewColumnSettings.cs b/src/Exceptionless.Core/Models/SavedViewColumnSettings.cs new file mode 100644 index 0000000000..c4746ecdac --- /dev/null +++ b/src/Exceptionless.Core/Models/SavedViewColumnSettings.cs @@ -0,0 +1,22 @@ +namespace Exceptionless.Core.Models; + +/// +/// Per-column display settings for a saved view. All properties are optional so new settings can +/// be added without changing the meaning of existing saved views. +/// +public sealed record SavedViewColumnSettings +{ + public const int MaxPosition = 49; + public const int MaxWidth = 1200; + public const int MinWidth = 48; + + /// Whether the column is visible. Null means use the table default. + public bool? Visible { get; set; } + + /// Zero-based display position. Null means use the table default order. + public int? Position { get; set; } + + /// Column width in pixels. Null means use the table default width. + public int? Width { get; set; } + +} diff --git a/src/Exceptionless.Core/Seed/PredefinedSavedViewConfiguration.cs b/src/Exceptionless.Core/Seed/PredefinedSavedViewConfiguration.cs index 32ff301b91..95e4ce00da 100644 --- a/src/Exceptionless.Core/Seed/PredefinedSavedViewConfiguration.cs +++ b/src/Exceptionless.Core/Seed/PredefinedSavedViewConfiguration.cs @@ -16,8 +16,7 @@ public static bool Apply(SavedView destination, SavedView source, string key, st changed |= SetIfChanged(destination, source.Time, static (view, value) => view.Time = value, static view => view.Time); changed |= SetIfChanged(destination, source.Sort, static (view, value) => view.Sort = value, static view => view.Sort); changed |= SetIfChanged(destination, source.FilterDefinitions, static (view, value) => view.FilterDefinitions = value, static view => view.FilterDefinitions); - changed |= SetDictionaryIfChanged(destination, source.Columns); - changed |= SetListIfChanged(destination, source.ColumnOrder); + changed |= SetColumnsIfChanged(destination, source.Columns); changed |= SetIfChanged(destination, source.ShowStats, static (view, value) => view.ShowStats = value, static view => view.ShowStats); changed |= SetIfChanged(destination, source.ShowChart, static (view, value) => view.ShowChart = value, static view => view.ShowChart); changed |= SetIfChanged(destination, source.Version, static (view, value) => view.Version = value, static view => view.Version); @@ -26,15 +25,6 @@ public static bool Apply(SavedView destination, SavedView source, string key, st return changed; } - private static bool SetDictionaryIfChanged(SavedView savedView, IReadOnlyDictionary? value) - { - if (DictionaryEquals(savedView.Columns, value)) - return false; - - savedView.Columns = value is null ? null : new Dictionary(value); - return true; - } - private static bool SetIfChanged(SavedView savedView, T value, Action setter, Func getter) { if (EqualityComparer.Default.Equals(getter(savedView), value)) @@ -44,16 +34,18 @@ private static bool SetIfChanged(SavedView savedView, T value, Action? value) + private static bool SetColumnsIfChanged(SavedView savedView, IReadOnlyDictionary? value) { - if (CollectionEquals(savedView.ColumnOrder, value)) + if (ColumnsEqual(savedView.Columns, value)) return false; - savedView.ColumnOrder = value is null ? null : [.. value]; + savedView.Columns = CopyColumns(value); return true; } - private static bool CollectionEquals(IReadOnlyCollection? left, IReadOnlyCollection? right) + private static bool ColumnsEqual( + IReadOnlyDictionary? left, + IReadOnlyDictionary? right) { if (ReferenceEquals(left, right)) return true; @@ -61,17 +53,9 @@ private static bool CollectionEquals(IReadOnlyCollection? left, IReadOnl if (left is null || right is null || left.Count != right.Count) return false; - return left.SequenceEqual(right, StringComparer.Ordinal); + return left.All(entry => right.TryGetValue(entry.Key, out var value) && entry.Value == value); } - private static bool DictionaryEquals(IReadOnlyDictionary? left, IReadOnlyDictionary? right) - { - if (ReferenceEquals(left, right)) - return true; - - if (left is null || right is null || left.Count != right.Count) - return false; - - return left.All(kvp => right.TryGetValue(kvp.Key, out bool value) && value == kvp.Value); - } + private static Dictionary? CopyColumns(IReadOnlyDictionary? value) + => value?.ToDictionary(entry => entry.Key, entry => entry.Value with { }); } diff --git a/src/Exceptionless.Core/Seed/PredefinedSavedViewContentHasher.cs b/src/Exceptionless.Core/Seed/PredefinedSavedViewContentHasher.cs index b7e021c6e3..cd5b111718 100644 --- a/src/Exceptionless.Core/Seed/PredefinedSavedViewContentHasher.cs +++ b/src/Exceptionless.Core/Seed/PredefinedSavedViewContentHasher.cs @@ -20,7 +20,6 @@ public static string GetContentHash(SavedView savedView) savedView.Sort, savedView.FilterDefinitions, savedView.Columns, - savedView.ColumnOrder, savedView.ShowStats, savedView.ShowChart); } @@ -43,7 +42,6 @@ public static string GetDefinitionsContentHash(IEnumerable? columns, - IReadOnlyCollection? columnOrder, + IReadOnlyDictionary? columns, bool? showStats, bool? showChart) { @@ -74,7 +71,6 @@ private static string GetContentHash( sort, filterDefinitions = CanonicalizeFilterDefinitions(filterDefinitions), Columns = columns?.OrderBy(column => column.Key, StringComparer.Ordinal), - columnOrder, showStats, showChart }; diff --git a/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs b/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs index 650b8f862a..1c7009c1ba 100644 --- a/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs +++ b/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs @@ -129,15 +129,9 @@ private static bool ApplyDefinition(SavedView existing, PredefinedSavedViewDefin changed = true; } - if (!DictionaryEquals(existing.Columns, definition.Columns)) + if (!ColumnsEqual(existing.Columns, definition.Columns)) { - existing.Columns = definition.Columns?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - changed = true; - } - - if (!CollectionEquals(existing.ColumnOrder, definition.ColumnOrder)) - { - existing.ColumnOrder = definition.ColumnOrder is null ? null : [.. definition.ColumnOrder]; + existing.Columns = CopyColumns(definition.Columns); changed = true; } @@ -190,8 +184,7 @@ private static SavedView CreateSavedView(PredefinedSavedViewDefinition definitio Time = definition.Time, Sort = definition.Sort, FilterDefinitions = GetRawJson(definition.FilterDefinitions), - Columns = definition.Columns?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value), - ColumnOrder = definition.ColumnOrder is null ? null : [.. definition.ColumnOrder], + Columns = CopyColumns(definition.Columns), ShowStats = definition.ShowStats, ShowChart = definition.ShowChart, Version = 1 @@ -201,7 +194,9 @@ private static SavedView CreateSavedView(PredefinedSavedViewDefinition definitio return savedView; } - private static bool CollectionEquals(IReadOnlyCollection? left, IReadOnlyCollection? right) + private static bool ColumnsEqual( + IReadOnlyDictionary? left, + IReadOnlyDictionary? right) { if (ReferenceEquals(left, right)) return true; @@ -209,19 +204,12 @@ private static bool CollectionEquals(IReadOnlyCollection? left, IReadOnl if (left is null || right is null || left.Count != right.Count) return false; - return left.SequenceEqual(right, StringComparer.Ordinal); + return left.All(entry => right.TryGetValue(entry.Key, out var value) && entry.Value == value); } - private static bool DictionaryEquals(IReadOnlyDictionary? left, IReadOnlyDictionary? right) - { - if (ReferenceEquals(left, right)) - return true; - - if (left is null || right is null || left.Count != right.Count) - return false; - - return left.All(kvp => right.TryGetValue(kvp.Key, out bool value) && value == kvp.Value); - } + private static Dictionary? CopyColumns( + IReadOnlyDictionary? value) + => value?.ToDictionary(entry => entry.Key, entry => entry.Value with { }); public static string? GetRawJson(JsonElement? value) { @@ -259,10 +247,7 @@ public sealed record PredefinedSavedViewDefinition public JsonElement? FilterDefinitions { get; init; } [JsonPropertyName("columns")] - public IReadOnlyDictionary? Columns { get; init; } - - [JsonPropertyName("columnOrder")] - public IReadOnlyCollection? ColumnOrder { get; init; } + public IReadOnlyDictionary? Columns { get; init; } [JsonPropertyName("showStats")] public bool? ShowStats { get; init; } diff --git a/src/Exceptionless.Core/Seed/predefined-saved-views.json b/src/Exceptionless.Core/Seed/predefined-saved-views.json index 52ab08fb15..e5231f7225 100644 --- a/src/Exceptionless.Core/Seed/predefined-saved-views.json +++ b/src/Exceptionless.Core/Seed/predefined-saved-views.json @@ -17,11 +17,11 @@ } ], "columns": { - "level": false, - "message": false, - "name": false, - "source": false, - "type": false + "level": { "visible": false }, + "message": { "visible": false }, + "name": { "visible": false }, + "source": { "visible": false }, + "type": { "visible": false } }, "showStats": true, "showChart": true @@ -56,12 +56,12 @@ } ], "columns": { - "exception_type": false, - "level": false, - "message": false, - "name": false, - "source": false, - "type": false + "exception_type": { "visible": false }, + "level": { "visible": false }, + "message": { "visible": false }, + "name": { "visible": false }, + "source": { "visible": false }, + "type": { "visible": false } }, "showStats": true, "showChart": true @@ -96,12 +96,12 @@ } ], "columns": { - "exception_type": false, - "level": false, - "message": false, - "name": false, - "source": false, - "type": false + "exception_type": { "visible": false }, + "level": { "visible": false }, + "message": { "visible": false }, + "name": { "visible": false }, + "source": { "visible": false }, + "type": { "visible": false } }, "showStats": true, "showChart": true diff --git a/src/Exceptionless.Job/JobRunnerOptions.cs b/src/Exceptionless.Job/JobRunnerOptions.cs index a8faf591e8..70dd715485 100644 --- a/src/Exceptionless.Job/JobRunnerOptions.cs +++ b/src/Exceptionless.Job/JobRunnerOptions.cs @@ -93,6 +93,7 @@ public JobRunnerOptions(string[] args) public bool MailMessage { get; } public bool MaintainIndexes { get; } public bool Migration { get; } + public bool RunDataSeedStartupAction => !Migration; public bool StackStatus { get; } public bool StackEventCount { get; } public bool WebHooks { get; } diff --git a/src/Exceptionless.Job/Program.cs b/src/Exceptionless.Job/Program.cs index 03f2903a2a..773bf8f3c9 100644 --- a/src/Exceptionless.Job/Program.cs +++ b/src/Exceptionless.Job/Program.cs @@ -127,7 +127,7 @@ public static IHostBuilder CreateHostBuilder(string[] args) AddJobs(services, jobOptions); services.AddAppOptions(options); - Bootstrapper.RegisterServices(services, options); + Bootstrapper.RegisterServices(services, options, jobOptions.RunDataSeedStartupAction); Insulation.Bootstrapper.RegisterServices(services, options, true); }) .AddApm(apmConfig); diff --git a/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs b/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs index c698e33fac..78d4edc886 100644 --- a/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs @@ -151,8 +151,7 @@ public async Task>> Ha Time = definition.Time, Sort = definition.Sort, FilterDefinitions = definition.FilterDefinitions is { } filterDefinitions ? JsonSerializer.Serialize(filterDefinitions) : null, - Columns = definition.Columns is { } columns ? new Dictionary(columns) : null, - ColumnOrder = definition.ColumnOrder?.ToList(), + Columns = CopyColumns(definition.Columns), ShowStats = definition.ShowStats, ShowChart = definition.ShowChart, Version = 1 @@ -377,7 +376,7 @@ private async Task> PostImplAsync(NewSavedView value) return Result.Invalid(ValidationError.Create("filter_definitions", "FilterDefinitions must be a valid JSON array.")); } - if (changedNames.Contains(nameof(UpdateSavedView.Columns)) || changedNames.Contains(nameof(UpdateSavedView.ColumnOrder))) + if (changedNames.Contains(nameof(UpdateSavedView.Columns))) { var patchedChanges = new UpdateSavedView(); changes.Patch(patchedChanges); @@ -487,12 +486,7 @@ private static Result PermissionToResult(PermissionResult permission) if (changes.Columns is not null && changes.Columns.Count > 50) return new ValidationResult("Columns cannot exceed 50 items.", [nameof(UpdateSavedView.Columns)]); - if (changes.ColumnOrder is not null && changes.ColumnOrder.Count > 50) - return new ValidationResult("ColumnOrder cannot exceed 50 items.", [nameof(UpdateSavedView.ColumnOrder)]); - - return NewSavedView.ValidateColumnKeys(viewType, changes.Columns) - .Concat(NewSavedView.ValidateColumnOrder(viewType, changes.ColumnOrder)) - .FirstOrDefault(); + return NewSavedView.ValidateColumns(viewType, changes.Columns).FirstOrDefault(); } // --- Predefined saved views logic --- @@ -668,8 +662,7 @@ private SavedView CreatePredefinedSavedView(string organizationId, PredefinedSav Time = definition.Time, Sort = definition.Sort, FilterDefinitions = PredefinedSavedViewsDataSeed.GetRawJson(definition.FilterDefinitions), - Columns = Copy(definition.Columns), - ColumnOrder = definition.ColumnOrder is null ? null : [.. definition.ColumnOrder], + Columns = CopyColumns(definition.Columns), ShowStats = definition.ShowStats, ShowChart = definition.ShowChart, Version = 1 @@ -689,8 +682,7 @@ private static bool ApplyPredefinedSavedView(SavedView savedView, PredefinedSave changed |= SetIfChanged(savedView, definition.Time, static (view, value) => view.Time = value, static view => view.Time); changed |= SetIfChanged(savedView, definition.Sort, static (view, value) => view.Sort = value, static view => view.Sort); changed |= SetIfChanged(savedView, PredefinedSavedViewsDataSeed.GetRawJson(definition.FilterDefinitions), static (view, value) => view.FilterDefinitions = value, static view => view.FilterDefinitions); - changed |= SetDictionaryIfChanged(savedView, definition.Columns); - changed |= SetListIfChanged(savedView, definition.ColumnOrder); + changed |= SetColumnsIfChanged(savedView, definition.Columns); changed |= SetIfChanged(savedView, definition.ShowStats, static (view, value) => view.ShowStats = value, static view => view.ShowStats); changed |= SetIfChanged(savedView, definition.ShowChart, static (view, value) => view.ShowChart = value, static view => view.ShowChart); changed |= SetIfChanged(savedView, 1, static (view, value) => view.Version = value, static view => view.Version); @@ -797,7 +789,6 @@ private static PredefinedSavedViewDefinition ToPredefinedSavedView(SavedView sav Sort = savedView.Sort, FilterDefinitions = ParseFilterDefinitions(savedView.FilterDefinitions), Columns = savedView.Columns, - ColumnOrder = savedView.ColumnOrder, ShowStats = savedView.ShowStats, ShowChart = savedView.ShowChart }; @@ -835,41 +826,30 @@ private static bool SetIfChanged(SavedView savedView, T value, Action? value) + private static bool SetColumnsIfChanged(SavedView savedView, IReadOnlyDictionary? value) { - if (DictionaryEquals(savedView.Columns, value)) + if (ColumnsEqual(savedView.Columns, value)) return false; - savedView.Columns = Copy(value); + savedView.Columns = CopyColumns(value); return true; } - private static bool SetListIfChanged(SavedView savedView, IReadOnlyCollection? value) - { - if ((savedView.ColumnOrder ?? []).SequenceEqual(value ?? [])) - return false; - - savedView.ColumnOrder = value is null ? null : [.. value]; - return true; - } + private static Dictionary? CopyColumns( + IReadOnlyDictionary? value) + => value?.ToDictionary(entry => entry.Key, entry => entry.Value with { }); - private static Dictionary? Copy(IReadOnlyDictionary? value) - { - return value is null ? null : value.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - } - - private static bool DictionaryEquals(IReadOnlyDictionary? left, IReadOnlyDictionary? right) + private static bool ColumnsEqual( + IReadOnlyDictionary? left, + IReadOnlyDictionary? right) { if (left is null) return right is null; - if (right is null) - return false; - - if (left.Count != right.Count) + if (right is null || left.Count != right.Count) return false; - return left.All(kvp => right.TryGetValue(kvp.Key, out var value) && value == kvp.Value); + return left.All(entry => right.TryGetValue(entry.Key, out var value) && entry.Value == value); } private static string GetUniqueSlug(string slug, IReadOnlyCollection existingViews, string? excludingId) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts index 3cd41c3162..1b03a03ff7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.test.ts @@ -15,6 +15,17 @@ describe('event table columns', () => { expect(defaultEventColumnVisibility.tags).toBe(false); }); + it('uses a wider resizable project column and keeps utility columns fixed', () => { + const columns = getColumns>(); + const project = columns.find((column) => column.id === 'project'); + const select = columns.find((column) => column.id === 'select'); + const summary = columns.find((column) => column.id === 'summary'); + + expect(project).toMatchObject({ maxSize: 800, minSize: 160, size: 240 }); + expect(select?.enableResizing).toBe(false); + expect(summary?.enableResizing).toBe(false); + }); + it('offers project and tags as hidden optional stack columns', () => { const columns = getColumns>('stack_frequent'); const columnIds = columns.map((column) => column.id); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts index 8e6803f273..f82d4bf988 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts @@ -50,6 +50,7 @@ export function getColumns props.row.getToggleSelectedHandler()({ target: { checked } }) }), enableHiding: false, + enableResizing: false, enableSorting: false, header: ({ table }) => renderComponent(Checkbox, { @@ -64,6 +65,7 @@ export function getColumns renderComponent(Summary, { showStatus: false, showType, summary: prop.row.original }), + enableResizing: false, header: 'Summary', id: 'summary', meta: { @@ -76,9 +78,12 @@ export function getColumns renderComponent(EventsUserIdentitySummaryCell, { summary: prop.row.original }), header: 'User', id: 'user', + maxSize: 480, meta: { class: 'w-28' - } + }, + minSize: 80, + size: 112 }, { accessorKey: nameof>('date'), cell: (prop) => renderComponent(TimeAgo, { value: prop.getValue() }), header: 'Date', id: 'date', + maxSize: 480, meta: { class: 'w-36' - } + }, + minSize: 96, + size: 144 }, { accessorKey: nameof>('tags'), @@ -108,9 +119,12 @@ export function getColumns getSummaryDataValue(row, 'Message'), @@ -118,18 +132,24 @@ export function getColumns>('type'), cell: (prop) => formatTextColumn(prop.getValue()), header: 'Type', id: 'type', + maxSize: 640, meta: { class: 'w-28' - } + }, + minSize: 80, + size: 112 }, { accessorKey: nameof>('version'), @@ -137,27 +157,36 @@ export function getColumns getSummaryDataValue(row, 'Type'), cell: (prop) => formatTextColumn(prop.getValue()), header: 'Exception Type', id: 'exception_type', + maxSize: 800, meta: { class: 'w-36' - } + }, + minSize: 112, + size: 144 }, { accessorFn: (row) => getSource(row), cell: (prop) => formatTextColumn(prop.getValue()), header: 'Source', id: 'source', + maxSize: 800, meta: { class: 'w-40' - } + }, + minSize: 112, + size: 160 }, { accessorFn: (row) => getSummaryDataValue(row, 'Name'), @@ -165,18 +194,24 @@ export function getColumns getSummaryDataValue(row, 'Level'), cell: (prop) => renderComponent(LogLevel, { level: prop.getValue() }), header: 'Level', id: 'level', + maxSize: 240, meta: { class: 'w-[4.5rem] min-w-[4.5rem] max-w-[4.5rem] px-1 text-center' - } + }, + minSize: 64, + size: 72 } ); } else { @@ -187,9 +222,12 @@ export function getColumns>('status'), @@ -197,18 +235,24 @@ export function getColumns renderComponent(StackUsersSummaryCell, { summary: prop.row.original }), enableSorting: false, header: 'Users', id: 'users', + maxSize: 320, meta: { class: 'w-24' - } + }, + minSize: 72, + size: 96 }, { accessorKey: nameof>('total'), @@ -216,9 +260,12 @@ export function getColumns>('first_occurrence'), @@ -226,9 +273,12 @@ export function getColumns>('last_occurrence'), @@ -236,9 +286,12 @@ export function getColumns { + it('builds extensible settings from the current table state', () => { + const result = buildColumnSettings( + ['select', 'summary', 'project'], + ['select', 'project', 'summary'], + { project: true, summary: true }, + { project: 360 } + ); + + expect(result).toEqual({ + project: { position: 0, visible: true, width: 360 }, + summary: { position: 1, visible: true } + }); + }); + + it('reads order, visibility, and width from structured columns', () => { + const view = { + columns: { + project: { position: 0, visible: true, width: 360 }, + summary: { position: 1, visible: false } + } + } as Pick; + + expect(getSavedColumnOrder(view)).toEqual(['project', 'summary']); + expect(getSavedColumnVisibility(view)).toEqual({ project: true, summary: false }); + expect(getSavedColumnSizing(view)).toEqual({ project: 360 }); + }); + + it('detects changed and reset column widths', () => { + const view = { + columns: { + project: { width: 360 } + } + } as Pick; + + expect(savedViewColumnSizingEqual({ project: 360 }, view)).toBe(true); + expect(savedViewColumnSizingEqual({ project: 420 }, view)).toBe(false); + expect(savedViewColumnSizingEqual({}, view)).toBe(false); + }); + + it('compares only columns with explicitly saved positions', () => { + const view = { + columns: { + date: { visible: true }, + project: { position: 0 }, + summary: { position: 1 } + } + } as Pick; + + expect(savedViewColumnOrderEqual(['select', 'project', 'date', 'summary', 'tags'], view)).toBe(true); + expect(savedViewColumnOrderEqual(['select', 'summary', 'date', 'project', 'tags'], view)).toBe(false); + }); + + it('treats views without saved positions as unchanged', () => { + const view = { + columns: { + project: { visible: true } + } + } as Pick; + + expect(savedViewColumnOrderEqual(['select', 'date', 'project'], view)).toBe(true); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.ts new file mode 100644 index 0000000000..472e00e833 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/column-settings.ts @@ -0,0 +1,71 @@ +import type { ColumnOrderState, ColumnSizingState, ColumnVisibilityState } from '@tanstack/svelte-table'; + +import type { SavedView, SavedViewColumnSettings } from './models'; + +type SavedColumnState = Pick; + +export function buildColumnSettings( + columnIds: string[], + columnOrder: ColumnOrderState, + columnVisibility: ColumnVisibilityState, + columnSizing: ColumnSizingState +): Record { + const availableColumnIds = columnIds.filter((columnId) => columnId !== 'select'); + const availableColumnIdSet = new Set(availableColumnIds); + const orderedColumnIds = [ + ...columnOrder.filter((columnId, index) => columnId !== 'select' && availableColumnIdSet.has(columnId) && columnOrder.indexOf(columnId) === index), + ...availableColumnIds.filter((columnId) => !columnOrder.includes(columnId)) + ]; + + return Object.fromEntries( + orderedColumnIds.map((columnId, position) => [ + columnId, + { + position, + visible: columnVisibility[columnId] ?? true, + ...(columnSizing[columnId] !== undefined ? { width: Math.round(columnSizing[columnId]) } : {}) + } + ]) + ); +} + +export function getSavedColumnOrder(view: SavedColumnState | undefined): ColumnOrderState { + const settingsOrder = Object.entries(view?.columns ?? {}) + .filter((entry): entry is [string, SavedViewColumnSettings & { position: number }] => entry[1].position != null) + .sort(([leftId, left], [rightId, right]) => left.position - right.position || leftId.localeCompare(rightId)) + .map(([columnId]) => columnId); + + return settingsOrder; +} + +export function getSavedColumnSizing(view: SavedColumnState | undefined): ColumnSizingState { + return Object.fromEntries( + Object.entries(view?.columns ?? {}) + .filter((entry): entry is [string, SavedViewColumnSettings & { width: number }] => entry[1].width != null) + .map(([columnId, settings]) => [columnId, settings.width]) + ); +} + +export function getSavedColumnVisibility(view: SavedColumnState | undefined): ColumnVisibilityState { + return Object.fromEntries( + Object.entries(view?.columns ?? {}) + .filter((entry): entry is [string, SavedViewColumnSettings & { visible: boolean }] => entry[1].visible != null) + .map(([columnId, settings]) => [columnId, settings.visible]) + ); +} + +export function savedViewColumnOrderEqual(current: ColumnOrderState | undefined, view: SavedColumnState): boolean { + const savedOrder = getSavedColumnOrder(view); + const savedColumnIds = new Set(savedOrder); + const currentSavedOrder = (current ?? []).filter((columnId) => columnId !== 'select' && savedColumnIds.has(columnId)); + + return currentSavedOrder.length === savedOrder.length && currentSavedOrder.every((columnId, index) => columnId === savedOrder[index]); +} + +export function savedViewColumnSizingEqual(current: ColumnSizingState | undefined, view: SavedColumnState): boolean { + const saved = getSavedColumnSizing(view); + const currentEntries = Object.entries(current ?? {}).filter(([columnId]) => columnId !== 'select'); + const savedEntries = Object.entries(saved); + + return currentEntries.length === savedEntries.length && currentEntries.every(([columnId, width]) => saved[columnId] === Math.round(width)); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/column-management-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/column-management-dialog.svelte index 758cd0e3ac..3b8d6422d0 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/column-management-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/column-management-dialog.svelte @@ -94,6 +94,7 @@ function resetColumns(): void { table.resetColumnVisibility(); table.resetColumnOrder(); + table.resetColumnSizing(); search = ''; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte index 9677cb709d..fe27d81b69 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte @@ -27,6 +27,7 @@ import type { NewSavedView, SavedView, UpdateSavedView } from '../models'; import { deleteSavedView, markSavedViewDeleted, patchSavedView, postSavedView, restoreDeletedSavedView } from '../api.svelte'; + import { buildColumnSettings } from '../column-settings'; import ColumnManagementDialog from './column-management-dialog.svelte'; import DeleteViewDialog from './delete-view-dialog.svelte'; import RenameViewDialog from './rename-view-dialog.svelte'; @@ -45,6 +46,7 @@ interface Props { activeSavedView?: SavedView; columnOrder?: string[]; + columnSizing?: Record; columnVisibility?: Record; filters: IFilter[]; isModified: boolean; @@ -65,6 +67,7 @@ let { activeSavedView, columnOrder, + columnSizing, columnVisibility, filters, isModified, @@ -154,11 +157,13 @@ isRenameDialogOpen = true; } - function getSavedColumnOrder(): string[] | undefined { - const currentColumnIds = new Set(table.getAllLeafColumns().map((column) => column.id)); - const savedColumnOrder = (columnOrder ?? []).filter((columnId) => columnId !== 'select' && currentColumnIds.has(columnId)); - - return savedColumnOrder.length > 0 ? savedColumnOrder : undefined; + function getSavedColumnSettings() { + return buildColumnSettings( + table.getAllLeafColumns().map((column) => column.id), + columnOrder ?? [], + columnVisibility ?? {}, + columnSizing ?? {} + ); } async function openDeleteDialog(savedView: SavedView) { @@ -178,10 +183,8 @@ } const filterDefinitions = serializeFilters(filters); - const savedColumnOrder = getSavedColumnOrder(); const body: NewSavedView = { - column_order: savedColumnOrder, - columns: columnVisibility, + columns: getSavedColumnSettings(), filter: currentFilterString || undefined, filter_definitions: filterDefinitions, is_private: isPrivate || undefined, @@ -221,8 +224,7 @@ function getUpdateBody(): UpdateSavedView { return { - column_order: getSavedColumnOrder() ?? null, - columns: columnVisibility, + columns: getSavedColumnSettings(), filter: currentFilterString || null, filter_definitions: serializeFilters(filters), show_chart: showChart, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/models.ts index ffc1d24f8f..74cb955d3a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/models.ts @@ -1,5 +1,5 @@ -import type { NewSavedView, UpdateSavedView, ViewSavedView } from '$generated/api'; +import type { NewSavedView, SavedViewColumnSettings, UpdateSavedView, ViewSavedView } from '$generated/api'; export type SavedView = ViewSavedView; -export type { NewSavedView, UpdateSavedView }; +export type { NewSavedView, SavedViewColumnSettings, UpdateSavedView }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts index e9abbc2703..375a4a5c4e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts @@ -1,5 +1,5 @@ import type { IFilter } from '$comp/faceted-filter'; -import type { ColumnOrderState, ColumnVisibilityState } from '@tanstack/svelte-table'; +import type { ColumnOrderState, ColumnSizingState, ColumnVisibilityState } from '@tanstack/svelte-table'; import { goto } from '$app/navigation'; import { buildFilterCacheKey, deserializeFilters, serializeFilters } from '$features/events/components/filters/helpers.svelte'; @@ -8,6 +8,7 @@ import { organization } from '$features/organizations/context.svelte'; import type { SavedView } from './models'; import { getSavedViewsByViewQuery } from './api.svelte'; +import { getSavedColumnOrder, getSavedColumnSizing, getSavedColumnVisibility, savedViewColumnOrderEqual, savedViewColumnSizingEqual } from './column-settings'; import { savedViewHref, savedViewResolvedSlug } from './slugs'; export interface SavedViewQueryParams { @@ -25,6 +26,7 @@ export interface UseSavedViewsOptions { defaultTime?: null | string; filterCacheKey: (filter: null | string) => string; getColumnOrder?: () => ColumnOrderState; + getColumnSizing?: () => ColumnSizingState; getColumnVisibility?: () => ColumnVisibilityState; getFilter?: () => null | string; getFilterDefinitions?: () => string; @@ -34,6 +36,7 @@ export interface UseSavedViewsOptions { getTime?: () => null | string | undefined; queryParams: SavedViewQueryParams; setColumnOrder?: (order: ColumnOrderState) => void; + setColumnSizing?: (sizing: ColumnSizingState) => void; setColumnVisibility?: (visibility: ColumnVisibilityState) => void; setShowChart?: (show: boolean) => void; setShowStats?: (show: boolean) => void; @@ -98,10 +101,6 @@ export function hasMissingSavedViewSlug(options: { return !!options.slug && !options.activeSavedView && !!options.savedViews && !options.isLoading; } -export function hasSavedColumnOrder(columnOrder: null | string[] | undefined): columnOrder is string[] { - return !!columnOrder?.length; -} - export function hasSavedViewColumnChanges( current: ColumnVisibilityState | undefined, saved: null | Record | undefined, @@ -185,14 +184,16 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur return undefined; }); - function applyColumnState(view: Pick | undefined): void { + function applyColumnState(view: Pick | undefined): void { if (options.setColumnVisibility) { - options.setColumnVisibility(view?.columns ?? {}); + options.setColumnVisibility(getSavedColumnVisibility(view)); } if (options.setColumnOrder) { - options.setColumnOrder(view?.column_order ?? []); + options.setColumnOrder(getSavedColumnOrder(view)); } + + options.setColumnSizing?.(getSavedColumnSizing(view)); } function applyDisplayState(view: Pick | undefined): void { @@ -277,11 +278,18 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur return true; } - if (options.getColumnVisibility && hasSavedViewColumnChanges(options.getColumnVisibility(), view.columns, options.defaultColumnVisibility)) { + if ( + options.getColumnVisibility && + hasSavedViewColumnChanges(options.getColumnVisibility(), getSavedColumnVisibility(view), options.defaultColumnVisibility) + ) { + return true; + } + + if (options.getColumnOrder && !savedViewColumnOrderEqual(options.getColumnOrder(), view)) { return true; } - if (options.getColumnOrder && hasSavedColumnOrder(view.column_order) && !columnOrderEqual(options.getColumnOrder(), view.column_order)) { + if (options.getColumnSizing && !savedViewColumnSizingEqual(options.getColumnSizing(), view)) { return true; } @@ -372,18 +380,6 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur }; } -function columnOrderEqual(a: ColumnOrderState | undefined, b: null | string[] | undefined): boolean { - const normalize = (value: null | string[] | undefined) => (value ?? []).filter((columnId) => columnId !== 'select'); - const aOrder = normalize(a); - const bOrder = normalize(b); - - if (aOrder.length !== bOrder.length) { - return false; - } - - return aOrder.every((columnId, index) => columnId === bOrder[index]); -} - function normalizeFilterDefinitions(value: null | string | undefined): string { if (!value) { return '[]'; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts index c140942118..1b0ab25c51 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts @@ -12,7 +12,6 @@ import { getComparableSavedViewFilter, getComparableSavedViewTime, hasMissingSavedViewSlug, - hasSavedColumnOrder, hasSavedViewColumnChanges, savedViewColumnsEqual, type SavedViewQueryParams, @@ -42,7 +41,6 @@ function buildSavedView({ id, name, ...overrides }: Partial & Pick { }); describe('column comparison', () => { - it('treats legacy visibility missing default-hidden columns as unchanged', () => { + it('treats visibility missing default-hidden columns as unchanged', () => { // Arrange const current = { project: false, summary: true, tags: false }; - const legacySaved = { summary: true }; + const saved = { summary: true }; const defaults = { project: false, tags: false }; // Act - const result = savedViewColumnsEqual(current, legacySaved, defaults); + const result = savedViewColumnsEqual(current, saved, defaults); // Assert expect(result).toBe(true); @@ -216,16 +214,15 @@ describe('useSavedViews', () => { it('detects a changed column after applying default visibility', () => { // Arrange const current = { project: true, summary: true, tags: false }; - const legacySaved = { summary: true }; + const saved = { summary: true }; const defaults = { project: false, tags: false }; // Act - const result = savedViewColumnsEqual(current, legacySaved, defaults); + const result = savedViewColumnsEqual(current, saved, defaults); // Assert expect(result).toBe(false); }); - it('marks a saved view as changed when adding a column omitted from its settings', () => { // Arrange const current = { project: true, tags: false }; @@ -260,18 +257,6 @@ describe('useSavedViews', () => { // Assert expect(result).toBe(false); }); - - it('does not compare column order when a saved view omits or clears column order', () => { - // Act & Assert - expect(hasSavedColumnOrder(null)).toBe(false); - expect(hasSavedColumnOrder(undefined)).toBe(false); - expect(hasSavedColumnOrder([])).toBe(false); - }); - - it('compares explicit saved column order', () => { - // Act & Assert - expect(hasSavedColumnOrder(['summary', 'events'])).toBe(true); - }); }); describe('time parameter detection', () => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte index 3d45cf2c9a..8cfd62c50a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte @@ -57,6 +57,14 @@ return classes.filter(Boolean).join(' '); } + function getColumnStyle(column: Cell['column'] | Header['column']): string | undefined { + if (!column.getCanResize() || getVisibleDataColumnCount() === 1) { + return undefined; + } + + return `width: ${column.getSize()}px; min-width: ${column.getSize()}px; max-width: ${column.getSize()}px;`; + } + function getMetaClass(meta: unknown): string { return (meta as { class?: string })?.class ?? ''; } @@ -100,6 +108,23 @@ rowClick(cell.row.original, event); } + function onResizeKeydown(event: KeyboardEvent, header: Header): void { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return; + } + + event.preventDefault(); + event.stopPropagation(); + const delta = event.key === 'ArrowLeft' ? -16 : 16; + table.setColumnSizing((current) => ({ + ...current, + [header.column.id]: Math.min( + header.column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER, + Math.max(header.column.columnDef.minSize ?? 20, header.column.getSize() + delta) + ) + })); + } + function removeWidthClasses(className: string): string { return className .split(' ') @@ -115,8 +140,24 @@ {#each headerGroup.headers as header (header.id)} {@const headerClass = getHeaderColumnClass(header)} - + + {#if header.column.getCanResize()} + + {/if} {/each} @@ -145,12 +186,12 @@ {#if rowHref && cell.row.original} {@const href = rowHref(cell.row.original)} onCellClick(event, cell)} variant="ghost"> - + {:else} - onCellClick(event, cell)}> + onCellClick(event, cell)} style={getColumnStyle(cell.column)}> {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts index d4a5788573..851de4eb10 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts @@ -3,6 +3,7 @@ import type { FetchClientResponse } from '@foundatiofx/fetchclient'; import { type ColumnDef, type ColumnOrderState, + type ColumnSizingState, type ColumnSort, type ColumnVisibilityState, createSortedRowModel, @@ -31,7 +32,9 @@ export interface TableConfiguration[]; configureOptions?: (options: TableOptions) => TableOptions | void; defaultColumnOrder?: ColumnOrderState; + defaultColumnSizing?: ColumnSizingState; defaultColumnVisibility?: ColumnVisibilityState; + enableColumnResizing?: boolean; paginationStrategy: TPaginationStrategy; queryData?: TData[]; queryMeta?: QueryMeta; @@ -107,6 +110,9 @@ export function getSharedTableOptions{}); + // Initialize pagination state from parameters const initialPageIndex = getPageIndexFromParameters(configuration.paginationStrategy, configuration.queryParameters, 0); @@ -255,6 +261,7 @@ export function getSharedTableOptions; - column_order?: string[] | null; + columns?: null | Record; show_stats?: null | boolean; show_chart?: null | boolean; /** If true, the view will only be visible to the current user. Defaults to false. */ @@ -368,8 +367,7 @@ export interface PredefinedSavedViewDefinition { time?: null | string; sort?: null | string; filterDefinitions?: null | JsonElement; - columns?: null | Record; - columnOrder?: string[] | null; + columns?: null | Record; showStats?: null | boolean; showChart?: null | boolean; } @@ -388,6 +386,23 @@ export interface ResetPasswordModel { password: string; } +/** + * Per-column display settings for a saved view. All properties are optional so new settings can * be added without changing the meaning of existing saved views. */ +export interface SavedViewColumnSettings { + /** Whether the column is visible. Null means use the table default. */ + visible?: null | boolean; + /** + * Zero-based display position. Null means use the table default order. + * @format int32 + */ + position?: null | number; + /** + * Column width in pixels. Null means use the table default width. + * @format int32 + */ + width?: null | number; +} + export interface Signup { name: string; /** The email address or domain username */ @@ -396,6 +411,20 @@ export interface Signup { invite_token?: null | string; } +export interface SourceMapArtifact { + id: string; + generated_file_url: string; + source_map_url?: null | string; + file_name?: null | string; + /** @format int64 */ + size: number; + is_auto_downloaded: boolean; + /** @format date-time */ + created_utc: string; + /** @format date-time */ + last_used_utc?: null | string; +} + export interface Stack { /** * Unique id that identifies a stack. @@ -501,8 +530,7 @@ export interface UpdateSavedView { sort?: null | string; slug?: null | string; filter_definitions?: null | string; - columns?: null | Record; - column_order?: string[] | null; + columns?: null | Record; show_stats?: null | boolean; show_chart?: null | boolean; } @@ -726,8 +754,7 @@ export interface ViewSavedView { updated_by_user_id?: null | string; filter?: null | string; filter_definitions?: null | string; - columns?: null | Record; - column_order?: string[] | null; + columns?: null | Record; show_stats?: null | boolean; show_chart?: null | boolean; name: string; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index c2cd06eb56..73bd18e397 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -210,8 +210,12 @@ export const NewSavedViewSchema = object({ .max(100000, "Filter definitions must be at most 100000 characters") .nullable() .optional(), - columns: record(string(), boolean()).nullable().optional(), - column_order: array(string()).nullable().optional(), + columns: record( + string(), + lazy(() => SavedViewColumnSettingsSchema), + ) + .nullable() + .optional(), show_stats: boolean().nullable().optional(), show_chart: boolean().nullable().optional(), is_private: boolean().nullable().optional(), @@ -474,8 +478,12 @@ export const PredefinedSavedViewDefinitionSchema = object({ time: string().min(1, "Time is required").nullable().optional(), sort: string().min(1, "Sort is required").nullable().optional(), filterDefinitions: unknown().optional(), - columns: record(string(), boolean()).nullable().optional(), - columnOrder: array(string()).nullable().optional(), + columns: record( + string(), + lazy(() => SavedViewColumnSettingsSchema), + ) + .nullable() + .optional(), showStats: boolean().nullable().optional(), showChart: boolean().nullable().optional(), }); @@ -503,6 +511,15 @@ export const ResetPasswordModelSchema = object({ }); export type ResetPasswordModelFormData = Infer; +export const SavedViewColumnSettingsSchema = object({ + visible: boolean().nullable().optional(), + position: int32().nullable().optional(), + width: int32().nullable().optional(), +}); +export type SavedViewColumnSettingsFormData = Infer< + typeof SavedViewColumnSettingsSchema +>; + export const SignupSchema = object({ name: string().min(1, "Name is required"), email: email(), @@ -516,6 +533,18 @@ export const SignupSchema = object({ }); export type SignupFormData = Infer; +export const SourceMapArtifactSchema = object({ + id: string().min(1, "Id is required"), + generated_file_url: url(), + source_map_url: url().nullable().optional(), + file_name: string().min(1, "File name is required").nullable().optional(), + size: int(), + is_auto_downloaded: boolean(), + created_utc: iso.datetime(), + last_used_utc: iso.datetime().nullable().optional(), +}); +export type SourceMapArtifactFormData = Infer; + export const StackSchema = object({ id: string() .length(24, "Id must be exactly 24 characters") @@ -595,8 +624,12 @@ export const UpdateSavedViewSchema = object({ .min(1, "Filter definitions is required") .nullable() .optional(), - columns: record(string(), boolean()).nullable().optional(), - column_order: array(string()).nullable().optional(), + columns: record( + string(), + lazy(() => SavedViewColumnSettingsSchema), + ) + .nullable() + .optional(), show_stats: boolean().nullable().optional(), show_chart: boolean().nullable().optional(), }); @@ -819,8 +852,12 @@ export const ViewSavedViewSchema = object({ .min(1, "Filter definitions is required") .nullable() .optional(), - columns: record(string(), boolean()).nullable().optional(), - column_order: array(string()).nullable().optional(), + columns: record( + string(), + lazy(() => SavedViewColumnSettingsSchema), + ) + .nullable() + .optional(), show_stats: boolean().nullable().optional(), show_chart: boolean().nullable().optional(), name: string().min(1, "Name is required"), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte index b0298ba5d8..ad890c0900 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte @@ -256,6 +256,7 @@ defaultTime: DEFAULT_TIME_RANGE, filterCacheKey, getColumnOrder: () => table.state.columnOrder, + getColumnSizing: () => table.state.columnSizing, getColumnVisibility: () => table.state.columnVisibility, getFilter: getEffectiveFilter, getFilterDefinitions: () => serializeFilters(filters ?? []), @@ -265,6 +266,7 @@ getTime: getQueryTime, queryParams, setColumnOrder: (v) => table.setColumnOrder(v), + setColumnSizing: (v) => table.setColumnSizing(v), setColumnVisibility: (v) => table.setColumnVisibility(v), setShowChart: (v) => (showChart = v), setShowStats: (v) => (showStats = v), @@ -691,6 +693,7 @@ }); }, defaultColumnVisibility: defaultEventColumnVisibility, + enableColumnResizing: true, paginationStrategy: 'cursor', get queryData() { return eventsQuery.data?.data ?? []; @@ -704,6 +707,7 @@ }), (state) => ({ columnOrder: state.columnOrder, + columnSizing: state.columnSizing, columnVisibility: state.columnVisibility, pagination: state.pagination }) @@ -947,6 +951,7 @@ table.state.columnOrder, + getColumnSizing: () => table.state.columnSizing, getColumnVisibility: () => table.state.columnVisibility, getFilter: getEffectiveFilter, getFilterDefinitions: () => serializeFilters(filters ?? []), @@ -254,6 +255,7 @@ getTime: getQueryTime, queryParams, setColumnOrder: (v) => table.setColumnOrder(v), + setColumnSizing: (v) => table.setColumnSizing(v), setColumnVisibility: (v) => table.setColumnVisibility(v), setShowChart: (v) => (showChart = v), setShowStats: (v) => (showStats = v), @@ -660,6 +662,7 @@ }); }, defaultColumnVisibility: defaultStackColumnVisibility, + enableColumnResizing: true, paginationStrategy: 'offset', get queryData() { return eventsQuery.data?.data ?? []; @@ -673,6 +676,7 @@ }), (state) => ({ columnOrder: state.columnOrder, + columnSizing: state.columnSizing, columnVisibility: state.columnVisibility, pagination: state.pagination }) @@ -843,6 +847,7 @@ table.state.columnOrder, + getColumnSizing: () => table.state.columnSizing, getColumnVisibility: () => table.state.columnVisibility, getFilterDefinitions: () => serializeFilters(filters ?? []), queryParams, setColumnOrder: (v) => table.setColumnOrder(v), + setColumnSizing: (v) => table.setColumnSizing(v), setColumnVisibility: (v) => table.setColumnVisibility(v), updateFilterCache, view: VIEW @@ -192,6 +194,7 @@ return options; }, defaultColumnVisibility: defaultEventColumnVisibility, + enableColumnResizing: true, paginationStrategy: 'cursor', get queryData() { return queryData; @@ -205,6 +208,7 @@ }), (state) => ({ columnOrder: state.columnOrder, + columnSizing: state.columnSizing, columnVisibility: state.columnVisibility }) ); @@ -326,6 +330,7 @@ ? Columns { get; set; } - - [MaxLength(50)] - public List? ColumnOrder { get; set; } + public Dictionary? Columns { get; set; } public bool? ShowStats { get; set; } @@ -87,64 +84,77 @@ public IEnumerable Validate(ValidationContext validationContex ); } - foreach (var error in ValidateColumnKeys(ViewType, Columns)) - { - yield return error; - } - - foreach (var error in ValidateColumnOrder(ViewType, ColumnOrder)) + foreach (var error in ValidateColumns(ViewType, Columns)) { yield return error; } } - internal static IEnumerable ValidateColumnKeys(string? view, Dictionary? columns) + internal static IEnumerable ValidateColumns( + string? view, + IReadOnlyDictionary? columns) { if (columns is null || columns.Count == 0) { yield break; } - var validKeys = view is not null && ValidColumnIds.TryGetValue(view, out var viewKeys) - ? viewKeys - : AllValidColumnIds; - - var invalidKeys = columns.Keys.Where(key => !validKeys.Contains(key)); - foreach (string key in invalidKeys) + if (columns.Count > 50) { yield return new ValidationResult( - $"Column key '{key}' is not a valid column. Valid columns are: {String.Join(", ", validKeys.Order())}.", + "Columns cannot exceed 50 items.", [nameof(Columns)] ); } - } - - internal static IEnumerable ValidateColumnOrder(string? view, IReadOnlyCollection? columnOrder) - { - if (columnOrder is null || columnOrder.Count == 0) - { - yield break; - } var validKeys = view is not null && ValidColumnIds.TryGetValue(view, out var viewKeys) ? viewKeys : AllValidColumnIds; - var invalidKeys = columnOrder.Where(key => !validKeys.Contains(key)).Distinct(); - foreach (string key in invalidKeys) + foreach (string key in columns.Keys.Where(key => !validKeys.Contains(key))) { yield return new ValidationResult( - $"Column order key '{key}' is not a valid column. Valid columns are: {String.Join(", ", validKeys.Order())}.", - [nameof(ColumnOrder)] + $"Column key '{key}' is not a valid column. Valid columns are: {String.Join(", ", validKeys.Order())}.", + [nameof(Columns)] ); } - var duplicateKeys = columnOrder.GroupBy(key => key).Where(group => group.Count() > 1).Select(group => group.Key); - foreach (string key in duplicateKeys) + foreach (var (key, settings) in columns) + { + if (settings is null) + { + yield return new ValidationResult( + $"Column configuration for '{key}' cannot be null.", + [nameof(Columns)] + ); + continue; + } + + if (settings.Position is < 0 or > SavedViewColumnSettings.MaxPosition) + { + yield return new ValidationResult( + $"Column position for '{key}' must be between 0 and {SavedViewColumnSettings.MaxPosition}.", + [nameof(Columns)] + ); + } + + if (settings.Width is < SavedViewColumnSettings.MinWidth or > SavedViewColumnSettings.MaxWidth) + { + yield return new ValidationResult( + $"Column width for '{key}' must be between {SavedViewColumnSettings.MinWidth} and {SavedViewColumnSettings.MaxWidth} pixels.", + [nameof(Columns)] + ); + } + } + + foreach (var duplicatePosition in columns + .Where(entry => entry.Value is { Position: not null }) + .GroupBy(entry => entry.Value.Position!.Value) + .Where(group => group.Count() > 1)) { yield return new ValidationResult( - $"Column order key '{key}' cannot be repeated.", - [nameof(ColumnOrder)] + $"Column position '{duplicatePosition.Key}' cannot be repeated.", + [nameof(Columns)] ); } } diff --git a/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs b/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs index 567981d765..b32e3e0852 100644 --- a/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs +++ b/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs @@ -19,9 +19,7 @@ public class UpdateSavedView : IValidatableObject [MaxLength(SavedView.MaxFilterDefinitionsLength)] public string? FilterDefinitions { get; set; } [MaxLength(50)] - public Dictionary? Columns { get; set; } - [MaxLength(50)] - public List? ColumnOrder { get; set; } + public Dictionary? Columns { get; set; } public bool? ShowStats { get; set; } public bool? ShowChart { get; set; } @@ -35,12 +33,7 @@ public IEnumerable Validate(ValidationContext validationContex ); } - foreach (var error in NewSavedView.ValidateColumnKeys(null, Columns)) - { - yield return error; - } - - foreach (var error in NewSavedView.ValidateColumnOrder(null, ColumnOrder)) + foreach (var error in NewSavedView.ValidateColumns(null, Columns)) { yield return error; } diff --git a/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs b/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs index 0a87f289e7..7ab1f51cc3 100644 --- a/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs +++ b/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs @@ -1,4 +1,5 @@ using Exceptionless.Core.Attributes; +using Exceptionless.Core.Models; using Foundatio.Repositories.Models; namespace Exceptionless.Web.Models; @@ -22,8 +23,7 @@ public record ViewSavedView : IIdentity, IHaveDates public string? Filter { get; set; } public string? FilterDefinitions { get; set; } - public Dictionary? Columns { get; set; } - public List? ColumnOrder { get; set; } + public Dictionary? Columns { get; set; } public bool? ShowStats { get; set; } public bool? ShowChart { get; set; } public string Name { get; set; } = null!; diff --git a/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs index 953bc61b6e..3e37b98026 100644 --- a/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs +++ b/src/Exceptionless.Web/Utility/OpenApi/DeltaSchemaTransformer.cs @@ -14,18 +14,18 @@ public class DeltaSchemaTransformer : IOpenApiSchemaTransformer { private static readonly NullabilityInfoContext NullabilityContext = new(); - public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) { var type = context.JsonTypeInfo.Type; // Check if this is a Delta type if (!IsDeltaType(type)) - return Task.CompletedTask; + return; // Get the inner type T from Delta var innerType = type.GetGenericArguments().FirstOrDefault(); if (innerType is null) - return Task.CompletedTask; + return; // Set the type to object schema.Type = JsonSchemaType.Object; @@ -45,6 +45,7 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext { bool isNullable = IsPropertyNullable(property); var propertySchema = CreateSchemaForType(property.PropertyType, isNullable); + await PopulateNestedSchemaAsync(propertySchema, property.PropertyType, context, cancellationToken); // Apply data annotations from the inner type's property DataAnnotationHelper.ApplyToSchema(propertySchema, property); @@ -57,7 +58,45 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext // Ensure no required array - all properties are optional for PATCH schema.Required = null; - return Task.CompletedTask; + } + + private static async Task PopulateNestedSchemaAsync( + OpenApiSchema schema, + Type type, + OpenApiSchemaTransformerContext context, + CancellationToken cancellationToken) + { + type = Nullable.GetUnderlyingType(type) ?? type; + + if (type.IsGenericType && type.GetInterfaces().Concat([type]).Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDictionary<,>))) + { + var valueType = type.GetGenericArguments().ElementAtOrDefault(1); + if (valueType is not null && IsComplexType(valueType)) + schema.AdditionalProperties = await context.GetOrCreateSchemaAsync(valueType, null, cancellationToken); + + return; + } + + if (TryGetEnumerableElementType(type, out var elementType) && IsComplexType(elementType)) + schema.Items = await context.GetOrCreateSchemaAsync(elementType, null, cancellationToken); + } + + private static bool IsComplexType(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + return type != typeof(string) + && type != typeof(bool) + && type != typeof(int) + && type != typeof(long) + && type != typeof(short) + && type != typeof(byte) + && type != typeof(double) + && type != typeof(float) + && type != typeof(decimal) + && type != typeof(DateTime) + && type != typeof(DateTimeOffset) + && type != typeof(Guid) + && !type.IsEnum; } private static bool IsDeltaType(Type type) diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index ac53fd4465..3350224f81 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -11357,17 +11357,7 @@ "object" ], "additionalProperties": { - "type": "boolean" - } - }, - "column_order": { - "maxItems": 50, - "type": [ - "null", - "array" - ], - "items": { - "type": "string" + "$ref": "#/components/schemas/SavedViewColumnSettings" } }, "show_stats": { @@ -12162,16 +12152,7 @@ "object" ], "additionalProperties": { - "type": "boolean" - } - }, - "columnOrder": { - "type": [ - "null", - "array" - ], - "items": { - "type": "string" + "$ref": "#/components/schemas/SavedViewColumnSettings" } }, "showStats": { @@ -12243,6 +12224,35 @@ } } }, + "SavedViewColumnSettings": { + "type": "object", + "properties": { + "visible": { + "type": [ + "null", + "boolean" + ], + "description": "Whether the column is visible. Null means use the table default." + }, + "position": { + "type": [ + "null", + "integer" + ], + "description": "Zero-based display position. Null means use the table default order.", + "format": "int32" + }, + "width": { + "type": [ + "null", + "integer" + ], + "description": "Column width in pixels. Null means use the table default width.", + "format": "int32" + } + }, + "description": "Per-column display settings for a saved view. All properties are optional so new settings can\nbe added without changing the meaning of existing saved views." + }, "Signup": { "required": [ "name", @@ -12617,17 +12627,7 @@ "object" ], "additionalProperties": { - "type": "boolean" - } - }, - "column_order": { - "maxItems": 50, - "type": [ - "null", - "array" - ], - "items": { - "type": "string" + "$ref": "#/components/schemas/SavedViewColumnSettings" } }, "show_stats": { @@ -13426,16 +13426,7 @@ "object" ], "additionalProperties": { - "type": "boolean" - } - }, - "column_order": { - "type": [ - "null", - "array" - ], - "items": { - "type": "string" + "$ref": "#/components/schemas/SavedViewColumnSettings" } }, "show_stats": { diff --git a/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs index 3dfda514d8..920610e84a 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/SavedViewEndpointTests.cs @@ -257,8 +257,10 @@ public async Task PutPredefinedAsync_AsGlobalAdmin_ReplacesPredefinedDefinitions Filter = "type:log", Time = "last 24 hours", Sort = "-date", - Columns = new Dictionary { ["message"] = true }, - ColumnOrder = ["message"], + Columns = new Dictionary + { + ["message"] = new() { Position = 0, Visible = true } + }, ShowChart = true, ShowStats = true } @@ -295,7 +297,12 @@ public async Task PostAsync_NewSavedView_MapsAllPropertiesToSavedView() Sort = "-date", ViewType = "events", FilterDefinitions = """[{"type":"keyword","value":"status:open"}]""", - ColumnOrder = ["summary", "date", "user"] + Columns = new Dictionary + { + ["summary"] = new() { Position = 0, Visible = true }, + ["date"] = new() { Position = 1, Visible = true }, + ["user"] = new() { Position = 2, Visible = true } + } }; // Act @@ -317,7 +324,7 @@ public async Task PostAsync_NewSavedView_MapsAllPropertiesToSavedView() Assert.Equal("[now-7D TO now]", result.Time); Assert.Equal("-date", result.Sort); Assert.Equal("events", result.ViewType); - Assert.Equal(["summary", "date", "user"], result.ColumnOrder); + Assert.Equal(2, result.Columns?["user"].Position); Assert.NotNull(result.FilterDefinitions); Assert.Equal(1, result.Version); Assert.NotNull(result.CreatedByUserId); @@ -330,7 +337,7 @@ public async Task PostAsync_NewSavedView_MapsAllPropertiesToSavedView() Assert.NotNull(savedView); Assert.Equal("Production Errors", savedView.Name); Assert.Equal("-date", savedView.Sort); - Assert.Equal(["summary", "date", "user"], savedView.ColumnOrder); + Assert.Equal(2, savedView.Columns?["user"].Position); } [Fact] @@ -360,6 +367,44 @@ public async Task PostAsync_NewSavedView_ReturnsAbsoluteLocation() Assert.StartsWith("/api/v2/saved-views/", response.Headers.Location.AbsolutePath); } + [Fact] + public async Task PostAsync_StructuredColumns_PersistsAllSettings() + { + // Arrange + var columnSettings = new Dictionary + { + ["summary"] = new() { Position = 0, Visible = true, Width = 420 }, + ["project"] = new() { Position = 1, Visible = true, Width = 240 } + }; + + // Act + var result = await SendRequestAsAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Resizable Project View", + ViewType = "events", + Columns = columnSettings + }) + .StatusCodeShouldBeCreated() + ); + + // Assert + Assert.NotNull(result); + Assert.Equal(240, result.Columns?["project"].Width); + Assert.True(result.Columns?["project"].Visible); + Assert.Equal(1, result.Columns?["project"].Position); + + var savedView = await _savedViewRepository.GetByIdAsync(result.Id); + Assert.NotNull(savedView); + Assert.Equal(240, savedView.Columns?["project"].Width); + Assert.True(savedView.Columns?["project"].Visible); + Assert.Equal(1, savedView.Columns?["project"].Position); + } + [Fact] public async Task PostAsync_WithIsPrivate_SetsUserIdOnSavedView() { @@ -1025,13 +1070,12 @@ public async Task PostPredefinedSavedViewAsync_GlobalAdmin_UsesPromotedConfigura savedLogs.Slug = "application-logs"; savedLogs.Filter = "type:log level:error"; savedLogs.ShowChart = false; - savedLogs.Columns = new Dictionary + savedLogs.Columns = new Dictionary { - ["summary"] = true, - ["date"] = true, - ["type"] = false + ["summary"] = new() { Position = 0, Visible = true }, + ["date"] = new() { Position = 1, Visible = true }, + ["type"] = new() { Position = 2, Visible = false } }; - savedLogs.ColumnOrder = ["summary", "date", "type"]; await _savedViewRepository.SaveAsync(savedLogs, o => o.ImmediateConsistency()); await RefreshDataAsync(); @@ -1068,9 +1112,9 @@ public async Task PostPredefinedSavedViewAsync_GlobalAdmin_UsesPromotedConfigura Assert.Equal("type:log level:error", applicationLogs.Filter); Assert.False(applicationLogs.ShowChart); Assert.NotNull(applicationLogs.Columns); - Assert.True(applicationLogs.Columns["summary"]); - Assert.False(applicationLogs.Columns["type"]); - Assert.Equal(new[] { "summary", "date", "type" }, applicationLogs.ColumnOrder); + Assert.True(applicationLogs.Columns["summary"].Visible); + Assert.False(applicationLogs.Columns["type"].Visible); + Assert.Equal(2, applicationLogs.Columns["type"].Position); Assert.DoesNotContain(updatedPredefinedViews, view => IsPredefinedSavedView(view, "events", "Logs")); Assert.Contains(updatedPredefinedViews, view => IsPredefinedSavedView(view, "events", "Errors")); } @@ -2092,7 +2136,7 @@ public Task PostAsync_InvalidColumnKey_ReturnsUnprocessableEntity() OrganizationId = SampleDataService.TEST_ORG_ID, Name = "Bad Columns", ViewType = "events", - Columns = new Dictionary { ["status"] = true } + Columns = new Dictionary { ["status"] = new() { Visible = true } } }) .StatusCodeShouldBeUnprocessableEntity() ); @@ -2112,7 +2156,7 @@ await SendRequestAsync(r => r .AppendPaths("saved-views", created.Id) .Content(new UpdateSavedView { - Columns = new Dictionary { ["INVALID_COLUMN"] = true } + Columns = new Dictionary { ["INVALID_COLUMN"] = new() { Visible = true } } }) .StatusCodeShouldBeUnprocessableEntity() ); @@ -2131,7 +2175,14 @@ public Task PostAsync_ValidColumnKeys_Succeeds() OrganizationId = SampleDataService.TEST_ORG_ID, Name = "Valid Columns", ViewType = "events", - Columns = new Dictionary { ["summary"] = true, ["type"] = false, ["exception_type"] = false, ["level"] = false, ["message"] = false } + Columns = new Dictionary + { + ["summary"] = new() { Visible = true }, + ["type"] = new() { Visible = false }, + ["exception_type"] = new() { Visible = false }, + ["level"] = new() { Visible = false }, + ["message"] = new() { Visible = false } + } }) .StatusCodeShouldBeCreated() ); @@ -2156,8 +2207,7 @@ public Task PostAsync_ProjectAndTagColumnsForSupportedViews_Succeeds(string view OrganizationId = SampleDataService.TEST_ORG_ID, Name = $"Valid {viewType} {column} Column", ViewType = viewType, - Columns = new Dictionary { [column] = false }, - ColumnOrder = [column] + Columns = new Dictionary { [column] = new() { Position = 0, Visible = false } } }) .StatusCodeShouldBeCreated() ); @@ -2178,15 +2228,20 @@ public Task PostAsync_VersionColumnForEventViews_Succeeds(string viewType) OrganizationId = SampleDataService.TEST_ORG_ID, Name = $"Valid {viewType} Version Column", ViewType = viewType, - Columns = new Dictionary { ["summary"] = true, ["version"] = false }, - ColumnOrder = ["summary", "version"] + Columns = new Dictionary + { + ["summary"] = new() { Position = 0, Visible = true }, + ["version"] = new() { Position = 1, Visible = false } + } }) .StatusCodeShouldBeCreated() ); } - [Fact] - public Task PostAsync_InvalidColumnOrderKey_ReturnsUnprocessableEntity() + [Theory] + [InlineData(47)] + [InlineData(1201)] + public Task PostAsync_ColumnSettingsWidthOutsideRange_ReturnsUnprocessableEntity(int width) { // Arrange & Act & Assert return SendRequestAsync(r => r @@ -2196,29 +2251,57 @@ public Task PostAsync_InvalidColumnOrderKey_ReturnsUnprocessableEntity() .Content(new NewSavedView { OrganizationId = SampleDataService.TEST_ORG_ID, - Name = "Bad Column Order", + Name = $"Invalid Column Width {width}", ViewType = "events", - ColumnOrder = ["summary", "status"] + Columns = new Dictionary + { + ["project"] = new() { Width = width } + } }) .StatusCodeShouldBeUnprocessableEntity() ); } [Fact] - public async Task PatchAsync_DuplicateColumnOrderKey_ReturnsUnprocessableEntity() + public Task PostAsync_DuplicateColumnPosition_ReturnsUnprocessableEntity() { - // Arrange - var created = await CreateSavedViewAsync("Patch Column Order Test", "status:open", "events"); - Assert.NotNull(created); + // Arrange & Act & Assert + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Duplicate Column Position", + ViewType = "events", + Columns = new Dictionary + { + ["summary"] = new() { Position = 0 }, + ["project"] = new() { Position = 0 } + } + }) + .StatusCodeShouldBeUnprocessableEntity() + ); + } - // Act & Assert - await SendRequestAsync(r => r - .Patch() + [Fact] + public Task PostAsync_NullColumnValue_ReturnsUnprocessableEntity() + { + // Arrange & Act & Assert + return SendRequestAsync(r => r + .Post() .AsGlobalAdminUser() - .AppendPaths("saved-views", created.Id) - .Content(new UpdateSavedView + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(new NewSavedView { - ColumnOrder = ["summary", "summary"] + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Null Column", + ViewType = "events", + Columns = new Dictionary + { + ["project"] = null! + } }) .StatusCodeShouldBeUnprocessableEntity() ); @@ -2347,9 +2430,9 @@ public async Task PatchAsync_ColumnsExceedsMaxCount_ReturnsUnprocessableEntity() // Build a columns dict with 51 entries (exceeds max of 50). // Count validation fires before key validation, so arbitrary keys work here. - var columns = new Dictionary(); + var columns = new Dictionary(); for (int i = 0; i < 51; i++) - columns[$"col{i}"] = true; + columns[$"col{i}"] = new(); // Act & Assert await SendRequestAsync(r => r diff --git a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs index 433eea8072..ee8ed3c4aa 100644 --- a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs +++ b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs @@ -81,6 +81,7 @@ public async Task GetOpenApiJson_Default_ContainsExpectedSchemasAndSecuritySchem Assert.True(schemas.TryGetProperty("Login", out _)); Assert.True(schemas.TryGetProperty("Signup", out _)); Assert.True(schemas.TryGetProperty("NewProject", out _)); + Assert.True(schemas.TryGetProperty("SavedViewColumnSettings", out _)); Assert.True(schemas.TryGetProperty("TokenResult", out _)); Assert.True(schemas.TryGetProperty("ViewOrganization", out _)); @@ -134,6 +135,9 @@ public async Task GetOpenApiJson_MigratedContracts_PreserveControllerMetadata() AssertRequiredJsonRequestBody(paths, "/api/v2/tokens/{id}", "put", "UpdateToken"); AssertRequiredJsonRequestBody(paths, "/api/v2/saved-views/{id}", "patch", "UpdateSavedView"); AssertRequiredJsonRequestBody(paths, "/api/v2/saved-views/{id}", "put", "UpdateSavedView"); + AssertDictionaryValueSchema(document.RootElement, "NewSavedView", "columns", "SavedViewColumnSettings"); + AssertDictionaryValueSchema(document.RootElement, "UpdateSavedView", "columns", "SavedViewColumnSettings"); + AssertDictionaryValueSchema(document.RootElement, "ViewSavedView", "columns", "SavedViewColumnSettings"); AssertRequiredJsonRequestBody(paths, "/api/v2/users/{id}", "patch", "UpdateUser"); AssertRequiredJsonRequestBody(paths, "/api/v2/users/{id}", "put", "UpdateUser"); @@ -301,6 +305,17 @@ private static void AssertOperationTag(JsonElement paths, string path, string ex Assert.Equal(expectedTag, Assert.Single(tags.EnumerateArray()).GetString()); } + private static void AssertDictionaryValueSchema(JsonElement document, string schemaName, string propertyName, string expectedValueSchema) + { + var property = document.GetProperty("components") + .GetProperty("schemas") + .GetProperty(schemaName) + .GetProperty("properties") + .GetProperty(propertyName); + + Assert.Equal($"#/components/schemas/{expectedValueSchema}", property.GetProperty("additionalProperties").GetProperty("$ref").GetString()); + } + private static void AssertOptionalParameter(JsonElement paths, string path, string method, string parameterName) { var parameter = Assert.Single(paths.GetProperty(path) diff --git a/tests/Exceptionless.Tests/Exceptionless.Tests.csproj b/tests/Exceptionless.Tests/Exceptionless.Tests.csproj index 8278f2a81e..7304429e53 100644 --- a/tests/Exceptionless.Tests/Exceptionless.Tests.csproj +++ b/tests/Exceptionless.Tests/Exceptionless.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/tests/Exceptionless.Tests/Jobs/JobRunnerOptionsTests.cs b/tests/Exceptionless.Tests/Jobs/JobRunnerOptionsTests.cs new file mode 100644 index 0000000000..7f5d4bb503 --- /dev/null +++ b/tests/Exceptionless.Tests/Jobs/JobRunnerOptionsTests.cs @@ -0,0 +1,31 @@ +using Exceptionless.Job; +using Xunit; + +namespace Exceptionless.Tests.Jobs; + +public sealed class JobRunnerOptionsTests +{ + [Fact] + public void RunDataSeedStartupAction_AllJobs_ReturnsFalse() + { + var options = new JobRunnerOptions([]); + + Assert.False(options.RunDataSeedStartupAction); + } + + [Fact] + public void RunDataSeedStartupAction_MigrationJob_ReturnsFalse() + { + var options = new JobRunnerOptions([nameof(JobRunnerOptions.Migration)]); + + Assert.False(options.RunDataSeedStartupAction); + } + + [Fact] + public void RunDataSeedStartupAction_NonMigrationJob_ReturnsTrue() + { + var options = new JobRunnerOptions([nameof(JobRunnerOptions.EventPosts)]); + + Assert.True(options.RunDataSeedStartupAction); + } +} diff --git a/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs b/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs index 7505fd4f52..a1f756479c 100644 --- a/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs +++ b/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs @@ -27,8 +27,12 @@ public void MapToSavedView_WithValidNewSavedView_MapsAllProperties() Sort = "-last", ViewType = "stacks", FilterDefinitions = "[{\"type\":\"status\",\"values\":[\"open\",\"regressed\"]}]", - Columns = new Dictionary { ["status"] = true, ["users"] = false }, - ColumnOrder = ["summary", "status", "users"] + Columns = new Dictionary + { + ["summary"] = new() { Position = 0, Visible = true }, + ["status"] = new() { Position = 1, Visible = true, Width = 180 }, + ["users"] = new() { Position = 2, Visible = false } + } }; // Act @@ -43,9 +47,10 @@ public void MapToSavedView_WithValidNewSavedView_MapsAllProperties() Assert.Equal("stacks", result.ViewType); Assert.Equal("[{\"type\":\"status\",\"values\":[\"open\",\"regressed\"]}]", result.FilterDefinitions); Assert.NotNull(result.Columns); - Assert.True(result.Columns["status"]); - Assert.False(result.Columns["users"]); - Assert.Equal(["summary", "status", "users"], result.ColumnOrder); + Assert.True(result.Columns["status"].Visible); + Assert.False(result.Columns["users"].Visible); + Assert.Equal(1, result.Columns["status"].Position); + Assert.Equal(180, result.Columns["status"].Width); } [Fact] @@ -89,7 +94,6 @@ public void MapToSavedView_WithNullOptionalFields_MapsNulls() Assert.Null(result.Time); Assert.Null(result.FilterDefinitions); Assert.Null(result.Columns); - Assert.Null(result.ColumnOrder); } [Fact] @@ -106,8 +110,11 @@ public void MapToViewSavedView_WithValidSavedView_MapsAllProperties() UpdatedByUserId = "1ecd0826e447ad1e78822666", Filter = "status:open", FilterDefinitions = "[{\"type\":\"status\",\"values\":[\"open\"]}]", - Columns = new Dictionary { ["status"] = true }, - ColumnOrder = ["summary", "status"], + Columns = new Dictionary + { + ["summary"] = new() { Position = 0, Visible = true }, + ["status"] = new() { Position = 1, Visible = true, Width = 180 } + }, Name = "My View", Time = "[now-30d TO now]", Sort = "-last", @@ -129,8 +136,9 @@ public void MapToViewSavedView_WithValidSavedView_MapsAllProperties() Assert.Equal("status:open", result.Filter); Assert.Equal("[{\"type\":\"status\",\"values\":[\"open\"]}]", result.FilterDefinitions); Assert.NotNull(result.Columns); - Assert.True(result.Columns["status"]); - Assert.Equal(["summary", "status"], result.ColumnOrder); + Assert.True(result.Columns["status"].Visible); + Assert.Equal(1, result.Columns["status"].Position); + Assert.Equal(180, result.Columns["status"].Width); Assert.Equal("My View", result.Name); Assert.Equal("[now-30d TO now]", result.Time); Assert.Equal("-last", result.Sort); @@ -163,7 +171,6 @@ public void MapToViewSavedView_WithNullOptionalFields_MapsNulls() Assert.Null(result.Filter); Assert.Null(result.FilterDefinitions); Assert.Null(result.Columns); - Assert.Null(result.ColumnOrder); Assert.Null(result.Time); Assert.Null(result.Sort); } diff --git a/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs b/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs new file mode 100644 index 0000000000..62829b2bf2 --- /dev/null +++ b/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsIntegrationTests.cs @@ -0,0 +1,184 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Elastic.Clients.Elasticsearch; +using Exceptionless.Core.Migrations; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Seed; +using Foundatio.Lock; +using Foundatio.Repositories; +using Foundatio.Repositories.Migrations; +using Foundatio.Utility; +using Xunit; + +namespace Exceptionless.Tests.Migrations; + +public sealed class MigrateSavedViewColumnsIntegrationTests : IntegrationTestsBase +{ + private readonly ElasticsearchClient _client; + private readonly ExceptionlessElasticConfiguration _configuration; + private readonly ISavedViewRepository _repository; + + public MigrateSavedViewColumnsIntegrationTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _configuration = GetService(); + _client = _configuration.Client; + _repository = GetService(); + } + + protected override void RegisterServices(IServiceCollection services) + { + services.AddTransient(); + services.AddSingleton(EmptyLock.Empty); + base.RegisterServices(services); + } + + [Fact] + public async Task RunAsync_LegacySavedView_ReplacesDocumentWithStructuredColumns() + { + // Arrange + const string savedViewId = "770000000000000000000099"; + const string customizedSavedViewId = "770000000000000000000098"; + var source = JsonNode.Parse( + $$""" + { + "id": "{{savedViewId}}", + "organization_id": "550000000000000000000001", + "created_by_user_id": "660000000000000000000001", + "name": "Legacy Columns", + "slug": "legacy-columns", + "view_type": "events", + "columns": { + "project": true, + "summary": false + }, + "column_order": [ + "summary", + "project" + ], + "version": 1, + "created_utc": "2026-01-01T00:00:00Z", + "updated_utc": "2026-01-01T00:00:00Z" + } + """ + )!.AsObject(); + source["predefined_content_hash"] = MigrateSavedViewColumns.GetLegacyContentHash(source); + var customizedSource = source.DeepClone().AsObject(); + customizedSource["id"] = customizedSavedViewId; + customizedSource["name"] = "Customized Legacy Columns"; + customizedSource["predefined_content_hash"] = "customized-content-hash"; + + var indexResponse = await _client.IndexAsync( + source, + request => request + .Index(_configuration.SavedViews.VersionedName) + .Id(savedViewId), + TestCancellationToken); + Assert.True(indexResponse.IsValidResponse); + + indexResponse = await _client.IndexAsync( + customizedSource, + request => request + .Index(_configuration.SavedViews.VersionedName) + .Id(customizedSavedViewId) + .Refresh(Refresh.WaitFor), + TestCancellationToken); + Assert.True(indexResponse.IsValidResponse); + + // Act + var migration = GetService(); + var context = new MigrationContext(GetService(), _logger, TestCancellationToken); + await migration.RunAsync(context); + + // Assert + var savedView = await _repository.GetByIdAsync(savedViewId, o => o.ImmediateConsistency()); + Assert.NotNull(savedView); + Assert.False(savedView.Columns?["summary"].Visible); + Assert.Equal(0, savedView.Columns?["summary"].Position); + Assert.True(savedView.Columns?["project"].Visible); + Assert.Equal(1, savedView.Columns?["project"].Position); + Assert.Equal(PredefinedSavedViewContentHasher.GetContentHash(savedView), savedView.PredefinedContentHash); + + var customizedSavedView = await _repository.GetByIdAsync(customizedSavedViewId, o => o.ImmediateConsistency()); + Assert.NotNull(customizedSavedView); + Assert.Equal("customized-content-hash", customizedSavedView.PredefinedContentHash); + Assert.Equal(1, customizedSavedView.Columns?["project"].Position); + + var getResponse = await _client.GetAsync( + savedViewId, + request => request.Index(_configuration.SavedViews.VersionedName), + TestCancellationToken); + Assert.True(getResponse.IsValidResponse); + Assert.False(getResponse.Source.TryGetProperty("column_order", out _)); + Assert.Equal(JsonValueKind.Object, getResponse.Source.GetProperty("columns").GetProperty("project").ValueKind); + } + + [Fact] + public async Task IndexMigratedDocumentAsync_ConcurrentUpdate_DoesNotOverwriteDocument() + { + // Arrange + const string savedViewId = "770000000000000000000097"; + var source = JsonNode.Parse( + $$""" + { + "id": "{{savedViewId}}", + "name": "Legacy Columns", + "columns": { + "project": true + } + } + """ + )!.AsObject(); + + var indexResponse = await _client.IndexAsync( + source, + request => request + .Index(_configuration.SavedViews.VersionedName) + .Id(savedViewId) + .Refresh(Refresh.WaitFor), + TestCancellationToken); + Assert.True(indexResponse.IsValidResponse); + + var staleDocument = await _client.GetAsync( + savedViewId, + request => request.Index(_configuration.SavedViews.VersionedName), + TestCancellationToken); + Assert.True(staleDocument.IsValidResponse); + Assert.NotNull(staleDocument.SeqNo); + Assert.NotNull(staleDocument.PrimaryTerm); + + var concurrentSource = source.DeepClone().AsObject(); + concurrentSource["name"] = "Edited During Migration"; + indexResponse = await _client.IndexAsync( + concurrentSource, + request => request + .Index(_configuration.SavedViews.VersionedName) + .Id(savedViewId) + .Refresh(Refresh.WaitFor), + TestCancellationToken); + Assert.True(indexResponse.IsValidResponse); + + Assert.True(MigrateSavedViewColumns.TryMigrate(source)); + var migration = GetService(); + + // Act + indexResponse = await migration.IndexMigratedDocumentAsync( + source, + savedViewId, + staleDocument.SeqNo.Value, + staleDocument.PrimaryTerm.Value, + TestCancellationToken); + + // Assert + Assert.False(indexResponse.IsValidResponse); + Assert.Equal(409, indexResponse.ApiCallDetails.HttpStatusCode); + + var currentDocument = await _client.GetAsync( + savedViewId, + request => request.Index(_configuration.SavedViews.VersionedName), + TestCancellationToken); + Assert.True(currentDocument.IsValidResponse); + Assert.Equal("Edited During Migration", currentDocument.Source.GetProperty("name").GetString()); + Assert.Equal(JsonValueKind.True, currentDocument.Source.GetProperty("columns").GetProperty("project").ValueKind); + } +} diff --git a/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsMigrationTests.cs b/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsMigrationTests.cs new file mode 100644 index 0000000000..f06c01345f --- /dev/null +++ b/tests/Exceptionless.Tests/Migrations/MigrateSavedViewColumnsMigrationTests.cs @@ -0,0 +1,101 @@ +using System.Text.Json.Nodes; +using Exceptionless.Core.Migrations; +using Xunit; + +namespace Exceptionless.Tests.Migrations; + +public sealed class MigrateSavedViewColumnsMigrationTests +{ + [Fact] + public void TryMigrate_LegacyColumnsAndOrder_ConvertsToStructuredColumns() + { + // Arrange + var source = JsonNode.Parse( + """ + { + "columns": { + "project": true, + "summary": false + }, + "column_order": [ + "summary", + "project", + "date" + ] + } + """ + )!.AsObject(); + + // Act + bool migrated = MigrateSavedViewColumns.TryMigrate(source); + + // Assert + Assert.True(migrated); + Assert.False(source.ContainsKey("column_order")); + var columns = Assert.IsType(source["columns"]); + Assert.False(columns["summary"]!["visible"]!.GetValue()); + Assert.Equal(0, columns["summary"]!["position"]!.GetValue()); + Assert.True(columns["project"]!["visible"]!.GetValue()); + Assert.Equal(1, columns["project"]!["position"]!.GetValue()); + Assert.Equal(2, columns["date"]!["position"]!.GetValue()); + Assert.Null(columns["date"]!["visible"]); + } + + [Fact] + public void TryMigrate_StructuredColumnsWithoutLegacyOrder_DoesNotChangeDocument() + { + // Arrange + var source = JsonNode.Parse( + """ + { + "columns": { + "project": { + "visible": true, + "position": 0, + "width": 320 + } + } + } + """ + )!.AsObject(); + string originalJson = source.ToJsonString(); + + // Act + bool migrated = MigrateSavedViewColumns.TryMigrate(source); + + // Assert + Assert.False(migrated); + Assert.Equal(originalJson, source.ToJsonString()); + } + + [Fact] + public void TryMigrate_PartiallyMigratedDocument_PreservesExistingWidths() + { + // Arrange + var source = JsonNode.Parse( + """ + { + "columns": { + "project": { + "visible": true, + "width": 360 + } + }, + "column_order": [ + "project" + ] + } + """ + )!.AsObject(); + + // Act + bool migrated = MigrateSavedViewColumns.TryMigrate(source); + + // Assert + Assert.True(migrated); + var project = source["columns"]!["project"]!; + Assert.True(project["visible"]!.GetValue()); + Assert.Equal(0, project["position"]!.GetValue()); + Assert.Equal(360, project["width"]!.GetValue()); + } +} diff --git a/tests/Exceptionless.Tests/Migrations/MigrationRegistrationTests.cs b/tests/Exceptionless.Tests/Migrations/MigrationRegistrationTests.cs new file mode 100644 index 0000000000..8b9a1b495a --- /dev/null +++ b/tests/Exceptionless.Tests/Migrations/MigrationRegistrationTests.cs @@ -0,0 +1,29 @@ +using Foundatio.Repositories.Migrations; +using Xunit; + +namespace Exceptionless.Tests.Migrations; + +public sealed class MigrationRegistrationTests : TestWithServices +{ + public MigrationRegistrationTests(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void RegisteredVersionedMigrations_AllVersions_AreUnique() + { + var duplicateVersions = GetService>() + .DistinctBy(migration => migration.GetType()) + .Where(migration => migration.MigrationType is MigrationType.Versioned or MigrationType.VersionedAndResumable) + .GroupBy(migration => migration.Version) + .Where(group => group.Count() > 1) + .Select(group => new + { + Version = group.Key, + Migrations = group.Select(migration => migration.GetType().Name).Order().ToArray() + }) + .ToList(); + + Assert.Empty(duplicateVersions); + } +} diff --git a/tests/Exceptionless.Tests/Seed/PredefinedSavedViewContentHasherTests.cs b/tests/Exceptionless.Tests/Seed/PredefinedSavedViewContentHasherTests.cs index f637598e1d..ce5698aedd 100644 --- a/tests/Exceptionless.Tests/Seed/PredefinedSavedViewContentHasherTests.cs +++ b/tests/Exceptionless.Tests/Seed/PredefinedSavedViewContentHasherTests.cs @@ -17,21 +17,20 @@ public void GetContentHash_FilterDefinitionsDifferOnlyByFormattingAndDictionaryI ViewType = "events", Filter = "type:log (status:open OR status:regressed)", FilterDefinitions = """[{"type":"status","value":"open"}]""", - Columns = new Dictionary + Columns = new Dictionary { - ["date"] = true, - ["summary"] = false - }, - ColumnOrder = ["summary", "date"] + ["date"] = new() { Position = 1, Visible = true }, + ["summary"] = new() { Position = 0, Visible = false } + } }; var reformatted = original with { Filter = "type:log (status:open OR status:regressed)", FilterDefinitions = """[{"type":"status", "value":"open"}]""", - Columns = new Dictionary + Columns = new Dictionary { - ["summary"] = false, - ["date"] = true + ["summary"] = new() { Position = 0, Visible = false }, + ["date"] = new() { Position = 1, Visible = true } } }; @@ -62,4 +61,55 @@ public void GetContentHash_StringFieldDiffersOnlyBySpaces_ReturnsDifferentHash() // Assert Assert.NotEqual(withSpacesHash, withoutSpacesHash); } + + [Fact] + public void GetContentHash_SameView_ReturnsSameHash() + { + // Arrange + var savedView = new SavedView + { + Name = "Open Issues", + Slug = "open-issues", + ViewType = "events" + }; + + // Act + string firstHash = PredefinedSavedViewContentHasher.GetContentHash(savedView); + string secondHash = PredefinedSavedViewContentHasher.GetContentHash(savedView); + + // Assert + Assert.Equal(firstHash, secondHash); + } + + [Fact] + public void GetContentHash_ColumnInsertionOrderDiffers_ReturnsSameHash() + { + // Arrange + var original = new SavedView + { + Name = "Logs", + Slug = "logs", + ViewType = "events", + Columns = new Dictionary + { + ["project"] = new() { Position = 1, Visible = true, Width = 240 }, + ["summary"] = new() { Position = 0, Visible = true, Width = 420 } + } + }; + var reordered = original with + { + Columns = new Dictionary + { + ["summary"] = new() { Position = 0, Visible = true, Width = 420 }, + ["project"] = new() { Position = 1, Visible = true, Width = 240 } + } + }; + + // Act + string originalHash = PredefinedSavedViewContentHasher.GetContentHash(original); + string reorderedHash = PredefinedSavedViewContentHasher.GetContentHash(reordered); + + // Assert + Assert.Equal(originalHash, reorderedHash); + } } diff --git a/tests/Exceptionless.Tests/Serializer/Models/SavedViewSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/SavedViewSerializerTests.cs index b2fee2a891..290ac3ec82 100644 --- a/tests/Exceptionless.Tests/Serializer/Models/SavedViewSerializerTests.cs +++ b/tests/Exceptionless.Tests/Serializer/Models/SavedViewSerializerTests.cs @@ -27,11 +27,11 @@ public void RoundTrip_WithAllProperties_PreservesValues() PredefinedContentHash = "95d71d134b0f8f62c6a70de8ec9819f7a0a0e450577764e9c1c13c863ccbe6ba", Filter = "(status:open OR status:regressed)", FilterDefinitions = """[{"field":"status","operator":"in","values":["open","regressed"]}]""", - Columns = new Dictionary + Columns = new Dictionary { - ["title"] = true, - ["date"] = true, - ["status"] = false + ["title"] = new() { Position = 0, Visible = true, Width = 420 }, + ["date"] = new() { Position = 1, Visible = true }, + ["status"] = new() { Position = 2, Visible = false, Width = 180 } }, Name = "Open Issues", Time = "[now-7d TO now]", @@ -59,8 +59,9 @@ public void RoundTrip_WithAllProperties_PreservesValues() Assert.Equal("stacks", result.ViewType); Assert.NotNull(result.Columns); Assert.Equal(3, result.Columns.Count); - Assert.True(result.Columns["title"]); - Assert.False(result.Columns["status"]); + Assert.True(result.Columns["title"].Visible); + Assert.False(result.Columns["status"].Visible); + Assert.Equal(420, result.Columns["title"].Width); } [Fact] diff --git a/tests/http/saved-views.http b/tests/http/saved-views.http index eee1d6bf89..6a3b0151d4 100644 --- a/tests/http/saved-views.http +++ b/tests/http/saved-views.http @@ -56,10 +56,20 @@ Content-Type: application/json "sort": "-date", "view_type": "events", "columns": { - "user": true, - "date": true - }, - "column_order": ["summary", "user", "date"] + "summary": { + "visible": true, + "position": 0 + }, + "user": { + "visible": true, + "position": 1 + }, + "date": { + "visible": true, + "position": 2, + "width": 180 + } + } } ### @@ -77,10 +87,19 @@ Content-Type: application/json "filter": "type:error", "view_type": "stream", "columns": { - "user": true, - "date": true + "summary": { + "visible": true, + "position": 0 + }, + "date": { + "visible": true, + "position": 1 + }, + "user": { + "visible": true, + "position": 2 + } }, - "column_order": ["summary", "date", "user"], "is_private": true } @@ -97,7 +116,21 @@ Content-Type: application/json "name": "Open Events (Last 30d)", "time": "[now-30d TO now]", "sort": "date", - "column_order": ["summary", "date", "user"] + "columns": { + "summary": { + "visible": true, + "position": 0 + }, + "date": { + "visible": true, + "position": 1, + "width": 220 + }, + "user": { + "visible": true, + "position": 2 + } + } } ### Save saved view as a predefined saved view