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}
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}
-
+