From f903f1566fb358f4d460fea373d0003f7b223bd4 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 24 May 2026 00:48:21 -0500 Subject: [PATCH 1/5] Add predefined saved views --- .../Exceptionless.AppHost.csproj | 10 +- src/Exceptionless.Core/Bootstrapper.cs | 5 + .../Exceptionless.Core.csproj | 3 + src/Exceptionless.Core/Models/SavedView.cs | 4 + .../Seed/DataSeedService.cs | 32 ++ src/Exceptionless.Core/Seed/IDataSeed.cs | 8 + .../Seed/PredefinedSavedViewsDataSeed.cs | 133 ++++++ .../Seed/predefined-saved-views.json | 141 ++++++ .../src/lib/features/admin/api.svelte.ts | 13 +- .../src/lib/features/admin/models.ts | 26 ++ .../organization-notifications.svelte | 21 +- .../lib/features/saved-views/api.svelte.ts | 49 ++ .../components/saved-view-picker.svelte | 103 +++- .../ClientApp/src/routes/(app)/+layout.svelte | 19 +- .../src/routes/(app)/events/+page.svelte | 27 +- .../[organizationId]/features/+page.svelte | 67 ++- .../(app)/organization/add/+page.svelte | 156 ++++--- .../(app)/project/[projectId]/+layout.svelte | 36 +- .../[projectId]/configure/+page.svelte | 30 +- .../project/[projectId]/manage/+page.svelte | 98 +++- .../project/[projectId]/routes.svelte.ts | 9 +- .../src/routes/(app)/project/add/+page.svelte | 14 +- .../system/actions/[[category]]/+page.svelte | 85 +++- .../src/routes/(auth)/signup/+page.svelte | 252 +++++----- .../Controllers/SavedViewController.cs | 390 +++++++++++++++- .../Controllers/Data/openapi.json | 210 +++++++++ .../Controllers/SavedViewControllerTests.cs | 442 ++++++++++++++++++ tests/http/saved-views.http | 16 + 28 files changed, 2129 insertions(+), 270 deletions(-) create mode 100644 src/Exceptionless.Core/Seed/DataSeedService.cs create mode 100644 src/Exceptionless.Core/Seed/IDataSeed.cs create mode 100644 src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs create mode 100644 src/Exceptionless.Core/Seed/predefined-saved-views.json diff --git a/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj b/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj index 3589e46ecf..be6988fe58 100644 --- a/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj +++ b/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe net10.0 @@ -8,10 +8,10 @@ $(NoWarn);ASPIRECERTIFICATES001 - - - - + + + + diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index 9d745131ac..f48aca1934 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -20,6 +20,7 @@ using Exceptionless.Core.Queues.Models; using Exceptionless.Core.Repositories; using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Seed; using Exceptionless.Core.Serialization; using Exceptionless.Core.Services; using Exceptionless.Core.Utility; @@ -94,6 +95,10 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(s => s.GetRequiredService()); services.AddStartupAction(); + services.AddSingleton(); + services.AddSingleton(); + services.AddStartupAction(); + services.AddStartupAction("Create Sample Data", CreateSampleDataAsync); services.AddSingleton(typeof(IWorkItemHandler), typeof(Bootstrapper).Assembly, typeof(ReindexWorkItemHandler).Assembly); diff --git a/src/Exceptionless.Core/Exceptionless.Core.csproj b/src/Exceptionless.Core/Exceptionless.Core.csproj index 47cb9f842e..4c144e281f 100644 --- a/src/Exceptionless.Core/Exceptionless.Core.csproj +++ b/src/Exceptionless.Core/Exceptionless.Core.csproj @@ -19,6 +19,9 @@ + + + diff --git a/src/Exceptionless.Core/Models/SavedView.cs b/src/Exceptionless.Core/Models/SavedView.cs index 54793aec4d..8ec400a9e3 100644 --- a/src/Exceptionless.Core/Models/SavedView.cs +++ b/src/Exceptionless.Core/Models/SavedView.cs @@ -55,6 +55,10 @@ public record SavedView : IOwnedByOrganizationWithIdentity, IHaveDates /// Whether the dashboard chart is shown for this view. Null means use the default. public bool? ShowChart { get; set; } + /// Stable identifier used to synchronize predefined saved views across organizations. + [MaxLength(150)] + public string? PredefinedKey { get; set; } + /// Display name shown in the sidebar and picker. [Required] [MaxLength(100)] diff --git a/src/Exceptionless.Core/Seed/DataSeedService.cs b/src/Exceptionless.Core/Seed/DataSeedService.cs new file mode 100644 index 0000000000..64e3e16418 --- /dev/null +++ b/src/Exceptionless.Core/Seed/DataSeedService.cs @@ -0,0 +1,32 @@ +using Foundatio.Extensions.Hosting.Startup; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Seed; + +public class DataSeedService : IStartupAction +{ + private readonly IEnumerable _seeds; + private readonly ILogger _logger; + + public DataSeedService(IEnumerable seeds, ILoggerFactory loggerFactory) + { + _seeds = seeds; + _logger = loggerFactory.CreateLogger(); + } + + public Task RunAsync(CancellationToken shutdownToken = default) + { + return SeedAsync(shutdownToken); + } + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + foreach (var seed in _seeds) + { + cancellationToken.ThrowIfCancellationRequested(); + + _logger.LogInformation("Running data seed {DataSeedName}", seed.Name); + await seed.SeedAsync(cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/Exceptionless.Core/Seed/IDataSeed.cs b/src/Exceptionless.Core/Seed/IDataSeed.cs new file mode 100644 index 0000000000..21cb69654d --- /dev/null +++ b/src/Exceptionless.Core/Seed/IDataSeed.cs @@ -0,0 +1,8 @@ +namespace Exceptionless.Core.Seed; + +public interface IDataSeed +{ + string Name { get; } + + Task SeedAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs b/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs new file mode 100644 index 0000000000..34fe4d1072 --- /dev/null +++ b/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs @@ -0,0 +1,133 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Foundatio.Lock; +using Foundatio.Repositories; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Seed; + +public class PredefinedSavedViewsDataSeed : IDataSeed +{ + public const string SystemOrganizationId = "000000000000000000000001"; + public const string SystemUserId = "000000000000000000000001"; + public const string SeedFileName = "predefined-saved-views.json"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + AllowTrailingCommas = true, + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip + }; + + private readonly ISavedViewRepository _savedViewRepository; + private readonly ILockProvider _lockProvider; + private readonly ILogger _logger; + + public PredefinedSavedViewsDataSeed(ISavedViewRepository savedViewRepository, ILockProvider lockProvider, ILoggerFactory loggerFactory) + { + _savedViewRepository = savedViewRepository; + _lockProvider = lockProvider; + _logger = loggerFactory.CreateLogger(); + } + + public string Name => "Predefined Saved Views"; + + public Task SeedAsync(CancellationToken cancellationToken = default) + { + return _lockProvider.TryUsingAsync("data-seed:predefined-saved-views", async () => + { + cancellationToken.ThrowIfCancellationRequested(); + + if (await _savedViewRepository.CountByOrganizationIdAsync(SystemOrganizationId) > 0) + return; + + var definitions = await ReadDefaultSavedViewsAsync(cancellationToken); + var savedViews = definitions.Select(CreateSavedView).ToList(); + await _savedViewRepository.AddAsync(savedViews, o => o.Cache().ImmediateConsistency()); + _logger.LogInformation("Seeded {Count} predefined saved views", savedViews.Count); + }, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(15)); + } + + public static async Task> ReadDefaultSavedViewsAsync(CancellationToken cancellationToken = default) + { + await using var stream = File.OpenRead(GetSeedFilePath()); + var definitions = await JsonSerializer.DeserializeAsync>(stream, JsonOptions, cancellationToken); + return definitions ?? []; + } + + private static string GetSeedFilePath() + { + return Path.Combine(AppContext.BaseDirectory, "Seed", SeedFileName); + } + + private static SavedView CreateSavedView(PredefinedSavedViewDefinition definition) + { + return new SavedView + { + OrganizationId = SystemOrganizationId, + CreatedByUserId = SystemUserId, + PredefinedKey = definition.Key, + Name = definition.Name, + Slug = definition.Slug, + ViewType = definition.ViewType, + Filter = definition.Filter, + 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], + ShowStats = definition.ShowStats, + ShowChart = definition.ShowChart, + Version = 1 + }; + } + + public static string? GetRawJson(JsonElement? value) + { + if (value is not { } element || element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + return null; + + return element.GetRawText(); + } +} + +public sealed record PredefinedSavedViewDefinition +{ + [JsonPropertyName("key")] + public required string Key { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("slug")] + public required string Slug { get; init; } + + [JsonPropertyName("viewType")] + public required string ViewType { get; init; } + + [JsonPropertyName("filter")] + public string? Filter { get; init; } + + [JsonPropertyName("time")] + public string? Time { get; init; } + + [JsonPropertyName("sort")] + public string? Sort { get; init; } + + [JsonPropertyName("filterDefinitions")] + public JsonElement? FilterDefinitions { get; init; } + + [JsonPropertyName("columns")] + public IReadOnlyDictionary? Columns { get; init; } + + [JsonPropertyName("columnOrder")] + public IReadOnlyCollection? ColumnOrder { get; init; } + + [JsonPropertyName("showStats")] + public bool? ShowStats { get; init; } + + [JsonPropertyName("showChart")] + public bool? ShowChart { get; init; } +} \ No newline at end of file diff --git a/src/Exceptionless.Core/Seed/predefined-saved-views.json b/src/Exceptionless.Core/Seed/predefined-saved-views.json new file mode 100644 index 0000000000..e5e1999457 --- /dev/null +++ b/src/Exceptionless.Core/Seed/predefined-saved-views.json @@ -0,0 +1,141 @@ +[ + { + "key": "events:errors", + "name": "Errors", + "slug": "errors", + "viewType": "events", + "filter": "type:error", + "time": "[now-7d TO now]", + "sort": "-date", + "filterDefinitions": [ + { + "type": "date", + "term": "date", + "value": "[now-7d TO now]" + }, + { + "type": "type", + "value": ["error"], + "hidden": true + } + ], + "columns": { + "level": false, + "message": false, + "name": false, + "source": false, + "type": false + }, + "showStats": true, + "showChart": true + }, + { + "key": "events:logs", + "name": "Logs", + "slug": "logs", + "viewType": "events", + "filter": "type:log", + "time": "[now-7d TO now]", + "sort": "-date", + "filterDefinitions": [ + { + "type": "date", + "term": "date", + "value": "[now-7d TO now]" + }, + { + "type": "type", + "value": ["log"], + "hidden": true + } + ], + "columns": { + "level": false, + "message": false, + "name": false, + "source": false, + "type": false + }, + "showStats": true, + "showChart": true + }, + { + "key": "issues:most-frequent-404s", + "name": "Most Frequent 404s", + "slug": "most-frequent-404s", + "viewType": "issues", + "filter": "(status:open OR status:regressed) type:404", + "time": "[now-7d TO now]", + "sort": "-events", + "filterDefinitions": [ + { + "type": "date", + "term": "date", + "value": "[now-7d TO now]" + }, + { + "type": "status", + "value": ["open", "regressed"], + "hidden": true + }, + { + "type": "type", + "value": ["404"], + "hidden": true + } + ], + "showStats": true, + "showChart": true + }, + { + "key": "issues:most-frequent-errors", + "name": "Most Frequent Errors", + "slug": "most-frequent-errors", + "viewType": "issues", + "filter": "type:error (status:open OR status:regressed)", + "time": "[now-7d TO now]", + "sort": "-events", + "filterDefinitions": [ + { + "type": "date", + "term": "date", + "value": "[now-7d TO now]" + }, + { + "type": "type", + "value": ["error"], + "hidden": true + }, + { + "type": "status", + "value": ["open", "regressed"], + "hidden": true + } + ], + "showStats": true, + "showChart": true + }, + { + "key": "issues:most-used-features", + "name": "Most Used Features", + "slug": "most-used-features", + "viewType": "issues", + "filter": "type:usage", + "time": "[now-7d TO now]", + "sort": "-events", + "filterDefinitions": [ + { + "type": "date", + "term": "date", + "value": "[now-7d TO now]" + }, + { + "type": "type", + "value": ["usage"], + "hidden": true + } + ], + "showStats": true, + "showChart": true + } +] 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 17a7257c6c..90bdf5b538 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 @@ -1,7 +1,7 @@ import { type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; import { createMutation, createQuery } from '@tanstack/svelte-query'; -import type { AdminStats, ElasticsearchInfo, ElasticsearchSnapshotsResponse, MigrationsResponse } from './models'; +import type { AdminStats, ElasticsearchInfo, ElasticsearchSnapshotsResponse, MigrationsResponse, PredefinedSavedViewDefinition } from './models'; export type RunMaintenanceJobParams = { name: string; @@ -77,6 +77,17 @@ export function getMigrationsQuery() { })); } +export function getPredefinedSavedViewsMutation() { + return createMutation(() => ({ + mutationFn: async () => { + const client = useFetchClient(); + const response = await client.getJSON('saved-views/predefined'); + + return JSON.stringify(response.data ?? [], null, 2); + } + })); +} + export function runMaintenanceJobMutation() { return createMutation(() => ({ mutationFn: async (params: RunMaintenanceJobParams) => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts index 0e2fe5ebe3..dd713a03b5 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts @@ -72,11 +72,13 @@ export type MaintenanceAction = { description: string; hasDateRange?: boolean; hasOrganizationId?: boolean; + kind?: MaintenanceActionKind; label: string; name: string; }; export type MaintenanceActionCategory = 'Billing' | 'Configuration' | 'Elasticsearch' | 'Maintenance' | 'Security' | 'Users'; +export type MaintenanceActionKind = 'maintenance-job' | 'predefined-saved-views'; export type MigrationsResponse = { current_version: number; states: MigrationState[]; @@ -93,6 +95,21 @@ export type MigrationState = { export type MigrationStatus = 'Completed' | 'Failed' | 'Pending' | 'Running'; +export type PredefinedSavedViewDefinition = { + columnOrder?: null | string[]; + columns?: null | Record; + filter?: null | string; + filterDefinitions?: unknown; + key: string; + name: string; + showChart?: boolean | null; + showStats?: boolean | null; + slug: string; + sort?: null | string; + time?: null | string; + viewType: string; +}; + export type ShardMetric = { id: string; label: string; @@ -132,6 +149,15 @@ export const maintenanceActions: MaintenanceAction[] = [ label: 'Update Project Default Bot Lists', name: 'update-project-default-bot-lists' }, + { + category: 'Configuration', + dangerous: false, + description: + 'Loads the current global predefined saved views from the API and displays the seed JSON for review or copying into the bundled predefined saved views file.', + kind: 'predefined-saved-views', + label: 'View Predefined Saved Views', + name: 'predefined-saved-views' + }, { category: 'Configuration', dangerous: false, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte index 6ce15d99c5..fa12a8bba7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte @@ -6,6 +6,9 @@ import { SuspensionCode } from '$features/organizations/models'; import { getOrganizationProjectsQuery } from '$features/projects/api.svelte'; import { getMeQuery } from '$features/users/api.svelte'; + import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models'; + import { useEventListener } from 'runed'; + import { debounce } from 'throttle-debounce'; import FreePlanNotification from './notifications/free-plan-notification.svelte'; import HourlyOverageNotification from './notifications/hourly-overage-notification.svelte'; @@ -65,7 +68,8 @@ const organization = $derived(organizationQuery.data); const projects = $derived((projectsQuery.data?.data ?? []).filter((p) => p.organization_id === currentOrganizationId.current)); - const projectsNeedingConfig = $derived(projects.filter((p) => p.is_configured === false)); + let configuredProjectIds = $state(new Set()); + const projectsNeedingConfig = $derived(projects.filter((p) => p.is_configured === false && !configuredProjectIds.has(p.id!))); const suspensionCode: SuspensionCode | undefined = $derived( organization?.suspension_code === 'Billing' @@ -89,6 +93,21 @@ projectsQuery.isSuccess && !ignoreConfigureProjects && projects.length > 0 && projectsNeedingConfig.length === projects.length ); const requiresPremiumUpgrade = $derived(requiresPremium && !organization?.has_premium_features && !needsProjectConfiguration); + + const refetchConfigurationState = debounce(1500, async () => { + await Promise.all([organizationQuery.refetch(), projectsQuery.refetch()]); + }); + + useEventListener(document, 'PersistentEventChanged', (event) => { + const message = (event as CustomEvent>).detail; + + if (message.change_type === ChangeType.Removed || message.organization_id !== currentOrganizationId.current || !message.project_id) { + return; + } + + configuredProjectIds = new Set(configuredProjectIds).add(message.project_id); + void refetchConfigurationState(); + }); {#if isImpersonating && organization} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts index a41e8a207d..8ab4db7e8d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts @@ -51,6 +51,7 @@ async function invalidateSavedViewCache(queryClient: QueryClient, organizationId export const queryKeys = { id: (id: string | undefined) => [...queryKeys.type, id] as const, organization: (organizationId: string | undefined) => [...queryKeys.type, 'organization', organizationId] as const, + predefined: (organizationId: string | undefined) => [...queryKeys.type, 'organization', organizationId, 'predefined'] as const, type: ['SavedView'] as const, view: (organizationId: string | undefined, view: string | undefined) => [...queryKeys.type, 'organization', organizationId, 'view', view] as const }; @@ -153,6 +154,54 @@ export function postSavedView(request: { route: { organizationId: string | undef })); } +export function postPredefinedSavedViews(request: { route: { organizationId: string | undefined } }) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.organizationId, + mutationFn: async () => { + const client = useFetchClient(); + const response = await client.postJSON(`organizations/${request.route.organizationId}/saved-views/predefined`, {}); + return response.data!; + }, + mutationKey: queryKeys.predefined(request.route.organizationId), + onSuccess: (savedViews: SavedView[]) => { + for (const savedView of savedViews) { + restoreDeletedSavedView(savedView); + syncSavedViewCaches(queryClient, savedView, request.route.organizationId); + } + + void queryClient.invalidateQueries({ queryKey: queryKeys.organization(request.route.organizationId) }); + for (const view of new Set(savedViews.map((savedView) => savedView.view_type))) { + void queryClient.invalidateQueries({ queryKey: queryKeys.view(request.route.organizationId, view) }); + } + } + })); +} + +export function postPredefinedSavedView(request: { route: { id: string | undefined } }) { + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.id, + mutationFn: async () => { + const client = useFetchClient(); + const response = await client.postJSON(`saved-views/${request.route.id}/predefined`, {}); + return response.data!; + } + })); +} + +export function deletePredefinedSavedView(request: { route: { id: string | undefined } }) { + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.id, + mutationFn: async () => { + const client = useFetchClient(); + await client.delete(`saved-views/${request.route.id}/predefined`, { + expectedStatusCodes: [204] + }); + } + })); +} + export function removeSavedViewFromCaches(queryClient: QueryClient, savedView: SavedView, organizationId: string | undefined = savedView.organization_id) { const evict = (cachedViews: SavedView[] | undefined) => cachedViews?.filter((v) => v.id !== savedView.id); queryClient.setQueryData(queryKeys.view(organizationId, savedView.view_type), evict); 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 cec87d8ad9..4bb1a6687c 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 @@ -14,10 +14,12 @@ import { toFilter } from '$features/events/components/filters/helpers.svelte'; import { serializeFilters } from '$features/events/components/filters/helpers.svelte'; import { organization } from '$features/organizations/context.svelte'; + import { getMeQuery } from '$features/users/api.svelte'; import GripVertical from '@lucide/svelte/icons/grip-vertical'; import Pencil from '@lucide/svelte/icons/pencil'; import Plus from '@lucide/svelte/icons/plus'; import Save from '@lucide/svelte/icons/save'; + import ShieldCheck from '@lucide/svelte/icons/shield-check'; import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal'; import Trash2 from '@lucide/svelte/icons/trash-2'; import Undo2 from '@lucide/svelte/icons/undo-2'; @@ -26,7 +28,15 @@ import type { NewSavedView, SavedView, UpdateSavedView } from '../models'; - import { deleteSavedView, markSavedViewDeleted, patchSavedView, postSavedView, restoreDeletedSavedView } from '../api.svelte'; + import { + deletePredefinedSavedView, + deleteSavedView, + markSavedViewDeleted, + patchSavedView, + postPredefinedSavedView, + postSavedView, + restoreDeletedSavedView + } from '../api.svelte'; import DeleteViewDialog from './delete-view-dialog.svelte'; import RenameViewDialog from './rename-view-dialog.svelte'; import SaveViewDialog from './save-view-dialog.svelte'; @@ -103,6 +113,27 @@ } } }); + const predefinedViewMutation = postPredefinedSavedView({ + route: { + get id() { + return predefinedViewTarget?.id; + } + } + }); + const deletePredefinedViewMutation = deletePredefinedSavedView({ + route: { + get id() { + return predefinedViewTarget?.id; + } + } + }); + const predefinedViewUpdateMutation = patchSavedView({ + route: { + get id() { + return predefinedViewTarget?.id; + } + } + }); const removeMutation = deleteSavedView({ route: { get organizationId() { @@ -110,8 +141,17 @@ } } }); - - const saving = $derived(createMutation.isPending || updateMutation.isPending || removeMutation.isPending); + const meQuery = getMeQuery(); + const isGlobalAdmin = $derived(!!meQuery.data?.roles?.includes('global')); + + const saving = $derived( + createMutation.isPending || + updateMutation.isPending || + predefinedViewUpdateMutation.isPending || + predefinedViewMutation.isPending || + deletePredefinedViewMutation.isPending || + removeMutation.isPending + ); const currentFilterString = $derived(toFilter(filters.filter((f) => f.type !== 'date'))); @@ -139,6 +179,7 @@ }); const activeView = $derived(activeSavedView); + const predefinedViewTarget = $derived(activeView ?? duplicateView); const hideableColumns = $derived(table.getAllLeafColumns().filter((column) => column.getCanHide())); const reorderableColumns = $derived(table.getAllLeafColumns().filter((column) => column.id !== 'select')); const visibleHideableColumnCount = $derived(hideableColumns.filter((column) => column.getIsVisible()).length); @@ -274,12 +315,8 @@ } } - async function handleUpdate() { - if (!activeView || !organizationId) { - return; - } - - const body: UpdateSavedView = { + function getUpdateBody(): UpdateSavedView { + return { column_order: getSavedColumnOrder() ?? null, columns: columnVisibility, filter: currentFilterString || null, @@ -289,15 +326,49 @@ sort: sort || null, time: time || null }; + } + + async function handleUpdate() { + if (!activeView || !organizationId) { + return; + } try { - await updateMutation.mutateAsync(body); + await updateMutation.mutateAsync(getUpdateBody()); toast.success(`View "${activeView.name}" saved.`); } catch (error) { toast.error(getErrorMessage(error, 'Failed to save view. Please try again.')); } } + async function handleSavePredefinedView() { + if (!predefinedViewTarget || !organizationId) { + return; + } + + try { + await predefinedViewUpdateMutation.mutateAsync(getUpdateBody()); + + const result = await predefinedViewMutation.mutateAsync(); + toast.success(`"${result.name}" is now a predefined saved view.`); + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to save predefined saved view. Please try again.')); + } + } + + async function handleDeletePredefinedView() { + if (!predefinedViewTarget || !organizationId) { + return; + } + + try { + await deletePredefinedViewMutation.mutateAsync(); + toast.success(`"${predefinedViewTarget.name}" is no longer a predefined saved view.`); + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to delete predefined saved view. Please try again.')); + } + } + async function handleDelete() { if (!viewToDelete || !organizationId) { return; @@ -368,6 +439,18 @@ Delete "{activeView.name}" {/if} + {#if isGlobalAdmin && predefinedViewTarget} + + Predefined Saved Views + + + + + {/if} {#if setShowStats || setShowChart} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 4f03195304..a76a8df242 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -443,8 +443,21 @@ function onIntercomUnreadCountChange(unreadCount: number) { intercomUnreadCount = Math.max(0, unreadCount); } + + const setupPath = resolve('/(app)/organization/add'); + const isSetupPage = $derived(page.url.pathname === setupPath); +{#snippet setupShell()} +
+
+
+ {@render children()} +
+
+
+{/snippet} + {#snippet appShell(openChat: () => void)} @@ -508,7 +521,11 @@ routeKey={page.url.pathname} > {#snippet children(openChat)} - {@render appShell(openChat)} + {#if isSetupPage} + {@render setupShell()} + {:else} + {@render appShell(openChat)} + {/if} {/snippet} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/+page.svelte index dd2ef6d42a..55ca7873b2 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/+page.svelte @@ -21,6 +21,7 @@ filterChanged, filterRemoved, getFiltersFromCache, + shouldRefreshPersistentEventChanged, serializeFilters, toFilter, updateFilterCache @@ -44,7 +45,7 @@ import { createTable } from '@tanstack/svelte-table'; import { queryParamsState } from 'kit-query-params'; import { useEventListener, watch } from 'runed'; - import { throttle } from 'throttle-debounce'; + import { debounce, throttle } from 'throttle-debounce'; let selectedEventId: null | string = $state(null); @@ -275,6 +276,7 @@ } const throttledLoadData = throttle(10000, loadData); + const debouncedLoadData = debounce(1500, loadData); async function onPersistentEventChanged(message: WebSocketMessageValue<'PersistentEventChanged'>) { if (message.id && message.change_type === ChangeType.Removed) { @@ -288,6 +290,16 @@ } } } + + if (message.change_type === ChangeType.Removed) { + return; + } + + if (!shouldRefreshPersistentEventChanged(filters, queryParams.filter, message.organization_id, message.project_id, message.stack_id, message.id)) { + return; + } + + await debouncedLoadData(); } useEventListener(document, 'PersistentEventChanged', async (event) => await onPersistentEventChanged((event as CustomEvent).detail)); @@ -356,6 +368,19 @@ }; }); + let lastStatsRefreshKey = $state(); + + $effect(() => { + const refreshKey = `${organization.current}:${page.url.search}:${stats.totalEvents}`; + + if (!clientResponse?.ok || stats.totalEvents <= 0 || !isTableEmpty(table) || lastStatsRefreshKey === refreshKey) { + return; + } + + lastStatsRefreshKey = refreshKey; + void loadData(); + }); + function onRangeSelect(start: Date, end: Date) { onFilterChanged(new DateFilter('date', toDateMathRange(start, end))); } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte index 206d7349fb..5ef62bd89c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte @@ -2,12 +2,19 @@ import { page } from '$app/state'; import ErrorMessage from '$comp/error-message.svelte'; import { Muted } from '$comp/typography'; + import { Button } from '$comp/ui/button'; import { Skeleton } from '$comp/ui/skeleton'; + import { Spinner } from '$comp/ui/spinner'; import { Switch } from '$comp/ui/switch'; import { getOrganizationQuery, removeOrganizationFeature, setOrganizationFeature } from '$features/organizations/api.svelte'; + import { postPredefinedSavedViews } from '$features/saved-views/api.svelte'; import { getMeQuery } from '$features/users/api.svelte'; + import { ProblemDetails } from '@exceptionless/fetchclient'; + import RefreshCw from '@lucide/svelte/icons/refresh-cw'; import { toast } from 'svelte-sonner'; + let toastId = $state(); + const organizationId = $derived(page.params.organizationId || ''); const meQuery = getMeQuery(); @@ -45,6 +52,14 @@ } }); + const predefinedSavedViews = postPredefinedSavedViews({ + route: { + get organizationId() { + return organizationId; + } + } + }); + async function handleToggleFeature(featureId: string, enabled: boolean) { if (!isGlobalAdmin) { return; @@ -59,6 +74,17 @@ toast.error('Failed to update feature. Please try again.'); } } + + async function updatePredefinedSavedViews() { + toast.dismiss(toastId); + try { + const savedViews = await predefinedSavedViews.mutateAsync(); + toastId = toast.success(`Updated ${savedViews.length} predefined saved views.`); + } catch (error: unknown) { + const message = error instanceof ProblemDetails ? error.title : 'Please try again.'; + toastId = toast.error(`An error occurred while updating predefined saved views: ${message}`); + } + } {#if organizationQuery.isError} @@ -67,9 +93,40 @@ {:else}
- Enable or disable features for this organization + Manage organization features + +
+
+

Saved views

+ Update predefined saved views without removing custom saved views. +
+ +
+
+ +
+
+
+ +
+
+

Feature flags

+ Manage feature flags +
-
{#if organizationQuery.isLoading} {#each Array.from({ length: KNOWN_FEATURES.length }, (_, index) => index) as i (`skeleton-${i}`)}
@@ -82,6 +139,10 @@
{/each} + {:else if KNOWN_FEATURES.length === 0} +
+ Feature flags will be available here when they are ready. +
{:else} {#each KNOWN_FEATURES as feature (feature.id)}
@@ -100,6 +161,6 @@
{/each} {/if} -
+ {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte index 9cd12be15f..ee0f265ffc 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/add/+page.svelte @@ -1,9 +1,13 @@ -
-
-

Add Organization

- Add a new organization to start tracking errors and events -
-
{ - e.preventDefault(); - e.stopPropagation(); - form.handleSubmit(); - }} - > - state.errors}> - {#snippet children(errors)} - - {/snippet} - - - {#snippet children(field)} - - Organization Name - field.handleChange(e.currentTarget.value)} - aria-invalid={ariaInvalid(field)} - /> - - - {/snippet} - - state.isSubmitting}> - {#snippet children(isSubmitting)} - - {/snippet} - -
-
+ + + + Set Up Exceptionless + Create an organization and project. Next, we'll connect your app. + + +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > + state.errors}> + {#snippet children(errors)} + + {/snippet} + + + {#snippet children(field)} + + Organization Name + field.handleChange(e.currentTarget.value)} + aria-invalid={ariaInvalid(field)} + /> + + + {/snippet} + + + {#snippet children(field)} + + Project Name + field.handleChange(e.currentTarget.value)} + aria-invalid={ariaInvalid(field)} + /> + + + {/snippet} + + state.isSubmitting}> + {#snippet children(isSubmitting)} + + {/snippet} + +
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/+layout.svelte index 97c52d4e0b..02117545cc 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/+layout.svelte @@ -33,6 +33,8 @@ } }); const currentPath = $derived(page.url.pathname); + const configurePath = $derived(resolve('/(app)/project/[projectId]/configure', { projectId })); + const isConfigurePage = $derived(currentPath === configurePath || currentPath.startsWith(configurePath + '/')); $effect(() => { if (projectQuery.isError) { @@ -53,7 +55,7 @@ {#if projectQuery.isSuccess} {projectQuery.data.name} {/if} - Settings + {isConfigurePage ? 'Configure Client' : 'Settings'}
@@ -68,21 +70,23 @@
- + {#if !isConfigurePage} + + {/if} {@render children()}
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte index b961b5ee92..ca8204fadb 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/configure/+page.svelte @@ -1,5 +1,4 @@
@@ -280,6 +291,15 @@ public partial class App : Application { {/if} + {#if queryParams.redirect} + + Waiting for your first event + +

Send an event from your app. When it arrives, we'll open the project Events page automatically.

+
+
+ {/if} +
  1. Select your project type.

    @@ -623,7 +643,7 @@ public partial class App : Application {
{#if isTokenDisabled} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/manage/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/manage/+page.svelte index 2cf64d876c..8d76bc08e5 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/manage/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/manage/+page.svelte @@ -6,12 +6,12 @@ import { page } from '$app/state'; import ErrorMessage from '$comp/error-message.svelte'; import { Muted } from '$comp/typography'; - import { Button, buttonVariants } from '$comp/ui/button'; - import * as DropdownMenu from '$comp/ui/dropdown-menu'; + import { Button } from '$comp/ui/button'; import * as Field from '$comp/ui/field'; import { Input } from '$comp/ui/input'; import { Spinner } from '$comp/ui/spinner'; - import { deleteProject, getProjectQuery, resetData, updateProject } from '$features/projects/api.svelte'; + import * as Tooltip from '$comp/ui/tooltip'; + import { deleteProject, generateSampleData, getProjectQuery, resetData, updateProject } from '$features/projects/api.svelte'; import RemoveProjectDialog from '$features/projects/components/dialogs/remove-project-dialog.svelte'; import ResetProjectDataDialog from '$features/projects/components/dialogs/reset-project-data-dialog.svelte'; import { type UpdateProjectFormData, UpdateProjectSchema } from '$features/projects/schemas'; @@ -19,6 +19,8 @@ import { ProblemDetails } from '@exceptionless/fetchclient'; import AlertTriangle from '@lucide/svelte/icons/alert-triangle'; import Issues from '@lucide/svelte/icons/bug'; + import Configure from '@lucide/svelte/icons/cloud-download'; + import Database from '@lucide/svelte/icons/database'; import X from '@lucide/svelte/icons/x'; import { createForm } from '@tanstack/svelte-form'; import { toast } from 'svelte-sonner'; @@ -77,6 +79,26 @@ toastId = toast.success('Successfully queued the project for data reset.'); } + const generateSampleDataMutation = generateSampleData({ + route: { + get id() { + return projectId; + } + } + }); + + async function generateProjectSampleData() { + toast.dismiss(toastId); + + try { + await generateSampleDataMutation.mutateAsync(); + toastId = toast.success('Sample data generation has been queued. Events will appear shortly.'); + } catch (error) { + toastId = toast.error('Failed to generate sample data. Please try again.'); + throw error; + } + } + const form = createForm(() => ({ defaultValues: { name: projectQuery.data?.name @@ -150,38 +172,64 @@ + + -
- - - - - - - Actions - - (showResetDialog = true)} disabled={resetProject.isPending}> +
+ + + {#snippet child({ props })} + + {/snippet} + + Reset project data + + + + + {#snippet child({ props })} + + {/snippet} + + Delete project +
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/routes.svelte.ts index 79e26afdcd..8883b45d37 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/routes.svelte.ts @@ -3,7 +3,6 @@ import { page } from '$app/state'; import Usage from '@lucide/svelte/icons/bar-chart'; import ClientConfig from '@lucide/svelte/icons/braces'; import Issues from '@lucide/svelte/icons/bug'; -import Configure from '@lucide/svelte/icons/cloud-download'; import ApiKey from '@lucide/svelte/icons/key'; import Integration from '@lucide/svelte/icons/plug-2'; import Settings from '@lucide/svelte/icons/settings'; @@ -44,7 +43,7 @@ export function routes(): NavigationItem[] { group: 'Project Settings', href: resolve('/(app)/project/[projectId]/configuration-values', { projectId: page.params.projectId }), icon: ClientConfig, - title: 'Configuration Values' + title: 'Configuration' }, { group: 'Project Settings', @@ -52,12 +51,6 @@ export function routes(): NavigationItem[] { icon: Integration, title: 'Integrations' }, - { - group: 'Project Settings', - href: resolve('/(app)/project/[projectId]/configure', { projectId: page.params.projectId }), - icon: Configure, - title: 'Configure Client' - }, { group: 'Project Settings', href: resolve('/(app)/project/[projectId]/issues', { projectId: page.params.projectId }), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte index c053d5de96..b7759af9c0 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte @@ -16,6 +16,7 @@ import { ariaInvalid, getFormErrorMessages, mapFieldErrors, problemDetailsToFormErrors } from '$features/shared/validation'; import { ProblemDetails } from '@exceptionless/fetchclient'; import { createForm } from '@tanstack/svelte-form'; + import { untrack } from 'svelte'; import { toast } from 'svelte-sonner'; let toastId = $state(); @@ -25,14 +26,14 @@ defaultValues: { delete_bot_data_enabled: true, name: '', - organization_id: organization.current + organization_id: organization.current ?? '' } as NewProjectFormData, validators: { onSubmit: NewProjectSchema, onSubmitAsync: async ({ value }) => { toast.dismiss(toastId); try { - const { id } = await createProject.mutateAsync(value as NewProject); + const { id } = await createProject.mutateAsync({ ...value, organization_id: organization.current ?? value.organization_id } as NewProject); toastId = toast.success('Project added successfully'); await goto(resolve('/(app)/project/[projectId]/configure', { projectId: id }) + '?redirect=true'); return null; @@ -52,12 +53,17 @@ } } })); + + $effect(() => { + const organizationId = organization.current ?? ''; + untrack(() => form.setFieldValue('organization_id', organizationId)); + });

Add Project

- Add a new project to start tracking errors and events + Create a project, then configure a client to send your first event.
{ @@ -94,7 +100,7 @@ {#if isSubmitting} Adding Project... {:else} - Add Project + Continue to Client Setup {/if} {/snippet} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/actions/[[category]]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/actions/[[category]]/+page.svelte index 4e6b26aa50..d53050baf7 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/actions/[[category]]/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/actions/[[category]]/+page.svelte @@ -4,13 +4,17 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import { page } from '$app/state'; - import { Muted } from '$comp/typography'; + import CopyToClipboardButton from '$comp/copy-to-clipboard-button.svelte'; + import { CodeBlock, Muted } from '$comp/typography'; import { Button } from '$comp/ui/button'; + import * as Dialog from '$comp/ui/dialog'; import { Input } from '$comp/ui/input'; - import { runMaintenanceJobMutation } from '$features/admin/api.svelte'; + import { Spinner } from '$comp/ui/spinner'; + import { getPredefinedSavedViewsMutation, runMaintenanceJobMutation } from '$features/admin/api.svelte'; import RunMaintenanceJobDialog from '$features/admin/components/dialogs/run-maintenance-job-dialog.svelte'; import { maintenanceActions } from '$features/admin/models'; import { ProblemDetails } from '@exceptionless/fetchclient'; + import FileJson from '@lucide/svelte/icons/file-json'; import Play from '@lucide/svelte/icons/play'; import TriangleAlert from '@lucide/svelte/icons/triangle-alert'; import { toast } from 'svelte-sonner'; @@ -21,10 +25,15 @@ let searchQuery = $state(page.url.searchParams.get('q') ?? ''); let selectedAction = $state(); + let jsonOutputAction = $state(); + let jsonOutput = $state(''); + let runningActionName = $state(); let openDialog = $state(false); + let openJsonDialog = $state(false); let toastId = $state(); const runJob = runMaintenanceJobMutation(); + const predefinedSavedViews = getPredefinedSavedViewsMutation(); $effect(() => { const query = searchQuery.trim(); @@ -65,11 +74,32 @@ const destructiveCount = $derived(filteredActions.filter((a) => a.dangerous).length); - function handleRun(action: MaintenanceAction) { + async function handleRun(action: MaintenanceAction) { + if (action.kind === 'predefined-saved-views') { + await showPredefinedSavedViews(action); + return; + } + selectedAction = action; openDialog = true; } + async function showPredefinedSavedViews(action: MaintenanceAction) { + toast.dismiss(toastId); + runningActionName = action.name; + + try { + jsonOutput = await predefinedSavedViews.mutateAsync(); + jsonOutputAction = action; + openJsonDialog = true; + } catch (error: unknown) { + const message = error instanceof ProblemDetails ? error.title : 'Please try again.'; + toastId = toast.error(`An error occurred while loading predefined saved views: ${message}`); + } finally { + runningActionName = undefined; + } + } + async function handleConfirm(params: Parameters[0]) { toast.dismiss(toastId); try { @@ -142,9 +172,27 @@

{action.description}

- {/each} @@ -157,3 +205,28 @@ {#if selectedAction && openDialog} {/if} + +{#if jsonOutputAction && openJsonDialog} + + + + {jsonOutputAction.label} + Current response from /api/v2/saved-views/predefined. + +
+ Copy JSON +
+ + + + +
+
+{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/signup/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/signup/+page.svelte index a4c960e7fc..4f7808ecec 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/signup/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/signup/+page.svelte @@ -32,8 +32,8 @@ import { ariaInvalid, getFormErrorMessages, mapFieldErrors, problemDetailsToFormErrors } from '$shared/validation'; import { createForm } from '@tanstack/svelte-form'; - const redirectUrl = resolve('/(app)/project/add'); const inviteToken = page.url.searchParams.get('token'); + const redirectUrl = inviteToken ? resolve('/(app)/project/add') : resolve('/(app)/organization/add'); const form = createForm(() => ({ defaultValues: { @@ -63,142 +63,144 @@ Signup for a FREE account in seconds - {#if enableOAuthLogin} -

Sign up with

-
- {#if microsoftClientId} - - {/if} - {#if googleClientId} - - {/if} - {#if facebookClientId} - - {/if} - {#if gitHubClientId} - - {/if} -
+
+ {#if enableOAuthLogin} +

Sign up with

+
+ {#if microsoftClientId} + + {/if} + {#if googleClientId} + + {/if} + {#if facebookClientId} + + {/if} + {#if gitHubClientId} + + {/if} +
-
-
-

OR

-
-
- {/if} +
+
+

OR

+
+
+ {/if} - { - e.preventDefault(); - e.stopPropagation(); - form.handleSubmit(); - }} - > - state.errors}> - {#snippet children(errors)} - - {/snippet} - - - {#snippet children(field)} - - Name - field.handleChange(e.currentTarget.value)} - aria-invalid={ariaInvalid(field)} - /> - - - {/snippet} - - validateEmailAvailability(value), onChangeAsyncDebounceMs: 1000 }}> - {#snippet children(field)} - - Email - - { + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > + state.errors}> + {#snippet children(errors)} + + {/snippet} + + + {#snippet children(field)} + + Name + field.handleChange(e.currentTarget.value)} aria-invalid={ariaInvalid(field)} /> - {#if field.state.meta.isValidating} - - - + + + {/snippet} + + validateEmailAvailability(value), onChangeAsyncDebounceMs: 1000 }}> + {#snippet children(field)} + + Email + + field.handleChange(e.currentTarget.value)} + aria-invalid={ariaInvalid(field)} + /> + {#if field.state.meta.isValidating} + + + + {/if} + + + + {/snippet} + + + {#snippet children(field)} + + Password + field.handleChange(e.currentTarget.value)} + aria-invalid={ariaInvalid(field)} + /> + + + {/snippet} + + state.isSubmitting}> + {#snippet children(isSubmitting)} + - {/snippet} - - + + {/snippet} + + -

- Already have an account? - Log In -

+

+ Already have an account? + Log In +

-

- By signing up, you agree to our Privacy Policy - and - Terms of Service. -

+

+ By signing up, you agree to our Privacy Policy + and + Terms of Service. +

+
diff --git a/src/Exceptionless.Web/Controllers/SavedViewController.cs b/src/Exceptionless.Web/Controllers/SavedViewController.cs index 227bded5dc..4f19944abf 100644 --- a/src/Exceptionless.Web/Controllers/SavedViewController.cs +++ b/src/Exceptionless.Web/Controllers/SavedViewController.cs @@ -1,17 +1,21 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json; using System.Text.RegularExpressions; using Exceptionless.Core.Authorization; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Queries.Validation; using Exceptionless.Core.Repositories; +using Exceptionless.Core.Seed; using Exceptionless.Web.Controllers; using Exceptionless.Web.Mapping; using Exceptionless.Web.Models; using Exceptionless.Web.Utility; +using Foundatio.Lock; using Foundatio.Repositories; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using DataDictionary = Exceptionless.Core.Models.DataDictionary; namespace Exceptionless.App.Controllers.API; @@ -20,14 +24,24 @@ namespace Exceptionless.App.Controllers.API; public class SavedViewController : RepositoryApiController { private const int MaxViewsPerOrganization = 100; + private const string PredefinedSavedViewsDataKey = "@@PredefinedSavedViewsVersion"; + private const int PredefinedSavedViewsVersion = 2; + + private readonly IOrganizationRepository _organizationRepository; + private readonly ILockProvider _lockProvider; public SavedViewController( ISavedViewRepository repository, + IOrganizationRepository organizationRepository, + ILockProvider lockProvider, ApiMapper mapper, IAppQueryValidator validator, TimeProvider timeProvider, ILoggerFactory loggerFactory) : base(repository, mapper, validator, timeProvider, loggerFactory) - { } + { + _organizationRepository = organizationRepository; + _lockProvider = lockProvider; + } protected override SavedView MapToModel(NewSavedView newModel) { @@ -61,6 +75,7 @@ public async Task>> GetByOrganiz return NotFound(); // Reads remain available even when the feature is disabled to preserve access to existing saved views. + await EnsurePredefinedSavedViewsCreatedAsync(organizationId); page = GetPage(page); limit = GetLimit(limit); @@ -89,6 +104,7 @@ public async Task>> GetByViewAsy return NotFound(); // Reads remain available even when the feature is disabled to preserve access to existing saved views. + await EnsurePredefinedSavedViewsCreatedAsync(organizationId); page = GetPage(page); limit = GetLimit(limit); @@ -132,6 +148,69 @@ public async Task> PostAsync(string organizationId, return await PostImplAsync(savedView); } + /// + /// Create or update predefined saved views + /// + /// The identifier of the organization. + /// The predefined saved views were created or updated. + /// The organization could not be found. + [HttpPost("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/saved-views/predefined")] + public async Task>> PostPredefinedAsync(string organizationId) + { + if (!IsInOrganization(organizationId)) + return NotFound(); + + var savedViews = await UpsertPredefinedSavedViewsAsync(organizationId); + return Ok(MapToViewModels(savedViews)); + } + + /// + /// Get global predefined saved views as seed JSON + /// + /// The current predefined saved views. + [HttpGet("predefined")] + [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] + public async Task>> GetPredefinedAsync() + { + return Ok(await GetPredefinedSavedViewsAsync()); + } + + /// + /// Save a saved view as a global predefined saved view + /// + /// The identifier of the saved view to promote. + /// The predefined saved view was created or updated. + /// The saved view could not be found. + [HttpPost("{id:objectid}/predefined")] + [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] + public async Task> PostPredefinedSavedViewAsync(string id) + { + var source = await _repository.GetByIdAsync(id); + if (source is null) + return NotFound(); + + var savedView = await UpsertSystemPredefinedSavedViewAsync(source); + return Ok(MapToViewModel(savedView)); + } + + /// + /// Delete a global predefined saved view + /// + /// The identifier of the saved view whose predefined saved view should be deleted. + /// The predefined saved view was deleted. + /// The saved view could not be found. + [HttpDelete("{id:objectid}/predefined")] + [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] + public async Task DeletePredefinedSavedViewAsync(string id) + { + var source = await _repository.GetByIdAsync(id); + if (source is null) + return NotFound(); + + await DeleteSystemPredefinedSavedViewAsync(source); + return NoContent(); + } + /// /// Update /// @@ -341,6 +420,314 @@ protected override async Task CanDeleteAsync(SavedView value) return await base.CanDeleteAsync(value); } + private async Task EnsurePredefinedSavedViewsCreatedAsync(string organizationId) + { + var organization = await _organizationRepository.GetByIdAsync(organizationId); + if (organization is null || HasCreatedPredefinedSavedViews(organization)) + return; + + await UpsertPredefinedSavedViewsAsync(organizationId, true); + } + + private async Task> UpsertPredefinedSavedViewsAsync(string organizationId, bool onlyIfNeverCreated = false) + { + List savedViews = []; + + await _lockProvider.TryUsingAsync($"predefined-saved-views:{organizationId}", async () => + { + var organization = await _organizationRepository.GetByIdAsync(organizationId); + if (organization is null) + return; + + if (onlyIfNeverCreated && HasCreatedPredefinedSavedViews(organization)) + return; + + savedViews = await UpsertPredefinedSavedViewsForOrganizationAsync(organizationId); + organization.Data ??= new DataDictionary(); + organization.Data[PredefinedSavedViewsDataKey] = PredefinedSavedViewsVersion.ToString(); + await _organizationRepository.SaveAsync(organization, o => o.Cache().ImmediateConsistency()); + }, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(15)); + + return savedViews; + } + + private async Task> UpsertPredefinedSavedViewsForOrganizationAsync(string organizationId) + { + var savedViewsByView = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var upserted = new List(); + + var definitions = await GetPredefinedSavedViewsAsync(); + foreach (var definition in definitions) + { + if (!savedViewsByView.TryGetValue(definition.ViewType, out var existingViews)) + { + var results = await _repository.GetByViewAsync(organizationId, definition.ViewType, o => o.PageLimit(1000)); + existingViews = results.Documents.ToList(); + savedViewsByView.Add(definition.ViewType, existingViews); + } + + var existing = existingViews.FirstOrDefault(view => view.UserId is null && String.Equals(view.PredefinedKey, definition.Key, StringComparison.OrdinalIgnoreCase)) + ?? existingViews.FirstOrDefault(view => view.UserId is null && String.IsNullOrWhiteSpace(view.PredefinedKey) && String.Equals(view.Name.Trim(), definition.Name, StringComparison.OrdinalIgnoreCase)); + var slug = GetUniqueSlug(definition.Slug, existingViews, existing?.Id); + + if (existing is null) + { + var savedView = CreatePredefinedSavedView(organizationId, definition, slug); + await _repository.AddAsync(savedView, o => o.Cache().ImmediateConsistency()); + existingViews.Add(savedView); + upserted.Add(savedView); + continue; + } + + if (ApplyPredefinedSavedView(existing, definition, slug)) + { + existing.UpdatedByUserId = CurrentUser.Id; + await _repository.SaveAsync(existing, o => o.Cache().ImmediateConsistency()); + } + + upserted.Add(existing); + } + + return upserted; + } + + private static bool HasCreatedPredefinedSavedViews(Organization organization) + { + return organization.Data is not null && organization.Data.ContainsKey(PredefinedSavedViewsDataKey); + } + + private SavedView CreatePredefinedSavedView(string organizationId, PredefinedSavedViewDefinition definition, string slug) + { + return new SavedView + { + OrganizationId = organizationId, + CreatedByUserId = CurrentUser.Id, + PredefinedKey = definition.Key, + Name = definition.Name, + Slug = slug, + ViewType = definition.ViewType, + Filter = definition.Filter, + Time = definition.Time, + Sort = definition.Sort, + FilterDefinitions = PredefinedSavedViewsDataSeed.GetRawJson(definition.FilterDefinitions), + Columns = Copy(definition.Columns), + ColumnOrder = definition.ColumnOrder is null ? null : [.. definition.ColumnOrder], + ShowStats = definition.ShowStats, + ShowChart = definition.ShowChart, + Version = 1 + }; + } + + private static bool ApplyPredefinedSavedView(SavedView savedView, PredefinedSavedViewDefinition definition, string slug) + { + var changed = false; + changed |= SetIfChanged(savedView, definition.Key, static (view, value) => view.PredefinedKey = value, static view => view.PredefinedKey); + changed |= SetIfChanged(savedView, definition.Name, static (view, value) => view.Name = value, static view => view.Name); + changed |= SetIfChanged(savedView, slug, static (view, value) => view.Slug = value, static view => view.Slug); + changed |= SetIfChanged(savedView, definition.Filter, static (view, value) => view.Filter = value, static view => view.Filter); + 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 |= 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); + + return changed; + } + + private async Task UpsertSystemPredefinedSavedViewAsync(SavedView source) + { + var existingPredefinedViews = await GetSystemPredefinedSavedViewsAsync(source.ViewType); + var key = GetPredefinedKey(source); + var existing = existingPredefinedViews.FirstOrDefault(view => String.Equals(view.PredefinedKey, key, StringComparison.OrdinalIgnoreCase)) + ?? existingPredefinedViews.FirstOrDefault(view => String.IsNullOrWhiteSpace(view.PredefinedKey) && String.Equals(view.Slug, source.Slug, StringComparison.OrdinalIgnoreCase)); + var slug = GetUniqueSlug(source.Slug, existingPredefinedViews, existing?.Id); + + if (existing is null) + { + var savedView = CreateSystemPredefinedSavedView(source, key, slug); + await _repository.AddAsync(savedView, o => o.Cache().ImmediateConsistency()); + return savedView; + } + + ApplySavedViewConfiguration(existing, source, key, slug); + existing.UpdatedByUserId = CurrentUser.Id; + await _repository.SaveAsync(existing, o => o.Cache().ImmediateConsistency()); + return existing; + } + + private SavedView CreateSystemPredefinedSavedView(SavedView source, string key, string slug) + { + var savedView = new SavedView + { + OrganizationId = PredefinedSavedViewsDataSeed.SystemOrganizationId, + CreatedByUserId = CurrentUser.Id, + Version = 1 + }; + + ApplySavedViewConfiguration(savedView, source, key, slug); + return savedView; + } + + private static void ApplySavedViewConfiguration(SavedView destination, SavedView source, string key, string slug) + { + destination.UserId = null; + destination.PredefinedKey = key; + destination.Name = source.Name; + destination.Slug = slug; + destination.ViewType = source.ViewType; + destination.Filter = source.Filter; + destination.Time = source.Time; + destination.Sort = source.Sort; + destination.FilterDefinitions = source.FilterDefinitions; + destination.Columns = Copy(source.Columns); + destination.ColumnOrder = source.ColumnOrder is null ? null : [.. source.ColumnOrder]; + destination.ShowStats = source.ShowStats; + destination.ShowChart = source.ShowChart; + } + + private async Task> GetPredefinedSavedViewsAsync() + { + var definitions = new List(); + + foreach (var viewType in NewSavedView.ValidViewTypes) + { + var savedViews = await GetSystemPredefinedSavedViewsAsync(viewType); + foreach (var savedView in savedViews) + { + var key = GetPredefinedKey(savedView); + definitions.Add(ToPredefinedSavedView(savedView, key)); + } + } + + return definitions; + } + + private async Task DeleteSystemPredefinedSavedViewAsync(SavedView source) + { + var key = GetPredefinedKey(source); + var existingPredefinedViews = await GetSystemPredefinedSavedViewsAsync(source.ViewType); + var existing = existingPredefinedViews.FirstOrDefault(view => String.Equals(view.PredefinedKey, key, StringComparison.OrdinalIgnoreCase)) + ?? existingPredefinedViews.FirstOrDefault(view => String.IsNullOrWhiteSpace(view.PredefinedKey) && String.Equals(view.Slug, source.Slug, StringComparison.OrdinalIgnoreCase)); + + if (existing is not null) + await _repository.RemoveAsync(existing.Id, o => o.ImmediateConsistency()); + } + + private async Task> GetSystemPredefinedSavedViewsAsync(string viewType) + { + var results = await _repository.GetByViewAsync(PredefinedSavedViewsDataSeed.SystemOrganizationId, viewType, o => o.PageLimit(1000)); + return results.Documents.Where(view => view.UserId is null).ToList(); + } + + private static PredefinedSavedViewDefinition ToPredefinedSavedView(SavedView savedView, string key) + { + return new PredefinedSavedViewDefinition + { + Key = key, + Name = savedView.Name, + Slug = savedView.Slug, + ViewType = savedView.ViewType, + Filter = savedView.Filter, + Time = savedView.Time, + Sort = savedView.Sort, + FilterDefinitions = ParseFilterDefinitions(savedView.FilterDefinitions), + Columns = savedView.Columns, + ColumnOrder = savedView.ColumnOrder, + ShowStats = savedView.ShowStats, + ShowChart = savedView.ShowChart + }; + } + + private static JsonElement? ParseFilterDefinitions(string? filterDefinitions) + { + if (String.IsNullOrWhiteSpace(filterDefinitions)) + return null; + + try + { + return JsonSerializer.Deserialize(filterDefinitions); + } + catch (JsonException) + { + return null; + } + } + + private static string GetPredefinedKey(SavedView savedView) + { + if (!String.IsNullOrWhiteSpace(savedView.PredefinedKey)) + return savedView.PredefinedKey; + + return $"{savedView.ViewType}:{ToSlug(String.IsNullOrWhiteSpace(savedView.Slug) ? savedView.Name : savedView.Slug)}"; + } + + private static bool SetIfChanged(SavedView savedView, T value, Action setValue, Func getValue) + { + if (EqualityComparer.Default.Equals(getValue(savedView), value)) + return false; + + setValue(savedView, value); + return true; + } + + private static bool SetDictionaryIfChanged(SavedView savedView, IReadOnlyDictionary? value) + { + if (DictionaryEquals(savedView.Columns, value)) + return false; + + savedView.Columns = Copy(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? Copy(IReadOnlyDictionary? value) + { + return value is null ? null : value.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + } + + private static bool DictionaryEquals(IReadOnlyDictionary? left, IReadOnlyDictionary? right) + { + if ((left?.Count ?? 0) != (right?.Count ?? 0)) + return false; + + if (left is null || right is null) + return true; + + return left.All(kvp => right.TryGetValue(kvp.Key, out var value) && value == kvp.Value); + } + + private static string GetUniqueSlug(string slug, IReadOnlyCollection existingViews, string? excludingId) + { + var baseSlug = ToSlug(slug); + if (String.IsNullOrWhiteSpace(baseSlug)) + baseSlug = "saved-view"; + + baseSlug = baseSlug.Length > 100 ? baseSlug[..100].Trim('-') : baseSlug; + var candidate = baseSlug; + var suffix = 2; + + while (existingViews.Any(view => view.Id != excludingId && String.Equals(ToFallbackSlug(String.IsNullOrWhiteSpace(view.Slug) ? view.Name : view.Slug, view.Id), candidate, StringComparison.OrdinalIgnoreCase))) + { + var suffixText = $"-{suffix}"; + var maxBaseLength = 100 - suffixText.Length; + candidate = $"{baseSlug[..Math.Min(baseSlug.Length, maxBaseLength)].Trim('-')}{suffixText}"; + suffix++; + } + + return candidate; + } + private async Task SlugExistsAsync(string organizationId, string viewType, string slug, string? excludingId) { var results = await _repository.GetByViewForUserAsync(organizationId, viewType, CurrentUser.Id, o => o.PageLimit(1000)); @@ -378,4 +765,5 @@ private static string ToFallbackSlug(string value, string id) return String.IsNullOrWhiteSpace(id) ? "saved-view" : $"saved-view-{id}"; } + } diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index a036591468..e7ed4f142a 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -337,6 +337,134 @@ } } }, + "/api/v2/organizations/{organizationId}/saved-views/predefined": { + "post": { + "tags": [ + "SavedView" + ], + "summary": "Create or update predefined saved views", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The predefined saved views were created or updated.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewSavedView" + } + } + } + } + }, + "404": { + "description": "The organization could not be found." + } + } + } + }, + "/api/v2/saved-views/predefined": { + "get": { + "tags": [ + "SavedView" + ], + "summary": "Get global predefined saved views as seed JSON", + "responses": { + "200": { + "description": "The current predefined saved views.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PredefinedSavedViewDefinition" + } + } + } + } + } + } + } + }, + "/api/v2/saved-views/{id}/predefined": { + "post": { + "tags": [ + "SavedView" + ], + "summary": "Save a saved view as a global predefined saved view", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the saved view to promote.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The predefined saved view was created or updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewSavedView" + } + } + } + }, + "404": { + "description": "The saved view could not be found." + } + } + }, + "delete": { + "tags": [ + "SavedView" + ], + "summary": "Delete a global predefined saved view", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the saved view whose predefined saved view should be deleted.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { } + } + }, + "204": { + "description": "The predefined saved view was deleted." + }, + "404": { + "description": "The saved view could not be found." + } + } + } + }, "/api/v2/saved-views/{ids}": { "delete": { "tags": [ @@ -7801,6 +7929,7 @@ } } }, + "JsonElement": { }, "Login": { "required": [ "email", @@ -8248,6 +8377,87 @@ } } }, + "PredefinedSavedViewDefinition": { + "required": [ + "key", + "name", + "slug", + "viewType" + ], + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "viewType": { + "type": "string" + }, + "filter": { + "type": [ + "null", + "string" + ] + }, + "time": { + "type": [ + "null", + "string" + ] + }, + "sort": { + "type": [ + "null", + "string" + ] + }, + "filterDefinitions": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/JsonElement" + } + ] + }, + "columns": { + "type": [ + "null", + "object" + ], + "additionalProperties": { + "type": "boolean" + } + }, + "columnOrder": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + }, + "showStats": { + "type": [ + "null", + "boolean" + ] + }, + "showChart": { + "type": [ + "null", + "boolean" + ] + } + } + }, "ResetPasswordModel": { "required": [ "password_reset_token", diff --git a/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs index a479dcd3f0..fd99787bf1 100644 --- a/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs @@ -1,6 +1,8 @@ using System.Net; +using System.Text.Json; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; +using Exceptionless.Core.Seed; using Exceptionless.Core.Services; using Exceptionless.Core.Utility; using Exceptionless.Tests.Extensions; @@ -14,12 +16,14 @@ namespace Exceptionless.Tests.Controllers; public sealed class SavedViewControllerTests : IntegrationTestsBase { + private readonly IOrganizationRepository _organizationRepository; private readonly ISavedViewRepository _savedViewRepository; private readonly IUserRepository _userRepository; private readonly OrganizationService _organizationService; public SavedViewControllerTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { + _organizationRepository = GetService(); _savedViewRepository = GetService(); _userRepository = GetService(); _organizationService = GetService(); @@ -30,6 +34,73 @@ protected override async Task ResetDataAsync() await base.ResetDataAsync(); var service = GetService(); await service.CreateDataAsync(); + await GetService().SeedAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task DataSeedAsync_ExistingDataWithoutPredefinedViews_CreatesSystemPredefinedViews() + { + // Arrange + Assert.True(await _organizationRepository.CountAsync() > 0); + + var existingSystemViews = await GetSystemPredefinedSavedViewsAsync(); + Assert.Equal(5, existingSystemViews.Count); + + foreach (var savedView in existingSystemViews) + await _savedViewRepository.RemoveAsync(savedView.Id, o => o.ImmediateConsistency()); + + await RefreshDataAsync(); + Assert.Equal(0, await _savedViewRepository.CountByOrganizationIdAsync(PredefinedSavedViewsDataSeed.SystemOrganizationId)); + + // Act + await GetService().SeedAsync(TestContext.Current.CancellationToken); + + // Assert + var seededSystemViews = await GetSystemPredefinedSavedViewsAsync(); + Assert.Equal(5, seededSystemViews.Count); + Assert.Contains(seededSystemViews, view => IsPredefinedSavedView(view, "events", "Logs")); + Assert.Contains(seededSystemViews, view => IsPredefinedSavedView(view, "events", "Errors")); + Assert.Contains(seededSystemViews, view => IsPredefinedSavedView(view, "issues", "Most Frequent Errors")); + Assert.Contains(seededSystemViews, view => IsPredefinedSavedView(view, "issues", "Most Frequent 404s")); + Assert.Contains(seededSystemViews, view => IsPredefinedSavedView(view, "issues", "Most Used Features")); + } + + [Fact] + public async Task GetPredefinedAsync_GlobalAdmin_ReturnsSeedJsonShape() + { + // Act + var response = await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("saved-views", "predefined") + .StatusCodeShouldBeOk() + ); + string json = await response.Content.ReadAsStringAsync(TestCancellationToken); + var definitions = await DeserializeResponseAsync>(response); + + // Assert + Assert.Contains("\"filterDefinitions\"", json); + Assert.DoesNotContain("\"filter_definitions\"", json); + Assert.NotNull(definitions); + Assert.Equal(5, definitions.Count); + + var logs = definitions.FirstOrDefault(view => String.Equals(view.Key, "events:logs", StringComparison.OrdinalIgnoreCase)); + Assert.NotNull(logs); + Assert.Equal("Logs", logs.Name); + Assert.Equal("logs", logs.Slug); + Assert.Equal("events", logs.ViewType); + Assert.Equal("type:log", logs.Filter); + Assert.NotNull(logs.FilterDefinitions); + Assert.Equal(JsonValueKind.Array, logs.FilterDefinitions.Value.ValueKind); + } + + [Fact] + public Task GetPredefinedAsync_User_ReturnsForbidden() + { + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("saved-views", "predefined") + .StatusCodeShouldBeForbidden() + ); } [Fact] @@ -436,6 +507,352 @@ public async Task GetByViewAsync_WithMixedViewTypes_ReturnsOnlyMatchingViewFilte Assert.DoesNotContain(filters, f => String.Equals(f.Id, issuesFilter.Id)); } + [Fact] + public async Task GetByOrganizationAsync_FirstRequest_CreatesPredefinedSavedViewsOnce() + { + // Act + var filters = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(filters); + Assert.Contains(filters, view => IsPredefinedSavedView(view, "events", "Logs")); + Assert.Contains(filters, view => IsPredefinedSavedView(view, "events", "Errors")); + Assert.Contains(filters, view => IsPredefinedSavedView(view, "issues", "Most Frequent Errors")); + Assert.Contains(filters, view => IsPredefinedSavedView(view, "issues", "Most Frequent 404s")); + Assert.Contains(filters, view => IsPredefinedSavedView(view, "issues", "Most Used Features")); + + foreach (var savedView in filters.Where(IsPredefinedSavedView)) + await _savedViewRepository.RemoveAsync(savedView.Id, o => o.ImmediateConsistency()); + + await RefreshDataAsync(); + + var afterDelete = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .StatusCodeShouldBeOk() + ); + + Assert.NotNull(afterDelete); + Assert.DoesNotContain(afterDelete, IsPredefinedSavedView); + } + + [Fact] + public async Task PostPredefinedAsync_AfterDelete_RecreatesPredefinedSavedViews() + { + // Arrange + var filters = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .StatusCodeShouldBeOk() + ); + + Assert.NotNull(filters); + foreach (var savedView in filters.Where(IsPredefinedSavedView)) + await _savedViewRepository.RemoveAsync(savedView.Id, o => o.ImmediateConsistency()); + + await RefreshDataAsync(); + + // Act + var predefinedViews = await SendRequestAsAsync>(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views", "predefined") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(predefinedViews); + Assert.Equal(5, predefinedViews.Count); + Assert.Contains(predefinedViews, view => IsPredefinedSavedView(view, "events", "Logs")); + Assert.Contains(predefinedViews, view => IsPredefinedSavedView(view, "events", "Errors")); + Assert.Contains(predefinedViews, view => IsPredefinedSavedView(view, "issues", "Most Frequent Errors")); + Assert.Contains(predefinedViews, view => IsPredefinedSavedView(view, "issues", "Most Frequent 404s")); + Assert.Contains(predefinedViews, view => IsPredefinedSavedView(view, "issues", "Most Used Features")); + } + + [Fact] + public async Task PostPredefinedAsync_ExistingViews_UpdatesByNameAndViewTypeAndPreservesCustomViews() + { + // Arrange + var predefinedViews = await SendRequestAsAsync>(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views", "predefined") + .StatusCodeShouldBeOk() + ); + + Assert.NotNull(predefinedViews); + var logs = predefinedViews.FirstOrDefault(view => IsPredefinedSavedView(view, "events", "Logs")); + Assert.NotNull(logs); + + var savedLogs = await _savedViewRepository.GetByIdAsync(logs.Id); + Assert.NotNull(savedLogs); + savedLogs.Filter = "type:error"; + savedLogs.Slug = "legacy-logs"; + await _savedViewRepository.SaveAsync(savedLogs, o => o.ImmediateConsistency()); + + var testUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + Assert.NotNull(testUser); + + var customView = await _savedViewRepository.AddAsync(new SavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Custom Investigation", + Filter = "type:error", + Slug = "custom-investigation", + ViewType = "events", + CreatedByUserId = testUser.Id + }, o => o.ImmediateConsistency()); + + await RefreshDataAsync(); + + // Act + var updatedPredefinedViews = await SendRequestAsAsync>(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views", "predefined") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(updatedPredefinedViews); + var updatedLogs = updatedPredefinedViews.FirstOrDefault(view => IsPredefinedSavedView(view, "events", "Logs")); + Assert.NotNull(updatedLogs); + Assert.Equal(logs.Id, updatedLogs.Id); + Assert.Equal("type:log", updatedLogs.Filter); + Assert.Equal("logs", updatedLogs.Slug); + + Assert.NotNull(await _savedViewRepository.GetByIdAsync(customView.Id)); + } + + [Fact] + public Task PostPredefinedAsync_CrossOrganization_ReturnsNotFound() + { + return SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.FREE_ORG_ID, "saved-views", "predefined") + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public async Task PostPredefinedSavedViewAsync_GlobalAdmin_UsesPromotedConfigurationForPredefinedViews() + { + // Arrange + var predefinedViews = await SendRequestAsAsync>(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views", "predefined") + .StatusCodeShouldBeOk() + ); + + Assert.NotNull(predefinedViews); + var logs = predefinedViews.FirstOrDefault(view => IsPredefinedSavedView(view, "events", "Logs")); + Assert.NotNull(logs); + + var savedLogs = await _savedViewRepository.GetByIdAsync(logs.Id); + Assert.NotNull(savedLogs); + savedLogs.Name = "Application Logs"; + savedLogs.Slug = "application-logs"; + savedLogs.Filter = "type:log level:error"; + savedLogs.ShowChart = false; + savedLogs.Columns = new Dictionary + { + ["summary"] = true, + ["date"] = true, + ["type"] = false + }; + savedLogs.ColumnOrder = ["summary", "date", "type"]; + await _savedViewRepository.SaveAsync(savedLogs, o => o.ImmediateConsistency()); + await RefreshDataAsync(); + + // Act + var promoted = await SendRequestAsAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("saved-views", logs.Id, "predefined") + .StatusCodeShouldBeOk() + ); + + Assert.NotNull(promoted); + Assert.Equal("Application Logs", promoted.Name); + Assert.Equal("application-logs", promoted.Slug); + + foreach (var savedView in predefinedViews) + await _savedViewRepository.RemoveAsync(savedView.Id, o => o.ImmediateConsistency()); + + await RefreshDataAsync(); + + var updatedPredefinedViews = await SendRequestAsAsync>(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views", "predefined") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(updatedPredefinedViews); + Assert.Equal(5, updatedPredefinedViews.Count); + var applicationLogs = updatedPredefinedViews.FirstOrDefault(view => IsPredefinedSavedView(view, "events", "Application Logs")); + Assert.NotNull(applicationLogs); + Assert.Equal("application-logs", applicationLogs.Slug); + 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.DoesNotContain(updatedPredefinedViews, view => IsPredefinedSavedView(view, "events", "Logs")); + Assert.Contains(updatedPredefinedViews, view => IsPredefinedSavedView(view, "events", "Errors")); + } + + [Fact] + public async Task PostPredefinedSavedViewAsync_AfterPatch_ExportsLatestConfiguration() + { + // Arrange + var predefinedViews = await SendRequestAsAsync>(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views", "predefined") + .StatusCodeShouldBeOk() + ); + + Assert.NotNull(predefinedViews); + var logs = predefinedViews.FirstOrDefault(view => IsPredefinedSavedView(view, "events", "Logs")); + Assert.NotNull(logs); + + await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("saved-views", logs.Id) + .StatusCodeShouldBeOk() + ); + + var changes = new UpdateSavedView + { + Filter = "type:log level:warn", + FilterDefinitions = """[{"type":"type","value":["log"],"hidden":true},{"type":"level","value":["Warn"]}]""" + }; + + await SendRequestAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", logs.Id) + .Content(changes) + .StatusCodeShouldBeOk() + ); + + // Act + await SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("saved-views", logs.Id, "predefined") + .StatusCodeShouldBeOk() + ); + + var definitions = await SendRequestAsAsync>(r => r + .AsGlobalAdminUser() + .AppendPaths("saved-views", "predefined") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(definitions); + var logsDefinition = definitions.FirstOrDefault(view => String.Equals(view.Key, "events:logs", StringComparison.OrdinalIgnoreCase)); + Assert.NotNull(logsDefinition); + Assert.Equal("type:log level:warn", logsDefinition.Filter); + Assert.NotNull(logsDefinition.FilterDefinitions); + + var filterDefinitions = logsDefinition.FilterDefinitions.Value; + Assert.Equal(JsonValueKind.Array, filterDefinitions.ValueKind); + Assert.Equal(2, filterDefinitions.GetArrayLength()); + Assert.True(filterDefinitions[0].GetProperty("hidden").GetBoolean()); + Assert.Equal("level", filterDefinitions[1].GetProperty("type").GetString()); + } + + [Fact] + public async Task PostPredefinedSavedViewAsync_User_ReturnsForbidden() + { + // Arrange + var created = await CreateSavedViewAsync("User View", "type:error", "events"); + Assert.NotNull(created); + + // Act & Assert + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("saved-views", created.Id, "predefined") + .StatusCodeShouldBeForbidden() + ); + } + + [Fact] + public async Task DeletePredefinedSavedViewAsync_GlobalAdmin_RemovesSeededPredefinedView() + { + // Arrange + var predefinedViews = await SendRequestAsAsync>(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views", "predefined") + .StatusCodeShouldBeOk() + ); + + Assert.NotNull(predefinedViews); + var logs = predefinedViews.FirstOrDefault(view => IsPredefinedSavedView(view, "events", "Logs")); + Assert.NotNull(logs); + + // Act + await SendRequestAsync(r => r + .Delete() + .AsGlobalAdminUser() + .AppendPaths("saved-views", logs.Id, "predefined") + .ExpectedStatus(HttpStatusCode.NoContent) + ); + + foreach (var savedView in predefinedViews) + await _savedViewRepository.RemoveAsync(savedView.Id, o => o.ImmediateConsistency()); + + await RefreshDataAsync(); + + var updatedPredefinedViews = await SendRequestAsAsync>(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views", "predefined") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(updatedPredefinedViews); + Assert.Equal(4, updatedPredefinedViews.Count); + Assert.DoesNotContain(updatedPredefinedViews, view => IsPredefinedSavedView(view, "events", "Logs")); + Assert.Contains(updatedPredefinedViews, view => IsPredefinedSavedView(view, "events", "Errors")); + + await GetService().SeedAsync(TestContext.Current.CancellationToken); + await RefreshDataAsync(); + + Assert.Equal(4, await _savedViewRepository.CountByOrganizationIdAsync(PredefinedSavedViewsDataSeed.SystemOrganizationId)); + } + + [Fact] + public async Task DeletePredefinedSavedViewAsync_User_ReturnsForbidden() + { + // Arrange + var created = await CreateSavedViewAsync("User View", "type:error", "events"); + Assert.NotNull(created); + + // Act & Assert + await SendRequestAsync(r => r + .Delete() + .AsTestOrganizationUser() + .AppendPaths("saved-views", created.Id, "predefined") + .StatusCodeShouldBeForbidden() + ); + } + [Fact] public async Task PatchAsync_UpdateName_UpdatesNameAndSetsUpdatedByUserId() { @@ -870,6 +1287,12 @@ public async Task RemoveUserSavedViews_WithMixedVisibility_OnlyDeletesPrivateVie return result; } + private async Task> GetSystemPredefinedSavedViewsAsync() + { + var results = await _savedViewRepository.GetByOrganizationForUserAsync(PredefinedSavedViewsDataSeed.SystemOrganizationId, PredefinedSavedViewsDataSeed.SystemUserId, o => o.PageLimit(1000)); + return results.Documents.Where(view => view.UserId is null).ToList(); + } + // CanUpdateAsync permission tests [Fact] @@ -1746,4 +2169,23 @@ public async Task RemoveByUserIdAsync_OnlyRemovesPrivateViewsForUser() Assert.NotNull(await _savedViewRepository.GetByIdAsync(publicView.Id)); } + private static bool IsPredefinedSavedView(ViewSavedView savedView, string viewType, string name) + { + return String.Equals(savedView.ViewType, viewType, StringComparison.OrdinalIgnoreCase) && String.Equals(savedView.Name, name, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsPredefinedSavedView(SavedView savedView, string viewType, string name) + { + return String.Equals(savedView.ViewType, viewType, StringComparison.OrdinalIgnoreCase) && String.Equals(savedView.Name, name, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsPredefinedSavedView(ViewSavedView savedView) + { + return IsPredefinedSavedView(savedView, "events", "Logs") + || IsPredefinedSavedView(savedView, "events", "Errors") + || IsPredefinedSavedView(savedView, "issues", "Most Frequent Errors") + || IsPredefinedSavedView(savedView, "issues", "Most Frequent 404s") + || IsPredefinedSavedView(savedView, "issues", "Most Used Features"); + } + } diff --git a/tests/http/saved-views.http b/tests/http/saved-views.http index 6e8a0322ae..ec930625ea 100644 --- a/tests/http/saved-views.http +++ b/tests/http/saved-views.http @@ -30,6 +30,14 @@ Authorization: Bearer {{token}} GET {{apiUrl}}/organizations/{{organizationId}}/saved-views/events Authorization: Bearer {{token}} +### Create or update predefined saved views +POST {{apiUrl}}/organizations/{{organizationId}}/saved-views/predefined +Authorization: Bearer {{token}} + +### Get global predefined saved views as seed JSON +GET {{apiUrl}}/saved-views/predefined +Authorization: Bearer {{token}} + ### Create organization-wide saved view # @name newSavedView POST {{apiUrl}}/organizations/{{organizationId}}/saved-views @@ -88,6 +96,14 @@ Content-Type: application/json "column_order": ["summary", "date", "user"] } +### Save saved view as a predefined saved view +POST {{apiUrl}}/saved-views/{{savedViewId}}/predefined +Authorization: Bearer {{token}} + +### Delete predefined saved view +DELETE {{apiUrl}}/saved-views/{{savedViewId}}/predefined +Authorization: Bearer {{token}} + ### Delete saved view DELETE {{apiUrl}}/saved-views/{{savedViewId}} Authorization: Bearer {{token}} From 05d07ddbbd763d345a249b10fc08d11135cb9a3b Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 24 May 2026 15:49:25 -0500 Subject: [PATCH 2/5] Address predefined saved view feedback --- .../Seed/PredefinedSavedViewsDataSeed.cs | 3 +- .../Controllers/SavedViewController.cs | 54 ++++++++++++++++--- .../Controllers/SavedViewControllerTests.cs | 8 ++- 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs b/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs index 34fe4d1072..3fed06e490 100644 --- a/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs +++ b/src/Exceptionless.Core/Seed/PredefinedSavedViewsDataSeed.cs @@ -59,7 +59,8 @@ public static async Task> Rea private static string GetSeedFilePath() { - return Path.Combine(AppContext.BaseDirectory, "Seed", SeedFileName); + var seedFileName = Path.GetFileName(SeedFileName); + return Path.Combine(AppContext.BaseDirectory, "Seed", seedFileName); } private static SavedView CreateSavedView(PredefinedSavedViewDefinition definition) diff --git a/src/Exceptionless.Web/Controllers/SavedViewController.cs b/src/Exceptionless.Web/Controllers/SavedViewController.cs index 4f19944abf..ecb0e2278a 100644 --- a/src/Exceptionless.Web/Controllers/SavedViewController.cs +++ b/src/Exceptionless.Web/Controllers/SavedViewController.cs @@ -433,14 +433,17 @@ private async Task> UpsertPredefinedSavedViewsAsy { List savedViews = []; - await _lockProvider.TryUsingAsync($"predefined-saved-views:{organizationId}", async () => + bool lockAcquired = await _lockProvider.TryUsingAsync($"predefined-saved-views:{organizationId}", async () => { var organization = await _organizationRepository.GetByIdAsync(organizationId); if (organization is null) return; if (onlyIfNeverCreated && HasCreatedPredefinedSavedViews(organization)) + { + savedViews = await GetExistingPredefinedSavedViewsForOrganizationAsync(organizationId); return; + } savedViews = await UpsertPredefinedSavedViewsForOrganizationAsync(organizationId); organization.Data ??= new DataDictionary(); @@ -448,6 +451,9 @@ await _lockProvider.TryUsingAsync($"predefined-saved-views:{organizationId}", as await _organizationRepository.SaveAsync(organization, o => o.Cache().ImmediateConsistency()); }, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(15)); + if (!lockAcquired) + return await GetExistingPredefinedSavedViewsForOrganizationAsync(organizationId); + return savedViews; } @@ -466,8 +472,7 @@ private async Task> UpsertPredefinedSavedViewsForOrganizationAsy savedViewsByView.Add(definition.ViewType, existingViews); } - var existing = existingViews.FirstOrDefault(view => view.UserId is null && String.Equals(view.PredefinedKey, definition.Key, StringComparison.OrdinalIgnoreCase)) - ?? existingViews.FirstOrDefault(view => view.UserId is null && String.IsNullOrWhiteSpace(view.PredefinedKey) && String.Equals(view.Name.Trim(), definition.Name, StringComparison.OrdinalIgnoreCase)); + var existing = FindPredefinedSavedView(definition, existingViews); var slug = GetUniqueSlug(definition.Slug, existingViews, existing?.Id); if (existing is null) @@ -491,9 +496,41 @@ private async Task> UpsertPredefinedSavedViewsForOrganizationAsy return upserted; } + private async Task> GetExistingPredefinedSavedViewsForOrganizationAsync(string organizationId) + { + var savedViewsByView = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var existingPredefinedViews = new List(); + + var definitions = await GetPredefinedSavedViewsAsync(); + foreach (var definition in definitions) + { + if (!savedViewsByView.TryGetValue(definition.ViewType, out var existingViews)) + { + var results = await _repository.GetByViewAsync(organizationId, definition.ViewType, o => o.PageLimit(1000)); + existingViews = results.Documents.ToList(); + savedViewsByView.Add(definition.ViewType, existingViews); + } + + var existing = FindPredefinedSavedView(definition, existingViews); + if (existing is not null) + existingPredefinedViews.Add(existing); + } + + return existingPredefinedViews; + } + + private static SavedView? FindPredefinedSavedView(PredefinedSavedViewDefinition definition, IReadOnlyCollection existingViews) + { + return existingViews.FirstOrDefault(view => view.UserId is null && String.Equals(view.PredefinedKey, definition.Key, StringComparison.OrdinalIgnoreCase)) + ?? existingViews.FirstOrDefault(view => view.UserId is null && String.IsNullOrWhiteSpace(view.PredefinedKey) && String.Equals(view.Name.Trim(), definition.Name, StringComparison.OrdinalIgnoreCase)); + } + private static bool HasCreatedPredefinedSavedViews(Organization organization) { - return organization.Data is not null && organization.Data.ContainsKey(PredefinedSavedViewsDataKey); + if (organization.Data is null || !organization.Data.TryGetValue(PredefinedSavedViewsDataKey, out object? versionValue)) + return false; + + return Int32.TryParse(versionValue?.ToString(), out int version) && version >= PredefinedSavedViewsVersion; } private SavedView CreatePredefinedSavedView(string organizationId, PredefinedSavedViewDefinition definition, string slug) @@ -698,11 +735,14 @@ private static bool SetListIfChanged(SavedView savedView, IReadOnlyCollection? left, IReadOnlyDictionary? right) { - if ((left?.Count ?? 0) != (right?.Count ?? 0)) + if (left is null) + return right is null; + + if (right is null) return false; - if (left is null || right is null) - return true; + if (left.Count != right.Count) + return false; return left.All(kvp => right.TryGetValue(kvp.Key, out var value) && value == kvp.Value); } diff --git a/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs index fd99787bf1..f4f80ab792 100644 --- a/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs @@ -89,8 +89,8 @@ public async Task GetPredefinedAsync_GlobalAdmin_ReturnsSeedJsonShape() Assert.Equal("logs", logs.Slug); Assert.Equal("events", logs.ViewType); Assert.Equal("type:log", logs.Filter); - Assert.NotNull(logs.FilterDefinitions); - Assert.Equal(JsonValueKind.Array, logs.FilterDefinitions.Value.ValueKind); + var filterDefinitions = logs.FilterDefinitions ?? throw new Xunit.Sdk.XunitException("Expected FilterDefinitions to be non-null."); + Assert.Equal(JsonValueKind.Array, filterDefinitions.ValueKind); } [Fact] @@ -765,9 +765,7 @@ await SendRequestAsync(r => r var logsDefinition = definitions.FirstOrDefault(view => String.Equals(view.Key, "events:logs", StringComparison.OrdinalIgnoreCase)); Assert.NotNull(logsDefinition); Assert.Equal("type:log level:warn", logsDefinition.Filter); - Assert.NotNull(logsDefinition.FilterDefinitions); - - var filterDefinitions = logsDefinition.FilterDefinitions.Value; + var filterDefinitions = logsDefinition.FilterDefinitions ?? throw new Xunit.Sdk.XunitException("Expected FilterDefinitions to be non-null."); Assert.Equal(JsonValueKind.Array, filterDefinitions.ValueKind); Assert.Equal(2, filterDefinitions.GetArrayLength()); Assert.True(filterDefinitions[0].GetProperty("hidden").GetBoolean()); From 05f96ba202f7b686fd365fde73768bf94a95b95f Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 24 May 2026 16:36:54 -0500 Subject: [PATCH 3/5] Format frontend saved view changes --- .../organization-notifications.svelte | 5 +- .../lib/features/saved-views/api.svelte.ts | 57 ++++++++++--------- .../components/saved-view-picker.svelte | 6 +- .../src/routes/(app)/events/+page.svelte | 2 +- .../[organizationId]/features/+page.svelte | 2 +- .../(app)/organization/add/+page.svelte | 2 +- .../[projectId]/configure/+page.svelte | 1 - 7 files changed, 38 insertions(+), 37 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte index fa12a8bba7..8b3f7da879 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte @@ -8,6 +8,7 @@ import { getMeQuery } from '$features/users/api.svelte'; import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models'; import { useEventListener } from 'runed'; + import { SvelteSet } from 'svelte/reactivity'; import { debounce } from 'throttle-debounce'; import FreePlanNotification from './notifications/free-plan-notification.svelte'; @@ -68,7 +69,7 @@ const organization = $derived(organizationQuery.data); const projects = $derived((projectsQuery.data?.data ?? []).filter((p) => p.organization_id === currentOrganizationId.current)); - let configuredProjectIds = $state(new Set()); + let configuredProjectIds = new SvelteSet(); const projectsNeedingConfig = $derived(projects.filter((p) => p.is_configured === false && !configuredProjectIds.has(p.id!))); const suspensionCode: SuspensionCode | undefined = $derived( @@ -105,7 +106,7 @@ return; } - configuredProjectIds = new Set(configuredProjectIds).add(message.project_id); + configuredProjectIds.add(message.project_id); void refetchConfigurationState(); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts index 8ab4db7e8d..71d980a81c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts @@ -58,6 +58,18 @@ export const queryKeys = { let deletedSavedViewIds = $state([]); +export function deletePredefinedSavedView(request: { route: { id: string | undefined } }) { + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.id, + mutationFn: async () => { + const client = useFetchClient(); + await client.delete(`saved-views/${request.route.id}/predefined`, { + expectedStatusCodes: [204] + }); + } + })); +} + export function deleteSavedView(request: { route: { organizationId: string | undefined } }) { const queryClient = useQueryClient(); @@ -138,18 +150,13 @@ export function patchSavedView(request: { route: { id: string | undefined } }) { })); } -export function postSavedView(request: { route: { organizationId: string | undefined } }) { - const queryClient = useQueryClient(); - - return createMutation(() => ({ - enabled: () => !!accessToken.current && !!request.route.organizationId, - mutationFn: async (data: NewSavedView) => { +export function postPredefinedSavedView(request: { route: { id: string | undefined } }) { + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.id, + mutationFn: async () => { const client = useFetchClient(); - const response = await client.postJSON(`organizations/${request.route.organizationId}/saved-views`, data); + const response = await client.postJSON(`saved-views/${request.route.id}/predefined`, {}); return response.data!; - }, - onSuccess: (savedView: SavedView) => { - syncSavedViewCaches(queryClient, savedView, request.route.organizationId); } })); } @@ -172,32 +179,26 @@ export function postPredefinedSavedViews(request: { route: { organizationId: str } void queryClient.invalidateQueries({ queryKey: queryKeys.organization(request.route.organizationId) }); - for (const view of new Set(savedViews.map((savedView) => savedView.view_type))) { + const viewTypes = savedViews.map((savedView) => savedView.view_type).filter((view, index, views) => views.indexOf(view) === index); + for (const view of viewTypes) { void queryClient.invalidateQueries({ queryKey: queryKeys.view(request.route.organizationId, view) }); } } })); } -export function postPredefinedSavedView(request: { route: { id: string | undefined } }) { - return createMutation(() => ({ - enabled: () => !!accessToken.current && !!request.route.id, - mutationFn: async () => { - const client = useFetchClient(); - const response = await client.postJSON(`saved-views/${request.route.id}/predefined`, {}); - return response.data!; - } - })); -} +export function postSavedView(request: { route: { organizationId: string | undefined } }) { + const queryClient = useQueryClient(); -export function deletePredefinedSavedView(request: { route: { id: string | undefined } }) { - return createMutation(() => ({ - enabled: () => !!accessToken.current && !!request.route.id, - mutationFn: async () => { + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.organizationId, + mutationFn: async (data: NewSavedView) => { const client = useFetchClient(); - await client.delete(`saved-views/${request.route.id}/predefined`, { - expectedStatusCodes: [204] - }); + const response = await client.postJSON(`organizations/${request.route.organizationId}/saved-views`, data); + return response.data!; + }, + onSuccess: (savedView: SavedView) => { + syncSavedViewCaches(queryClient, savedView, request.route.organizationId); } })); } 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 4bb1a6687c..43ceebd333 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 @@ -147,9 +147,9 @@ const saving = $derived( createMutation.isPending || updateMutation.isPending || - predefinedViewUpdateMutation.isPending || - predefinedViewMutation.isPending || - deletePredefinedViewMutation.isPending || + predefinedViewUpdateMutation.isPending || + predefinedViewMutation.isPending || + deletePredefinedViewMutation.isPending || removeMutation.isPending ); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/+page.svelte index 55ca7873b2..1baf6d472e 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/+page.svelte @@ -21,8 +21,8 @@ filterChanged, filterRemoved, getFiltersFromCache, - shouldRefreshPersistentEventChanged, serializeFilters, + shouldRefreshPersistentEventChanged, toFilter, updateFilterCache } from '$features/events/components/filters/helpers.svelte'; diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte index 5ef62bd89c..f4e7779b3c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte @@ -101,7 +101,7 @@ Update predefined saved views without removing custom saved views. -
+