diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index 9cfb19de39..efcfae9efb 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -87,6 +87,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO { var handlers = new WorkItemHandlers(); handlers.Register(s.GetRequiredService); + handlers.Register(s.GetRequiredService); handlers.Register(s.GetRequiredService); handlers.Register(s.GetRequiredService); handlers.Register(s.GetRequiredService); diff --git a/src/Exceptionless.Core/Jobs/WorkItemHandlers/ForcePredefinedSavedViewsWorkItemHandler.cs b/src/Exceptionless.Core/Jobs/WorkItemHandlers/ForcePredefinedSavedViewsWorkItemHandler.cs new file mode 100644 index 0000000000..93afec4023 --- /dev/null +++ b/src/Exceptionless.Core/Jobs/WorkItemHandlers/ForcePredefinedSavedViewsWorkItemHandler.cs @@ -0,0 +1,182 @@ +using System.Text.RegularExpressions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.WorkItems; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Seed; +using Foundatio.Jobs; +using Foundatio.Lock; +using Foundatio.Repositories; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Jobs.WorkItemHandlers; + +public class ForcePredefinedSavedViewsWorkItemHandler : WorkItemHandlerBase +{ + private const int SavedViewPageLimit = 1000; + private static readonly string[] ValidViewTypes = ["events", "stacks", "stream"]; + + private readonly ISavedViewRepository _savedViewRepository; + private readonly ILockProvider _lockProvider; + + public ForcePredefinedSavedViewsWorkItemHandler( + ISavedViewRepository savedViewRepository, + ILockProvider lockProvider, + ILoggerFactory loggerFactory) : base(loggerFactory) + { + _savedViewRepository = savedViewRepository; + _lockProvider = lockProvider; + } + + public override Task GetWorkItemLockAsync(object workItem, CancellationToken cancellationToken = default) + { + return _lockProvider.TryAcquireAsync(nameof(ForcePredefinedSavedViewsWorkItemHandler), TimeSpan.FromHours(1), cancellationToken); + } + + public override async Task HandleItemAsync(WorkItemContext context) + { + var workItem = context.GetData()!; + var predefinedSavedViewsByKey = await GetPredefinedSavedViewsByKeyAsync(); + + Log.LogInformation( + "Starting predefined saved view force update. Definitions: {DefinitionCount} InitiatedByUserId: {UserId}", + predefinedSavedViewsByKey.Count, + workItem.UserId); + + int organizationsUpdated = 0; + int savedViewsUpdated = 0; + var savedViews = await _savedViewRepository.GetPredefinedForForceUpdateAsync( + PredefinedSavedViewsDataSeed.SystemOrganizationId, + predefinedSavedViewsByKey.Keys.ToArray(), + o => o.SearchAfterPaging().PageLimit(SavedViewPageLimit)); + + do + { + foreach (var savedViewsByOrganization in savedViews.Documents.GroupBy(savedView => savedView.OrganizationId)) + { + if (context.CancellationToken.IsCancellationRequested) + break; + + var updateResult = await UpdateOrganizationSavedViewsAsync( + savedViewsByOrganization.Key, + savedViewsByOrganization.Select(savedView => savedView.Id), + workItem.UserId, + predefinedSavedViewsByKey); + + int updatedForOrganization = updateResult; + if (updatedForOrganization > 0) + { + organizationsUpdated++; + savedViewsUpdated += updatedForOrganization; + } + + await context.RenewLockAsync(); + } + } while (!context.CancellationToken.IsCancellationRequested && await savedViews.NextPageAsync()); + + Log.LogInformation( + "Completed predefined saved view force update. OrganizationsUpdated: {OrganizationsUpdated} SavedViewsUpdated: {SavedViewsUpdated} InitiatedByUserId: {UserId}", + organizationsUpdated, + savedViewsUpdated, + workItem.UserId); + } + + private async Task> GetPredefinedSavedViewsByKeyAsync() + { + var savedViewsByKey = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (string viewType in ValidViewTypes) + { + var results = await _savedViewRepository.GetByViewAsync(PredefinedSavedViewsDataSeed.SystemOrganizationId, viewType, o => o.PageLimit(SavedViewPageLimit)); + foreach (var savedView in results.Documents.Where(view => view.UserId is null && !String.IsNullOrWhiteSpace(view.PredefinedKey))) + { + if (!savedViewsByKey.TryAdd(savedView.PredefinedKey!, savedView)) + throw new InvalidOperationException($"Predefined saved view key '{savedView.PredefinedKey}' is duplicated."); + } + } + + return savedViewsByKey; + } + + private async Task UpdateOrganizationSavedViewsAsync( + string organizationId, + IEnumerable savedViewIds, + string userId, + IReadOnlyDictionary predefinedSavedViewsByKey) + { + int updatedSavedViews = 0; + bool lockAcquired = await _lockProvider.TryUsingAsync($"predefined-saved-views:{organizationId}", async () => + { + updatedSavedViews = await UpdateOrganizationSavedViewsWithLockAsync(organizationId, savedViewIds, userId, predefinedSavedViewsByKey); + }, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(15)); + + if (!lockAcquired) + Log.LogWarning("Skipping force update for organization {OrganizationId} because the saved view synchronization lock could not be acquired", organizationId); + + return updatedSavedViews; + } + + private async Task UpdateOrganizationSavedViewsWithLockAsync( + string organizationId, + IEnumerable savedViewIds, + string userId, + IReadOnlyDictionary predefinedSavedViewsByKey) + { + var savedViewsToSave = new List(); + var savedViewIdsToUpdate = savedViewIds.ToHashSet(StringComparer.Ordinal); + var results = await _savedViewRepository.FindAsync(q => q.Organization(organizationId), o => o.PageLimit(SavedViewPageLimit)); + var savedViewsByViewType = results.Documents.GroupBy(savedView => savedView.ViewType, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase); + + foreach (var savedViews in savedViewsByViewType.Values) + { + foreach (var savedView in savedViews.Where(savedView => savedViewIdsToUpdate.Contains(savedView.Id))) + { + if (savedView.UserId is not null + || String.IsNullOrWhiteSpace(savedView.PredefinedKey) + || !predefinedSavedViewsByKey.TryGetValue(savedView.PredefinedKey, out var predefinedSavedView)) + { + continue; + } + + string slug = GetUniqueSlug(predefinedSavedView.Slug, savedViews, savedView.Id); + if (PredefinedSavedViewConfiguration.Apply(savedView, predefinedSavedView, predefinedSavedView.PredefinedKey!, slug)) + { + savedView.UpdatedByUserId = userId; + savedViewsToSave.Add(savedView); + } + } + } + + if (savedViewsToSave.Count > 0) + await _savedViewRepository.SaveAsync(savedViewsToSave, o => o.Cache()); + + return savedViewsToSave.Count; + } + + private static string GetUniqueSlug(string slug, IReadOnlyCollection existingViews, string? excludingId) + { + string baseSlug = ToSlug(slug); + if (String.IsNullOrWhiteSpace(baseSlug)) + baseSlug = "saved-view"; + + baseSlug = baseSlug.Length > 100 ? baseSlug[..100].Trim('-') : baseSlug; + string candidate = baseSlug; + int suffix = 2; + + while (existingViews.Any(view => view.Id != excludingId && String.Equals(view.Slug, candidate, StringComparison.OrdinalIgnoreCase))) + { + string suffixText = $"-{suffix}"; + int maxBaseLength = 100 - suffixText.Length; + candidate = $"{baseSlug[..Math.Min(baseSlug.Length, maxBaseLength)].Trim('-')}{suffixText}"; + suffix++; + } + + return candidate; + } + + private static string ToSlug(string value) + { + string slug = Regex.Replace(value.Trim().ToLowerInvariant(), "[^a-z0-9]+", "-").Trim('-'); + return Regex.Replace(slug, "-+", "-"); + } +} diff --git a/src/Exceptionless.Core/Models/SavedView.cs b/src/Exceptionless.Core/Models/SavedView.cs index a345fd6a52..1c3bcc9393 100644 --- a/src/Exceptionless.Core/Models/SavedView.cs +++ b/src/Exceptionless.Core/Models/SavedView.cs @@ -61,6 +61,10 @@ public partial record SavedView : IOwnedByOrganizationWithIdentity, IHaveDates [MaxLength(150)] public string? PredefinedKey { get; set; } + /// Content hash of the predefined configuration last applied to this view. + [MaxLength(64)] + public string? PredefinedContentHash { get; set; } + /// Display name shown in the sidebar and picker. [Required] [MaxLength(100)] diff --git a/src/Exceptionless.Core/Models/WorkItems/ForcePredefinedSavedViewsWorkItem.cs b/src/Exceptionless.Core/Models/WorkItems/ForcePredefinedSavedViewsWorkItem.cs new file mode 100644 index 0000000000..b018a6cdc8 --- /dev/null +++ b/src/Exceptionless.Core/Models/WorkItems/ForcePredefinedSavedViewsWorkItem.cs @@ -0,0 +1,12 @@ +using Foundatio.Queues; + +namespace Exceptionless.Core.Models.WorkItems; + +public record ForcePredefinedSavedViewsWorkItem : IHaveUniqueIdentifier +{ + public required string UserId { get; init; } + + public Guid RunId { get; init; } = Guid.NewGuid(); + + public string UniqueIdentifier => $"{nameof(ForcePredefinedSavedViewsWorkItem)}:{RunId:N}"; +} diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/SavedViewIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/SavedViewIndex.cs index d0fbc703ed..823247a896 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/Indexes/SavedViewIndex.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/SavedViewIndex.cs @@ -12,7 +12,7 @@ public sealed class SavedViewIndex : VersionedIndex private readonly ExceptionlessElasticConfiguration _configuration; - public SavedViewIndex(ExceptionlessElasticConfiguration configuration) : base(configuration, configuration.Options.ScopePrefix + "saved-views", 1) + public SavedViewIndex(ExceptionlessElasticConfiguration configuration) : base(configuration, configuration.Options.ScopePrefix + "saved-views", 3) { _configuration = configuration; } @@ -27,6 +27,7 @@ public override void ConfigureIndexMapping(TypeMappingDescriptor e.UserId) .Keyword(e => e.CreatedByUserId) .Keyword(e => e.UpdatedByUserId) + .Text(e => e.PredefinedKey, t => t.Analyzer(KEYWORD_LOWERCASE_ANALYZER)) .Text(e => e.Name, t => t.Analyzer(KEYWORD_LOWERCASE_ANALYZER).AddKeywordField()) .Keyword(e => e.ViewType) .IntegerNumber(e => e.Version)); diff --git a/src/Exceptionless.Core/Repositories/Interfaces/ISavedViewRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/ISavedViewRepository.cs index e61e7b3d2e..a27553f5f3 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/ISavedViewRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/ISavedViewRepository.cs @@ -9,6 +9,7 @@ public interface ISavedViewRepository : IRepositoryOwnedByOrganization> GetByViewAsync(string organizationId, string viewType, CommandOptionsDescriptor? options = null); Task> GetByViewForUserAsync(string organizationId, string viewType, string userId, CommandOptionsDescriptor? options = null); Task> GetByOrganizationForUserAsync(string organizationId, string userId, CommandOptionsDescriptor? options = null); + Task> GetPredefinedForForceUpdateAsync(string systemOrganizationId, IReadOnlyCollection predefinedKeys, CommandOptionsDescriptor? options = null); Task CountByOrganizationIdAsync(string organizationId); /// Removes all private saved views belonging to a specific user within an organization. diff --git a/src/Exceptionless.Core/Repositories/SavedViewRepository.cs b/src/Exceptionless.Core/Repositories/SavedViewRepository.cs index 42ff830d63..c26237c791 100644 --- a/src/Exceptionless.Core/Repositories/SavedViewRepository.cs +++ b/src/Exceptionless.Core/Repositories/SavedViewRepository.cs @@ -42,6 +42,31 @@ public Task> GetByOrganizationForUserAsync(string organiz .FieldEquals(e => e.UserId!, userId)), options); } + public Task> GetPredefinedForForceUpdateAsync( + string systemOrganizationId, + IReadOnlyCollection predefinedKeys, + CommandOptionsDescriptor? options = null) + { + ArgumentException.ThrowIfNullOrEmpty(systemOrganizationId); + ArgumentNullException.ThrowIfNull(predefinedKeys); + + if (predefinedKeys.Count == 0) + return FindAsync(q => q.FieldEquals(e => e.Id, String.Empty), options); + + return FindAsync(q => + { + q.FieldEmpty(e => e.UserId!) + .FieldNotEquals(e => e.OrganizationId, systemOrganizationId) + .FieldOr(g => + { + foreach (string predefinedKey in predefinedKeys) + g.FieldContains(e => e.PredefinedKey!, predefinedKey); + }); + + return q.SortAscending(e => e.OrganizationId).SortAscending(e => e.Id); + }, options); + } + public async Task RemovePrivateByUserIdAsync(string organizationId, string userId) { var results = await FindAsync(q => q diff --git a/src/Exceptionless.Core/Seed/PredefinedSavedViewConfiguration.cs b/src/Exceptionless.Core/Seed/PredefinedSavedViewConfiguration.cs new file mode 100644 index 0000000000..32ff301b91 --- /dev/null +++ b/src/Exceptionless.Core/Seed/PredefinedSavedViewConfiguration.cs @@ -0,0 +1,77 @@ +using Exceptionless.Core.Models; + +namespace Exceptionless.Core.Seed; + +public static class PredefinedSavedViewConfiguration +{ + public static bool Apply(SavedView destination, SavedView source, string key, string slug) + { + bool changed = false; + changed |= SetIfChanged(destination, null, static (view, value) => view.UserId = value, static view => view.UserId); + changed |= SetIfChanged(destination, key, static (view, value) => view.PredefinedKey = value, static view => view.PredefinedKey); + changed |= SetIfChanged(destination, source.Name, static (view, value) => view.Name = value, static view => view.Name); + changed |= SetIfChanged(destination, slug, static (view, value) => view.Slug = value, static view => view.Slug); + changed |= SetIfChanged(destination, source.ViewType, static (view, value) => view.ViewType = value, static view => view.ViewType); + changed |= SetIfChanged(destination, source.Filter, static (view, value) => view.Filter = value, static view => view.Filter); + 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 |= 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); + changed |= SetIfChanged(destination, PredefinedSavedViewContentHasher.GetContentHash(destination), static (view, value) => view.PredefinedContentHash = value, static view => view.PredefinedContentHash); + + 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)) + return false; + + setter(savedView, value); + return true; + } + + private static bool SetListIfChanged(SavedView savedView, IReadOnlyCollection? value) + { + if (CollectionEquals(savedView.ColumnOrder, value)) + return false; + + savedView.ColumnOrder = value is null ? null : [.. value]; + return true; + } + + private static bool CollectionEquals(IReadOnlyCollection? left, IReadOnlyCollection? 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); + } + + 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); + } +} diff --git a/src/Exceptionless.Core/Seed/PredefinedSavedViewContentHasher.cs b/src/Exceptionless.Core/Seed/PredefinedSavedViewContentHasher.cs new file mode 100644 index 0000000000..b7e021c6e3 --- /dev/null +++ b/src/Exceptionless.Core/Seed/PredefinedSavedViewContentHasher.cs @@ -0,0 +1,105 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; + +namespace Exceptionless.Core.Seed; + +public static class PredefinedSavedViewContentHasher +{ + public static string GetContentHash(SavedView savedView) + { + ArgumentNullException.ThrowIfNull(savedView); + + return GetContentHash( + savedView.Name, + savedView.Slug, + savedView.ViewType, + savedView.Filter, + savedView.Time, + savedView.Sort, + savedView.FilterDefinitions, + savedView.Columns, + savedView.ColumnOrder, + savedView.ShowStats, + savedView.ShowChart); + } + + public static string GetDefinitionsContentHash(IEnumerable definitions) + { + ArgumentNullException.ThrowIfNull(definitions); + + var content = definitions + .OrderBy(definition => definition.Key, StringComparer.Ordinal) + .Select(definition => new + { + definition.Key, + Hash = GetContentHash( + definition.Name, + definition.Slug, + definition.ViewType, + definition.Filter, + definition.Time, + definition.Sort, + PredefinedSavedViewsDataSeed.GetRawJson(definition.FilterDefinitions), + definition.Columns, + definition.ColumnOrder, + definition.ShowStats, + definition.ShowChart) + }); + + return SerializeAndHash(content); + } + + private static string GetContentHash( + string name, + string slug, + string viewType, + string? filter, + string? time, + string? sort, + string? filterDefinitions, + IReadOnlyDictionary? columns, + IReadOnlyCollection? columnOrder, + bool? showStats, + bool? showChart) + { + var content = new + { + name, + slug, + viewType, + filter, + time, + sort, + filterDefinitions = CanonicalizeFilterDefinitions(filterDefinitions), + Columns = columns?.OrderBy(column => column.Key, StringComparer.Ordinal), + columnOrder, + showStats, + showChart + }; + + return SerializeAndHash(content); + } + + private static string SerializeAndHash(T content) + { + string json = JsonSerializer.Serialize(content); + return json.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/Seed/PredefinedSavedViewsDataSeed.cs b/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs index e3912aeeb3..650b8f862a 100644 --- a/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs +++ b/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs @@ -110,12 +110,56 @@ private static bool ApplyDefinition(SavedView existing, PredefinedSavedViewDefin changed = true; } + if (!String.Equals(existing.Time, definition.Time, StringComparison.Ordinal)) + { + existing.Time = definition.Time; + changed = true; + } + if (!String.Equals(existing.Sort, definition.Sort, StringComparison.Ordinal)) { existing.Sort = definition.Sort; changed = true; } + string? filterDefinitions = GetRawJson(definition.FilterDefinitions); + if (!String.Equals(existing.FilterDefinitions, filterDefinitions, StringComparison.Ordinal)) + { + existing.FilterDefinitions = filterDefinitions; + changed = true; + } + + if (!DictionaryEquals(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]; + changed = true; + } + + if (existing.ShowStats != definition.ShowStats) + { + existing.ShowStats = definition.ShowStats; + changed = true; + } + + if (existing.ShowChart != definition.ShowChart) + { + existing.ShowChart = definition.ShowChart; + changed = true; + } + + string contentHash = PredefinedSavedViewContentHasher.GetContentHash(existing); + if (!String.Equals(existing.PredefinedContentHash, contentHash, StringComparison.Ordinal)) + { + existing.PredefinedContentHash = contentHash; + changed = true; + } + return changed; } @@ -134,7 +178,7 @@ private static string GetSeedFilePath() private static SavedView CreateSavedView(PredefinedSavedViewDefinition definition) { - return new SavedView + var savedView = new SavedView { OrganizationId = SystemOrganizationId, CreatedByUserId = SystemUserId, @@ -152,6 +196,31 @@ private static SavedView CreateSavedView(PredefinedSavedViewDefinition definitio ShowChart = definition.ShowChart, Version = 1 }; + + savedView.PredefinedContentHash = PredefinedSavedViewContentHasher.GetContentHash(savedView); + return savedView; + } + + private static bool CollectionEquals(IReadOnlyCollection? left, IReadOnlyCollection? 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); + } + + 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); } public static string? GetRawJson(JsonElement? value) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts index b3a9c24416..e3e3cd0aaf 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts @@ -145,6 +145,19 @@ export function getPredefinedSavedViewsMutation() { })); } +export function postForceUpdatePredefinedSavedViewsMutation() { + return createMutation(() => ({ + mutationFn: async () => { + const client = useFetchClient(); + const response = await client.post('saved-views/predefined/force-update'); + + if (!response.ok) { + throw response.problem; + } + } + })); +} + export function postOAuthApplicationMutation() { const queryClient = useQueryClient(); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/saved-views/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/saved-views/+page.svelte index 3b62a86c7c..4ddf7a661d 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/saved-views/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/saved-views/+page.svelte @@ -1,10 +1,18 @@