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 051dc8122c..cff82c94d0 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, toFilter } from './helpers.svelte'; +import { applyTimeFilter, deserializeFilters, quoteIfSpecialCharacters, serializeFilters, toFilter, toFilterFromSerializedFilters } from './helpers.svelte'; import { BooleanFilter, DateFilter, @@ -63,6 +63,19 @@ describe('helpers.svelte', () => { }); }); +describe('applyTimeFilter', () => { + it('removes an existing date filter when time is explicitly empty', () => { + // Arrange + const filters = [new DateFilter('date', '[now-7d TO now]'), new StringFilter('stack', 'stack-1')]; + + // Act + const result = applyTimeFilter(filters, null); + + // Assert + expect(result.map((filter) => filter.key)).toEqual(['string-stack']); + }); +}); + describe('serializeFilters', () => { it('serializes an empty array', () => { expect(serializeFilters([])).toBe('[]'); @@ -186,6 +199,30 @@ describe('serializeFilters', () => { }); }); +describe('toFilterFromSerializedFilters', () => { + it('derives the filter expression from serialized filter controls', () => { + // Arrange + const serialized = serializeFilters([new DateFilter('date', '[now-7d TO now]'), new StringFilter('stack', 'stack-1')]); + + // Act + const result = toFilterFromSerializedFilters(serialized); + + // Assert + expect(result).toBe('stack:"stack-1"'); + }); + + it('returns null when serialized filters contain only date controls', () => { + // Arrange + const serialized = serializeFilters([new DateFilter('date', '[now-7d TO now]')]); + + // Act + const result = toFilterFromSerializedFilters(serialized); + + // Assert + expect(result).toBeNull(); + }); +}); + describe('deserializeFilters', () => { it('deserializes an empty array', () => { expect(deserializeFilters('[]')).toEqual([]); 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 15ae6d5e54..c80969ed61 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 @@ -37,6 +37,8 @@ export function applyTimeFilter(filters: IFilter[], time: null | string): IFilte if (time) { const dateFilter = filters[dateFilterIndex] as DateFilter; dateFilter.value = time; + } else { + filters.splice(dateFilterIndex, 1); } } else if (time) { filters.push(new DateFilter('date', time)); @@ -222,6 +224,11 @@ export function toFilter(filters: IFilter[]): string { .trim(); } +export function toFilterFromSerializedFilters(json: string): null | string { + const filter = toFilter(deserializeFilters(json).filter((f) => f.type !== 'date')); + return filter || null; +} + export function updateFilterCache(cacheKey: string, filters: IFilter[]) { // Prevent unbounded growth if (filterCache.size >= 100) { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts index 1e7763357f..93dda18964 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/index.ts @@ -1,6 +1,8 @@ import type { LogLevel } from '$features/events/models/event-data'; import type { StackStatus } from '$features/stacks/models'; +import { resolve } from '$app/paths'; + export interface EventErrorSummaryData { Message?: string; Method?: string; @@ -146,3 +148,12 @@ export type SummaryTemplateKeys = | 'stack-session-summary' | 'stack-simple-summary' | 'stack-summary'; + +export function buildStackEventsHref(stackId: string): string { + const queryParams = new URLSearchParams({ + stack: stackId, + time: 'all' + }); + + return `${resolve('/(app)/event')}?${queryParams}`; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-error-summary.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-error-summary.svelte index 702d474fd9..a60a451702 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-error-summary.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-error-summary.svelte @@ -1,12 +1,11 @@ {#if stackQuery.isSuccess} @@ -134,7 +148,7 @@ - + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-detail-sheet.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-detail-sheet.svelte index e044e9a37a..d7d3b50510 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-detail-sheet.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-detail-sheet.svelte @@ -29,6 +29,6 @@ {#if stackId} - + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-details.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-details.svelte index cb443885c7..59c156811c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-details.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-details.svelte @@ -11,13 +11,15 @@ interface Props { filterChanged: (filter: IFilter) => void; handleError: (problem: ProblemDetails) => void; + onDeleted?: () => void; stackId: string; } - let { filterChanged, handleError, stackId }: Props = $props(); + let { filterChanged, handleError, onDeleted, stackId }: Props = $props(); let eventId = $state(null); let lastStackId = $state(''); + let handledEventsErrorForStackId = $state(''); const stackEventsQuery = getStackEventsQuery({ params: { @@ -34,6 +36,7 @@ $effect(() => { if (stackId !== lastStackId) { lastStackId = stackId; + handledEventsErrorForStackId = ''; eventId = null; } }); @@ -44,6 +47,13 @@ } }); + $effect(() => { + if (stackEventsQuery.isError && handledEventsErrorForStackId !== stackId) { + handledEventsErrorForStackId = stackId; + handleError(stackEventsQuery.error); + } + }); + function handleNavigate(newEventId: string) { eventId = newEventId; } @@ -54,7 +64,7 @@ {:else if stackEventsQuery.isSuccess}

Stack

- +
No events available for this stack. {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-options-dropdown-menu.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-options-dropdown-menu.svelte index 160d5bc520..f993c09a9e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-options-dropdown-menu.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-options-dropdown-menu.svelte @@ -18,10 +18,11 @@ import RequiresPromotedWebHookDialog from './dialogs/requires-promoted-web-hook-dialog.svelte'; interface Props { + onDeleted?: () => void; stack: Stack; } - let { stack }: Props = $props(); + let { onDeleted, stack }: Props = $props(); let openAddStackReferenceDialog = $state(false); let openRemoveStackDialog = $state(false); let openRequiresPromotedWebHookDialog = $state(false); @@ -107,6 +108,7 @@ async function remove() { await removeStacks.mutateAsync(); toast.success('Successfully queued the stack for deletion.'); + onDeleted?.(); } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte index c47c2f53ac..43a8e6ee72 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte @@ -13,7 +13,19 @@ import EventDetailSheet from '$features/events/components/event-detail-sheet.svelte'; import EventsDashboardChart from '$features/events/components/events-dashboard-chart.svelte'; import EventsStatsDashboard from '$features/events/components/events-stats-dashboard.svelte'; - import { DateFilter, ProjectFilter, StatusFilter } from '$features/events/components/filters'; + import { + BooleanFilter, + DateFilter, + LevelFilter, + ProjectFilter, + ReferenceFilter, + SessionFilter, + StatusFilter, + StringFilter, + TagFilter, + TypeFilter, + VersionFilter + } from '$features/events/components/filters'; import { applyTimeFilter, buildFilterCacheKey, @@ -47,8 +59,17 @@ import { createTable } from '@tanstack/svelte-table'; import { queryParamsState } from 'kit-query-params'; import { useEventListener, watch } from 'runed'; + import { untrack } from 'svelte'; import { debounce, throttle } from 'throttle-debounce'; + import { + ALL_TIME_QUERY_VALUE, + deserializeTimeQueryParam, + getEventsNavigationOptionsForFilter, + redirectToEventsWithFilter, + serializeTimeQueryParam + } from '../redirect-to-events.svelte'; + let selectedEventId: null | string = $state(null); function handleEventError(problem: ProblemDetails) { @@ -68,10 +89,21 @@ const DEFAULT_FILTER = '(status:open OR status:regressed)'; const DEFAULT_FILTERS = [new DateFilter('date', DEFAULT_TIME_RANGE), new ProjectFilter([]), new StatusFilter([StackStatus.Open, StackStatus.Regressed])]; const DEFAULT_PARAMS = { + bot: undefined as string | undefined, filter: undefined as string | undefined, + first: undefined as string | undefined, + level: undefined as string | undefined, limit: DEFAULT_LIMIT, + project: undefined as string | undefined, + reference: undefined as string | undefined, + session: undefined as string | undefined, sort: undefined as string | undefined, - time: undefined as string | undefined + stack: undefined as string | undefined, + status: undefined as string | undefined, + tag: undefined as string | undefined, + time: undefined as string | undefined, + type: undefined as string | undefined, + version: undefined as string | undefined }; function filterCacheKey(filter: null | string): string { @@ -80,18 +112,90 @@ function getQueryTime(): null | string { if (queryParams.time != null) { - return queryParams.time || null; + if (queryParams.time === ALL_TIME_QUERY_VALUE) { + return null; + } + + return queryParams.time ? deserializeTimeQueryParam(queryParams.time) : null; } return savedViewsState.activeSavedView?.time ?? DEFAULT_TIME_RANGE; } function getEffectiveFilter(): null | string { - if (queryParams.filter != null) { - return queryParams.filter; + const filter = toFilter(getCurrentFiltersWithoutTime()); + return filter || null; + } + + function getQueryFilters(): FacetedFilter.IFilter[] | null { + const filters: FacetedFilter.IFilter[] = []; + + if (queryParams.project) { + filters.push(new ProjectFilter(splitQueryParam(queryParams.project))); + } + + if (queryParams.stack) { + filters.push(new StringFilter('stack', queryParams.stack)); + } + + const bot = parseBooleanQueryParam(queryParams.bot); + if (bot !== undefined) { + filters.push(new BooleanFilter('bot', bot)); + } + + const first = parseBooleanQueryParam(queryParams.first); + if (first !== undefined) { + filters.push(new BooleanFilter('first', first)); + } + + if (queryParams.level) { + filters.push(new LevelFilter(splitQueryParam(queryParams.level) as never[])); + } + + if (queryParams.reference) { + filters.push(new ReferenceFilter(queryParams.reference)); + } + + if (queryParams.session) { + filters.push(new SessionFilter(queryParams.session)); + } + + if (queryParams.status) { + filters.push(new StatusFilter(splitQueryParam(queryParams.status) as never[])); + } + + if (queryParams.tag) { + filters.push(new TagFilter(splitQueryParam(queryParams.tag) as never[])); + } + + if (queryParams.type) { + filters.push(new TypeFilter(splitQueryParam(queryParams.type) as never[])); + } + + if (queryParams.version) { + filters.push(new VersionFilter('version', queryParams.version)); } - return savedViewsState.activeSavedView?.filter ?? DEFAULT_FILTER; + return filters.length > 0 ? filters : null; + } + + function parseBooleanQueryParam(value: null | string | undefined): boolean | undefined { + if (value === 'true') { + return true; + } + + if (value === 'false') { + return false; + } + + return undefined; + } + + function splitQueryParam(value: string): string[] { + return value + .split(',') + .map((item) => item.trim()) + .filter((item) => item); } function getEffectiveSort(): null | string | undefined { @@ -107,10 +211,21 @@ default: DEFAULT_PARAMS, pushHistory: true, schema: { + bot: 'string', filter: 'string', + first: 'string', + level: 'string', limit: 'number', + project: 'string', + reference: 'string', + session: 'string', sort: 'string', - time: 'string' + stack: 'string', + status: 'string', + tag: 'string', + time: 'string', + type: 'string', + version: 'string' } }); @@ -153,7 +268,11 @@ // NOTE: This might be applying query string parameters when redirecting away. watch( () => organization.current, - () => { + (_currentOrganizationId, previousOrganizationId) => { + if (previousOrganizationId === undefined) { + return; + } + updateFilterCache(filterCacheKey(DEFAULT_FILTER), DEFAULT_FILTERS); //params.$reset(); // Work around for https://github.com/beynar/kit-query-params/issues/7 Object.assign(queryParams, DEFAULT_PARAMS); @@ -163,15 +282,107 @@ ); function getCurrentFilters(): FacetedFilter.IFilter[] { - const filter = getEffectiveFilter(); + return applyTimeFilter(getCurrentFiltersWithoutTime(), getQueryTime()); + } + + function getCurrentFiltersWithoutTime(): FacetedFilter.IFilter[] { + const savedViewFilters = getSavedViewFilters(); + const queryFilters = getQueryFilters() ?? []; + const expressionFilters = + queryParams.filter != null + ? getFiltersFromCache(filterCacheKey(queryParams.filter), queryParams.filter).filter((filter) => filter.type !== 'date') + : []; + + if (savedViewFilters) { + return mergeFilterOverrides( + savedViewFilters.filter((filter) => filter.type !== 'date'), + [...expressionFilters, ...queryFilters], + getQueryFilterRemovalKeys(savedViewFilters) + ); + } + + if (expressionFilters.length > 0 || queryFilters.length > 0) { + return [...expressionFilters, ...queryFilters]; + } + + const filter = savedViewsState.activeSavedView?.filter ?? DEFAULT_FILTER; + return getFiltersFromCache(filterCacheKey(filter), filter).filter((filter) => filter.type !== 'date'); + } + + function getSavedViewFilters(): FacetedFilter.IFilter[] | null { const savedView = savedViewsState.activeSavedView; + if (!savedView?.filter_definitions) { + return null; + } + + return deserializeFilters(savedView.filter_definitions); + } + + function getQueryFilterRemovalKeys(savedViewFilters: FacetedFilter.IFilter[]): string[] { + const removedKeys: string[] = []; + + if (queryParams.bot === '') { + removedKeys.push('boolean-bot'); + } + + if (queryParams.first === '') { + removedKeys.push('boolean-first'); + } - if (queryParams.filter == null && savedView?.filter_definitions && filter === (savedView.filter ?? null)) { - const hydrated = deserializeFilters(savedView.filter_definitions); - return applyTimeFilter(hydrated, getQueryTime()); + if (queryParams.filter === '') { + removedKeys.push(...savedViewFilters.filter((filter) => filter.type !== 'date' && !isQueryParamFilter(filter)).map((filter) => filter.key)); } - return applyTimeFilter(getFiltersFromCache(filterCacheKey(filter), filter), getQueryTime()); + if (queryParams.level === '') { + removedKeys.push('level'); + } + + if (queryParams.project === '') { + removedKeys.push('project'); + } + + if (queryParams.reference === '') { + removedKeys.push('reference'); + } + + if (queryParams.session === '') { + removedKeys.push('session'); + } + + if (queryParams.stack === '') { + removedKeys.push('string-stack'); + } + + if (queryParams.status === '') { + removedKeys.push('status'); + } + + if (queryParams.tag === '') { + removedKeys.push('tag'); + } + + if (queryParams.type === '') { + removedKeys.push('type'); + } + + if (queryParams.version === '') { + removedKeys.push('version-version'); + } + + return removedKeys; + } + + function mergeFilterOverrides( + baseFilters: FacetedFilter.IFilter[], + overrideFilters: FacetedFilter.IFilter[], + removedFilterKeys: string[] = [] + ): FacetedFilter.IFilter[] { + if (overrideFilters.length === 0 && removedFilterKeys.length === 0) { + return baseFilters; + } + + const overrideKeys = new Set([...overrideFilters.map((filter) => filter.key), ...removedFilterKeys]); + return [...baseFilters.filter((filter) => !overrideKeys.has(filter.key)), ...overrideFilters]; } let filters = $state(getCurrentFilters()); @@ -194,7 +405,14 @@ queryParams.limit ??= DEFAULT_LIMIT; }); - function onFilterChanged(addedOrUpdated: FacetedFilter.IFilter): void { + async function onFilterChanged(addedOrUpdated: FacetedFilter.IFilter): Promise { + const navigationOptions = getEventsNavigationOptionsForFilter(addedOrUpdated); + if (navigationOptions) { + selectedEventId = null; + await redirectToEventsWithFilter(organization.current, addedOrUpdated, navigationOptions); + return; + } + const isNew = !filters?.some((f) => f.id === addedOrUpdated.id); const updatedFilters = filterChanged(filters ?? [], addedOrUpdated); updateFilters(updatedFilters); @@ -213,24 +431,134 @@ function updateFilters(updatedFilters: FacetedFilter.IFilter[]): void { const filter = toFilter(updatedFilters.filter((f) => f.type !== 'date')); + const expressionFilters = updatedFilters.filter((f) => f.type !== 'date' && !isQueryParamFilter(f)); + const filterParam = toFilter(expressionFilters); const time = ((updatedFilters.find((f) => f.type === 'date') as DateFilter | undefined)?.value as string | undefined) ?? null; - const baseFilter = savedViewsState.activeSavedView?.filter ?? DEFAULT_FILTER; const baseTime = savedViewsState.activeSavedView?.time ?? DEFAULT_TIME_RANGE; + const baseFilter = savedViewsState.activeSavedView?.filter ?? DEFAULT_FILTER; + const savedViewFilters = getSavedViewFilters(); + const baseQueryFilterParams = getQueryFilterParams(savedViewFilters ?? []); + const queryFilterParams = getQueryFilterParamDeltas(getQueryFilterParams(updatedFilters), baseQueryFilterParams); + const baseExpressionFilterParam = savedViewFilters ? toFilter(savedViewFilters.filter((f) => f.type !== 'date' && !isQueryParamFilter(f))) : baseFilter; - const newFilterParam = filter === baseFilter ? null : filter; - const newTimeParam = time === baseTime ? null : (time ?? ''); + const newFilterParam = filterParam === baseExpressionFilterParam ? null : filterParam || (savedViewFilters && baseExpressionFilterParam ? '' : null); + const newTimeParam = time === baseTime ? null : time ? serializeTimeQueryParam(time) : ALL_TIME_QUERY_VALUE; updateFilterCache(filterCacheKey(filter), updatedFilters); // Only skip the watch when the URL will actually change from our update. // If the URL doesn't change, the watch won't fire and the flag would stay stale. - if (newFilterParam !== queryParams.filter || newTimeParam !== queryParams.time) { + if ( + newFilterParam !== queryParams.filter || + newTimeParam !== queryParams.time || + queryFilterParams.bot !== queryParams.bot || + queryFilterParams.first !== queryParams.first || + queryFilterParams.level !== queryParams.level || + queryFilterParams.project !== queryParams.project || + queryFilterParams.reference !== queryParams.reference || + queryFilterParams.session !== queryParams.session || + queryFilterParams.stack !== queryParams.stack || + queryFilterParams.status !== queryParams.status || + queryFilterParams.tag !== queryParams.tag || + queryFilterParams.type !== queryParams.type || + queryFilterParams.version !== queryParams.version + ) { isInternalFilterUpdate = true; } + queryParams.bot = queryFilterParams.bot; + queryParams.first = queryFilterParams.first; + queryParams.level = queryFilterParams.level; + queryParams.project = queryFilterParams.project; + queryParams.reference = queryFilterParams.reference; + queryParams.session = queryFilterParams.session; + queryParams.stack = queryFilterParams.stack; + queryParams.status = queryFilterParams.status; + queryParams.tag = queryFilterParams.tag; + queryParams.type = queryFilterParams.type; + queryParams.version = queryFilterParams.version; queryParams.time = newTimeParam; queryParams.filter = newFilterParam; } + $effect(() => { + const activeSavedViewId = savedViewsState.activeSavedView?.id; + if (!activeSavedViewId) { + return; + } + + untrack(() => { + updateFilters(getCurrentFilters()); + }); + }); + + function getQueryFilterParams(filters: FacetedFilter.IFilter[]) { + const botFilter = filters.find((f): f is BooleanFilter => f instanceof BooleanFilter && f.term === 'bot'); + const firstFilter = filters.find((f): f is BooleanFilter => f instanceof BooleanFilter && f.term === 'first'); + const levelFilter = filters.find((f): f is LevelFilter => f.type === 'level'); + const projectFilter = filters.find((f): f is ProjectFilter => f.type === 'project'); + const referenceFilter = filters.find((f): f is ReferenceFilter => f.type === 'reference'); + const sessionFilter = filters.find((f): f is SessionFilter => f.type === 'session'); + const stackFilter = filters.find((f): f is StringFilter => f.type === 'string' && f.key === 'string-stack'); + const statusFilter = filters.find((f): f is StatusFilter => f.type === 'status'); + const tagFilter = filters.find((f): f is TagFilter => f.type === 'tag'); + const typeFilter = filters.find((f): f is TypeFilter => f.type === 'type'); + const versionFilter = filters.find((f): f is VersionFilter => f instanceof VersionFilter && f.term === 'version'); + + return { + bot: botFilter?.value === undefined ? null : String(botFilter.value), + first: firstFilter?.value === undefined ? null : String(firstFilter.value), + level: levelFilter?.value.length ? levelFilter.value.join(',') : null, + project: projectFilter?.value.length ? projectFilter.value.join(',') : null, + reference: referenceFilter?.value?.trim() ? referenceFilter.value : null, + session: sessionFilter?.value?.trim() ? sessionFilter.value : null, + stack: stackFilter?.value?.trim() ? stackFilter.value : null, + status: statusFilter?.value.length ? statusFilter.value.join(',') : null, + tag: tagFilter?.value.length ? tagFilter.value.join(',') : null, + type: typeFilter?.value.length ? typeFilter.value.join(',') : null, + version: versionFilter?.value?.trim() ? versionFilter.value : null + }; + } + + function getQueryFilterParamDeltas(currentParams: ReturnType, baseParams: ReturnType) { + const getDelta = (currentValue: null | string, baseValue: null | string): null | string => { + if (currentValue === baseValue) { + return null; + } + + return currentValue ?? (baseValue ? '' : null); + }; + + return { + bot: getDelta(currentParams.bot, baseParams.bot), + first: getDelta(currentParams.first, baseParams.first), + level: getDelta(currentParams.level, baseParams.level), + project: getDelta(currentParams.project, baseParams.project), + reference: getDelta(currentParams.reference, baseParams.reference), + session: getDelta(currentParams.session, baseParams.session), + stack: getDelta(currentParams.stack, baseParams.stack), + status: getDelta(currentParams.status, baseParams.status), + tag: getDelta(currentParams.tag, baseParams.tag), + type: getDelta(currentParams.type, baseParams.type), + version: getDelta(currentParams.version, baseParams.version) + }; + } + + function isQueryParamFilter(filter: FacetedFilter.IFilter): boolean { + if (filter.type === 'string' && filter.key === 'string-stack') { + return true; + } + + if (filter.type === 'boolean' && filter instanceof BooleanFilter && (filter.term === 'bot' || filter.term === 'first') && filter.value !== undefined) { + return true; + } + + if (filter.type === 'version' && filter instanceof VersionFilter && filter.term !== 'version') { + return false; + } + + return ['level', 'project', 'reference', 'session', 'status', 'tag', 'type', 'version'].includes(filter.type); + } + const eventsQueryParameters: GetEventsParams = $state({ get filter() { return getEffectiveFilter()!; @@ -257,7 +585,7 @@ return getQueryTime() ?? undefined; }, set time(value) { - queryParams.time = value ?? null; + queryParams.time = value ? serializeTimeQueryParam(value) : ALL_TIME_QUERY_VALUE; } }); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId=objectid]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId=objectid]/+page.svelte index c880522752..72c1c90caf 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId=objectid]/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId=objectid]/+page.svelte @@ -11,7 +11,7 @@ import { watch } from 'runed'; import { toast } from 'svelte-sonner'; - import { redirectToEventsWithFilter } from '../../redirect-to-events.svelte.js'; + import { getEventsNavigationOptionsForFilter, redirectToEventsWithFilter } from '../../redirect-to-events.svelte.js'; // TODO: Have this happen automatically when the organization changes. watch( @@ -23,7 +23,7 @@ ); async function filterChanged(addedOrUpdated: FacetedFilter.IFilter) { - await redirectToEventsWithFilter(organization.current, addedOrUpdated); + await redirectToEventsWithFilter(organization.current, addedOrUpdated, getEventsNavigationOptionsForFilter(addedOrUpdated)); } async function handleError(problem: ProblemDetails) { diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/stacks/[stackId]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/stacks/[stackId]/+page.svelte index 229713ac02..d438b5862e 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/stacks/[stackId]/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/stacks/[stackId]/+page.svelte @@ -11,7 +11,7 @@ import { watch } from 'runed'; import { toast } from 'svelte-sonner'; - import { redirectToEventsWithFilter } from '../../../../redirect-to-events.svelte.js'; + import { getEventsNavigationOptionsForFilter, redirectToEventsWithFilter } from '../../../../redirect-to-events.svelte.js'; const stackId = $derived(page.params.stackId || ''); @@ -24,7 +24,7 @@ ); async function filterChanged(addedOrUpdated: IFilter) { - await redirectToEventsWithFilter(organization.current, addedOrUpdated); + await redirectToEventsWithFilter(organization.current, addedOrUpdated, getEventsNavigationOptionsForFilter(addedOrUpdated)); } function handleError(problem: ProblemDetails) { @@ -35,9 +35,13 @@ toast.error('Unable to load stack event details.'); } + async function handleDeleted() { + await goto(resolve('/(app)/project/[projectId]/stacks', { projectId: page.params.projectId || '' })); + } + $effect(() => { document.title = 'Stack Details - Exceptionless'; }); - + diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.test.ts new file mode 100644 index 0000000000..a0727a1664 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.test.ts @@ -0,0 +1,120 @@ +import { DateFilter, ProjectFilter, StatusFilter, StringFilter } from '$features/events/components/filters/models.svelte'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +vi.mock('$app/paths', () => ({ + resolve: (path: string) => path +})); + +describe('redirect-to-events', () => { + it('uses an explicit all-time query value for stack event drilldowns', async () => { + // Arrange + const { ALL_TIME_QUERY_VALUE, buildListPageHref, getEventsNavigationOptionsForFilter } = await import('./redirect-to-events.svelte'); + const stackFilter = new StringFilter('stack', 'stack-1'); + const options = getEventsNavigationOptionsForFilter(stackFilter); + + // Act + const href = buildListPageHref('events', 'org-1', [new DateFilter('date', '[now-7d TO now]'), stackFilter], options); + const url = new URL(href, 'https://example.test'); + + // Assert + expect(url.pathname).toBe('/(app)/event'); + expect(url.searchParams.get('time')).toBe(ALL_TIME_QUERY_VALUE); + expect(url.searchParams.get('stack')).toBe('stack-1'); + expect(url.searchParams.has('filters')).toBe(false); + }); + + it('uses relative duration shortcuts for time query parameters', async () => { + // Arrange + const { buildListPageHref, deserializeTimeQueryParam } = await import('./redirect-to-events.svelte'); + + // Act + const href = buildListPageHref('events', 'org-1', [new DateFilter('date', '[now-1h TO now]')]); + const url = new URL(href, 'https://example.test'); + + // Assert + expect(url.searchParams.get('time')).toBe('1h'); + expect(deserializeTimeQueryParam(url.searchParams.get('time')!)).toBe('[now-1h TO now]'); + }); + + it('omits date range brackets from custom time query parameters', async () => { + // Arrange + const { buildListPageHref, deserializeTimeQueryParam } = await import('./redirect-to-events.svelte'); + + // Act + const href = buildListPageHref('events', 'org-1', [new DateFilter('date', '[2025-01-01 TO 2025-02-01]')]); + const url = new URL(href, 'https://example.test'); + + // Assert + expect(url.searchParams.get('time')).toBe('2025-01-01 TO 2025-02-01'); + expect(deserializeTimeQueryParam(url.searchParams.get('time')!)).toBe('[2025-01-01 TO 2025-02-01]'); + }); + + it('accepts existing expanded time query parameters', async () => { + // Arrange + const { deserializeTimeQueryParam } = await import('./redirect-to-events.svelte'); + + // Act + const time = deserializeTimeQueryParam('now-1h TO now'); + + // Assert + expect(time).toBe('[now-1h TO now]'); + }); + + it('accepts existing bracketed time query parameters', async () => { + // Arrange + const { deserializeTimeQueryParam } = await import('./redirect-to-events.svelte'); + + // Act + const time = deserializeTimeQueryParam('[now-1h TO now]'); + + // Assert + expect(time).toBe('[now-1h TO now]'); + }); + + it('maps project filters to the project query parameter', async () => { + // Arrange + const { buildListPageHref } = await import('./redirect-to-events.svelte'); + + // Act + const href = buildListPageHref('events', 'org-1', [new ProjectFilter(['project-1'])]); + const url = new URL(href, 'https://example.test'); + + // Assert + expect(url.pathname).toBe('/(app)/event'); + expect(url.searchParams.get('project')).toBe('project-1'); + expect(url.searchParams.has('filters')).toBe(false); + }); + + it('maps registered filters to explicit query parameters', async () => { + // Arrange + const { buildListPageHref } = await import('./redirect-to-events.svelte'); + + // Act + const href = buildListPageHref('events', 'org-1', [new StatusFilter(['open', 'regressed'] as never[])]); + const url = new URL(href, 'https://example.test'); + + // Assert + expect(url.pathname).toBe('/(app)/event'); + expect(url.searchParams.get('status')).toBe('open,regressed'); + expect(url.searchParams.has('filter')).toBe(false); + expect(url.searchParams.has('filters')).toBe(false); + }); + + it('falls back to raw filter expressions for unmapped filters', async () => { + // Arrange + const { buildListPageHref } = await import('./redirect-to-events.svelte'); + + // Act + const href = buildListPageHref('events', 'org-1', [new StringFilter('message', 'hello')]); + const url = new URL(href, 'https://example.test'); + + // Assert + expect(url.pathname).toBe('/(app)/event'); + expect(url.searchParams.get('filter')).toBe('message:hello'); + expect(url.searchParams.has('filters')).toBe(false); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.ts index 74bd4f63aa..4e901a777d 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.ts @@ -2,16 +2,173 @@ import type { IFilter } from '$comp/faceted-filter'; import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; -import { buildFilterCacheKey, toFilter, updateFilterCache } from '$features/events/components/filters/helpers.svelte'; +import { toFilter } from '$features/events/components/filters/helpers.svelte'; +import { SvelteURLSearchParams } from 'svelte/reactivity'; + +interface ListNavigationOptions { + time?: null | string; +} + +type ListPage = 'events' | 'stacks'; + +export const ALL_TIME_QUERY_VALUE = 'all'; + +const DATE_RANGE_PATTERN = /^\[?(?.+?)\s+TO\s+(?.+?)\]?$/i; +const RELATIVE_TO_NOW_PATTERN = /^now-(?\d+[Mdhmswy])$/; +const TIME_SHORTCUT_PATTERN = /^(?\d+[Mdhmswy])$/; + +const listPagePaths = { + events: '/(app)/event', + stacks: '/(app)/stack' +} as const; + +export function buildListPageHref(page: ListPage, _organizationId: string | undefined, filters: IFilter[], options: ListNavigationOptions = {}): string { + const path = resolve(listPagePaths[page]); + const filtersForNavigation = options.time === null ? filters.filter((filter) => filter.type !== 'date') : filters; + const queryParams = new SvelteURLSearchParams(); + const rawFilters = filtersForNavigation.filter((filter) => filter.type !== 'date' && !trySetRegisteredFilterQueryParam(queryParams, filter)); + const rawFilter = toFilter(rawFilters); + if (rawFilter) { + queryParams.set('filter', rawFilter); + } + + const dateFilter = filters.find((filter): filter is IFilter & { value: unknown } => filter.type === 'date' && 'value' in filter); + const time = 'time' in options ? options.time : dateFilter?.value; + if ('time' in options || typeof time === 'string') { + queryParams.set('time', typeof time === 'string' ? serializeTimeQueryParam(time) : ALL_TIME_QUERY_VALUE); + } + + return `${path}?${queryParams}`; +} + +export function deserializeTimeQueryParam(time: string): string { + const trimmed = time.trim(); + const shortcutMatch = TIME_SHORTCUT_PATTERN.exec(trimmed); + if (shortcutMatch?.groups?.duration) { + return `[now-${shortcutMatch.groups.duration} TO now]`; + } + + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + return trimmed; + } + + if (trimmed.includes(' TO ')) { + return `[${trimmed}]`; + } + + return trimmed; +} + +/** + * Stack filter drilldowns mean "show every event for this stack". + * Clear any active date range so the destination cannot hide older stack events. + */ +export function getEventsNavigationOptionsForFilter(filter: IFilter): ListNavigationOptions | undefined { + if (filter.type === 'string' && filter.key === 'string-stack') { + return { time: null }; + } + + return undefined; +} + +export async function navigateToListPage(page: ListPage, organizationId: string | undefined, filters: IFilter[], options: ListNavigationOptions = {}) { + await goto(buildListPageHref(page, organizationId, filters, options)); +} /** * Redirects to the events page with the given filter. * Primes the filter cache so the filter is preserved. */ -export async function redirectToEventsWithFilter(organizationId: string | undefined, addedOrUpdated: IFilter): Promise { - const filter = toFilter([addedOrUpdated]); - const filterCacheKey = buildFilterCacheKey(organizationId, resolve('/(app)/event'), filter); - updateFilterCache(filterCacheKey, [addedOrUpdated]); +export async function redirectToEventsWithFilter( + organizationId: string | undefined, + addedOrUpdated: IFilter, + options: { time?: null | string } = {} +): Promise { + await navigateToListPage('events', organizationId, [addedOrUpdated], options); +} + +export function serializeTimeQueryParam(time: string): string { + const trimmed = time.trim(); + const rangeMatch = DATE_RANGE_PATTERN.exec(trimmed); + const rangeStart = rangeMatch?.groups?.start?.trim(); + const rangeEnd = rangeMatch?.groups?.end?.trim(); + if (rangeStart && rangeEnd?.toLowerCase() === 'now') { + const shortcutMatch = RELATIVE_TO_NOW_PATTERN.exec(rangeStart); + if (shortcutMatch?.groups?.duration) { + return shortcutMatch.groups.duration; + } + } + + if (rangeStart && rangeEnd) { + return `${rangeStart} TO ${rangeEnd}`; + } + + return trimmed; +} + +function trySetRegisteredFilterQueryParam(queryParams: SvelteURLSearchParams, filter: IFilter): boolean { + if (filter.type === 'string' && filter.key === 'string-stack' && 'value' in filter && typeof filter.value === 'string' && filter.value.trim()) { + queryParams.set('stack', filter.value); + return true; + } + + if (filter.type === 'project' && 'value' in filter && Array.isArray(filter.value) && filter.value.length > 0) { + queryParams.set('project', filter.value.join(',')); + return true; + } + + if ( + filter.type === 'boolean' && + 'term' in filter && + (filter.term === 'bot' || filter.term === 'first') && + 'value' in filter && + typeof filter.value === 'boolean' + ) { + queryParams.set(filter.term, String(filter.value)); + return true; + } + + if (filter.type === 'level' && 'value' in filter && Array.isArray(filter.value) && filter.value.length > 0) { + queryParams.set('level', filter.value.join(',')); + return true; + } + + if (filter.type === 'reference' && 'value' in filter && typeof filter.value === 'string' && filter.value.trim()) { + queryParams.set('reference', filter.value); + return true; + } + + if (filter.type === 'session' && 'value' in filter && typeof filter.value === 'string' && filter.value.trim()) { + queryParams.set('session', filter.value); + return true; + } + + if (filter.type === 'status' && 'value' in filter && Array.isArray(filter.value) && filter.value.length > 0) { + queryParams.set('status', filter.value.join(',')); + return true; + } + + if (filter.type === 'tag' && 'value' in filter && Array.isArray(filter.value) && filter.value.length > 0) { + queryParams.set('tag', filter.value.join(',')); + return true; + } + + if (filter.type === 'type' && 'value' in filter && Array.isArray(filter.value) && filter.value.length > 0) { + queryParams.set('type', filter.value.join(',')); + return true; + } + + if ( + filter.type === 'version' && + 'term' in filter && + filter.term === 'version' && + 'value' in filter && + typeof filter.value === 'string' && + filter.value.trim() + ) { + queryParams.set('version', filter.value); + return true; + } - await goto(`${resolve('/(app)/event')}?filter=${encodeURIComponent(filter)}`); + return false; } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte index 643c9c634d..3c8442eb3e 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte @@ -11,7 +11,19 @@ import { type GetEventsParams, getOrganizationCountQuery } from '$features/events/api.svelte'; import EventsDashboardChart from '$features/events/components/events-dashboard-chart.svelte'; import EventsStatsDashboard from '$features/events/components/events-stats-dashboard.svelte'; - import { DateFilter, ProjectFilter, StatusFilter, TypeFilter } from '$features/events/components/filters'; + import { + BooleanFilter, + DateFilter, + LevelFilter, + ProjectFilter, + ReferenceFilter, + SessionFilter, + StatusFilter, + StringFilter, + TagFilter, + TypeFilter, + VersionFilter + } from '$features/events/components/filters'; import { applyTimeFilter, buildFilterCacheKey, @@ -44,9 +56,16 @@ import { createTable } from '@tanstack/svelte-table'; import { queryParamsState } from 'kit-query-params'; import { useEventListener, watch } from 'runed'; + import { untrack } from 'svelte'; import { throttle } from 'throttle-debounce'; - import { redirectToEventsWithFilter } from '../redirect-to-events.svelte'; + import { + ALL_TIME_QUERY_VALUE, + deserializeTimeQueryParam, + getEventsNavigationOptionsForFilter, + redirectToEventsWithFilter, + serializeTimeQueryParam + } from '../redirect-to-events.svelte'; // TODO: Update this page to use StackSummaryModel instead of EventSummaryModel. let selectedStackId = $state(); @@ -73,9 +92,20 @@ new StatusFilter([StackStatus.Open, StackStatus.Regressed]) ]; const DEFAULT_PARAMS = { + bot: undefined as string | undefined, filter: undefined as string | undefined, + first: undefined as string | undefined, + level: undefined as string | undefined, limit: DEFAULT_LIMIT, - time: undefined as string | undefined + project: undefined as string | undefined, + reference: undefined as string | undefined, + session: undefined as string | undefined, + stack: undefined as string | undefined, + status: undefined as string | undefined, + tag: undefined as string | undefined, + time: undefined as string | undefined, + type: undefined as string | undefined, + version: undefined as string | undefined }; function filterCacheKey(filter: null | string): string { @@ -84,18 +114,90 @@ function getQueryTime(): null | string { if (queryParams.time != null) { - return queryParams.time || null; + if (queryParams.time === ALL_TIME_QUERY_VALUE) { + return null; + } + + return queryParams.time ? deserializeTimeQueryParam(queryParams.time) : null; } return savedViewsState.activeSavedView?.time ?? DEFAULT_TIME_RANGE; } function getEffectiveFilter(): null | string { - if (queryParams.filter != null) { - return queryParams.filter; + const filter = toFilter(getCurrentFiltersWithoutTime()); + return filter || null; + } + + function getQueryFilters(): FacetedFilter.IFilter[] | null { + const filters: FacetedFilter.IFilter[] = []; + + if (queryParams.project) { + filters.push(new ProjectFilter(splitQueryParam(queryParams.project))); + } + + if (queryParams.stack) { + filters.push(new StringFilter('stack', queryParams.stack)); + } + + const bot = parseBooleanQueryParam(queryParams.bot); + if (bot !== undefined) { + filters.push(new BooleanFilter('bot', bot)); + } + + const first = parseBooleanQueryParam(queryParams.first); + if (first !== undefined) { + filters.push(new BooleanFilter('first', first)); + } + + if (queryParams.level) { + filters.push(new LevelFilter(splitQueryParam(queryParams.level) as never[])); + } + + if (queryParams.reference) { + filters.push(new ReferenceFilter(queryParams.reference)); + } + + if (queryParams.session) { + filters.push(new SessionFilter(queryParams.session)); + } + + if (queryParams.status) { + filters.push(new StatusFilter(splitQueryParam(queryParams.status) as never[])); } - return savedViewsState.activeSavedView?.filter ?? DEFAULT_FILTER; + if (queryParams.tag) { + filters.push(new TagFilter(splitQueryParam(queryParams.tag) as never[])); + } + + if (queryParams.type) { + filters.push(new TypeFilter(splitQueryParam(queryParams.type) as never[])); + } + + if (queryParams.version) { + filters.push(new VersionFilter('version', queryParams.version)); + } + + return filters.length > 0 ? filters : null; + } + + function parseBooleanQueryParam(value: null | string | undefined): boolean | undefined { + if (value === 'true') { + return true; + } + + if (value === 'false') { + return false; + } + + return undefined; + } + + function splitQueryParam(value: string): string[] { + return value + .split(',') + .map((item) => item.trim()) + .filter((item) => item); } updateFilterCache(filterCacheKey(DEFAULT_FILTER), DEFAULT_FILTERS); @@ -103,9 +205,20 @@ default: DEFAULT_PARAMS, pushHistory: true, schema: { + bot: 'string', filter: 'string', + first: 'string', + level: 'string', limit: 'number', - time: 'string' + project: 'string', + reference: 'string', + session: 'string', + stack: 'string', + status: 'string', + tag: 'string', + time: 'string', + type: 'string', + version: 'string' } }); @@ -145,7 +258,11 @@ watch( () => organization.current, - () => { + (_currentOrganizationId, previousOrganizationId) => { + if (previousOrganizationId === undefined) { + return; + } + updateFilterCache(filterCacheKey(DEFAULT_FILTER), DEFAULT_FILTERS); //params.$reset(); // Work around for https://github.com/beynar/kit-query-params/issues/7 Object.assign(queryParams, DEFAULT_PARAMS); @@ -155,15 +272,107 @@ ); function getCurrentFilters(): FacetedFilter.IFilter[] { - const filter = getEffectiveFilter(); + return applyTimeFilter(getCurrentFiltersWithoutTime(), getQueryTime()); + } + + function getCurrentFiltersWithoutTime(): FacetedFilter.IFilter[] { + const savedViewFilters = getSavedViewFilters(); + const queryFilters = getQueryFilters() ?? []; + const expressionFilters = + queryParams.filter != null + ? getFiltersFromCache(filterCacheKey(queryParams.filter), queryParams.filter).filter((filter) => filter.type !== 'date') + : []; + + if (savedViewFilters) { + return mergeFilterOverrides( + savedViewFilters.filter((filter) => filter.type !== 'date'), + [...expressionFilters, ...queryFilters], + getQueryFilterRemovalKeys(savedViewFilters) + ); + } + + if (expressionFilters.length > 0 || queryFilters.length > 0) { + return [...expressionFilters, ...queryFilters]; + } + + const filter = savedViewsState.activeSavedView?.filter ?? DEFAULT_FILTER; + return getFiltersFromCache(filterCacheKey(filter), filter).filter((filter) => filter.type !== 'date'); + } + + function getSavedViewFilters(): FacetedFilter.IFilter[] | null { const savedView = savedViewsState.activeSavedView; + if (!savedView?.filter_definitions) { + return null; + } + + return deserializeFilters(savedView.filter_definitions); + } + + function getQueryFilterRemovalKeys(savedViewFilters: FacetedFilter.IFilter[]): string[] { + const removedKeys: string[] = []; + + if (queryParams.bot === '') { + removedKeys.push('boolean-bot'); + } + + if (queryParams.first === '') { + removedKeys.push('boolean-first'); + } - if (queryParams.filter == null && savedView?.filter_definitions && filter === (savedView.filter ?? null)) { - const hydrated = deserializeFilters(savedView.filter_definitions); - return applyTimeFilter(hydrated, getQueryTime()); + if (queryParams.filter === '') { + removedKeys.push(...savedViewFilters.filter((filter) => filter.type !== 'date' && !isQueryParamFilter(filter)).map((filter) => filter.key)); } - return applyTimeFilter(getFiltersFromCache(filterCacheKey(filter), filter), getQueryTime()); + if (queryParams.level === '') { + removedKeys.push('level'); + } + + if (queryParams.project === '') { + removedKeys.push('project'); + } + + if (queryParams.reference === '') { + removedKeys.push('reference'); + } + + if (queryParams.session === '') { + removedKeys.push('session'); + } + + if (queryParams.stack === '') { + removedKeys.push('string-stack'); + } + + if (queryParams.status === '') { + removedKeys.push('status'); + } + + if (queryParams.tag === '') { + removedKeys.push('tag'); + } + + if (queryParams.type === '') { + removedKeys.push('type'); + } + + if (queryParams.version === '') { + removedKeys.push('version-version'); + } + + return removedKeys; + } + + function mergeFilterOverrides( + baseFilters: FacetedFilter.IFilter[], + overrideFilters: FacetedFilter.IFilter[], + removedFilterKeys: string[] = [] + ): FacetedFilter.IFilter[] { + if (overrideFilters.length === 0 && removedFilterKeys.length === 0) { + return baseFilters; + } + + const overrideKeys = new Set([...overrideFilters.map((filter) => filter.key), ...removedFilterKeys]); + return [...baseFilters.filter((filter) => !overrideKeys.has(filter.key)), ...overrideFilters]; } let filters = $state(getCurrentFilters()); @@ -189,7 +398,7 @@ async function onFilterChanged(addedOrUpdated: FacetedFilter.IFilter) { // If this is a stack filter, redirect to the Events page if (addedOrUpdated.type === 'string' && addedOrUpdated.key === 'string-stack') { - await redirectToEventsWithFilter(organization.current, addedOrUpdated); + await redirectToEventsWithFilter(organization.current, addedOrUpdated, getEventsNavigationOptionsForFilter(addedOrUpdated)); return; } @@ -214,24 +423,134 @@ function updateFilters(updatedFilters: FacetedFilter.IFilter[]): void { const filter = toFilter(updatedFilters.filter((f) => f.type !== 'date')); + const expressionFilters = updatedFilters.filter((f) => f.type !== 'date' && !isQueryParamFilter(f)); + const filterParam = toFilter(expressionFilters); const time = ((updatedFilters.find((f) => f.type === 'date') as DateFilter | undefined)?.value as string | undefined) ?? null; - const baseFilter = savedViewsState.activeSavedView?.filter ?? DEFAULT_FILTER; const baseTime = savedViewsState.activeSavedView?.time ?? DEFAULT_TIME_RANGE; + const baseFilter = savedViewsState.activeSavedView?.filter ?? DEFAULT_FILTER; + const savedViewFilters = getSavedViewFilters(); + const baseQueryFilterParams = getQueryFilterParams(savedViewFilters ?? []); + const queryFilterParams = getQueryFilterParamDeltas(getQueryFilterParams(updatedFilters), baseQueryFilterParams); + const baseExpressionFilterParam = savedViewFilters ? toFilter(savedViewFilters.filter((f) => f.type !== 'date' && !isQueryParamFilter(f))) : baseFilter; - const newFilterParam = filter === baseFilter ? null : filter; - const newTimeParam = time === baseTime ? null : (time ?? ''); + const newFilterParam = filterParam === baseExpressionFilterParam ? null : filterParam || (savedViewFilters && baseExpressionFilterParam ? '' : null); + const newTimeParam = time === baseTime ? null : time ? serializeTimeQueryParam(time) : ALL_TIME_QUERY_VALUE; updateFilterCache(filterCacheKey(filter), updatedFilters); // Only skip the watch when the URL will actually change from our update. // If the URL doesn't change, the watch won't fire and the flag would stay stale. - if (newFilterParam !== queryParams.filter || newTimeParam !== queryParams.time) { + if ( + newFilterParam !== queryParams.filter || + newTimeParam !== queryParams.time || + queryFilterParams.bot !== queryParams.bot || + queryFilterParams.first !== queryParams.first || + queryFilterParams.level !== queryParams.level || + queryFilterParams.project !== queryParams.project || + queryFilterParams.reference !== queryParams.reference || + queryFilterParams.session !== queryParams.session || + queryFilterParams.stack !== queryParams.stack || + queryFilterParams.status !== queryParams.status || + queryFilterParams.tag !== queryParams.tag || + queryFilterParams.type !== queryParams.type || + queryFilterParams.version !== queryParams.version + ) { isInternalFilterUpdate = true; } + queryParams.bot = queryFilterParams.bot; + queryParams.first = queryFilterParams.first; + queryParams.level = queryFilterParams.level; + queryParams.project = queryFilterParams.project; + queryParams.reference = queryFilterParams.reference; + queryParams.session = queryFilterParams.session; + queryParams.stack = queryFilterParams.stack; + queryParams.status = queryFilterParams.status; + queryParams.tag = queryFilterParams.tag; + queryParams.type = queryFilterParams.type; + queryParams.version = queryFilterParams.version; queryParams.time = newTimeParam; queryParams.filter = newFilterParam; } + $effect(() => { + const activeSavedViewId = savedViewsState.activeSavedView?.id; + if (!activeSavedViewId) { + return; + } + + untrack(() => { + updateFilters(getCurrentFilters()); + }); + }); + + function getQueryFilterParams(filters: FacetedFilter.IFilter[]) { + const botFilter = filters.find((f): f is BooleanFilter => f instanceof BooleanFilter && f.term === 'bot'); + const firstFilter = filters.find((f): f is BooleanFilter => f instanceof BooleanFilter && f.term === 'first'); + const levelFilter = filters.find((f): f is LevelFilter => f.type === 'level'); + const projectFilter = filters.find((f): f is ProjectFilter => f.type === 'project'); + const referenceFilter = filters.find((f): f is ReferenceFilter => f.type === 'reference'); + const sessionFilter = filters.find((f): f is SessionFilter => f.type === 'session'); + const stackFilter = filters.find((f): f is StringFilter => f.type === 'string' && f.key === 'string-stack'); + const statusFilter = filters.find((f): f is StatusFilter => f.type === 'status'); + const tagFilter = filters.find((f): f is TagFilter => f.type === 'tag'); + const typeFilter = filters.find((f): f is TypeFilter => f.type === 'type'); + const versionFilter = filters.find((f): f is VersionFilter => f instanceof VersionFilter && f.term === 'version'); + + return { + bot: botFilter?.value === undefined ? null : String(botFilter.value), + first: firstFilter?.value === undefined ? null : String(firstFilter.value), + level: levelFilter?.value.length ? levelFilter.value.join(',') : null, + project: projectFilter?.value.length ? projectFilter.value.join(',') : null, + reference: referenceFilter?.value?.trim() ? referenceFilter.value : null, + session: sessionFilter?.value?.trim() ? sessionFilter.value : null, + stack: stackFilter?.value?.trim() ? stackFilter.value : null, + status: statusFilter?.value.length ? statusFilter.value.join(',') : null, + tag: tagFilter?.value.length ? tagFilter.value.join(',') : null, + type: typeFilter?.value.length ? typeFilter.value.join(',') : null, + version: versionFilter?.value?.trim() ? versionFilter.value : null + }; + } + + function getQueryFilterParamDeltas(currentParams: ReturnType, baseParams: ReturnType) { + const getDelta = (currentValue: null | string, baseValue: null | string): null | string => { + if (currentValue === baseValue) { + return null; + } + + return currentValue ?? (baseValue ? '' : null); + }; + + return { + bot: getDelta(currentParams.bot, baseParams.bot), + first: getDelta(currentParams.first, baseParams.first), + level: getDelta(currentParams.level, baseParams.level), + project: getDelta(currentParams.project, baseParams.project), + reference: getDelta(currentParams.reference, baseParams.reference), + session: getDelta(currentParams.session, baseParams.session), + stack: getDelta(currentParams.stack, baseParams.stack), + status: getDelta(currentParams.status, baseParams.status), + tag: getDelta(currentParams.tag, baseParams.tag), + type: getDelta(currentParams.type, baseParams.type), + version: getDelta(currentParams.version, baseParams.version) + }; + } + + function isQueryParamFilter(filter: FacetedFilter.IFilter): boolean { + if (filter.type === 'string' && filter.key === 'string-stack') { + return true; + } + + if (filter.type === 'boolean' && filter instanceof BooleanFilter && (filter.term === 'bot' || filter.term === 'first') && filter.value !== undefined) { + return true; + } + + if (filter.type === 'version' && filter instanceof VersionFilter && filter.term !== 'version') { + return false; + } + + return ['level', 'project', 'reference', 'session', 'status', 'tag', 'type', 'version'].includes(filter.type); + } + const eventsQueryParameters: GetEventsParams = $state({ get filter() { return getEffectiveFilter()!; @@ -248,11 +567,11 @@ mode: 'stack_frequent', offset: DEFAULT_OFFSET, get time() { - return getQueryTime()!; + return getQueryTime() ?? undefined; }, set time(value) { const baseTime = savedViewsState.activeSavedView?.time ?? DEFAULT_TIME_RANGE; - queryParams.time = value === baseTime ? null : (value ?? ''); + queryParams.time = value === baseTime ? null : value ? serializeTimeQueryParam(value) : ALL_TIME_QUERY_VALUE; } }); @@ -317,6 +636,10 @@ async function onStackChanged(message: WebSocketMessageValue<'StackChanged'>) { if (message.id && message.change_type === ChangeType.Removed) { + if (message.id === selectedStackId) { + selectedStackId = undefined; + } + removeTableSelection(table, message.id); if (removeTableData(table, (doc: EventSummaryModel) => doc.id === message.id)) { diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/[stackId=objectid]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/[stackId=objectid]/+page.svelte index 6f14e036d5..53b79504ab 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/[stackId=objectid]/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/[stackId=objectid]/+page.svelte @@ -11,7 +11,7 @@ import { watch } from 'runed'; import { toast } from 'svelte-sonner'; - import { redirectToEventsWithFilter } from '../../redirect-to-events.svelte.js'; + import { getEventsNavigationOptionsForFilter, redirectToEventsWithFilter } from '../../redirect-to-events.svelte.js'; const stackId = $derived(page.params.stackId || ''); @@ -24,7 +24,7 @@ ); async function filterChanged(addedOrUpdated: IFilter) { - await redirectToEventsWithFilter(organization.current, addedOrUpdated); + await redirectToEventsWithFilter(organization.current, addedOrUpdated, getEventsNavigationOptionsForFilter(addedOrUpdated)); } function handleError(problem: ProblemDetails) { @@ -35,9 +35,13 @@ toast.error('Unable to load stack event details.'); } + async function handleDeleted() { + await goto(resolve('/(app)/stack')); + } + $effect(() => { document.title = 'Stack Details - Exceptionless'; }); - + diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte index ce0564ec70..a616fc4dca 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte @@ -39,7 +39,7 @@ import { useEventListener, watch } from 'runed'; import { debounce } from 'throttle-debounce'; - import { redirectToEventsWithFilter } from '../redirect-to-events.svelte'; + import { getEventsNavigationOptionsForFilter, redirectToEventsWithFilter } from '../redirect-to-events.svelte'; let selectedEventId: null | string = $state(null); @@ -120,7 +120,7 @@ async function onFilterChanged(addedOrUpdated: FacetedFilter.IFilter) { // If this is a stack filter, redirect to the Events page if (addedOrUpdated.type === 'string' && addedOrUpdated.key === 'string-stack') { - await redirectToEventsWithFilter(organization.current, addedOrUpdated); + await redirectToEventsWithFilter(organization.current, addedOrUpdated, getEventsNavigationOptionsForFilter(addedOrUpdated)); return; } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/overview/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/overview/+page.svelte index 4088b35cab..adc764f6e0 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/overview/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/overview/+page.svelte @@ -206,7 +206,7 @@ {:else if eventsAllTimeChartData.length === 0}

No event history available.

{:else} - + No growth data available.

{:else} - +