diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 99de9e2946..8cbdf4a04a 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -32,9 +32,9 @@ jobs: run: | export PATH="$HOME/.dotnet/tools:$PATH" if dotnet tool list --global | grep -Eiq '^aspire\.cli[[:space:]]'; then - dotnet tool update --global Aspire.Cli --version 13.2.4 + dotnet tool update --global Aspire.Cli --version 13.3.3 else - dotnet tool install --global Aspire.Cli --version 13.2.4 + dotnet tool install --global Aspire.Cli --version 13.3.3 fi echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" aspire --version diff --git a/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj b/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj index 0510e6e08c..2644129894 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/Models/SavedView.cs b/src/Exceptionless.Core/Models/SavedView.cs index d152e0c76e..a7f1cb7646 100644 --- a/src/Exceptionless.Core/Models/SavedView.cs +++ b/src/Exceptionless.Core/Models/SavedView.cs @@ -56,6 +56,10 @@ public record SavedView : IOwnedByOrganizationWithIdentity, IHaveDates [MaxLength(100)] public string? Time { get; set; } + /// Sort expression for the dashboard table, e.g. "-date". + [MaxLength(100)] + public string? Sort { get; set; } + /// Schema version for future filter definition migrations. public int Version { get; set; } = 1; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/boolean-faceted-filter.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/boolean-faceted-filter.svelte index f09de76261..1b2cb49c05 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/boolean-faceted-filter.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/boolean-faceted-filter.svelte @@ -6,6 +6,11 @@ import type { BooleanFilter } from './models.svelte'; let { filter, filterChanged, filterRemoved, ...props }: FacetedFilterProps = $props(); + + function toggleHidden() { + filter.hidden = !filter.hidden; + filterChanged(filter); + } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/date-faceted-filter.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/date-faceted-filter.svelte index 9d7dda02f4..47758b9ce1 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/date-faceted-filter.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/date-faceted-filter.svelte @@ -12,7 +12,7 @@ import { DateFilter } from './models.svelte'; - let { filter, filterChanged, open = $bindable(false), title = 'Date Range' }: FacetedFilterProps = $props(); + let { filter, filterChanged, filterRemoved, open = $bindable(false), title = 'Date Range' }: FacetedFilterProps = $props(); let dateRangePickerRef: DateRangePickerType | undefined = $state(); let shouldApply = $state(true); @@ -36,6 +36,25 @@ shouldApply = false; } } + + function onClearFilter() { + shouldApply = false; + filter.value = undefined; + filterChanged(filter); + open = false; + } + + function onRemoveFilter() { + shouldApply = false; + filterRemoved(filter); + open = false; + } + + function toggleHidden() { + shouldApply = false; + filter.hidden = !filter.hidden; + filterChanged(filter); + } @@ -55,5 +74,6 @@
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts index 1ac9668db7..051dc8122c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { deserializeFilters, quoteIfSpecialCharacters, serializeFilters } from './helpers.svelte'; +import { deserializeFilters, quoteIfSpecialCharacters, serializeFilters, toFilter } from './helpers.svelte'; import { BooleanFilter, DateFilter, @@ -111,6 +111,14 @@ describe('serializeFilters', () => { expect(result[0]).toEqual({ type: 'project', value: ['proj1', 'proj2'] }); }); + it('serializes hidden filters when hidden is true', () => { + const filter = new ProjectFilter(['proj1']); + filter.hidden = true; + const result = JSON.parse(serializeFilters([filter])); + + expect(result[0]).toEqual({ hidden: true, type: 'project', value: ['proj1'] }); + }); + it('serializes a ReferenceFilter with value', () => { const filters = [new ReferenceFilter('ref-123')]; const result = JSON.parse(serializeFilters(filters)); @@ -234,6 +242,14 @@ describe('deserializeFilters', () => { expect((filters[0] as ProjectFilter).value).toEqual(['p1', 'p2']); }); + it('deserializes hidden filters', () => { + const filters = deserializeFilters('[{"type":"project","value":["p1"],"hidden":true}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(ProjectFilter); + expect(filters[0]?.hidden).toBe(true); + }); + it('deserializes a ReferenceFilter', () => { const filters = deserializeFilters('[{"type":"reference","value":"ref-123"}]'); @@ -301,6 +317,19 @@ describe('deserializeFilters', () => { }); describe('round-trip serialization', () => { + it('round-trips hidden state without changing filter output', () => { + const projectFilter = new ProjectFilter(['proj1']); + projectFilter.hidden = true; + const originalFilterString = toFilter([projectFilter]); + + const result = deserializeFilters(serializeFilters([projectFilter])); + + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(ProjectFilter); + expect(result[0]?.hidden).toBe(true); + expect(toFilter(result)).toBe(originalFilterString); + }); + it('round-trips a BooleanFilter', () => { const original = [new BooleanFilter('is_fixed', true)]; const result = deserializeFilters(serializeFilters(original)); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts index 93b2597290..3e89d5c08c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts @@ -30,6 +30,7 @@ export function filterCacheVersionNumber() { const filterCache = new SvelteMap(); interface SerializedFilter { + hidden?: boolean; term?: string; type: string; value?: unknown; @@ -155,6 +156,10 @@ export function serializeFilters(filters: IFilter[]): string { entry.value = (filter as { value?: unknown }).value; } + if (filter.hidden) { + entry.hidden = true; + } + return entry; }); @@ -238,6 +243,8 @@ function processFilterRules(filters: IFilter[]): IFilter[] { } else if (filter.value !== undefined) { existingFilter.value = filter.value; } + + existingFilter.hidden = filter.hidden; } else { throw new Error('Unable to merge filters'); } @@ -253,34 +260,54 @@ function processFilterRules(filters: IFilter[]): IFilter[] { } function reconstructFilter(data: SerializedFilter): IFilter | null { + let filter: IFilter | null; switch (data.type) { case 'boolean': - return new BooleanFilter(data.term, data.value as boolean | undefined); + filter = new BooleanFilter(data.term, data.value as boolean | undefined); + break; case 'date': - return new DateFilter(data.term, data.value as Date | string | undefined); + filter = new DateFilter(data.term, data.value as Date | string | undefined); + break; case 'keyword': - return new KeywordFilter(data.value as string | undefined); + filter = new KeywordFilter(data.value as string | undefined); + break; case 'level': - return new LevelFilter(data.value as LogLevel[] | undefined); + filter = new LevelFilter(data.value as LogLevel[] | undefined); + break; case 'number': - return new NumberFilter(data.term, data.value as number | undefined); + filter = new NumberFilter(data.term, data.value as number | undefined); + break; case 'project': - return new ProjectFilter(data.value as string[] | undefined); + filter = new ProjectFilter(data.value as string[] | undefined); + break; case 'reference': - return new ReferenceFilter(data.value as string | undefined); + filter = new ReferenceFilter(data.value as string | undefined); + break; case 'session': - return new SessionFilter(data.value as string | undefined); + filter = new SessionFilter(data.value as string | undefined); + break; case 'status': - return new StatusFilter(data.value as StackStatus[] | undefined); + filter = new StatusFilter(data.value as StackStatus[] | undefined); + break; case 'string': - return new StringFilter(data.term, data.value as string | undefined); + filter = new StringFilter(data.term, data.value as string | undefined); + break; case 'tag': - return new TagFilter(data.value as PersistentEventKnownTypes[] | undefined); + filter = new TagFilter(data.value as PersistentEventKnownTypes[] | undefined); + break; case 'type': - return new TypeFilter(data.value as PersistentEventKnownTypes[] | undefined); + filter = new TypeFilter(data.value as PersistentEventKnownTypes[] | undefined); + break; case 'version': - return new VersionFilter(data.term, data.value as string | undefined); + filter = new VersionFilter(data.term, data.value as string | undefined); + break; default: - return null; + filter = null; } + + if (filter) { + filter.hidden = data.hidden === true; + } + + return filter; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/keyword-faceted-filter.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/keyword-faceted-filter.svelte index 7fa08a1dd0..330b2a066e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/keyword-faceted-filter.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/keyword-faceted-filter.svelte @@ -6,6 +6,11 @@ import { KeywordFilter } from './models.svelte'; let { filter, filterChanged, filterRemoved, title = 'Keyword', ...props }: FacetedFilterProps = $props(); + + function toggleHidden() { + filter.hidden = !filter.hidden; + filterChanged(filter); + } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/level-faceted-filter.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/level-faceted-filter.svelte index 3514bc25d5..81ef5c9475 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/level-faceted-filter.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/level-faceted-filter.svelte @@ -7,6 +7,11 @@ import { LevelFilter } from './models.svelte'; let { filter, filterChanged, filterRemoved, title = 'Log Level', ...props }: FacetedFilterProps = $props(); + + function toggleHidden() { + filter.hidden = !filter.hidden; + filterChanged(filter); + } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/models.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/models.svelte.ts index a9706ab8b2..b40082a77c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/models.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/models.svelte.ts @@ -6,6 +6,7 @@ import type { StackStatus } from '$features/stacks/models'; import { quoteIfSpecialCharacters } from './helpers.svelte'; export class BooleanFilter implements IFilter { + public hidden = $state(false); public id: string = crypto.randomUUID(); public term = $state(); public type: string = 'boolean'; @@ -23,6 +24,7 @@ export class BooleanFilter implements IFilter { public clone(): IFilter { const filter = new BooleanFilter(this.term, this.value); + filter.hidden = this.hidden; filter.id = this.id; return filter; } @@ -41,6 +43,7 @@ export class BooleanFilter implements IFilter { } export class DateFilter implements IFilter { + public hidden = $state(false); public id: string = crypto.randomUUID(); public term = $state(); public type: string = 'date'; @@ -58,6 +61,7 @@ export class DateFilter implements IFilter { public clone(): IFilter { const filter = new DateFilter(this.term, this.value); + filter.hidden = this.hidden; filter.id = this.id; return filter; } @@ -77,6 +81,7 @@ export class DateFilter implements IFilter { } export class KeywordFilter implements IFilter { + public hidden = $state(false); public id: string = crypto.randomUUID(); public type: string = 'keyword'; @@ -92,6 +97,7 @@ export class KeywordFilter implements IFilter { public clone(): IFilter { const filter = new KeywordFilter(this.value); + filter.hidden = this.hidden; filter.id = this.id; return filter; } @@ -106,6 +112,7 @@ export class KeywordFilter implements IFilter { } export class LevelFilter implements IFilter { + public hidden = $state(false); public id: string = crypto.randomUUID(); public type: string = 'level'; @@ -121,6 +128,7 @@ export class LevelFilter implements IFilter { public clone(): IFilter { const filter = new LevelFilter(this.value); + filter.hidden = this.hidden; filter.id = this.id; return filter; } @@ -139,6 +147,7 @@ export class LevelFilter implements IFilter { } export class NumberFilter implements IFilter { + public hidden = $state(false); public id: string = crypto.randomUUID(); public term = $state(); public type: string = 'number'; @@ -156,6 +165,7 @@ export class NumberFilter implements IFilter { public clone(): IFilter { const filter = new NumberFilter(this.term, this.value); + filter.hidden = this.hidden; filter.id = this.id; return filter; } @@ -174,6 +184,7 @@ export class NumberFilter implements IFilter { } export class ProjectFilter implements IFilter { + public hidden = $state(false); public id: string = crypto.randomUUID(); public type: string = 'project'; @@ -189,6 +200,7 @@ export class ProjectFilter implements IFilter { public clone(): IFilter { const filter = new ProjectFilter(this.value); + filter.hidden = this.hidden; filter.id = this.id; return filter; } @@ -207,6 +219,7 @@ export class ProjectFilter implements IFilter { } export class ReferenceFilter implements IFilter { + public hidden = $state(false); public id: string = crypto.randomUUID(); public type: string = 'reference'; @@ -222,6 +235,7 @@ export class ReferenceFilter implements IFilter { public clone(): IFilter { const filter = new ReferenceFilter(this.value); + filter.hidden = this.hidden; filter.id = this.id; return filter; } @@ -236,6 +250,7 @@ export class ReferenceFilter implements IFilter { } export class SessionFilter implements IFilter { + public hidden = $state(false); public id: string = crypto.randomUUID(); public type: string = 'session'; @@ -251,6 +266,7 @@ export class SessionFilter implements IFilter { public clone(): IFilter { const filter = new SessionFilter(this.value); + filter.hidden = this.hidden; filter.id = this.id; return filter; } @@ -266,6 +282,7 @@ export class SessionFilter implements IFilter { } export class StatusFilter implements IFilter { + public hidden = $state(false); public id: string = crypto.randomUUID(); public type: string = 'status'; @@ -281,6 +298,7 @@ export class StatusFilter implements IFilter { public clone(): IFilter { const filter = new StatusFilter(this.value); + filter.hidden = this.hidden; filter.id = this.id; return filter; } @@ -299,6 +317,7 @@ export class StatusFilter implements IFilter { } export class StringFilter implements IFilter { + public hidden = $state(false); public id: string = crypto.randomUUID(); public term = $state(); public type: string = 'string'; @@ -316,6 +335,7 @@ export class StringFilter implements IFilter { public clone(): IFilter { const filter = new StringFilter(this.term, this.value); + filter.hidden = this.hidden; filter.id = this.id; return filter; } @@ -334,6 +354,7 @@ export class StringFilter implements IFilter { } export class TagFilter implements IFilter { + public hidden = $state(false); public id: string = crypto.randomUUID(); public type: string = 'tag'; @@ -349,6 +370,7 @@ export class TagFilter implements IFilter { public clone(): IFilter { const filter = new TagFilter(this.value); + filter.hidden = this.hidden; filter.id = this.id; return filter; } @@ -367,6 +389,7 @@ export class TagFilter implements IFilter { } export class TypeFilter implements IFilter { + public hidden = $state(false); public id: string = crypto.randomUUID(); public type: string = 'type'; @@ -382,6 +405,7 @@ export class TypeFilter implements IFilter { public clone(): IFilter { const filter = new TypeFilter(this.value); + filter.hidden = this.hidden; filter.id = this.id; return filter; } @@ -400,6 +424,7 @@ export class TypeFilter implements IFilter { } export class VersionFilter implements IFilter { + public hidden = $state(false); public id: string = crypto.randomUUID(); public term = $state(); public type: string = 'version'; @@ -417,6 +442,7 @@ export class VersionFilter implements IFilter { public clone(): IFilter { const filter = new VersionFilter(this.term, this.value); + filter.hidden = this.hidden; filter.id = this.id; return filter; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/number-faceted-filter.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/number-faceted-filter.svelte index 1dcdc6bbd6..6911ca0ed4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/number-faceted-filter.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/number-faceted-filter.svelte @@ -6,6 +6,11 @@ import { NumberFilter } from './models.svelte'; let { filter, filterChanged, filterRemoved, ...props }: FacetedFilterProps = $props(); + + function toggleHidden() { + filter.hidden = !filter.hidden; + filterChanged(filter); + } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/project-faceted-filter.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/project-faceted-filter.svelte index 6bcb419826..9e9d961e19 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/project-faceted-filter.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/project-faceted-filter.svelte @@ -9,6 +9,11 @@ let { filter, filterChanged, filterRemoved, open = $bindable(false), title = 'Project', ...props }: FacetedFilterProps = $props(); + function toggleHidden() { + filter.hidden = !filter.hidden; + filterChanged(filter); + } + // Create query with conditional enabled - only fetch when dropdown is open and organization is available. // The organizationId getter ensures reactive updates when the organization changes. const projectsQuery = getOrganizationProjectsQuery({ @@ -54,7 +59,9 @@ filter.value = []; filterRemoved(filter); }} + hidden={filter.hidden} {title} + {toggleHidden} values={filter.value} {...props} > diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/reference-faceted-filter.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/reference-faceted-filter.svelte index 566e9d28df..a79fd84d6f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/reference-faceted-filter.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/reference-faceted-filter.svelte @@ -6,6 +6,11 @@ import { ReferenceFilter } from './models.svelte'; let { filter, filterChanged, filterRemoved, title = 'Reference', ...props }: FacetedFilterProps = $props(); + + function toggleHidden() { + filter.hidden = !filter.hidden; + filterChanged(filter); + } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/session-faceted-filter.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/session-faceted-filter.svelte index 74ded071eb..f751172493 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/session-faceted-filter.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/session-faceted-filter.svelte @@ -6,6 +6,11 @@ import { SessionFilter } from './models.svelte'; let { filter, filterChanged, filterRemoved, title = 'Session', ...props }: FacetedFilterProps = $props(); + + function toggleHidden() { + filter.hidden = !filter.hidden; + filterChanged(filter); + } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/status-faceted-filter.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/status-faceted-filter.svelte index 97d0d94dd7..5ef6b5f0ef 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/status-faceted-filter.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/status-faceted-filter.svelte @@ -8,6 +8,11 @@ import { StatusFilter } from './models.svelte'; let { filter, filterChanged, filterRemoved, title = 'Status', ...props }: FacetedFilterProps = $props(); + + function toggleHidden() { + filter.hidden = !filter.hidden; + filterChanged(filter); + } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/string-faceted-filter.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/string-faceted-filter.svelte index 161c2ddc9e..ec7188000c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/string-faceted-filter.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/string-faceted-filter.svelte @@ -6,6 +6,11 @@ import { StringFilter } from './models.svelte'; let { filter, filterChanged, filterRemoved, ...props }: FacetedFilterProps = $props(); + + function toggleHidden() { + filter.hidden = !filter.hidden; + filterChanged(filter); + } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte index 4392dd4602..c686d92e84 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte @@ -10,6 +10,11 @@ let { filter, filterChanged, filterRemoved, open = $bindable(false), title = 'Tag', ...props }: FacetedFilterProps = $props(); + function toggleHidden() { + filter.hidden = !filter.hidden; + filterChanged(filter); + } + // Store the organizationId to prevent loading when switching organizations. const organizationId = organization.current; @@ -59,7 +64,9 @@ filter.value = []; filterRemoved(filter); }} + hidden={filter.hidden} {title} + {toggleHidden} values={filter.value} {...props} > diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/type-faceted-filter.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/type-faceted-filter.svelte index 7e5957cbbe..92f815edb2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/type-faceted-filter.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/type-faceted-filter.svelte @@ -7,6 +7,11 @@ import { TypeFilter } from './models.svelte'; let { filter, filterChanged, filterRemoved, title = 'Type', ...props }: FacetedFilterProps = $props(); + + function toggleHidden() { + filter.hidden = !filter.hidden; + filterChanged(filter); + } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/version-faceted-filter.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/version-faceted-filter.svelte index d352a618e4..859ea36c65 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/version-faceted-filter.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/version-faceted-filter.svelte @@ -6,6 +6,11 @@ import type { VersionFilter } from './models.svelte'; let { filter, filterChanged, filterRemoved, ...props }: FacetedFilterProps = $props(); + + function toggleHidden() { + filter.hidden = !filter.hidden; + filterChanged(filter); + } 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 7fc609af48..29dff9b6d7 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 @@ -7,24 +7,42 @@ import { createMutation, createQuery, type QueryClient, useQueryClient } from '@ import type { NewSavedView, SavedView, UpdateSavedView } from './models'; +export const SAVED_VIEW_REFRESH_DELAY_MS = 1500; + export async function invalidateSavedViewQueries(queryClient: QueryClient, message: WebSocketMessageValue<'SavedViewChanged'>) { const { change_type, id, organization_id } = message; // Removals: evict from cache immediately without a refetch. - if (change_type === ChangeType.Removed && id && organization_id) { - const cached = queryClient.getQueryData(queryKeys.organization(organization_id)); - const savedView = cached?.find((v) => v.id === id); - if (savedView) { - removeSavedViewFromCaches(queryClient, savedView, organization_id); - return; + if (change_type === ChangeType.Removed) { + if (id && organization_id) { + const cached = queryClient.getQueryData(queryKeys.organization(organization_id)); + const savedView = cached?.find((v) => v.id === id); + if (savedView) { + removeSavedViewFromCaches(queryClient, savedView, organization_id); + return; + } } + + await invalidateSavedViewCache(queryClient, organization_id); + return; + } + + // Added/Saved websocket events can arrive before Elasticsearch refresh exposes the + // saved view to list queries. Mutations already seed the cache, so keep that optimistic + // item visible and refetch after the refresh window. + if (change_type === ChangeType.Added || change_type === ChangeType.Saved) { + setTimeout(() => { + void invalidateSavedViewCache(queryClient, organization_id); + }, SAVED_VIEW_REFRESH_DELAY_MS); + return; } - // Added/Saved: mutations already wrote optimistic updates via syncSavedViewCaches so the - // UI is immediately correct. The WS event arriving signals ES has committed the change, - // so we invalidate now to pull the authoritative ES data into the cache. - if (organization_id) { - await queryClient.invalidateQueries({ queryKey: queryKeys.organization(organization_id) }); + await invalidateSavedViewCache(queryClient, organization_id); +} + +async function invalidateSavedViewCache(queryClient: QueryClient, organizationId: string | undefined) { + if (organizationId) { + await queryClient.invalidateQueries({ queryKey: queryKeys.organization(organizationId) }); } else { await queryClient.invalidateQueries({ queryKey: queryKeys.type }); } @@ -107,7 +125,7 @@ export function postSavedView(request: { route: { organizationId: string | undef return response.data!; }, onSuccess: (savedView: SavedView) => { - syncSavedViewCaches(queryClient, 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 4aa3e56492..fabf5aaa6a 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 @@ -1,28 +1,30 @@ - + + {#snippet child({ props })} - {/snippet} - - {#if activeSavedView && isModified} - - Modified View - - - Update "{activeSavedView.name}" - - - - Save as new view - - - - Reset to saved - - - - {/if} - - {#if savedViews.length === 0} - - No saved views yet - - - {:else} - - Saved Views - {#each sortedViews as savedView (savedView.id)} - handleSelect(savedView)}> - - - {#if activeView?.id === savedView.id} - - {/if} - - - - {savedView.name} - {#if savedView.is_default} - default - {/if} - {#if savedView.user_id} - private - {/if} - - {#if savedView.filter || savedView.time} - - - {#snippet child({ props: tipProps })} - - {formatViewSummary(savedView)} - - {/snippet} - - - {#if savedView.filter} -

{savedView.filter}

- {/if} - {#if savedView.time} - {timeLabels.get(savedView.time) ?? savedView.time} - {/if} -
-
- {:else} - No filters - {/if} -
-
- -
- {/each} -
- - {/if} - + - {#if duplicateView && !activeSavedView} - handleSelect(duplicateView)}> - - Load "{duplicateView.name}" (matches current) - - {/if} - {#if !activeView} - - - Save current view + Saved View + {#if activeView} + + {/if} + + {#if activeView} - - - Rename "{activeView.name}" + + {#if !activeView.user_id} {#if activeView.is_default} - + {/if} - Clear Saved View + + + + openDeleteDialog(activeView)}> - + {/if} + {#if hideableColumns.length > 0} + + + Columns + {#each hideableColumns as column (column.id)} + column.toggleVisibility()}> + {column.columnDef.header} + + {/each} + + {/if}
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts index 7151ccdef3..8491fdbe24 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts @@ -15,12 +15,14 @@ const SAVED_VIEWS_FEATURE = 'feature-saved-views'; export interface SavedViewQueryParams { filter: null | string; saved: null | string | undefined; + sort?: null | string; time?: null | string; } export interface UseSavedViewsOptions { filterCacheKey: (filter: null | string) => string; getColumnVisibility?: () => ColumnVisibilityState; + getFilterDefinitions?: () => string; queryParams: SavedViewQueryParams; setColumnVisibility?: (visibility: ColumnVisibilityState) => void; updateFilterCache: (key: string, filters: IFilter[]) => void; @@ -37,12 +39,22 @@ export interface UseSavedViewsReturn { savedViews: SavedView[]; } +export function setSortQueryParam(queryParams: SavedViewQueryParams, value: null | string): void { + if (supportsSortQueryParam(queryParams)) { + queryParams.sort = value; + } +} + export function setTimeQueryParam(queryParams: SavedViewQueryParams, value: null | string): void { if (supportsTimeQueryParam(queryParams)) { queryParams.time = value; } } +export function supportsSortQueryParam(queryParams: SavedViewQueryParams): queryParams is SavedViewQueryParams & { sort: null | string | undefined } { + return Object.prototype.hasOwnProperty.call(queryParams, 'sort'); +} + export function supportsTimeQueryParam(queryParams: SavedViewQueryParams): queryParams is SavedViewQueryParams & { time: null | string | undefined } { return Object.prototype.hasOwnProperty.call(queryParams, 'time'); } @@ -59,7 +71,8 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur // Feature flag gate: only enable saved views if the organization has the feature const isEnabled = $derived(organizationQuery.data?.features?.includes(SAVED_VIEWS_FEATURE) ?? false); - // Some routes, such as stream, do not declare a time query parameter. + // Some routes, such as stream, do not declare every saved-view query parameter. + const supportsSort = supportsSortQueryParam(options.queryParams); const supportsTime = supportsTimeQueryParam(options.queryParams); const savedViewsListQuery = getSavedViewsByViewQuery({ @@ -106,6 +119,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur options.queryParams.saved = null; }); options.queryParams.filter = null; + setSortQueryParam(options.queryParams, null); setTimeQueryParam(options.queryParams, null); hasAutoRestored = false; return; @@ -128,6 +142,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur } options.queryParams.filter = view.filter ?? null; + setSortQueryParam(options.queryParams, view.sort ?? null); setTimeQueryParam(options.queryParams, view.time ?? null); if (view.columns && options.setColumnVisibility) { @@ -163,7 +178,8 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur hasAutoRestored = true; const search = window.location.search; - const hasExplicitParams = /[?&]saved(?:[=&]|$)/.test(search) || /[?&]filter(?:[=&]|$)/.test(search) || /[?&]time(?:[=&]|$)/.test(search); + const hasExplicitParams = + /[?&]saved(?:[=&]|$)/.test(search) || /[?&]filter(?:[=&]|$)/.test(search) || /[?&]sort(?:[=&]|$)/.test(search) || /[?&]time(?:[=&]|$)/.test(search); if (hasExplicitParams) { return; } @@ -191,6 +207,14 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur return true; } + if (supportsSort && (options.queryParams.sort ?? null) !== (view.sort ?? null)) { + return true; + } + + if (options.getFilterDefinitions && view.filter_definitions && !filterDefinitionsEqual(options.getFilterDefinitions(), view.filter_definitions)) { + return true; + } + if (options.getColumnVisibility && !columnsEqual(options.getColumnVisibility(), view.columns)) { return true; } @@ -218,6 +242,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur } options.queryParams.filter = view.filter ?? null; + setSortQueryParam(options.queryParams, view.sort ?? null); setTimeQueryParam(options.queryParams, view.time ?? null); if (view.columns && options.setColumnVisibility) { options.setColumnVisibility(view.columns); @@ -227,6 +252,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur function handleClearSavedView() { options.queryParams.saved = null; options.queryParams.filter = null; + setSortQueryParam(options.queryParams, null); setTimeQueryParam(options.queryParams, null); if (options.setColumnVisibility) { options.setColumnVisibility({}); @@ -265,3 +291,24 @@ function columnsEqual(a: ColumnVisibilityState | undefined, b: null | Record { + vi.useRealTimers(); +}); + function buildSavedView({ id, name, ...overrides }: Partial & Pick): SavedView { return { columns: {}, @@ -21,6 +25,7 @@ function buildSavedView({ id, name, ...overrides }: Partial & Pick { }); }); + describe('sort parameter detection', () => { + it('detects when sort is not in query params', () => { + // Arrange + const queryParamsWithoutSort: SavedViewQueryParams = { + filter: null, + saved: undefined + }; + + // Act + const supportsSort = supportsSortQueryParam(queryParamsWithoutSort); + + // Assert + expect(supportsSort).toBe(false); + }); + + it('detects when sort is in query params', () => { + // Arrange + const queryParamsWithSort: SavedViewQueryParams = { + filter: null, + saved: undefined, + sort: '-date' + }; + + // Act + const supportsSort = supportsSortQueryParam(queryParamsWithSort); + + // Assert + expect(supportsSort).toBe(true); + }); + }); + + describe('sort parameter updates', () => { + it('does not write sort when the route does not support it', () => { + // Arrange + const target: SavedViewQueryParams = { + filter: null, + saved: undefined + }; + const queryParams = new Proxy(target, { + set(obj, prop, value) { + if (prop === 'sort') { + throw new Error(`unexpected sort assignment: ${String(value)}`); + } + + return Reflect.set(obj, prop, value); + } + }) as SavedViewQueryParams; + + // Act & Assert + expect(() => { + setSortQueryParam(queryParams, null); + }).not.toThrow(); + expect('sort' in target).toBe(false); + }); + + it('updates sort when the route supports it', () => { + // Arrange + const queryParams: SavedViewQueryParams = { + filter: null, + saved: undefined, + sort: undefined + }; + + // Act + setSortQueryParam(queryParams, '-date'); + + // Assert + expect(queryParams.sort).toBe('-date'); + }); + }); + describe('saved view websocket invalidation', () => { - it('invalidates immediately for Added events', async () => { + it('delays invalidation for Added events so optimistic caches stay visible', async () => { // Arrange + vi.useFakeTimers(); const queryClient = new QueryClient(); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries').mockImplementation(async () => {}); @@ -148,11 +225,15 @@ describe('useSavedViews', () => { }); // Assert + expect(invalidateSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(SAVED_VIEW_REFRESH_DELAY_MS); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.organization(TEST_ORG_ID) }); }); - it('invalidates immediately for Saved events', async () => { + it('delays invalidation for Saved events so optimistic caches stay visible', async () => { // Arrange + vi.useFakeTimers(); const queryClient = new QueryClient(); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries').mockImplementation(async () => {}); @@ -165,6 +246,9 @@ describe('useSavedViews', () => { }); // Assert + expect(invalidateSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(SAVED_VIEW_REFRESH_DELAY_MS); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.organization(TEST_ORG_ID) }); }); @@ -227,6 +311,19 @@ describe('useSavedViews', () => { expect(queryClient.getQueryData(queryKeys.organization(TEST_ORG_ID))).toEqual([existingView, createdView]); }); + it('uses the explicit organization id when syncing a created view', () => { + // Arrange + const queryClient = new QueryClient(); + const createdView = buildSavedView({ id: 'view-1', name: 'New View', organization_id: undefined as never }); + + // Act + syncSavedViewCaches(queryClient, createdView, TEST_ORG_ID); + + // Assert + expect(queryClient.getQueryData(queryKeys.view(TEST_ORG_ID, 'issues'))).toEqual([createdView]); + expect(queryClient.getQueryData(queryKeys.organization(TEST_ORG_ID))).toEqual([createdView]); + }); + it('syncs an updated view into both caches immediately', () => { // Arrange const queryClient = new QueryClient(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-actions.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-actions.svelte index 3335095265..a14f11af01 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-actions.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-actions.svelte @@ -4,11 +4,13 @@ interface Props { clear: () => void; + hidden?: boolean; remove: () => void; showClear: boolean; + toggleHidden?: () => void; } - let { clear, remove, showClear }: Props = $props(); + let { clear, hidden = false, remove, showClear, toggleHidden }: Props = $props();
@@ -16,5 +18,8 @@ {#if showClear} {/if} + {#if toggleHidden} + + {/if}
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-boolean.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-boolean.svelte index d0f8141f7b..bddf3a452a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-boolean.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-boolean.svelte @@ -9,13 +9,15 @@ interface Props { changed: (value?: boolean) => void; + hidden?: boolean; open: boolean; remove: () => void; title: string; + toggleHidden?: () => void; value?: boolean; } - let { changed, open = $bindable(), remove, title, value }: Props = $props(); + let { changed, hidden = false, open = $bindable(), remove, title, toggleHidden, value }: Props = $props(); // eslint-disable-next-line svelte/prefer-writable-derived let updatedValue = $state(); @@ -78,7 +80,7 @@ {#snippet child({ props })} - {/snippet} - + @@ -176,6 +219,11 @@
+ {#if hiddenFilterCount > 0} + + {/if} {#if filters.some((f) => f.type !== 'date')} {/if} @@ -183,24 +231,3 @@
- -{#if children} - {@render children()} -{/if} - -{#each facets as facet (facet.filter.id)} - {@const Facet = facet.component} - facet.open, - (isOpen) => { - lastOpenFilterId = isOpen ? facet.filter.id : undefined; - facet.open = isOpen; - } - } - title={facet.title} - /> -{/each} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-drop-down.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-drop-down.svelte index 60e673b9aa..0c09b5a08c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-drop-down.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-drop-down.svelte @@ -15,16 +15,29 @@ interface Props { changed: (value?: string) => void; + hidden?: boolean; loading?: boolean; noOptionsText?: string; open: boolean; options: Option[]; remove: () => void; title: string; + toggleHidden?: () => void; value?: string; } - let { changed, loading = false, noOptionsText = 'No results found.', open = $bindable(), options, remove, title, value }: Props = $props(); + let { + changed, + hidden = false, + loading = false, + noOptionsText = 'No results found.', + open = $bindable(), + options, + remove, + title, + toggleHidden, + value + }: Props = $props(); // eslint-disable-next-line svelte/prefer-writable-derived let updatedValue = $state(); @@ -90,7 +103,7 @@ {#snippet child({ props })} -