Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
235 changes: 235 additions & 0 deletions src/Exceptionless.Core/Migrations/003_MigrateSavedViewColumns.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
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 = 3;
Comment thread
ejsmith marked this conversation as resolved.
Outdated
}

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"))
.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;

string? legacyContentHash = GetLegacyContentHash(source);
if (!TryMigrate(source))
continue;

UpdatePredefinedContentHash(source, legacyContentHash);

var indexResponse = await _client.IndexAsync(
source,
request => request
.Index(_configuration.SavedViews.VersionedName)
.Id(hit.Id),
Comment thread
ejsmith marked this conversation as resolved.
Outdated
context.CancellationToken);
_logger.LogRequest(indexResponse);

if (!indexResponse.IsValidResponse)
throw new InvalidOperationException($"Unable to migrate saved view '{hit.Id}'.");

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 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; }
Comment thread
ejsmith marked this conversation as resolved.

}
36 changes: 10 additions & 26 deletions src/Exceptionless.Core/Seed/PredefinedSavedViewConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -26,15 +25,6 @@ public static bool Apply(SavedView destination, SavedView source, string key, st
return changed;
}

private static bool SetDictionaryIfChanged(SavedView savedView, IReadOnlyDictionary<string, bool>? value)
{
if (DictionaryEquals(savedView.Columns, value))
return false;

savedView.Columns = value is null ? null : new Dictionary<string, bool>(value);
return true;
}

private static bool SetIfChanged<T>(SavedView savedView, T value, Action<SavedView, T> setter, Func<SavedView, T> getter)
{
if (EqualityComparer<T>.Default.Equals(getter(savedView), value))
Expand All @@ -44,34 +34,28 @@ private static bool SetIfChanged<T>(SavedView savedView, T value, Action<SavedVi
return true;
}

private static bool SetListIfChanged(SavedView savedView, IReadOnlyCollection<string>? value)
private static bool SetColumnsIfChanged(SavedView savedView, IReadOnlyDictionary<string, SavedViewColumnSettings>? 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<string>? left, IReadOnlyCollection<string>? right)
private static bool ColumnsEqual(
IReadOnlyDictionary<string, SavedViewColumnSettings>? left,
IReadOnlyDictionary<string, SavedViewColumnSettings>? right)
{
if (ReferenceEquals(left, right))
return true;

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<string, bool>? left, IReadOnlyDictionary<string, bool>? 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<string, SavedViewColumnSettings>? CopyColumns(IReadOnlyDictionary<string, SavedViewColumnSettings>? value)
=> value?.ToDictionary(entry => entry.Key, entry => entry.Value with { });
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ public static string GetContentHash(SavedView savedView)
savedView.Sort,
savedView.FilterDefinitions,
savedView.Columns,
savedView.ColumnOrder,
savedView.ShowStats,
savedView.ShowChart);
}
Expand All @@ -43,7 +42,6 @@ public static string GetDefinitionsContentHash(IEnumerable<PredefinedSavedViewDe
definition.Sort,
PredefinedSavedViewsDataSeed.GetRawJson(definition.FilterDefinitions),
definition.Columns,
definition.ColumnOrder,
definition.ShowStats,
definition.ShowChart)
});
Expand All @@ -59,8 +57,7 @@ private static string GetContentHash(
string? time,
string? sort,
string? filterDefinitions,
IReadOnlyDictionary<string, bool>? columns,
IReadOnlyCollection<string>? columnOrder,
IReadOnlyDictionary<string, SavedViewColumnSettings>? columns,
bool? showStats,
bool? showChart)
{
Expand All @@ -74,7 +71,6 @@ private static string GetContentHash(
sort,
filterDefinitions = CanonicalizeFilterDefinitions(filterDefinitions),
Columns = columns?.OrderBy(column => column.Key, StringComparer.Ordinal),
columnOrder,
showStats,
showChart
};
Expand Down
Loading
Loading