@@ -79,7 +186,7 @@
-
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte
index 9bf8cd3b7d..cec87d8ad9 100644
--- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte
+++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte
@@ -26,7 +26,7 @@
import type { NewSavedView, SavedView, UpdateSavedView } from '../models';
- import { deleteSavedView, patchSavedView, postSavedView } from '../api.svelte';
+ import { deleteSavedView, markSavedViewDeleted, patchSavedView, postSavedView, restoreDeletedSavedView } from '../api.svelte';
import DeleteViewDialog from './delete-view-dialog.svelte';
import RenameViewDialog from './rename-view-dialog.svelte';
import SaveViewDialog from './save-view-dialog.svelte';
@@ -48,7 +48,7 @@
filters: IFilter[];
isModified: boolean;
onClearSavedView: () => void;
- onLoadView: (id: string) => void;
+ onLoadView: (view: SavedView) => void;
onResetToSaved: () => void;
savedViews: SavedView[];
setShowChart?: (show: boolean) => void;
@@ -227,7 +227,7 @@
isDeleteDialogOpen = true;
}
- async function handleSave(name: string, isPrivate: boolean) {
+ async function handleSave(name: string, slug: string, isPrivate: boolean) {
if (!organizationId) {
return;
}
@@ -244,6 +244,7 @@
organization_id: organizationId,
show_chart: showChart,
show_stats: showStats,
+ slug,
sort: sort || undefined,
time: time || undefined,
view_type: view
@@ -252,20 +253,20 @@
try {
const result = await createMutation.mutateAsync(body);
isSaveDialogOpen = false;
- onLoadView(result.id);
+ onLoadView(result);
toast.success(`Saved view "${result.name}" created.`);
} catch (error) {
toast.error(getErrorMessage(error, 'Failed to save view. Please try again.'));
}
}
- async function handleRename(name: string) {
+ async function handleRename(name: string, slug: string) {
if (!activeView || !organizationId) {
return;
}
try {
- const result = await updateMutation.mutateAsync({ name });
+ const result = await updateMutation.mutateAsync({ name, slug });
isRenameDialogOpen = false;
toast.success(`View renamed to "${result.name}".`);
} catch (error) {
@@ -303,15 +304,22 @@
}
const target = viewToDelete;
+ const wasActiveView = activeSavedView?.id === target.id;
+ markSavedViewDeleted(target);
+ if (wasActiveView) {
+ onClearSavedView();
+ }
+
try {
await removeMutation.mutateAsync(target);
- if (activeSavedView?.id === target.id) {
- onClearSavedView();
- }
-
toast.success(`View "${target.name}" deleted.`);
} catch {
+ restoreDeletedSavedView(target);
+ if (wasActiveView) {
+ onLoadView(target);
+ }
+
toast.error('Failed to delete view. Please try again.');
} finally {
isDeleteDialogOpen = false;
@@ -323,9 +331,12 @@
{#snippet child({ props })}
-
+
View
+ {#if isModified}
+
+ {/if}
{/snippet}
@@ -437,11 +448,28 @@
{#if isSaveDialogOpen}
-
(isSaveDialogOpen = false)} {onLoadView} />
+ (isSaveDialogOpen = false)}
+ {onLoadView}
+ />
{/if}
{#if isRenameDialogOpen && activeView}
- (isRenameDialogOpen = false)} />
+ (isRenameDialogOpen = false)}
+ />
{/if}
{#if isDeleteDialogOpen}
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/slugs.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/slugs.ts
new file mode 100644
index 0000000000..95d42318ab
--- /dev/null
+++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/slugs.ts
@@ -0,0 +1,59 @@
+import { resolve } from '$app/paths';
+
+import type { SavedView } from './models';
+
+type SavedViewIdentity = Pick & { slug?: null | string };
+type SavedViewLink = Pick & { slug?: null | string };
+
+export const SAVED_VIEW_NAME_MAX_LENGTH = 100;
+export const SAVED_VIEW_SLUG_MAX_LENGTH = 100;
+export const SAVED_VIEW_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
+export const SAVED_VIEW_OBJECT_ID_PATTERN = /^[a-f0-9]{24}$/;
+
+export function findSavedViewByName(savedViews: SavedViewIdentity[], name: string, excludingId?: string): SavedViewIdentity | undefined {
+ const normalizedName = name.trim().toLowerCase();
+ if (!normalizedName) {
+ return undefined;
+ }
+
+ return savedViews.find((savedView) => savedView.id !== excludingId && savedView.name.trim().toLowerCase() === normalizedName);
+}
+
+export function findSavedViewBySlug(savedViews: SavedViewIdentity[], slug: string, excludingId?: string): SavedViewIdentity | undefined {
+ const normalizedSlug = savedViewSlug(slug);
+ if (!normalizedSlug) {
+ return undefined;
+ }
+
+ return savedViews.find((savedView) => savedView.id !== excludingId && savedViewResolvedSlug(savedView) === normalizedSlug);
+}
+
+export function isSavedViewSlugReserved(slug: string): boolean {
+ return SAVED_VIEW_OBJECT_ID_PATTERN.test(slug);
+}
+
+export function isSavedViewSlugValid(slug: string): boolean {
+ return SAVED_VIEW_SLUG_PATTERN.test(slug) && !isSavedViewSlugReserved(slug);
+}
+
+export function savedViewHref(savedView: SavedViewLink): string {
+ if (savedView.view_type === 'stream') {
+ return `${resolve('/(app)/stream')}?saved=${savedView.id}`;
+ }
+
+ const base = savedView.view_type === 'events' ? resolve('/(app)/events') : resolve('/(app)/issues');
+ return `${base}/${savedViewResolvedSlug(savedView)}`;
+}
+
+export function savedViewResolvedSlug(savedView: Pick): string {
+ return savedView.slug || savedViewSlug(savedView.name);
+}
+
+export function savedViewSlug(value: string): string {
+ return value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .replace(/-+/g, '-');
+}
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts
index 367603fd5a..b29d5201c3 100644
--- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts
+++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts
@@ -1,34 +1,40 @@
import type { IFilter } from '$comp/faceted-filter';
import type { ColumnOrderState, ColumnVisibilityState } from '@tanstack/svelte-table';
+import { goto } from '$app/navigation';
import { deserializeFilters } from '$features/events/components/filters/helpers.svelte';
import { organization } from '$features/organizations/context.svelte';
-import { untrack } from 'svelte';
import type { SavedView } from './models';
import { getSavedViewsByViewQuery } from './api.svelte';
+import { savedViewHref, savedViewResolvedSlug } from './slugs';
export interface SavedViewQueryParams {
- filter: null | string;
- saved: null | string | undefined;
+ filter: null | string | undefined;
+ saved?: null | string | undefined;
sort?: null | string;
time?: null | string;
}
export interface UseSavedViewsOptions {
+ baseHref?: string;
defaultColumnVisibility?: ColumnVisibilityState;
filterCacheKey: (filter: null | string) => string;
getColumnOrder?: () => ColumnOrderState;
getColumnVisibility?: () => ColumnVisibilityState;
+ getFilter?: () => null | string;
getFilterDefinitions?: () => string;
getShowChart?: () => boolean;
getShowStats?: () => boolean;
+ getSort?: () => null | string | undefined;
+ getTime?: () => null | string | undefined;
queryParams: SavedViewQueryParams;
setColumnOrder?: (order: ColumnOrderState) => void;
setColumnVisibility?: (visibility: ColumnVisibilityState) => void;
setShowChart?: (show: boolean) => void;
setShowStats?: (show: boolean) => void;
+ slug?: string;
updateFilterCache: (key: string, filters: IFilter[]) => void;
view: string;
}
@@ -36,7 +42,7 @@ export interface UseSavedViewsOptions {
export interface UseSavedViewsReturn {
activeSavedView: SavedView | undefined;
handleClearSavedView: () => void;
- handleLoadView: (id: string) => void;
+ handleLoadView: (view: SavedView) => void;
handleResetToSaved: () => void;
isEnabled: boolean;
isLoading: boolean;
@@ -82,7 +88,22 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur
}
});
- const activeSavedView = $derived(savedViewsListQuery.data?.find((v) => v.id === options.queryParams.saved));
+ const activeSavedView = $derived.by(() => {
+ const views = savedViewsListQuery.data;
+ if (!views) {
+ return undefined;
+ }
+
+ if (options.slug) {
+ return views.find((view) => savedViewResolvedSlug(view) === options.slug);
+ }
+
+ if (options.queryParams.saved) {
+ return views.find((view) => view.id === options.queryParams.saved);
+ }
+
+ return undefined;
+ });
function applyColumnState(view: Pick | undefined): void {
if (options.setColumnVisibility) {
@@ -99,17 +120,18 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur
options.setShowChart?.(view?.show_chart ?? true);
}
- // Hydrate filters/columns when a saved view loads, or clear params if the view is no longer found.
+ // Hydrate saved view state when a saved view loads. Query params remain URL overrides.
// lastLoadedViewId prevents re-hydration on background refetches (which would stomp user edits).
let lastLoadedViewId = '';
$effect(() => {
- const savedId = options.queryParams.saved;
+ const savedViewKey = options.slug ?? options.queryParams.saved;
+ const view = activeSavedView;
const isLoading = savedViewsListQuery.isLoading;
const isFetching = savedViewsListQuery.isFetching;
const views = savedViewsListQuery.data;
- if (!savedId || isLoading || !views) {
- if (!savedId) {
+ if (!savedViewKey || isLoading || !views) {
+ if (!savedViewKey) {
if (lastLoadedViewId !== '') {
applyColumnState(undefined);
applyDisplayState(undefined);
@@ -121,29 +143,21 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur
return;
}
- const view = views.find((v) => v.id === savedId);
if (!view) {
// Skip while refetching to avoid false-positive clears during cache invalidation
if (isFetching) {
return;
}
- // View not found after a definitive load — clear params and allow auto-restore to re-run
- untrack(() => {
- options.queryParams.saved = null;
- });
- options.queryParams.filter = null;
- setSortQueryParam(options.queryParams, null);
- setTimeQueryParam(options.queryParams, null);
return;
}
// Already loaded this view — skip to avoid stomping user edits on background refetch
- if (savedId === lastLoadedViewId) {
+ if (view.id === lastLoadedViewId) {
return;
}
- lastLoadedViewId = savedId;
+ lastLoadedViewId = view.id;
if (view.filter_definitions) {
try {
@@ -154,9 +168,6 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur
}
}
- options.queryParams.filter = view.filter ?? null;
- setSortQueryParam(options.queryParams, view.sort ?? null);
- setTimeQueryParam(options.queryParams, view.time ?? null);
applyColumnState(view);
applyDisplayState(view);
});
@@ -164,19 +175,19 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur
// Detect if current filters or columns differ from the active saved view
const isModified = $derived.by(() => {
const view = activeSavedView;
- if (!view || !options.queryParams.saved) {
+ if (!view) {
return false;
}
- if ((options.queryParams.filter ?? null) !== (view.filter ?? null)) {
+ if ((options.getFilter?.() ?? options.queryParams.filter ?? null) !== (view.filter ?? null)) {
return true;
}
- if (supportsTime && (options.queryParams.time ?? null) !== (view.time ?? null)) {
+ if (supportsTime && (options.getTime?.() ?? options.queryParams.time ?? null) !== (view.time ?? null)) {
return true;
}
- if (supportsSort && (options.queryParams.sort ?? null) !== (view.sort ?? null)) {
+ if (supportsSort && (options.getSort?.() ?? options.queryParams.sort ?? null) !== (view.sort ?? null)) {
return true;
}
@@ -203,8 +214,13 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur
return false;
});
- function handleLoadView(id: string) {
- options.queryParams.saved = id;
+ function handleLoadView(view: SavedView) {
+ if (options.baseHref) {
+ goto(savedViewHref(view));
+ return;
+ }
+
+ options.queryParams.saved = view.id;
}
function handleResetToSaved() {
@@ -222,20 +238,27 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur
}
}
- options.queryParams.filter = view.filter ?? null;
- setSortQueryParam(options.queryParams, view.sort ?? null);
- setTimeQueryParam(options.queryParams, view.time ?? null);
+ options.queryParams.filter = null;
+ setSortQueryParam(options.queryParams, null);
+ setTimeQueryParam(options.queryParams, null);
applyColumnState(view);
applyDisplayState(view);
}
function handleClearSavedView() {
- options.queryParams.saved = null;
options.queryParams.filter = null;
setSortQueryParam(options.queryParams, null);
setTimeQueryParam(options.queryParams, null);
applyColumnState(undefined);
applyDisplayState(undefined);
+
+ if (supportsSavedQueryParam(options.queryParams)) {
+ options.queryParams.saved = undefined;
+ }
+
+ if (options.baseHref) {
+ goto(options.baseHref);
+ }
}
return {
@@ -311,3 +334,7 @@ function normalizeFilterDefinitions(value: null | string | undefined): string {
return value;
}
}
+
+function supportsSavedQueryParam(queryParams: SavedViewQueryParams): queryParams is SavedViewQueryParams & { saved: null | string | undefined } {
+ return Object.prototype.hasOwnProperty.call(queryParams, 'saved');
+}
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts
index 74c3a81d56..72bd3de7dc 100644
--- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts
+++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts
@@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import type { SavedView } from './models';
-import { invalidateSavedViewQueries, queryKeys, SAVED_VIEW_REFRESH_DELAY_MS, syncSavedViewCaches } from './api.svelte';
+import { invalidateSavedViewQueries, queryKeys, removeSavedViewFromCaches, SAVED_VIEW_REFRESH_DELAY_MS, syncSavedViewCaches } from './api.svelte';
import { type SavedViewQueryParams, setSortQueryParam, setTimeQueryParam, supportsSortQueryParam, supportsTimeQueryParam } from './use-saved-views.svelte';
const TEST_ORG_ID = '507f1f77bcf86cd799439011';
@@ -15,6 +15,13 @@ afterEach(() => {
});
function buildSavedView({ id, name, ...overrides }: Partial & Pick): SavedView {
+ const slug =
+ overrides.slug ??
+ name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '');
+
return {
column_order: null,
columns: {},
@@ -32,7 +39,8 @@ function buildSavedView({ id, name, ...overrides }: Partial & Pick {
expect(queryClient.getQueryData(queryKeys.view(TEST_ORG_ID, 'issues'))).toEqual([updatedView, otherView]);
expect(queryClient.getQueryData(queryKeys.organization(TEST_ORG_ID))).toEqual([updatedView, otherView]);
});
+
+ it('removes a deleted view from every saved-view list cache', () => {
+ // Arrange
+ const queryClient = new QueryClient();
+ const deletedView = buildSavedView({ id: 'view-1', name: 'Deleted View' });
+ const otherView = buildSavedView({ id: 'view-2', name: 'Other View' });
+
+ queryClient.setQueryData(queryKeys.organization(TEST_ORG_ID), [deletedView, otherView]);
+ queryClient.setQueryData(queryKeys.view(TEST_ORG_ID, 'issues'), [deletedView, otherView]);
+ queryClient.setQueryData(queryKeys.view(TEST_ORG_ID, 'events'), [deletedView, otherView]);
+
+ // Act
+ removeSavedViewFromCaches(queryClient, deletedView, TEST_ORG_ID);
+
+ // Assert
+ expect(queryClient.getQueryData(queryKeys.organization(TEST_ORG_ID))).toEqual([otherView]);
+ expect(queryClient.getQueryData(queryKeys.view(TEST_ORG_ID, 'issues'))).toEqual([otherView]);
+ expect(queryClient.getQueryData(queryKeys.view(TEST_ORG_ID, 'events'))).toEqual([otherView]);
+ });
});
describe('rename cache update pattern', () => {
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/issue-detail-sheet.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/issue-detail-sheet.svelte
new file mode 100644
index 0000000000..438b73106a
--- /dev/null
+++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/issue-detail-sheet.svelte
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+ Issue Details
+
+
+
+
+
+
+ {#if stackId}
+
+ {/if}
+
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/issue-details.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/issue-details.svelte
new file mode 100644
index 0000000000..6282725c14
--- /dev/null
+++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/issue-details.svelte
@@ -0,0 +1,53 @@
+
+
+{#if stackEventsQuery.isSuccess && !eventId}
+
+ This issue has no events to display.
+{:else if eventId}
+
+{/if}
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts
index 9199c50736..89cbe977b8 100644
--- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts
+++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts
@@ -137,6 +137,7 @@ export interface NewSavedView {
filter?: null | string;
time?: null | string;
sort?: null | string;
+ slug?: null | string;
view_type: string;
filter_definitions?: null | string;
columns?: null | Record;
@@ -375,6 +376,7 @@ export interface UpdateSavedView {
filter?: null | string;
time?: null | string;
sort?: null | string;
+ slug?: null | string;
filter_definitions?: null | string;
columns?: null | Record;
column_order?: string[] | null;
@@ -575,6 +577,7 @@ export interface ViewSavedView {
show_stats?: null | boolean;
show_chart?: null | boolean;
name: string;
+ slug: string;
time?: null | string;
sort?: null | string;
/** @format int32 */
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts
index b4f664af0d..e93216315a 100644
--- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts
+++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts
@@ -194,6 +194,12 @@ export const NewSavedViewSchema = object({
.max(100, "Sort must be at most 100 characters")
.nullable()
.optional(),
+ slug: string()
+ .min(1, "Slug is required")
+ .max(100, "Slug must be at most 100 characters")
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Slug has invalid format")
+ .nullable()
+ .optional(),
view_type: string().min(1, "View type is required"),
filter_definitions: string()
.min(1, "Filter definitions is required")
@@ -419,6 +425,12 @@ export const UpdateSavedViewSchema = object({
filter: string().min(1, "Filter is required").nullable().optional(),
time: string().min(1, "Time is required").nullable().optional(),
sort: string().min(1, "Sort is required").nullable().optional(),
+ slug: string()
+ .min(1, "Slug is required")
+ .max(100, "Slug must be at most 100 characters")
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Slug has invalid format")
+ .nullable()
+ .optional(),
filter_definitions: string()
.min(1, "Filter definitions is required")
.nullable()
@@ -619,6 +631,10 @@ export const ViewSavedViewSchema = object({
show_stats: boolean().nullable().optional(),
show_chart: boolean().nullable().optional(),
name: string().min(1, "Name is required"),
+ slug: string()
+ .min(1, "Slug is required")
+ .max(100, "Slug must be at most 100 characters")
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Slug has invalid format"),
time: string().min(1, "Time is required").nullable().optional(),
sort: string().min(1, "Sort is required").nullable().optional(),
version: int32(),
diff --git a/src/Exceptionless.Web/ClientApp/src/params/objectid.ts b/src/Exceptionless.Web/ClientApp/src/params/objectid.ts
new file mode 100644
index 0000000000..9db7a8b832
--- /dev/null
+++ b/src/Exceptionless.Web/ClientApp/src/params/objectid.ts
@@ -0,0 +1,3 @@
+export function match(param: string): boolean {
+ return /^[a-fA-F0-9]{24}$/.test(param);
+}
diff --git a/src/Exceptionless.Web/ClientApp/src/params/savedview.ts b/src/Exceptionless.Web/ClientApp/src/params/savedview.ts
new file mode 100644
index 0000000000..80e19a1560
--- /dev/null
+++ b/src/Exceptionless.Web/ClientApp/src/params/savedview.ts
@@ -0,0 +1,3 @@
+export function match(param: string): boolean {
+ return /^(?![a-fA-F0-9]{24}$)[a-z0-9]+(?:-[a-z0-9]+)*$/.test(param);
+}
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte
index 2774abbe5b..ca2625356f 100644
--- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte
+++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte
@@ -25,7 +25,7 @@
-
+
{#if isMediumScreenQuery.current}
{:else}
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte
index 5162743c7b..a3b0be6783 100644
--- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte
+++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte
@@ -72,12 +72,12 @@
}
async function handleImpersonate(organization: ViewOrganization): Promise {
- await goto(resolve('/(app)'));
+ await goto(resolve('/(app)/issues'));
currentOrganizationId = organization.id;
}
async function stopImpersonating(): Promise {
- await goto(resolve('/(app)'));
+ await goto(resolve('/(app)/issues'));
currentOrganizationId = organizations[0]?.id;
}
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte
index 3a785bd17f..6523513bb6 100644
--- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte
+++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte
@@ -23,7 +23,11 @@
return isOnRoute && activeSavedParam === savedId;
}
- function isPathActive(href: string): boolean {
+ function isPathActive(href: string | undefined): boolean {
+ if (!href) {
+ return false;
+ }
+
return page.url.pathname === href || page.url.pathname.startsWith(href + '/');
}
@@ -47,7 +51,7 @@
}
function hasSavedViewChildren(route: NavigationItem): boolean {
- return route.children?.some((childItem) => isSavedViewChild(childItem)) ?? false;
+ return !!route.view || (route.children?.some((childItem) => isSavedViewChild(childItem)) ?? false);
}
function isRouteActive(route: NavigationItem): boolean {
@@ -272,7 +276,6 @@
{/if}
{:else if route.children?.length}
- {@const hasSavedViews = hasSavedViewChildren(route)}
setRouteGroupOpen(route, open)} class="group/collapsible">
{#snippet child({ props: collapsibleProps })}
@@ -280,30 +283,13 @@
{#snippet child({ props: triggerProps })}
{#snippet child({ props: buttonProps })}
- {#if hasSavedViews}
-
-
- {route.title}
-
-
- {:else}
-
-
- {route.title}
-
-
- {/if}
+
+
+ {route.title}
+
+
{/snippet}
{/snippet}
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte
index 6979800f9c..2cb41c5a8e 100644
--- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte
+++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte
@@ -175,14 +175,14 @@
}
function getEventHref(result: CommandSearchResult): string {
- return resolve('/(app)/event/[eventId]', { eventId: result.id });
+ return resolve('/(app)/events/[eventId=objectid]', { eventId: result.id });
}
function getIssueHref(result: CommandSearchResult): string {
- return resolve('/(app)/issues/[stackId]', { stackId: result.id });
+ return resolve('/(app)/issues/[stackId=objectid]', { stackId: result.id });
}
- const eventSearchHref = $derived(buildSearchHref(resolve('/(app)'), debouncedSearchText));
+ const eventSearchHref = $derived(buildSearchHref(resolve('/(app)/events'), debouncedSearchText));
const issueSearchHref = $derived(buildSearchHref(resolve('/(app)/issues'), debouncedSearchText));
const commandRoutes = $derived(
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte
index 9b6a0a6e4b..4f03195304 100644
--- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte
+++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte
@@ -20,7 +20,8 @@
import { organization, showOrganizationNotifications } from '$features/organizations/context.svelte';
import { premiumPage } from '$features/organizations/premium-page.svelte';
import { invalidateProjectQueries } from '$features/projects/api.svelte';
- import { getSavedViewsQuery, invalidateSavedViewQueries } from '$features/saved-views/api.svelte';
+ import { getSavedViewsQuery, invalidateSavedViewQueries, isSavedViewDeleted } from '$features/saved-views/api.svelte';
+ import { savedViewHref } from '$features/saved-views/slugs';
import { appKeyboardShortcuts, isKeyboardShortcut } from '$features/shared/keyboard-shortcuts';
import { invalidateStackQueries } from '$features/stacks/api.svelte';
import { invalidateTokenQueries } from '$features/tokens/api.svelte';
@@ -223,7 +224,7 @@
if (isKeyboardShortcut(e, appKeyboardShortcuts.allEvents)) {
e.preventDefault();
- void goto(resolve('/(app)'));
+ void goto(resolve('/(app)/events'));
return;
}
@@ -354,27 +355,13 @@
});
const viewToHref: Record = {
- events: resolve('/(app)'),
+ events: resolve('/(app)/events'),
issues: resolve('/(app)/issues'),
stream: resolve('/(app)/stream')
};
- function buildSavedViewHref(baseHref: string, savedView: SavedView): string {
- const queryEntries: [string, string][] = [['saved', savedView.id]];
- if (savedView.filter) {
- queryEntries.push(['filter', savedView.filter]);
- }
-
- if (savedView.time) {
- queryEntries.push(['time', savedView.time]);
- }
-
- if (savedView.sort && savedView.view_type !== 'issues') {
- queryEntries.push(['sort', savedView.sort]);
- }
-
- const queryParams = new URLSearchParams(queryEntries);
- return `${baseHref}?${queryParams.toString()}`;
+ function buildSavedViewHref(savedView: SavedView): string {
+ return savedViewHref(savedView);
}
const filteredRoutes = $derived.by(() => {
@@ -386,24 +373,30 @@
return route;
}
- if (route.href === resolve('/(app)') && page.params.eventId) {
+ if (route.href === resolve('/(app)/events') && page.params.eventId) {
return {
...route,
- children: [...(route.children ?? []), { href: resolve('/(app)/event/[eventId]', { eventId: page.params.eventId }), title: 'Details' }]
+ children: [
+ ...(route.children ?? []),
+ { href: resolve('/(app)/events/[eventId=objectid]', { eventId: page.params.eventId }), title: 'Details' }
+ ]
};
}
if (route.href === resolve('/(app)/issues') && page.params.stackId) {
return {
...route,
- children: [...(route.children ?? []), { href: resolve('/(app)/issues/[stackId]', { stackId: page.params.stackId }), title: 'Details' }]
+ children: [
+ ...(route.children ?? []),
+ { href: resolve('/(app)/issues/[stackId=objectid]', { stackId: page.params.stackId }), title: 'Details' }
+ ]
};
}
return route;
});
- const savedViews = savedViewsQuery.data ?? [];
+ const savedViews = (savedViewsQuery.data ?? []).filter((savedView) => !isSavedViewDeleted(savedView));
if (savedViews.length === 0) {
return allRoutes;
}
@@ -427,7 +420,7 @@
const children = [
...sortedViews.map((savedView) => ({
- href: buildSavedViewHref(route.href, savedView),
+ href: buildSavedViewHref(savedView),
title: savedView.name
})),
...(route.children ?? [])
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte
index f869cfe778..bda44fa9eb 100644
--- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte
+++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte
@@ -1,401 +1,8 @@
-
-
-
-
{pageTitle}
-
-
-
-
-
-
- {#if savedViewsState.isEnabled}
- (showChart = v)}
- setShowStats={(v) => (showStats = v)}
- sort={queryParams.sort ?? undefined}
- {table}
- time={queryParams.time ?? undefined}
- view={VIEW}
- />
- {/if}
-
-
-
-
-
- {#if showStats}
-
- {/if}
-
- {#if showChart}
-
- {/if}
-
-
- {#snippet footerChildren()}
-
- {#if table.getSelectedRowModel().flatRows.length}
-
- {/if}
-
-
-
-
-
-
-
-
- {/snippet}
-
-
-
-
- (selectedEventId = null)} onError={handleEventError} />
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/+page.svelte
new file mode 100644
index 0000000000..dd2ef6d42a
--- /dev/null
+++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/+page.svelte
@@ -0,0 +1,437 @@
+
+
+
+
+
{pageTitle}
+
+
+
+
+
+
+ {#if savedViewsState.isEnabled}
+ (showChart = v)}
+ setShowStats={(v) => (showStats = v)}
+ sort={getEffectiveSort() ?? undefined}
+ {table}
+ time={getQueryTime() ?? undefined}
+ view={VIEW}
+ />
+ {/if}
+
+
+
+
+
+ {#if showStats}
+
+ {/if}
+
+ {#if showChart}
+
+ {/if}
+
+
+ {#snippet footerChildren()}
+
+ {#if table.getSelectedRowModel().flatRows.length}
+
+ {/if}
+
+
+
+
+
+
+
+
+ {/snippet}
+
+
+
+
+ (selectedEventId = null)} onError={handleEventError} />
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/[eventId=objectid]/+page.svelte
similarity index 94%
rename from src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId]/+page.svelte
rename to src/Exceptionless.Web/ClientApp/src/routes/(app)/events/[eventId=objectid]/+page.svelte
index 737bec4301..8b41c0240a 100644
--- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId]/+page.svelte
+++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/[eventId=objectid]/+page.svelte
@@ -18,7 +18,7 @@
watch(
() => organization.current,
() => {
- goto(resolve('/(app)'));
+ goto(resolve('/(app)/events'));
},
{ lazy: true }
);
@@ -33,7 +33,7 @@
}
toast.error(`The event "${page.params.eventId}" could not be found.`);
- await goto(resolve('/(app)'));
+ await goto(resolve('/(app)/events'));
}
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/[slug=savedview]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/[slug=savedview]/+page.svelte
new file mode 100644
index 0000000000..27d94bd534
--- /dev/null
+++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/[slug=savedview]/+page.svelte
@@ -0,0 +1,5 @@
+
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/routes.svelte.ts
similarity index 82%
rename from src/Exceptionless.Web/ClientApp/src/routes/(app)/event/routes.svelte.ts
rename to src/Exceptionless.Web/ClientApp/src/routes/(app)/events/routes.svelte.ts
index 26e0392b59..127fa4c716 100644
--- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/routes.svelte.ts
+++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/events/routes.svelte.ts
@@ -12,7 +12,7 @@ export function routes(): NavigationItem[] {
return [
{
group: 'Event',
- href: resolve('/(app)/event/[eventId]', { eventId: page.params.eventId }),
+ href: resolve('/(app)/events/[eventId=objectid]', { eventId: page.params.eventId }),
icon: Events,
show: () => false,
title: 'Event Details'
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte
index d1c17b8771..b534b61184 100644
--- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte
+++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte
@@ -8,8 +8,7 @@
import RefreshButton from '$comp/refresh-button.svelte';
import { H3 } from '$comp/typography';
import { showBillingDialogOnUpgradeProblem } from '$features/billing/upgrade-required.svelte';
- import { type GetEventsParams, getOrganizationCountQuery, getStackEventsQuery } from '$features/events/api.svelte';
- import EventDetailSheet from '$features/events/components/event-detail-sheet.svelte';
+ 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';
@@ -34,6 +33,7 @@
import { getSharedTableOptions, isTableEmpty, removeTableData, removeTableSelection } from '$features/shared/table.svelte';
import { fillDateSeries } from '$features/shared/utils/charts';
import { parseDateMathRange, toDateMathRange } from '$features/shared/utils/datemath';
+ import IssueDetailSheet from '$features/stacks/components/issue-detail-sheet.svelte';
import TableStacksBulkActionsDropdownMenu from '$features/stacks/components/stacks-bulk-actions-dropdown-menu.svelte';
import { StackStatus } from '$features/stacks/models';
import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models';
@@ -48,6 +48,7 @@
// TODO: Update this page to use StackSummaryModel instead of EventSummaryModel.
let selectedStackId = $state();
+ const DEFAULT_FILTER = '(type:404 OR type:error) (status:open OR status:regressed)';
function handleStackError(problem: ProblemDetails) {
showBillingDialogOnUpgradeProblem(problem, organization.current);
@@ -58,22 +59,8 @@
selectedStackId = row.id;
}
- // Load the latest event for the stack and display it in the sidebar.
- const eventsQuery = getStackEventsQuery({
- params: {
- limit: 1
- },
- route: {
- get stackId() {
- return selectedStackId;
- }
- }
- });
- const eventId = $derived(eventsQuery?.data?.[0]?.id);
-
function rowHref(row: EventSummaryModel): string {
- const stackFilter = `stack:${row.id}`;
- return `${resolve('/(app)')}?filter=${encodeURIComponent(stackFilter)}`;
+ return resolve('/(app)/issues/[stackId=objectid]', { stackId: row.id });
}
const DEFAULT_TIME_RANGE = '[now-7d TO now]';
@@ -84,24 +71,38 @@
new StatusFilter([StackStatus.Open, StackStatus.Regressed])
];
const DEFAULT_PARAMS = {
- filter: '(type:404 OR type:error) (status:open OR status:regressed)',
+ filter: undefined as string | undefined,
limit: DEFAULT_LIMIT,
- saved: undefined as string | undefined,
- time: DEFAULT_TIME_RANGE
+ time: undefined as string | undefined
};
function filterCacheKey(filter: null | string): string {
return buildFilterCacheKey(organization.current, page.url.pathname, filter);
}
- updateFilterCache(filterCacheKey(DEFAULT_PARAMS.filter), DEFAULT_FILTERS);
+ function getQueryTime(): null | string {
+ if (page.url.searchParams.has('time')) {
+ return page.url.searchParams.get('time') === '' ? null : queryParams.time;
+ }
+
+ return savedViewsState.activeSavedView?.time ?? DEFAULT_TIME_RANGE;
+ }
+
+ function getEffectiveFilter(): null | string {
+ if (page.url.searchParams.has('filter')) {
+ return queryParams.filter ?? '';
+ }
+
+ return savedViewsState.activeSavedView?.filter ?? DEFAULT_FILTER;
+ }
+
+ updateFilterCache(filterCacheKey(DEFAULT_FILTER), DEFAULT_FILTERS);
const queryParams = queryParamsState({
default: DEFAULT_PARAMS,
pushHistory: true,
schema: {
filter: 'string',
limit: 'number',
- saved: 'string',
time: 'string'
}
});
@@ -110,17 +111,23 @@
let showStats = $state(true);
let showChart = $state(true);
const savedViewsState = useSavedViews({
+ baseHref: resolve('/(app)/issues'),
filterCacheKey,
- getColumnOrder: () => table.store.state.columnOrder,
- getColumnVisibility: () => table.store.state.columnVisibility,
+ getColumnOrder: () => table.state.columnOrder,
+ getColumnVisibility: () => table.state.columnVisibility,
+ getFilter: getEffectiveFilter,
getFilterDefinitions: () => serializeFilters(filters ?? []),
getShowChart: () => showChart,
getShowStats: () => showStats,
+ getTime: getQueryTime,
queryParams,
setColumnOrder: (v) => table.setColumnOrder(v),
setColumnVisibility: (v) => table.setColumnVisibility(v),
setShowChart: (v) => (showChart = v),
setShowStats: (v) => (showStats = v),
+ get slug() {
+ return page.params.slug;
+ },
updateFilterCache,
view: VIEW
});
@@ -129,7 +136,7 @@
watch(
() => organization.current,
() => {
- updateFilterCache(filterCacheKey(DEFAULT_PARAMS.filter), DEFAULT_FILTERS);
+ 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);
reset();
@@ -137,11 +144,12 @@
{ lazy: true }
);
- let filters = $state(applyTimeFilter(getFiltersFromCache(filterCacheKey(queryParams.filter), queryParams.filter), queryParams.time));
+ let filters = $state(applyTimeFilter(getFiltersFromCache(filterCacheKey(getEffectiveFilter()), getEffectiveFilter()), getQueryTime()));
watch(
- [() => queryParams.filter, () => queryParams.time, () => filterCacheVersionNumber()],
- ([filter, time]) => {
- filters = applyTimeFilter(getFiltersFromCache(filterCacheKey(filter), filter), time);
+ [() => page.url.search, () => savedViewsState.activeSavedView, () => filterCacheVersionNumber()],
+ () => {
+ const filter = getEffectiveFilter();
+ filters = applyTimeFilter(getFiltersFromCache(filterCacheKey(filter), filter), getQueryTime());
},
{ lazy: true }
);
@@ -169,15 +177,18 @@
function updateFilters(updatedFilters: FacetedFilter.IFilter[]): void {
const filter = toFilter(updatedFilters.filter((f) => f.type !== 'date'));
+ 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;
updateFilterCache(filterCacheKey(filter), updatedFilters);
- queryParams.time = ((updatedFilters.find((f) => f.type === 'date') as DateFilter | undefined)?.value as string | undefined) ?? null;
- queryParams.filter = filter;
+ queryParams.time = time === baseTime ? null : (time ?? '');
+ queryParams.filter = filter === baseFilter ? null : filter;
}
const eventsQueryParameters: GetEventsParams = $state({
get filter() {
- return queryParams.filter!;
+ return getEffectiveFilter()!;
},
set filter(value) {
queryParams.filter = value;
@@ -191,10 +202,11 @@
mode: 'stack_frequent',
offset: DEFAULT_OFFSET,
get time() {
- return queryParams.time!;
+ return getQueryTime()!;
},
set time(value) {
- queryParams.time = value;
+ const baseTime = savedViewsState.activeSavedView?.time ?? DEFAULT_TIME_RANGE;
+ queryParams.time = value === baseTime ? null : (value ?? '');
}
});
@@ -218,10 +230,15 @@
get queryParameters() {
return eventsQueryParameters;
}
+ }),
+ (state) => ({
+ columnOrder: state.columnOrder,
+ columnVisibility: state.columnVisibility,
+ pagination: state.pagination
})
);
- const canRefresh = $derived(!table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() && table.store.state.pagination.pageIndex === 0);
+ const canRefresh = $derived(!table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() && table.state.pagination.pageIndex === 0);
function reset() {
table.resetRowSelection();
@@ -290,7 +307,7 @@
});
const chartData = $derived(() => {
- const timeRange = parseDateMathRange(queryParams.time);
+ const timeRange = parseDateMathRange(getQueryTime());
const buildZeroFilledSeries = () => {
const series = fillDateSeries(timeRange.start, timeRange.end, (date: Date) => ({
@@ -319,7 +336,7 @@
const stats = $derived.by(() => {
const aggregations = chartDataQuery.data?.aggregations;
- const timeRange = parseDateMathRange(queryParams.time);
+ const timeRange = parseDateMathRange(getQueryTime());
const totalEvents = agg.sum(aggregations, 'sum_count')?.value ?? chartDataQuery.data?.total ?? 0;
const totalIssues = agg.cardinality(aggregations, 'cardinality_stack')?.value ?? 0;
const newIssues = agg.terms(aggregations, 'terms_first')?.buckets[0]?.total ?? 0;
@@ -350,8 +367,8 @@
{#if savedViewsState.isEnabled}
(showChart = v)}
setShowStats={(v) => (showStats = v)}
{table}
- time={queryParams.time ?? undefined}
+ time={getQueryTime() ?? undefined}
view={VIEW}
/>
{/if}
@@ -408,4 +425,4 @@
-