Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/Exceptionless.Core/Bootstrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -81,7 +81,8 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO

services.AddSingleton<DataSeedService>();
services.AddSingleton<IDataSeed, PredefinedSavedViewsDataSeed>();
services.AddStartupAction<DataSeedService>();
if (runDataSeedStartupAction)
services.AddStartupAction<DataSeedService>();

services.AddStartupAction("Create Sample Data", CreateSampleDataAsync);

Expand Down
5 changes: 5 additions & 0 deletions src/Exceptionless.Core/Jobs/Elastic/MigrationJob.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -12,23 +13,27 @@ 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
) : base(timeProvider, resiliencePolicyProvider, loggerFactory)
{
_migrationManager = migrationManager;
_configuration = configuration;
_dataSeedService = dataSeedService;
}

protected override async Task<JobResult> RunInternalAsync(JobContext context)
{
await _configuration.ConfigureIndexesAsync(null, false);
await _migrationManager.RunMigrationsAsync();
await _dataSeedService.SeedAsync(context.CancellationToken);

var tasks = _configuration.Indexes.OfType<VersionedIndex>().Select(ReindexIfNecessary);
await Task.WhenAll(tasks);
Expand Down
261 changes: 261 additions & 0 deletions src/Exceptionless.Core/Migrations/004_MigrateSavedViewColumns.cs
Original file line number Diff line number Diff line change
@@ -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<JsonElement>(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<IndexResponse> 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<bool>(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<bool>(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<string>();
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<string>();
if (String.IsNullOrWhiteSpace(predefinedContentHash))
return;

if (!String.Equals(predefinedContentHash, legacyContentHash, StringComparison.Ordinal))
return;

var savedView = _serializer.Deserialize<SavedView>(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<bool>(out _)) == true)
return null;

var legacyColumns = columns?.ToDictionary(
entry => entry.Key,
entry => entry.Value!.GetValue<bool>());
var columnOrder = (source["column_order"] as JsonArray)?
.Select(value => value?.GetValue<string>())
.Where(value => value is not null)
.Cast<string>()
.ToList();

var content = new
{
name = source["name"]?.GetValue<string>(),
slug = source["slug"]?.GetValue<string>(),
viewType = source["view_type"]?.GetValue<string>(),
filter = source["filter"]?.GetValue<string>(),
time = source["time"]?.GetValue<string>(),
sort = source["sort"]?.GetValue<string>(),
filterDefinitions = CanonicalizeFilterDefinitions(source["filter_definitions"]?.GetValue<string>()),
Columns = legacyColumns?.OrderBy(column => column.Key, StringComparer.Ordinal),
columnOrder,
showStats = source["show_stats"]?.GetValue<bool?>(),
showChart = source["show_chart"]?.GetValue<bool?>()
};

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;
}
}
}
7 changes: 2 additions & 5 deletions src/Exceptionless.Core/Models/SavedView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,8 @@ public partial record SavedView : IOwnedByOrganizationWithIdentity, IHaveDates
[MaxLength(MaxFilterDefinitionsLength)]
public string? FilterDefinitions { get; set; }

/// <summary>Column visibility state per dashboard table, keyed by column id.</summary>
public Dictionary<string, bool>? Columns { get; set; }

/// <summary>Column display order per dashboard table, excluding utility columns.</summary>
public List<string>? ColumnOrder { get; set; }
/// <summary>Extensible display settings keyed by dashboard column id.</summary>
public Dictionary<string, SavedViewColumnSettings>? Columns { get; set; }
Comment thread
ejsmith marked this conversation as resolved.

/// <summary>Whether dashboard statistic cards are shown for this view. Null means use the default.</summary>
public bool? ShowStats { get; set; }
Expand Down
22 changes: 22 additions & 0 deletions src/Exceptionless.Core/Models/SavedViewColumnSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace Exceptionless.Core.Models;

/// <summary>
/// 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.
/// </summary>
public sealed record SavedViewColumnSettings
{
public const int MaxPosition = 49;
public const int MaxWidth = 1200;
public const int MinWidth = 48;

/// <summary>Whether the column is visible. Null means use the table default.</summary>
public bool? Visible { get; set; }

/// <summary>Zero-based display position. Null means use the table default order.</summary>
public int? Position { get; set; }

/// <summary>Column width in pixels. Null means use the table default width.</summary>
public int? Width { get; set; }

}
Loading
Loading