From 113322234db0eae94b7fccefa254565c84ef926d Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 15 May 2026 22:35:04 -0500 Subject: [PATCH 01/42] feat: Add stacks dashboard page route and API query - Add getStacksQuery function for listing stacks with filtering - Create /stacks route with basic stacks list page - Support filtering by status, tags, date ranges, and custom Lucene expressions - Display stack information: title, tags, event count, first/last occurrence, status Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/lib/features/stacks/api.svelte.ts | 38 ++++++ .../src/routes/(app)/stacks/+page.svelte | 114 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/api.svelte.ts index c4693d0fc8..94eab98d98 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/api.svelte.ts @@ -21,6 +21,15 @@ export const queryKeys = { deleteStack: (ids: string[] | undefined) => [...queryKeys.ids(ids), 'delete'] as const, id: (id: string | undefined) => [...queryKeys.type, id] as const, ids: (ids: string[] | undefined) => [...queryKeys.type, ...(ids ?? [])] as const, + list: (params: GetStacksParams | undefined) => [ + ...queryKeys.type, + 'list', + params?.filter, + params?.sort, + params?.time, + params?.page, + params?.limit + ] as const, postAddLink: (id: string | undefined) => [...queryKeys.id(id), 'add-link'] as const, postChangeStatus: (ids: string[] | undefined) => [...queryKeys.ids(ids), 'change-status'] as const, postMarkCritical: (ids: string[] | undefined) => [...queryKeys.ids(ids), 'mark-critical'] as const, @@ -43,6 +52,14 @@ export interface GetStackRequest { }; } +export interface GetStacksParams { + filter?: string; + sort?: string; + time?: string; + page?: number; + limit?: number; +} + export interface PostAddLinkRequest { route: { id: string | undefined; @@ -138,6 +155,27 @@ export function getStackQuery(request: GetStackRequest) { })); } +export function getStacksQuery(params: GetStacksParams | undefined) { + return createQuery(() => ({ + enabled: () => !!accessToken.current, + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const queryParams = new URLSearchParams(); + if (params?.filter) queryParams.append('filter', params.filter); + if (params?.sort) queryParams.append('sort', params.sort); + if (params?.time) queryParams.append('time', params.time); + if (params?.page) queryParams.append('page', params.page.toString()); + if (params?.limit) queryParams.append('limit', params.limit.toString()); + + const url = queryParams.toString() ? `stacks?${queryParams.toString()}` : 'stacks'; + const response = await client.getJSON(url, { signal }); + + return response.data!; + }, + queryKey: queryKeys.list(params) + })); +} + export function postAddLink(request: PostAddLinkRequest) { const queryClient = useQueryClient(); return createMutation(() => ({ diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte new file mode 100644 index 0000000000..066bdf5a92 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte @@ -0,0 +1,114 @@ + + +
+
+

Stacks

+ +
+ + +
+ handleFilterChange(e.currentTarget.value)} + /> +
+ + +
+ {#if $stacksQuery.isPending} +
Loading stacks...
+ {:else if !$stacksQuery.data || $stacksQuery.data.length === 0} +
No stacks found
+ {:else} + {#each $stacksQuery.data as stack (stack.id)} +
+
{stack.title || 'Untitled'}
+
+ {#if stack.tags && stack.tags.length > 0} + {stack.tags.join(', ')} · + {/if} + {stack.totalOccurrences || 0} events +
+
+ {#if stack.firstOccurrence} + First: {new Date(stack.firstOccurrence).toLocaleDateString()} · + {/if} + {#if stack.lastOccurrence} + Last: {new Date(stack.lastOccurrence).toLocaleDateString()} · + {/if} + Status: {stack.status} +
+
+ {/each} + {/if} +
+ + + {#if $stacksQuery.data && $stacksQuery.data.length > 0} +
+ Showing {$stacksQuery.data.length} stacks +
+ {/if} +
+ + From b541420d85091f13c358f1a9d9803537ca2b8ebd Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 15 May 2026 22:39:14 -0500 Subject: [PATCH 02/42] build: Simplify stacks page to avoid table library issues - Remove complex TanStack table setup that was causing build errors - Use semantic HTML table with Tailwind styling for simplicity - Implement row selection with checkboxes and bulk actions UI - Support filter input, limit selector, and pagination controls - Display stack properties: title, tags, event count, last occurrence, status - Add loading and empty state handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/routes/(app)/stacks/+page.svelte | 175 ++++++++++++++---- 1 file changed, 143 insertions(+), 32 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte index 066bdf5a92..7bd4fdaaae 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte @@ -1,20 +1,24 @@
+

Stacks

@@ -63,52 +92,134 @@ handleFilterChange(e.currentTarget.value)} /> +
- -
+ +
{#if $stacksQuery.isPending} -
Loading stacks...
- {:else if !$stacksQuery.data || $stacksQuery.data.length === 0} -
No stacks found
+ +
+
+

Loading stacks...

+
+
+ {:else if stacks.length === 0} +
+

No stacks found matching your filter.

+
{:else} - {#each $stacksQuery.data as stack (stack.id)} -
-
{stack.title || 'Untitled'}
-
- {#if stack.tags && stack.tags.length > 0} - {stack.tags.join(', ')} · - {/if} - {stack.totalOccurrences || 0} events -
-
- {#if stack.firstOccurrence} - First: {new Date(stack.firstOccurrence).toLocaleDateString()} · - {/if} - {#if stack.lastOccurrence} - Last: {new Date(stack.lastOccurrence).toLocaleDateString()} · - {/if} - Status: {stack.status} + +
+
+ 0} + onchange={toggleAllSelection} + class="w-4 h-4 cursor-pointer" + /> +
+
Title
+
Tags
+
Events
+
Last Occurrence
+
Status
- {/each} +
+ + +
+ {#each stacks as stack (stack.id)} +
toggleRowSelection(stack.id)} + role="button" + tabindex="0" + onkeydown={(e) => { + if (e.key === 'Enter') toggleRowSelection(stack.id); + }} + > + { + e.stopPropagation(); + toggleRowSelection(stack.id); + }} + /> +
+
+ {stack.title || 'Untitled'} +
+
+ {(stack.tags || []).join(', ') || '-'} +
+
+ {(stack.totalOccurrences || 0).toLocaleString()} +
+
+ {#if stack.lastOccurrence} + {new Date(stack.lastOccurrence).toLocaleDateString()} + {:else} + - + {/if} +
+
+ +
+
+
+ {/each} +
{/if}
- - {#if $stacksQuery.data && $stacksQuery.data.length > 0} -
- Showing {$stacksQuery.data.length} stacks + + {#if selectedIds.length > 0} +
+ + {selectedIds.length} stack{selectedIds.length === 1 ? '' : 's'} selected + +
+ + +
{/if} + + +
+ Showing {stacks.length} stack{stacks.length === 1 ? '' : 's'} +
- From f587e25651329a797c674fbeada66960feafe233 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 15 May 2026 22:40:13 -0500 Subject: [PATCH 03/42] feat: Add stacks bulk actions button with all operations - Create StacksBulkActionsButton component for simplified bulk operations - Support mark open/fixed/snoozed/ignored/discarded and delete actions - Integrate with RemoveStackDialog and status change dialogs - Wire bulk actions into stacks page with selection tracking - All dialogs and confirmations included Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../stacks-bulk-actions-button.svelte | 192 ++++++++++++++++++ .../src/routes/(app)/stacks/+page.svelte | 8 +- 2 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stacks-bulk-actions-button.svelte diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stacks-bulk-actions-button.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stacks-bulk-actions-button.svelte new file mode 100644 index 0000000000..87eca6cc5e --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stacks-bulk-actions-button.svelte @@ -0,0 +1,192 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + + Bulk Actions + + markOpen()}>Mark Open + (openMarkStackFixedInVersionDialog = true)}>Mark Fixed + + markSnoozed()} + >Mark Snoozed + + markSnoozed('6hours')}>6 Hours + markSnoozed('day')}>1 Day + markSnoozed('week')}>1 Week + markSnoozed('month')}>1 Month + + + markIgnored()}>Mark Ignored + (openMarkStackDiscardedDialog = true)}>Mark Discarded + + (openRemoveStackDialog = true)} class="text-destructive" title="Delete stacks">Delete + + + + +{#if openMarkStackDiscardedDialog} + +{/if} +{#if openMarkStackFixedInVersionDialog} + +{/if} +{#if openRemoveStackDialog} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte index 7bd4fdaaae..5aed2c4f7b 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte @@ -6,6 +6,7 @@ import type { Stack } from '$features/stacks/models'; import { organization } from '$features/organizations/context.svelte'; import StackStatusBadge from '$features/stacks/components/stack-status-badge.svelte'; + import StacksBulkActionsButton from '$features/stacks/components/stacks-bulk-actions-button.svelte'; import { queryParamsState } from 'kit-query-params'; import { watch } from 'runed'; @@ -196,12 +197,7 @@ {selectedIds.length} stack{selectedIds.length === 1 ? '' : 's'} selected
- + (selectedIds = [])} /> - {/snippet} - - - - Bulk Actions - - markOpen()}>Mark Open - (openMarkStackFixedInVersionDialog = true)}>Mark Fixed - - markSnoozed()} - >Mark Snoozed - - markSnoozed('6hours')}>6 Hours - markSnoozed('day')}>1 Day - markSnoozed('week')}>1 Week - markSnoozed('month')}>1 Month - - - markIgnored()}>Mark Ignored - (openMarkStackDiscardedDialog = true)}>Mark Discarded - - (openRemoveStackDialog = true)} class="text-destructive" title="Delete stacks">Delete - - - - -{#if openMarkStackDiscardedDialog} - -{/if} -{#if openMarkStackFixedInVersionDialog} - -{/if} -{#if openRemoveStackDialog} - -{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/options.svelte.ts new file mode 100644 index 0000000000..f38678fcc5 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/options.svelte.ts @@ -0,0 +1,91 @@ +import type { Stack } from '$features/stacks/models'; + +import NumberFormatter from '$comp/formatters/number.svelte'; +import TimeAgo from '$comp/formatters/time-ago.svelte'; +import { Checkbox } from '$comp/ui/checkbox'; +import { nameof } from '$lib/utils'; +import { type ColumnDef, renderComponent, type StockFeatures } from '@tanstack/svelte-table'; + +import StackStatusCell from './stack-status-cell.svelte'; +import StackTagsCell from './stack-tags-cell.svelte'; + +export function getColumns(): ColumnDef[] { + return [ + { + cell: (props) => + renderComponent(Checkbox, { + 'aria-label': 'Select row', + checked: props.row.getIsSelected(), + class: 'translate-y-[2px]', + disabled: !props.row.getCanSelect(), + indeterminate: props.row.getIsSomeSelected(), + onCheckedChange: (checked: 'indeterminate' | boolean) => props.row.getToggleSelectedHandler()({ target: { checked } }) + }), + enableHiding: false, + enableSorting: false, + header: ({ table }) => + renderComponent(Checkbox, { + checked: table.getIsAllRowsSelected(), + indeterminate: table.getIsSomeRowsSelected(), + onCheckedChange: (checked: 'indeterminate' | boolean) => table.getToggleAllRowsSelectedHandler()({ target: { checked } }) + }), + id: 'select', + meta: { + class: 'w-6' + } + }, + { + accessorKey: nameof('title'), + enableHiding: false, + header: 'Title', + id: 'title' + }, + { + accessorKey: nameof('status'), + cell: (prop) => renderComponent(StackStatusCell, { value: prop.getValue() }), + enableSorting: false, + header: 'Status', + id: 'status', + meta: { + class: 'w-28' + } + }, + { + accessorKey: nameof('tags'), + cell: (prop) => renderComponent(StackTagsCell, { tags: prop.getValue() }), + enableSorting: false, + header: 'Tags', + id: 'tags', + meta: { + class: 'w-40' + } + }, + { + accessorKey: nameof('total_occurrences'), + cell: (prop) => renderComponent(NumberFormatter, { value: prop.getValue() }), + header: 'Events', + id: 'events', + meta: { + class: 'w-24 text-right' + } + }, + { + accessorKey: nameof('first_occurrence'), + cell: (prop) => renderComponent(TimeAgo, { value: prop.getValue() }), + header: 'First', + id: 'first', + meta: { + class: 'w-36' + } + }, + { + accessorKey: nameof('last_occurrence'), + cell: (prop) => renderComponent(TimeAgo, { value: prop.getValue() }), + header: 'Last', + id: 'last', + meta: { + class: 'w-36' + } + } + ]; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-status-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-status-cell.svelte new file mode 100644 index 0000000000..7f915e9602 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-status-cell.svelte @@ -0,0 +1,29 @@ + + +{statusLabels[value] ?? value} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte new file mode 100644 index 0000000000..170cbce6d0 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte @@ -0,0 +1,20 @@ + + +{#if tags && tags.length > 0} +
+ {#each tags.slice(0, 3) as tag} + {tag} + {/each} + {#if tags.length > 3} + +{tags.length - 3} + {/if} +
+{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stacks-data-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stacks-data-table.svelte new file mode 100644 index 0000000000..55b7ceda36 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stacks-data-table.svelte @@ -0,0 +1,51 @@ + + + + + {#if isLoading} + + + + {:else} + + {/if} + + + {#if footerChildren} + {@render footerChildren()} + {:else} +
+
+ +
+ +
+ +
+ +
+ + +
+
+ {/if} +
+
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts index 71de87a4e7..ac93bd4cf8 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts @@ -32,18 +32,18 @@ export function routes(): NavigationItem[] { icon: Issues, title: 'Issues' }, - { - group: 'Dashboards', - href: resolve('/(app)/stacks'), - icon: Stacks, - title: 'Stacks' - }, { group: 'Dashboards', href: resolve('/(app)/stream'), icon: EventStream, title: 'Event Stream' }, + { + group: 'Reports', + href: resolve('/(app)/stacks'), + icon: Stacks, + title: 'Stacks' + }, { group: 'Reports', href: resolve('/(app)/sessions'), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte index 5aed2c4f7b..7a7f866ec3 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte @@ -1,221 +1,232 @@ -
- -
-

Stacks

- -
+ const throttledLoadData = throttle(5000, loadData); - -
- handleFilterChange(e.currentTarget.value)} - /> - -
+ async function onStackChanged(message: WebSocketMessageValue<'StackChanged'>) { + if (message.id && message.change_type === ChangeType.Removed) { + removeTableSelection(table, message.id); - -
- {#if $stacksQuery.isPending} - -
-
-

Loading stacks...

-
-
- {:else if stacks.length === 0} -
-

No stacks found matching your filter.

-
- {:else} - -
-
- 0} - onchange={toggleAllSelection} - class="w-4 h-4 cursor-pointer" - /> -
-
Title
-
Tags
-
Events
-
Last Occurrence
-
Status
-
-
-
+ if (removeTableData(table, (doc: Stack) => doc.id === message.id)) { + if (isTableEmpty(table)) { + await throttledLoadData(); + return; + } + } + } - -
- {#each stacks as stack (stack.id)} -
toggleRowSelection(stack.id)} - role="button" - tabindex="0" - onkeydown={(e) => { - if (e.key === 'Enter') toggleRowSelection(stack.id); - }} - > - { - e.stopPropagation(); - toggleRowSelection(stack.id); - }} - /> -
-
- {stack.title || 'Untitled'} -
-
- {(stack.tags || []).join(', ') || '-'} -
-
- {(stack.totalOccurrences || 0).toLocaleString()} -
-
- {#if stack.lastOccurrence} - {new Date(stack.lastOccurrence).toLocaleDateString()} - {:else} - - - {/if} -
-
- -
-
-
- {/each} -
- {/if} + // Refresh data on any other stack change + await throttledLoadData(); + } + + useEventListener(document, 'StackChanged', async (event) => await onStackChanged((event as CustomEvent).detail)); + + $effect(() => { + loadData(); + }); + + +
+
+

Stacks

+
+ + + +
+
+ + +
- - {#if selectedIds.length > 0} -
- - {selectedIds.length} stack{selectedIds.length === 1 ? '' : 's'} selected - -
- (selectedIds = [])} /> - + + {#snippet footerChildren()} +
+
-
- {/if} - -
- Showing {stacks.length} stack{stacks.length === 1 ? '' : 's'} -
+ + +
+ + +
+ {/snippet} +
- - From 398f00f8045469c64aab29ef16481ab0642288a7 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 16 May 2026 06:43:29 -0500 Subject: [PATCH 07/42] fix: lint errors in stacks page Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../features/stacks/components/table/stack-tags-cell.svelte | 2 +- .../ClientApp/src/routes/(app)/stacks/+page.svelte | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte index 170cbce6d0..36e01bc9f4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte @@ -10,7 +10,7 @@ {#if tags && tags.length > 0}
- {#each tags.slice(0, 3) as tag} + {#each tags.slice(0, 3) as tag (tag)} {tag} {/each} {#if tags.length > 3} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte index 7a7f866ec3..ee3668b938 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte @@ -1,4 +1,6 @@ + +
+
+

Stacks

+ +
+ + +
+ handleFilterChange(e.currentTarget.value)} + /> +
+ + +
+ {#if $stacksQuery.isPending} +
Loading stacks...
+ {:else if !$stacksQuery.data || $stacksQuery.data.length === 0} +
No stacks found
+ {:else} + {#each $stacksQuery.data as stack (stack.id)} +
+
{stack.title || 'Untitled'}
+
+ {#if stack.tags && stack.tags.length > 0} + {stack.tags.join(', ')} · + {/if} + {stack.totalOccurrences || 0} events +
+
+ {#if stack.firstOccurrence} + First: {new Date(stack.firstOccurrence).toLocaleDateString()} · + {/if} + {#if stack.lastOccurrence} + Last: {new Date(stack.lastOccurrence).toLocaleDateString()} · + {/if} + Status: {stack.status} +
+
+ {/each} + {/if} +
+ + + {#if $stacksQuery.data && $stacksQuery.data.length > 0} +
+ Showing {$stacksQuery.data.length} stacks +
+ {/if} +
+ + From 92ee15665cab86f550b33f873d9fc8aa0cf08c06 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 15 May 2026 22:39:14 -0500 Subject: [PATCH 11/42] build: Simplify stacks page to avoid table library issues - Remove complex TanStack table setup that was causing build errors - Use semantic HTML table with Tailwind styling for simplicity - Implement row selection with checkboxes and bulk actions UI - Support filter input, limit selector, and pagination controls - Display stack properties: title, tags, event count, last occurrence, status - Add loading and empty state handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/routes/(app)/stacks/+page.svelte | 175 ++++++++++++++---- 1 file changed, 143 insertions(+), 32 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte index 066bdf5a92..7bd4fdaaae 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte @@ -1,20 +1,24 @@
+

Stacks

@@ -63,52 +92,134 @@ handleFilterChange(e.currentTarget.value)} /> +
- -
+ +
{#if $stacksQuery.isPending} -
Loading stacks...
- {:else if !$stacksQuery.data || $stacksQuery.data.length === 0} -
No stacks found
+ +
+
+

Loading stacks...

+
+
+ {:else if stacks.length === 0} +
+

No stacks found matching your filter.

+
{:else} - {#each $stacksQuery.data as stack (stack.id)} -
-
{stack.title || 'Untitled'}
-
- {#if stack.tags && stack.tags.length > 0} - {stack.tags.join(', ')} · - {/if} - {stack.totalOccurrences || 0} events -
-
- {#if stack.firstOccurrence} - First: {new Date(stack.firstOccurrence).toLocaleDateString()} · - {/if} - {#if stack.lastOccurrence} - Last: {new Date(stack.lastOccurrence).toLocaleDateString()} · - {/if} - Status: {stack.status} + +
+
+ 0} + onchange={toggleAllSelection} + class="w-4 h-4 cursor-pointer" + /> +
+
Title
+
Tags
+
Events
+
Last Occurrence
+
Status
- {/each} +
+ + +
+ {#each stacks as stack (stack.id)} +
toggleRowSelection(stack.id)} + role="button" + tabindex="0" + onkeydown={(e) => { + if (e.key === 'Enter') toggleRowSelection(stack.id); + }} + > + { + e.stopPropagation(); + toggleRowSelection(stack.id); + }} + /> +
+
+ {stack.title || 'Untitled'} +
+
+ {(stack.tags || []).join(', ') || '-'} +
+
+ {(stack.totalOccurrences || 0).toLocaleString()} +
+
+ {#if stack.lastOccurrence} + {new Date(stack.lastOccurrence).toLocaleDateString()} + {:else} + - + {/if} +
+
+ +
+
+
+ {/each} +
{/if}
- - {#if $stacksQuery.data && $stacksQuery.data.length > 0} -
- Showing {$stacksQuery.data.length} stacks + + {#if selectedIds.length > 0} +
+ + {selectedIds.length} stack{selectedIds.length === 1 ? '' : 's'} selected + +
+ + +
{/if} + + +
+ Showing {stacks.length} stack{stacks.length === 1 ? '' : 's'} +
- From c407206e24e31c2814393633e36efedb1c502441 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 15 May 2026 22:40:13 -0500 Subject: [PATCH 12/42] feat: Add stacks bulk actions button with all operations - Create StacksBulkActionsButton component for simplified bulk operations - Support mark open/fixed/snoozed/ignored/discarded and delete actions - Integrate with RemoveStackDialog and status change dialogs - Wire bulk actions into stacks page with selection tracking - All dialogs and confirmations included Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../stacks-bulk-actions-button.svelte | 192 ++++++++++++++++++ .../src/routes/(app)/stacks/+page.svelte | 8 +- 2 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stacks-bulk-actions-button.svelte diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stacks-bulk-actions-button.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stacks-bulk-actions-button.svelte new file mode 100644 index 0000000000..87eca6cc5e --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stacks-bulk-actions-button.svelte @@ -0,0 +1,192 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + + Bulk Actions + + markOpen()}>Mark Open + (openMarkStackFixedInVersionDialog = true)}>Mark Fixed + + markSnoozed()} + >Mark Snoozed + + markSnoozed('6hours')}>6 Hours + markSnoozed('day')}>1 Day + markSnoozed('week')}>1 Week + markSnoozed('month')}>1 Month + + + markIgnored()}>Mark Ignored + (openMarkStackDiscardedDialog = true)}>Mark Discarded + + (openRemoveStackDialog = true)} class="text-destructive" title="Delete stacks">Delete + + + + +{#if openMarkStackDiscardedDialog} + +{/if} +{#if openMarkStackFixedInVersionDialog} + +{/if} +{#if openRemoveStackDialog} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte index 7bd4fdaaae..5aed2c4f7b 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte @@ -6,6 +6,7 @@ import type { Stack } from '$features/stacks/models'; import { organization } from '$features/organizations/context.svelte'; import StackStatusBadge from '$features/stacks/components/stack-status-badge.svelte'; + import StacksBulkActionsButton from '$features/stacks/components/stacks-bulk-actions-button.svelte'; import { queryParamsState } from 'kit-query-params'; import { watch } from 'runed'; @@ -196,12 +197,7 @@ {selectedIds.length} stack{selectedIds.length === 1 ? '' : 's'} selected
- + (selectedIds = [])} /> - {/snippet} - - - - Bulk Actions - - markOpen()}>Mark Open - (openMarkStackFixedInVersionDialog = true)}>Mark Fixed - - markSnoozed()} - >Mark Snoozed - - markSnoozed('6hours')}>6 Hours - markSnoozed('day')}>1 Day - markSnoozed('week')}>1 Week - markSnoozed('month')}>1 Month - - - markIgnored()}>Mark Ignored - (openMarkStackDiscardedDialog = true)}>Mark Discarded - - (openRemoveStackDialog = true)} class="text-destructive" title="Delete stacks">Delete - - - - -{#if openMarkStackDiscardedDialog} - -{/if} -{#if openMarkStackFixedInVersionDialog} - -{/if} -{#if openRemoveStackDialog} - -{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/options.svelte.ts new file mode 100644 index 0000000000..f38678fcc5 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/options.svelte.ts @@ -0,0 +1,91 @@ +import type { Stack } from '$features/stacks/models'; + +import NumberFormatter from '$comp/formatters/number.svelte'; +import TimeAgo from '$comp/formatters/time-ago.svelte'; +import { Checkbox } from '$comp/ui/checkbox'; +import { nameof } from '$lib/utils'; +import { type ColumnDef, renderComponent, type StockFeatures } from '@tanstack/svelte-table'; + +import StackStatusCell from './stack-status-cell.svelte'; +import StackTagsCell from './stack-tags-cell.svelte'; + +export function getColumns(): ColumnDef[] { + return [ + { + cell: (props) => + renderComponent(Checkbox, { + 'aria-label': 'Select row', + checked: props.row.getIsSelected(), + class: 'translate-y-[2px]', + disabled: !props.row.getCanSelect(), + indeterminate: props.row.getIsSomeSelected(), + onCheckedChange: (checked: 'indeterminate' | boolean) => props.row.getToggleSelectedHandler()({ target: { checked } }) + }), + enableHiding: false, + enableSorting: false, + header: ({ table }) => + renderComponent(Checkbox, { + checked: table.getIsAllRowsSelected(), + indeterminate: table.getIsSomeRowsSelected(), + onCheckedChange: (checked: 'indeterminate' | boolean) => table.getToggleAllRowsSelectedHandler()({ target: { checked } }) + }), + id: 'select', + meta: { + class: 'w-6' + } + }, + { + accessorKey: nameof('title'), + enableHiding: false, + header: 'Title', + id: 'title' + }, + { + accessorKey: nameof('status'), + cell: (prop) => renderComponent(StackStatusCell, { value: prop.getValue() }), + enableSorting: false, + header: 'Status', + id: 'status', + meta: { + class: 'w-28' + } + }, + { + accessorKey: nameof('tags'), + cell: (prop) => renderComponent(StackTagsCell, { tags: prop.getValue() }), + enableSorting: false, + header: 'Tags', + id: 'tags', + meta: { + class: 'w-40' + } + }, + { + accessorKey: nameof('total_occurrences'), + cell: (prop) => renderComponent(NumberFormatter, { value: prop.getValue() }), + header: 'Events', + id: 'events', + meta: { + class: 'w-24 text-right' + } + }, + { + accessorKey: nameof('first_occurrence'), + cell: (prop) => renderComponent(TimeAgo, { value: prop.getValue() }), + header: 'First', + id: 'first', + meta: { + class: 'w-36' + } + }, + { + accessorKey: nameof('last_occurrence'), + cell: (prop) => renderComponent(TimeAgo, { value: prop.getValue() }), + header: 'Last', + id: 'last', + meta: { + class: 'w-36' + } + } + ]; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-status-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-status-cell.svelte new file mode 100644 index 0000000000..7f915e9602 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-status-cell.svelte @@ -0,0 +1,29 @@ + + +{statusLabels[value] ?? value} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte new file mode 100644 index 0000000000..170cbce6d0 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte @@ -0,0 +1,20 @@ + + +{#if tags && tags.length > 0} +
+ {#each tags.slice(0, 3) as tag} + {tag} + {/each} + {#if tags.length > 3} + +{tags.length - 3} + {/if} +
+{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stacks-data-table.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stacks-data-table.svelte new file mode 100644 index 0000000000..55b7ceda36 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stacks-data-table.svelte @@ -0,0 +1,51 @@ + + + + + {#if isLoading} + + + + {:else} + + {/if} + + + {#if footerChildren} + {@render footerChildren()} + {:else} +
+
+ +
+ +
+ +
+ +
+ + +
+
+ {/if} +
+
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts index 71de87a4e7..ac93bd4cf8 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts @@ -32,18 +32,18 @@ export function routes(): NavigationItem[] { icon: Issues, title: 'Issues' }, - { - group: 'Dashboards', - href: resolve('/(app)/stacks'), - icon: Stacks, - title: 'Stacks' - }, { group: 'Dashboards', href: resolve('/(app)/stream'), icon: EventStream, title: 'Event Stream' }, + { + group: 'Reports', + href: resolve('/(app)/stacks'), + icon: Stacks, + title: 'Stacks' + }, { group: 'Reports', href: resolve('/(app)/sessions'), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte index 5aed2c4f7b..7a7f866ec3 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte @@ -1,221 +1,232 @@ -
- -
-

Stacks

- -
+ const throttledLoadData = throttle(5000, loadData); - -
- handleFilterChange(e.currentTarget.value)} - /> - -
+ async function onStackChanged(message: WebSocketMessageValue<'StackChanged'>) { + if (message.id && message.change_type === ChangeType.Removed) { + removeTableSelection(table, message.id); - -
- {#if $stacksQuery.isPending} - -
-
-

Loading stacks...

-
-
- {:else if stacks.length === 0} -
-

No stacks found matching your filter.

-
- {:else} - -
-
- 0} - onchange={toggleAllSelection} - class="w-4 h-4 cursor-pointer" - /> -
-
Title
-
Tags
-
Events
-
Last Occurrence
-
Status
-
-
-
+ if (removeTableData(table, (doc: Stack) => doc.id === message.id)) { + if (isTableEmpty(table)) { + await throttledLoadData(); + return; + } + } + } - -
- {#each stacks as stack (stack.id)} -
toggleRowSelection(stack.id)} - role="button" - tabindex="0" - onkeydown={(e) => { - if (e.key === 'Enter') toggleRowSelection(stack.id); - }} - > - { - e.stopPropagation(); - toggleRowSelection(stack.id); - }} - /> -
-
- {stack.title || 'Untitled'} -
-
- {(stack.tags || []).join(', ') || '-'} -
-
- {(stack.totalOccurrences || 0).toLocaleString()} -
-
- {#if stack.lastOccurrence} - {new Date(stack.lastOccurrence).toLocaleDateString()} - {:else} - - - {/if} -
-
- -
-
-
- {/each} -
- {/if} + // Refresh data on any other stack change + await throttledLoadData(); + } + + useEventListener(document, 'StackChanged', async (event) => await onStackChanged((event as CustomEvent).detail)); + + $effect(() => { + loadData(); + }); + + +
+
+

Stacks

+
+ + + +
+
+ + +
- - {#if selectedIds.length > 0} -
- - {selectedIds.length} stack{selectedIds.length === 1 ? '' : 's'} selected - -
- (selectedIds = [])} /> - + + {#snippet footerChildren()} +
+
-
- {/if} - -
- Showing {stacks.length} stack{stacks.length === 1 ? '' : 's'} -
+ + +
+ + +
+ {/snippet} +
- - From ca855f76b1a2599c4197cb8815f0282fdc5973f4 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 16 May 2026 06:43:29 -0500 Subject: [PATCH 16/42] fix: lint errors in stacks page Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../features/stacks/components/table/stack-tags-cell.svelte | 2 +- .../ClientApp/src/routes/(app)/stacks/+page.svelte | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte index 170cbce6d0..36e01bc9f4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte @@ -10,7 +10,7 @@ {#if tags && tags.length > 0}
- {#each tags.slice(0, 3) as tag} + {#each tags.slice(0, 3) as tag (tag)} {tag} {/each} {#if tags.length > 3} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte index 7a7f866ec3..ee3668b938 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte @@ -1,4 +1,6 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts index 224c504a20..8150ddacd7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts @@ -170,9 +170,14 @@ export function getSharedTableOptions { setMeta(meta); const limit = configuration.queryParameters.limit ?? DEFAULT_LIMIT; - - const total = isMemoryPaging ? allData().length : ((meta?.total as number) ?? 0); - const totalPages = Math.ceil(total / limit); + const currentPage = + (configuration.paginationStrategy === 'offset' + ? (configuration.queryParameters as TableOffsetPagingParameters).page + : (configuration.queryParameters as TableMemoryPagingParameters).page) ?? 1; + const total = isMemoryPaging ? allData().length : (meta?.total as number | undefined); + const totalPages = total != null + ? Math.ceil(total / limit) + : (meta?.links?.next ? currentPage + 1 : currentPage); setPageCount(totalPages); // // Only adjust pagination for offset pagination here diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/filters/stack-faceted-filter-builder.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/filters/stack-faceted-filter-builder.svelte new file mode 100644 index 0000000000..7d1cf7938d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/filters/stack-faceted-filter-builder.svelte @@ -0,0 +1,29 @@ + + + +{#if includeProject} + +{/if} + + + + + + + + + + + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/options.svelte.ts index f38678fcc5..9056019772 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/options.svelte.ts @@ -6,10 +6,12 @@ import { Checkbox } from '$comp/ui/checkbox'; import { nameof } from '$lib/utils'; import { type ColumnDef, renderComponent, type StockFeatures } from '@tanstack/svelte-table'; +import StackSeverityCell from './stack-severity-cell.svelte'; import StackStatusCell from './stack-status-cell.svelte'; import StackTagsCell from './stack-tags-cell.svelte'; +import StackTypeBadge from './stack-type-badge.svelte'; -export function getColumns(): ColumnDef[] { +export function getColumns(onTagClick?: (tag: string) => void): ColumnDef[] { return [ { cell: (props) => @@ -43,17 +45,33 @@ export function getColumns(): ColumnDef[] { { accessorKey: nameof('status'), cell: (prop) => renderComponent(StackStatusCell, { value: prop.getValue() }), - enableSorting: false, header: 'Status', id: 'status', meta: { class: 'w-28' } }, + { + accessorKey: nameof('type'), + cell: (prop) => renderComponent(StackTypeBadge, { value: prop.getValue() }), + header: 'Type', + id: 'type', + meta: { + class: 'w-24' + } + }, + { + accessorKey: nameof('occurrences_are_critical'), + cell: (prop) => renderComponent(StackSeverityCell, { isCritical: prop.getValue() }), + header: 'Critical', + id: 'critical', + meta: { + class: 'w-24' + } + }, { accessorKey: nameof('tags'), - cell: (prop) => renderComponent(StackTagsCell, { tags: prop.getValue() }), - enableSorting: false, + cell: (prop) => renderComponent(StackTagsCell, { onTagClick, tags: prop.getValue() }), header: 'Tags', id: 'tags', meta: { @@ -86,6 +104,14 @@ export function getColumns(): ColumnDef[] { meta: { class: 'w-36' } + }, + { + accessorKey: nameof('fixed_in_version'), + header: 'Fixed In', + id: 'fixed_in_version', + meta: { + class: 'w-28' + } } ]; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-severity-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-severity-cell.svelte new file mode 100644 index 0000000000..80cab43601 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-severity-cell.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-status-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-status-cell.svelte index 7f915e9602..86a162b57e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-status-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-status-cell.svelte @@ -17,9 +17,9 @@ }; const statusVariants: Record = { - discarded: 'outline', + discarded: 'destructive', fixed: 'secondary', - ignored: 'outline', + ignored: 'destructive', open: 'default', regressed: 'destructive', snoozed: 'secondary' diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte index 36e01bc9f4..1e12aee798 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte @@ -1,20 +1,86 @@ {#if tags && tags.length > 0}
{#each tags.slice(0, 3) as tag (tag)} - {tag} + {/each} {#if tags.length > 3} - +{tags.length - 3} + + + {#snippet child({ props })} + +{tags.length - 3} + {/snippet} + + + {tags.slice(3).join(', ')} + + {/if}
{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-type-badge.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-type-badge.svelte new file mode 100644 index 0000000000..e42196fa17 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-type-badge.svelte @@ -0,0 +1,23 @@ + + +{label} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/stack-filter-support.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/stack-filter-support.ts new file mode 100644 index 0000000000..74d239c16f --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/stack-filter-support.ts @@ -0,0 +1,65 @@ +import type { IFilter } from '$comp/faceted-filter'; + +export const STACK_CRITICAL_TERM = 'critical'; +const STACK_BOOLEAN_TERMS = new Set(['occurrences_are_critical', STACK_CRITICAL_TERM]); +const STACK_DATE_TERMS = new Set(['date_fixed', 'first', 'first_occurrence', 'fixedon', 'last', 'last_occurrence', 'snooze_until_utc']); +const STACK_NUMBER_TERMS = new Set(['occurrences', 'total_occurrences']); +const STACK_STRING_TERMS = new Set(['description', 'fixed_in_version', 'links', 'stack', 'title', 'type', 'version_fixed']); + +export const stackFilterTerms = { + boolean: STACK_BOOLEAN_TERMS, + date: STACK_DATE_TERMS, + number: STACK_NUMBER_TERMS, + string: STACK_STRING_TERMS +} as const; + +export function describeStackFilter(filter: IFilter): string { + if ('term' in filter && typeof filter.term === 'string' && filter.term.length > 0) { + return `${filter.type}:${filter.term}`; + } + + return filter.type; +} + +export function isStackFilterSupported(filter: IFilter): boolean { + if (filter.type === 'keyword' || filter.type === 'project' || filter.type === 'status' || filter.type === 'tag' || filter.type === 'type') { + return true; + } + + if (filter.type === 'boolean') { + return hasTerm(filter) && STACK_BOOLEAN_TERMS.has(filter.term); + } + + if (filter.type === 'date') { + return hasTerm(filter) && STACK_DATE_TERMS.has(filter.term); + } + + if (filter.type === 'number') { + return hasTerm(filter) && STACK_NUMBER_TERMS.has(filter.term); + } + + if (filter.type === 'string') { + return hasTerm(filter) && STACK_STRING_TERMS.has(filter.term); + } + + return false; +} + +export function splitSupportedStackFilters(filters: IFilter[]): { supported: IFilter[]; unsupported: IFilter[] } { + const supported: IFilter[] = []; + const unsupported: IFilter[] = []; + + for (const filter of filters) { + if (isStackFilterSupported(filter)) { + supported.push(filter); + } else { + unsupported.push(filter); + } + } + + return { supported, unsupported }; +} + +function hasTerm(filter: IFilter): filter is IFilter & { term: string } { + return 'term' in filter && typeof filter.term === 'string' && filter.term.length > 0; +} 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 f41701d997..877d1c57e9 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 @@ -7,7 +7,9 @@ import * as Collapsible from '$comp/ui/collapsible'; import * as Sidebar from '$comp/ui/sidebar'; import { useSidebar } from '$comp/ui/sidebar'; + import { getProjectQuery } from '$features/projects/api.svelte'; import ChevronRight from '@lucide/svelte/icons/chevron-right'; + import Folder from '@lucide/svelte/icons/folder'; import Settings from '@lucide/svelte/icons/settings-2'; import Wrench from '@lucide/svelte/icons/wrench'; @@ -32,7 +34,19 @@ const reportRoutes = $derived(routes.filter((route) => route.group === 'Reports')); const settingsRoutes = $derived(routes.filter((route) => route.group === 'Settings')); - const settingsIsActive = $derived(settingsRoutes.some((route) => route.href === page.url.pathname)); + const projectSettingsRoutes = $derived(routes.filter((route) => route.group === 'Project Settings')); + const settingsIsActive = $derived( + settingsRoutes.some((route) => route.href === page.url.pathname) || projectSettingsRoutes.some((route) => route.href === page.url.pathname) + ); + const currentProjectId = $derived(page.params.projectId); + const currentProjectQuery = getProjectQuery({ + route: { + get id() { + return currentProjectId; + } + } + }); + const currentProjectName = $derived(currentProjectQuery.data?.name ?? currentProjectId ?? 'Project'); const systemRoutes = $derived(routes.filter((route) => route.group === 'System')); const systemBasePath = resolve('/(app)/system'); @@ -177,6 +191,31 @@ {/each} + + {#if projectSettingsRoutes.length > 0} + +
+ + Projects +
+
+ +
+ {currentProjectName} +
+
+ {#each projectSettingsRoutes as subItem (subItem.href)} + + + {#snippet child({ props })} + + {subItem.title} + + {/snippet} + + + {/each} + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index dc691194d6..dcad5d2abe 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -1,5 +1,4 @@

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 14c6282d0a..d2aa27e3f6 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte @@ -41,49 +41,31 @@ import { createTable } from '@tanstack/svelte-table'; import { queryParamsState } from 'kit-query-params'; import { useEventListener, watch } from 'runed'; + import { toast } from 'svelte-sonner'; import { throttle } from 'throttle-debounce'; import { redirectToEventsWithFilter } from '../redirect-to-events.svelte'; - // TODO: Update this page to use StackSummaryModel instead of EventSummaryModel. let selectedStackId = $state(); + let lastNoEventsStackId = $state(); function handleStackError(problem: ProblemDetails) { showBillingDialogOnUpgradeProblem(problem, organization.current); selectedStackId = undefined; } - function rowClick(row: EventSummaryModel) { + function rowclick(row: EventSummaryModel) { 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]', { stackId: row.id }); } const DEFAULT_TIME_RANGE = '[now-7d TO now]'; - const DEFAULT_FILTERS = [ - new DateFilter('date', DEFAULT_TIME_RANGE), - new ProjectFilter([]), - new TypeFilter(['404', 'error']), - new StatusFilter([StackStatus.Open, StackStatus.Regressed]) - ]; + const DEFAULT_FILTERS = [new DateFilter('date', DEFAULT_TIME_RANGE), new ProjectFilter([]), new TypeFilter(['404', 'error']), new StatusFilter([StackStatus.Open, StackStatus.Regressed, StackStatus.Ignored, StackStatus.Discarded])]; const DEFAULT_PARAMS = { - filter: '(type:404 OR type:error) (status:open OR status:regressed)', + filter: '(type:404 OR type:error) (status:open OR status:regressed OR status:ignored OR status:discarded)', limit: DEFAULT_LIMIT, saved: undefined as string | undefined, time: DEFAULT_TIME_RANGE @@ -122,6 +104,8 @@ //params.$reset(); // Work around for https://github.com/beynar/kit-query-params/issues/7 Object.assign(queryParams, DEFAULT_PARAMS); reset(); + selectedStackId = undefined; + lastNoEventsStackId = undefined; }, { lazy: true } ); @@ -190,6 +174,32 @@ const client = useFetchClient(); const clientStatus = useFetchClientStatus(client); let clientResponse = $state[]>>(); + const stackEventsQuery = getStackEventsQuery({ + params: { + limit: 1 + }, + route: { + get stackId() { + return selectedStackId; + } + } + }); + const eventId = $derived(stackEventsQuery.data?.[0]?.id); + + $effect(() => { + const stackId = selectedStackId; + if (!stackId || !stackEventsQuery.isSuccess || stackEventsQuery.data?.length) { + return; + } + + if (lastNoEventsStackId === stackId) { + return; + } + + lastNoEventsStackId = stackId; + selectedStackId = undefined; + toast.info('This issue has no events to display in the sidebar.'); + }); const table = createTable( getSharedTableOptions>({ @@ -316,20 +326,18 @@
- {#if savedViewsState.isEnabled} - - {/if} + - + {#snippet footerChildren()}
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/[stackId]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/[stackId]/+page.svelte new file mode 100644 index 0000000000..149b0d067c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/[stackId]/+page.svelte @@ -0,0 +1,69 @@ + + +
+

Issue Details

+ {#if stackEventsQuery.isSuccess && !eventId} + + This issue has no events to display. + {:else if eventId} + (eventId = nextEventId)} /> + {/if} +
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/+layout.svelte index 705de1b7ab..7bd80df0db 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/+layout.svelte @@ -7,7 +7,6 @@ import { getOrganizationQuery } from '$features/organizations/api.svelte'; import OrganizationAdminActionsDropdownMenu from '$features/organizations/components/organization-admin-actions-dropdown-menu.svelte'; import { organization } from '$features/organizations/context.svelte'; - import * as SplitLayout from '$features/shared/components/layouts/split-layout'; import GlobalUser from '$features/users/components/global-user.svelte'; import { toast } from 'svelte-sonner'; @@ -61,12 +60,7 @@ {/if}
- - - - - - {@render children()} - - + + + {@render children()}
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte index 5bccd12d1b..6960f2d4bd 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte @@ -6,6 +6,7 @@ import { Skeleton } from '$comp/ui/skeleton'; import { Switch } from '$comp/ui/switch'; import { getOrganizationQuery, removeOrganizationFeature, setOrganizationFeature } from '$features/organizations/api.svelte'; + import { organizationFeatureDefinitions } from '$features/organizations/organization-features'; import { getMeQuery } from '$features/users/api.svelte'; import { toast } from 'svelte-sonner'; @@ -24,14 +25,6 @@ const organization = $derived(organizationQuery.data); - const KNOWN_FEATURES: { description: string; id: string; name: string }[] = [ - { - description: 'Allows users to save and reuse filter combinations across dashboard pages.', - id: 'feature-saved-views', - name: 'Saved Views' - } - ]; - function hasFeature(featureId: string) { return organization?.features?.includes(featureId) ?? false; } @@ -57,7 +50,7 @@ return; } - const featureName = KNOWN_FEATURES.find((f) => f.id === featureId)?.name ?? featureId; + const featureName = organizationFeatureDefinitions.find((f) => f.id === featureId)?.name ?? featureId; const success = enabled ? await setFeature.mutateAsync(featureId) : await removeFeature.mutateAsync(featureId); if (success) { @@ -80,37 +73,41 @@
-
- {#if organizationQuery.isLoading} - {#each Array.from({ length: KNOWN_FEATURES.length }, (_, index) => index) as i (`skeleton-${i}`)} -
-
-
- - + {#if organizationFeatureDefinitions.length === 0} + No organization feature toggles are currently available. + {:else} +
+ {#if organizationQuery.isLoading} + {#each Array.from({ length: organizationFeatureDefinitions.length }, (_, index) => index) as i (`skeleton-${i}`)} +
+
+
+ + +
+
-
-
- {/each} - {:else} - {#each KNOWN_FEATURES as feature (feature.id)} -
-
-
-
{feature.name}
- {feature.description} + {/each} + {:else} + {#each organizationFeatureDefinitions as feature (feature.id)} +
+
+
+
{feature.name}
+ {feature.description} +
+ handleToggleFeature(feature.id, checked)} + />
- handleToggleFeature(feature.id, checked)} - />
-
- {/each} - {/if} -
+ {/each} + {/if} +
+ {/if}
{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts index 5c215a6cd3..2cba3ff6cc 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts @@ -1,6 +1,7 @@ import { resolve } from '$app/paths'; import { page } from '$app/state'; import { organization } from '$features/organizations/context.svelte'; +import { organizationFeatureDefinitions } from '$features/organizations/organization-features'; import Usage from '@lucide/svelte/icons/bar-chart'; import Billing from '@lucide/svelte/icons/credit-card'; import Folder from '@lucide/svelte/icons/folder'; @@ -52,9 +53,21 @@ export function routes(): NavigationItem[] { group: 'Organization Settings', href: resolve('/(app)/organization/[organizationId]/features', { organizationId }), icon: Zap, - show: (ctx) => !!ctx.user?.roles?.includes('global'), + show: (ctx) => organizationFeatureDefinitions.length > 0 && !!ctx.user?.roles?.includes('global'), title: 'Features' }, + { + group: 'Settings', + href: resolve('/(app)/organization/[organizationId]/manage', { organizationId }), + icon: Settings, + title: 'General' + }, + { + group: 'Settings', + href: resolve('/(app)/organization/[organizationId]/usage', { organizationId }), + icon: Usage, + title: 'Usage' + }, { group: 'Settings', href: resolve('/(app)/organization/[organizationId]/projects', { organizationId }), @@ -66,6 +79,19 @@ export function routes(): NavigationItem[] { href: resolve('/(app)/organization/[organizationId]/users', { organizationId }), icon: Users, title: 'Users' + }, + { + group: 'Settings', + href: resolve('/(app)/organization/[organizationId]/billing', { organizationId }), + icon: Billing, + title: 'Billing' + }, + { + group: 'Settings', + href: resolve('/(app)/organization/[organizationId]/features', { organizationId }), + icon: Zap, + show: (ctx) => organizationFeatureDefinitions.length > 0 && !!ctx.user?.roles?.includes('global'), + title: 'Features' } ]; } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/routes.svelte.ts index 03fa8bc5df..1b80b303f1 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/routes.svelte.ts @@ -1,5 +1,5 @@ import { resolve } from '$app/paths'; -import Settings from '@lucide/svelte/icons/settings'; +import Building2 from '@lucide/svelte/icons/building-2'; import type { NavigationItem } from '../../routes.svelte'; @@ -10,7 +10,7 @@ export function routes(): NavigationItem[] { { group: 'Settings', href: resolve('/(app)/organization/list'), - icon: Settings, + icon: Building2, show: (context) => !context.impersonating, title: 'Organizations' }, diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/+layout.svelte index bddcfd5add..55cf4e252b 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/+layout.svelte @@ -9,7 +9,6 @@ import OrganizationAdminActionsDropdownMenu from '$features/organizations/components/organization-admin-actions-dropdown-menu.svelte'; import { organization } from '$features/organizations/context.svelte'; import { getProjectQuery } from '$features/projects/api.svelte'; - import * as SplitLayout from '$features/shared/components/layouts/split-layout'; import GlobalUser from '$features/users/components/global-user.svelte'; import NotificationSettings from '@lucide/svelte/icons/mail'; import { toast } from 'svelte-sonner'; @@ -82,12 +81,7 @@
- - - - - - {@render children()} - - + + + {@render children()}
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/+page.svelte new file mode 100644 index 0000000000..d8ce94077c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/+page.svelte @@ -0,0 +1,317 @@ + + +
+
+

Issue Management

+
+ + + +
+
+ + +
+
+ + + {#snippet footerChildren()} +
+ +
+ + + +
+ + +
+ {/snippet} +
+
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/[stackId]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/[stackId]/+page.svelte new file mode 100644 index 0000000000..b079503476 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/[stackId]/+page.svelte @@ -0,0 +1,69 @@ + + +
+

Issue Details

+ {#if stackEventsQuery.isSuccess && !eventId} + + This issue has no events to display. + {:else if eventId} + (eventId = nextEventId)} /> + {/if} +
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/manage/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/manage/+page.svelte index 2e0671f0d0..c76763cd30 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/manage/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/manage/+page.svelte @@ -157,7 +157,7 @@
-
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/routes.svelte.ts index d333d84db4..79e26afdcd 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/routes.svelte.ts @@ -2,6 +2,7 @@ import { resolve } from '$app/paths'; import { page } from '$app/state'; import Usage from '@lucide/svelte/icons/bar-chart'; import ClientConfig from '@lucide/svelte/icons/braces'; +import Issues from '@lucide/svelte/icons/bug'; import Configure from '@lucide/svelte/icons/cloud-download'; import ApiKey from '@lucide/svelte/icons/key'; import Integration from '@lucide/svelte/icons/plug-2'; @@ -56,6 +57,12 @@ export function routes(): NavigationItem[] { href: resolve('/(app)/project/[projectId]/configure', { projectId: page.params.projectId }), icon: Configure, title: 'Configure Client' + }, + { + group: 'Project Settings', + href: resolve('/(app)/project/[projectId]/issues', { projectId: page.params.projectId }), + icon: Issues, + title: 'Issue Management' } ]; } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts index ac93bd4cf8..b2d938c7cd 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts @@ -7,7 +7,6 @@ import Issues from '@lucide/svelte/icons/bug'; import EventStream from '@lucide/svelte/icons/calendar-arrow-down'; import Events from '@lucide/svelte/icons/calendar-days'; import Support from '@lucide/svelte/icons/circle-help'; -import Stacks from '@lucide/svelte/icons/layers'; import Sessions from '@lucide/svelte/icons/timer'; import type { NavigationItem } from '../routes.svelte'; @@ -38,12 +37,6 @@ export function routes(): NavigationItem[] { icon: EventStream, title: 'Event Stream' }, - { - group: 'Reports', - href: resolve('/(app)/stacks'), - icon: Stacks, - title: 'Stacks' - }, { group: 'Reports', href: resolve('/(app)/sessions'), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte index e8030d23db..cabcccdd39 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte @@ -1,44 +1,58 @@ @@ -203,10 +438,21 @@

Stacks

- +
+
- + {#snippet footerChildren()}
@@ -232,3 +478,5 @@ {/snippet}
+ + (selectedStackId = undefined)} onError={handleStackError} /> 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 8e194d3e29..096d54fc82 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte @@ -275,19 +275,17 @@
- {#if savedViewsState.isEnabled} - - {/if} +
diff --git a/src/Exceptionless.Web/Controllers/SavedViewController.cs b/src/Exceptionless.Web/Controllers/SavedViewController.cs index 1cc973000f..4f92d913ac 100644 --- a/src/Exceptionless.Web/Controllers/SavedViewController.cs +++ b/src/Exceptionless.Web/Controllers/SavedViewController.cs @@ -18,18 +18,14 @@ namespace Exceptionless.App.Controllers.API; public class SavedViewController : RepositoryApiController { private const int MaxViewsPerOrganization = 100; - private readonly IOrganizationRepository _organizationRepository; public SavedViewController( ISavedViewRepository repository, - IOrganizationRepository organizationRepository, ApiMapper mapper, IAppQueryValidator validator, TimeProvider timeProvider, ILoggerFactory loggerFactory) : base(repository, mapper, validator, timeProvider, loggerFactory) - { - _organizationRepository = organizationRepository; - } + { } protected override SavedView MapToModel(NewSavedView newModel) => _mapper.MapToSavedView(newModel); protected override ViewSavedView MapToViewModel(SavedView model) => _mapper.MapToViewSavedView(model); @@ -63,7 +59,7 @@ public async Task>> GetByOrganiz /// Get by organization and view /// /// The identifier of the organization. - /// The dashboard view type (events, issues, stream). + /// The dashboard view type (events, issues, stacks, stream). /// The page parameter is used for pagination. This value must be greater than 0. /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. /// The organization could not be found. @@ -179,9 +175,6 @@ protected override async Task CanAddAsync(SavedView value) if (String.IsNullOrEmpty(value.OrganizationId) || !IsInOrganization(value.OrganizationId)) return PermissionResult.Deny; - if (!await IsFeatureEnabledAsync(value.OrganizationId)) - return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "The saved views feature is not enabled for this organization."); - var count = await _repository.CountByOrganizationIdAsync(value.OrganizationId); if (count >= MaxViewsPerOrganization) return PermissionResult.DenyWithMessage($"Organization is limited to {MaxViewsPerOrganization} saved views."); @@ -194,9 +187,6 @@ protected override async Task CanUpdateAsync(SavedView origina if (original.UserId is not null && original.UserId != CurrentUser.Id && !User.IsInRole(AuthorizationRoles.GlobalAdmin)) return PermissionResult.DenyWithNotFound(original.Id); - if (!await IsFeatureEnabledAsync(original.OrganizationId)) - return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "The saved views feature is not enabled for this organization."); - // Private views cannot be set as the default if (original.UserId is not null && changes.GetChangedPropertyNames().Contains(nameof(UpdateSavedView.IsDefault)) @@ -307,15 +297,6 @@ protected override async Task CanDeleteAsync(SavedView value) if (value.UserId is not null && value.UserId != CurrentUser.Id && !User.IsInRole(AuthorizationRoles.GlobalAdmin)) return PermissionResult.DenyWithNotFound(value.Id); - if (!await IsFeatureEnabledAsync(value.OrganizationId)) - return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "The saved views feature is not enabled for this organization."); - return await base.CanDeleteAsync(value); } - - private async Task IsFeatureEnabledAsync(string organizationId) - { - var organization = await _organizationRepository.GetByIdAsync(organizationId, o => o.Cache()); - return organization?.Features?.Contains(OrganizationFeatures.SavedViews) ?? false; - } } diff --git a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs index eceea2728e..f203c88cbc 100644 --- a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs +++ b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs @@ -9,7 +9,7 @@ namespace Exceptionless.Web.Models; public record NewSavedView : IOwnedByOrganization, IValidatableObject { /// The set of valid dashboard view type identifiers. - public static readonly string[] ValidViewTypes = ["events", "issues", "stream"]; + public static readonly string[] ValidViewTypes = ["events", "issues", "stacks", "stream"]; /// Valid column IDs per view, matching the TanStack Table column definitions. public static readonly IReadOnlyDictionary> ValidColumnIds = @@ -17,6 +17,7 @@ public record NewSavedView : IOwnedByOrganization, IValidatableObject { ["events"] = new HashSet { "user", "date" }, ["issues"] = new HashSet { "status", "users", "events", "first", "last" }, + ["stacks"] = new HashSet { "critical", "events", "first", "fixed_in_version", "last", "status", "tags", "type" }, ["stream"] = new HashSet { "user", "date" } }; diff --git a/src/Exceptionless.Web/Startup.cs b/src/Exceptionless.Web/Startup.cs index 4a37df1963..fe99245685 100644 --- a/src/Exceptionless.Web/Startup.cs +++ b/src/Exceptionless.Web/Startup.cs @@ -21,6 +21,7 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Microsoft.Extensions.FileProviders; using Microsoft.Net.Http.Headers; using Scalar.AspNetCore; using Serilog; @@ -283,6 +284,16 @@ ApplicationException applicationException when applicationException.Message.Cont }); app.UseStaticFiles(); + string nextBuildPath = Path.Combine(app.ApplicationServices.GetRequiredService().ContentRootPath, "ClientApp", "build"); + if (Directory.Exists(nextBuildPath)) + { + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = new PhysicalFileProvider(nextBuildPath), + RequestPath = "/next" + }); + } + app.UseDefaultFiles(); app.UseFileServer(); app.UseRouting(); @@ -329,9 +340,11 @@ ApplicationException applicationException when applicationException.Message.Cont private static RequestDelegate CreateRequestDelegate(IEndpointRouteBuilder endpoints, string filePath) { var app = endpoints.CreateApplicationBuilder(); + var webHostEnvironment = endpoints.ServiceProvider.GetRequiredService(); var apiPathSegment = new PathString("/api"); var docsPathSegment = new PathString("/docs"); var nextPathSegment = new PathString("/next"); + var normalizedFilePath = filePath.StartsWith('/') ? filePath : $"/{filePath}"; app.Use(next => context => { bool isApiRequest = context.Request.Path.StartsWithSegments(apiPathSegment); @@ -339,9 +352,9 @@ private static RequestDelegate CreateRequestDelegate(IEndpointRouteBuilder endpo bool isNextRequest = context.Request.Path.StartsWithSegments(nextPathSegment); if (!isApiRequest && !isDocsRequest && !isNextRequest) - context.Request.Path = "/" + filePath; + context.Request.Path = normalizedFilePath; else if (!isApiRequest && !isDocsRequest) - context.Request.Path = "/next/" + filePath; + context.Request.Path = nextPathSegment.Add(new PathString(normalizedFilePath)); // Set endpoint to null so the static files middleware will handle the request. context.SetEndpoint(null); @@ -349,6 +362,16 @@ private static RequestDelegate CreateRequestDelegate(IEndpointRouteBuilder endpo return next(context); }); + string nextBuildPath = Path.Combine(webHostEnvironment.ContentRootPath, "ClientApp", "build"); + if (Directory.Exists(nextBuildPath)) + { + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = new PhysicalFileProvider(nextBuildPath), + RequestPath = "/next" + }); + } + app.UseStaticFiles(); return app.Build(); } diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index 579e976ad8..e2b9d14ced 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -150,7 +150,7 @@ { "name": "viewType", "in": "path", - "description": "The dashboard view type (events, issues, stream).", + "description": "The dashboard view type (events, issues, stacks, stream).", "required": true, "schema": { "type": "string" diff --git a/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs index 7ba3768a49..75a2773c6f 100644 --- a/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs @@ -185,6 +185,57 @@ public async Task PostAsync_AsOrganizationUser_CanCreateSavedView() Assert.Equal("Organization User View", result.Name); } + [Fact] + public async Task PostAsync_WithStacksViewType_ReturnsCreated() + { + var newView = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Stacks - Snoozed", + Filter = "status:snoozed", + ViewType = "stacks" + }; + + var result = await SendRequestAsAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newView) + .StatusCodeShouldBeCreated() + ); + + Assert.NotNull(result); + Assert.Equal("stacks", result.ViewType); + } + + [Fact] + public async Task PostAsync_WithStacksColumns_ReturnsCreated() + { + var newView = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Stacks - Custom Columns", + ViewType = "stacks", + Columns = new Dictionary + { + ["critical"] = true, + ["fixed_in_version"] = false, + ["type"] = true + } + }; + + var result = await SendRequestAsAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newView) + .StatusCodeShouldBeCreated() + ); + + Assert.NotNull(result); + Assert.Equal("stacks", result.ViewType); + } + [Fact] public Task PostAsync_WithEmptyName_ReturnsUnprocessableEntity() { @@ -609,7 +660,7 @@ await SendRequestAsync(r => r } [Fact] - public async Task PostAsync_WhenFeatureDisabled_ReturnsUnprocessableEntity() + public async Task PostAsync_WhenFeatureDisabled_ReturnsCreated() { // Arrange — disable the saved views feature var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); @@ -630,12 +681,12 @@ await SendRequestAsync(r => r .AsTestOrganizationUser() .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") .Content(newView) - .StatusCodeShouldBeUnprocessableEntity() + .StatusCodeShouldBeCreated() ); } [Fact] - public async Task PatchAsync_WhenFeatureDisabled_ReturnsUnprocessableEntity() + public async Task PatchAsync_WhenFeatureDisabled_ReturnsOk() { // Arrange — create a view directly (bypassing feature check), then disable feature var savedView = await _savedViewRepository.AddAsync(new SavedView @@ -660,12 +711,12 @@ await SendRequestAsync(r => r .AsGlobalAdminUser() .AppendPaths("saved-views", savedView.Id) .Content(new UpdateSavedView { Name = "Updated Name" }) - .StatusCodeShouldBeUnprocessableEntity() + .StatusCodeShouldBeOk() ); } [Fact] - public async Task DeleteAsync_WhenFeatureDisabled_ReturnsUnprocessableEntity() + public async Task DeleteAsync_WhenFeatureDisabled_ReturnsAccepted() { // Arrange — create a view directly, then disable feature var savedView = await _savedViewRepository.AddAsync(new SavedView @@ -689,7 +740,7 @@ await SendRequestAsync(r => r .Delete() .AsGlobalAdminUser() .AppendPaths("saved-views", savedView.Id) - .StatusCodeShouldBeUnprocessableEntity() + .StatusCodeShouldBeAccepted() ); } From be4d60440a6180d4bc50c03cb980a4b0725acc7f Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 17 May 2026 20:33:39 -0500 Subject: [PATCH 20/42] fix: polish row interactions and issue detail links Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/event-detail-sheet.svelte | 5 ++-- .../data-table/data-table-body.svelte | 23 ++++++++++++++++++- .../(app)/(components)/layouts/sidebar.svelte | 16 +++++++++---- .../ClientApp/src/routes/(app)/+page.svelte | 1 + .../src/routes/(app)/issues/+page.svelte | 10 +++++++- .../project/[projectId]/issues/+page.svelte | 3 ++- .../src/routes/(app)/stream/+page.svelte | 1 + 7 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-detail-sheet.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-detail-sheet.svelte index 8e80de8012..a161add6e0 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-detail-sheet.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-detail-sheet.svelte @@ -10,13 +10,14 @@ import EventsOverview from './events-overview.svelte'; interface Props { + detailsHref?: string; eventId: null | string; filterChanged: (filter: IFilter) => void; onClose: () => void; onError?: (problem: ProblemDetails) => void; } - let { eventId = $bindable(), filterChanged, onClose, onError }: Props = $props(); + let { detailsHref, eventId = $bindable(), filterChanged, onClose, onError }: Props = $props(); function handleOpenChange() { onClose(); @@ -36,7 +37,7 @@ Event Details ): void { + if (cell.column.id === 'select') { + return; + } + + if (!rowClick) { + return; + } + + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + + event.preventDefault(); + rowClick(cell.row.original); + }
@@ -96,7 +117,7 @@ {:else} - onCellClick(event, cell)}> + onCellClick(event, cell)} onkeydown={(event) => onCellKeydown(event, cell)} tabindex={cell.column.id === 'select' ? undefined : 0}> {/if} 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 877d1c57e9..6d32add2cb 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,6 +23,10 @@ return isOnRoute && (savedItem.isDefault ? !activeSavedParam || activeSavedParam === savedId : activeSavedParam === savedId); } + function isRouteActive(href: string): boolean { + return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`); + } + type Props = ComponentProps & { footer?: Snippet; header?: Snippet; @@ -35,9 +39,7 @@ const settingsRoutes = $derived(routes.filter((route) => route.group === 'Settings')); const projectSettingsRoutes = $derived(routes.filter((route) => route.group === 'Project Settings')); - const settingsIsActive = $derived( - settingsRoutes.some((route) => route.href === page.url.pathname) || projectSettingsRoutes.some((route) => route.href === page.url.pathname) - ); + const settingsIsActive = $derived(settingsRoutes.some((route) => isRouteActive(String(route.href))) || projectSettingsRoutes.some((route) => isRouteActive(String(route.href)))); const currentProjectId = $derived(page.params.projectId); const currentProjectQuery = getProjectQuery({ route: { @@ -178,7 +180,7 @@ {#each settingsRoutes as subItem (subItem.href)} - + {#snippet child({ props })} {#if subItem.icon} @@ -206,9 +208,13 @@ {#each projectSettingsRoutes as subItem (subItem.href)} - + {#snippet child({ props })} + {#if subItem.icon} + {@const Icon = subItem.icon} + + {/if} {subItem.title} {/snippet} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte index 3d0ad70478..0149f6ec9c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte @@ -60,6 +60,7 @@ return resolve('/(app)/event/[eventId]', { eventId: row.id }); } + const DEFAULT_TIME_RANGE = '[now-7d TO now]'; const DEFAULT_FILTERS = [new DateFilter('date', DEFAULT_TIME_RANGE), new ProjectFilter([]), new StatusFilter([StackStatus.Open, StackStatus.Regressed])]; const DEFAULT_PARAMS = { 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 d2aa27e3f6..7eecaf71ad 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte @@ -62,6 +62,7 @@ return resolve('/(app)/issues/[stackId]', { stackId: row.id }); } + const DEFAULT_TIME_RANGE = '[now-7d TO now]'; const DEFAULT_FILTERS = [new DateFilter('date', DEFAULT_TIME_RANGE), new ProjectFilter([]), new TypeFilter(['404', 'error']), new StatusFilter([StackStatus.Open, StackStatus.Regressed, StackStatus.Ignored, StackStatus.Discarded])]; const DEFAULT_PARAMS = { @@ -185,6 +186,7 @@ } }); const eventId = $derived(stackEventsQuery.data?.[0]?.id); + const issueDetailsHref = $derived(selectedStackId ? resolve('/(app)/issues/[stackId]', { stackId: selectedStackId }) : undefined); $effect(() => { const stackId = selectedStackId; @@ -368,4 +370,10 @@
- (selectedStackId = undefined)} onError={handleStackError} /> + (selectedStackId = undefined)} + onError={handleStackError} +/> diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/+page.svelte index d8ce94077c..72f6ca0bf8 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/+page.svelte @@ -7,7 +7,7 @@ import DataTableViewOptions from '$comp/data-table/data-table-view-options.svelte'; import * as FacetedFilter from '$comp/faceted-filter'; import RefreshButton from '$comp/refresh-button.svelte'; - import { H3 } from '$comp/typography'; + import { H3, Muted } from '$comp/typography'; import { showBillingDialogOnUpgradeProblem } from '$features/billing/upgrade-required.svelte'; import { StatusFilter, StringFilter, TagFilter } from '$features/events/components/filters'; import { @@ -284,6 +284,7 @@

Issue Management

+ Defaults to ignored and discarded issues.
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 096d54fc82..c652d62c34 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte @@ -56,6 +56,7 @@ return resolve('/(app)/event/[eventId]', { eventId: row.id }); } + const DEFAULT_FILTERS = [new ProjectFilter([]), new StatusFilter([StackStatus.Open, StackStatus.Regressed])]; const DEFAULT_PARAMS = { filter: '(status:open OR status:regressed)', From 369940dc77ec0e18c98f3ef3f50c9521477fb482 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 17 May 2026 20:43:00 -0500 Subject: [PATCH 21/42] fix: clean up settings sidebar context Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/routes/(app)/(components)/layouts/sidebar.svelte | 7 +++++-- .../ClientApp/src/routes/(app)/routes.svelte.ts | 7 ------- 2 files changed, 5 insertions(+), 9 deletions(-) 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 6d32add2cb..21b6ba6c5a 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 @@ -196,9 +196,12 @@ {#if projectSettingsRoutes.length > 0} -
+
+ + +
- Projects + Current Project
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts index ac93bd4cf8..b2d938c7cd 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts @@ -7,7 +7,6 @@ import Issues from '@lucide/svelte/icons/bug'; import EventStream from '@lucide/svelte/icons/calendar-arrow-down'; import Events from '@lucide/svelte/icons/calendar-days'; import Support from '@lucide/svelte/icons/circle-help'; -import Stacks from '@lucide/svelte/icons/layers'; import Sessions from '@lucide/svelte/icons/timer'; import type { NavigationItem } from '../routes.svelte'; @@ -38,12 +37,6 @@ export function routes(): NavigationItem[] { icon: EventStream, title: 'Event Stream' }, - { - group: 'Reports', - href: resolve('/(app)/stacks'), - icon: Stacks, - title: 'Stacks' - }, { group: 'Reports', href: resolve('/(app)/sessions'), From bcd83d4326c5f8357ffe08462c13b77c2e23e27a Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 17 May 2026 20:46:48 -0500 Subject: [PATCH 22/42] fix: nest project settings under projects nav Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../(app)/(components)/layouts/sidebar.svelte | 60 +++++++++---------- 1 file changed, 28 insertions(+), 32 deletions(-) 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 21b6ba6c5a..18eb1ea6e5 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 @@ -9,7 +9,6 @@ import { useSidebar } from '$comp/ui/sidebar'; import { getProjectQuery } from '$features/projects/api.svelte'; import ChevronRight from '@lucide/svelte/icons/chevron-right'; - import Folder from '@lucide/svelte/icons/folder'; import Settings from '@lucide/svelte/icons/settings-2'; import Wrench from '@lucide/svelte/icons/wrench'; @@ -192,39 +191,36 @@ {/snippet} - {/each} - - {#if projectSettingsRoutes.length > 0} - -
-
- -
- - Current Project -
-
- -
- {currentProjectName} -
-
- {#each projectSettingsRoutes as subItem (subItem.href)} + {#if subItem.title === 'Projects' && projectSettingsRoutes.length > 0} - - {#snippet child({ props })} - - {#if subItem.icon} - {@const Icon = subItem.icon} - - {/if} - {subItem.title} - - {/snippet} - +
+ {currentProjectName} +
- {/each} - {/if} + {#each projectSettingsRoutes as projectSubItem (projectSubItem.href)} + + + {#snippet child({ props })} + + {#if projectSubItem.icon} + {@const Icon = projectSubItem.icon} + + {/if} + {projectSubItem.title} + + {/snippet} + + + {/each} + {/if} + {/each} From 04d6e2c58e918e8edef503385ed3e65993faff5a Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 17 May 2026 20:55:38 -0500 Subject: [PATCH 23/42] fix: show project submenu hierarchy in settings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/routes/(app)/(components)/layouts/sidebar.svelte | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 18eb1ea6e5..3c72401d7c 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 @@ -193,7 +193,10 @@ {#if subItem.title === 'Projects' && projectSettingsRoutes.length > 0} -
+
{currentProjectName}
@@ -206,7 +209,7 @@ href={projectSubItem.href} title={projectSubItem.title} onclick={onMenuClick} - class="pl-10" + class="relative pl-12 before:bg-border before:absolute before:top-0 before:bottom-0 before:left-7 before:w-px" {...props} > {#if projectSubItem.icon} From 73289792070807838dc9c95351c3e025290122eb Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 17 May 2026 21:00:22 -0500 Subject: [PATCH 24/42] fix: render project settings as nested sidebar tree Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/routes/(app)/(components)/layouts/sidebar.svelte | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 3c72401d7c..8e5d785903 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 @@ -192,24 +192,23 @@ {#if subItem.title === 'Projects' && projectSettingsRoutes.length > 0} - +
{currentProjectName}
{#each projectSettingsRoutes as projectSubItem (projectSubItem.href)} - - + + {#snippet child({ props })} {#if projectSubItem.icon} From b33192f4eba3a30f38835615bccfe230778c5a7e Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 17 May 2026 21:04:05 -0500 Subject: [PATCH 25/42] fix: add settings separator after organizations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/routes/(app)/(components)/layouts/sidebar.svelte | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 8e5d785903..d3f72a483b 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 @@ -177,7 +177,12 @@ - {#each settingsRoutes as subItem (subItem.href)} + {#each settingsRoutes as subItem, index (subItem.href)} + {#if index > 0 && settingsRoutes[index - 1]?.title === 'Organizations'} + +
+
+ {/if} {#snippet child({ props })} From 78cf97e6a51e894a89de54b8ca32e3ed9f923e54 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 17 May 2026 21:22:49 -0500 Subject: [PATCH 26/42] Polish sidebar hierarchy and issue management framing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../(app)/(components)/layouts/sidebar.svelte | 59 ++++++++++++++++++- .../ClientApp/src/routes/(app)/+layout.svelte | 42 ++++++++++--- .../src/routes/(app)/account/routes.svelte.ts | 2 +- .../[organizationId]/+layout.svelte | 11 +++- .../project/[projectId]/issues/+page.svelte | 5 +- 5 files changed, 105 insertions(+), 14 deletions(-) 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 d3f72a483b..afddfb17cb 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 @@ -10,6 +10,7 @@ import { getProjectQuery } from '$features/projects/api.svelte'; import ChevronRight from '@lucide/svelte/icons/chevron-right'; import Settings from '@lucide/svelte/icons/settings-2'; + import User from '@lucide/svelte/icons/user'; import Wrench from '@lucide/svelte/icons/wrench'; import type { NavigationItem } from '../../../routes.svelte'; @@ -26,6 +27,17 @@ return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`); } + function isChildItemActive(childItem: { href: string; isDefault?: boolean }, routeHref: string): boolean { + const childUrl = new URL(childItem.href, page.url.origin); + const hasSavedViewParam = childUrl.searchParams.has('saved'); + + if (hasSavedViewParam || childItem.isDefault !== undefined) { + return isSavedItemActive(childItem, routeHref); + } + + return isRouteActive(childUrl.pathname); + } + type Props = ComponentProps & { footer?: Snippet; header?: Snippet; @@ -52,6 +64,8 @@ const systemRoutes = $derived(routes.filter((route) => route.group === 'System')); const systemBasePath = resolve('/(app)/system'); const systemIsActive = $derived(page.url.pathname === systemBasePath || page.url.pathname.startsWith(systemBasePath + '/')); + const accountRoutes = $derived(routes.filter((route) => route.group === 'My Account')); + const accountIsActive = $derived(accountRoutes.some((route) => isRouteActive(String(route.href)))); const sidebar = useSidebar(); @@ -74,7 +88,7 @@ {#each dashboardRoutes as route (route.href)} {@const Icon = route.icon} {#if route.children?.length} - {@const isChildActive = route.href === page.url.pathname || route.children.some((c) => isSavedItemActive(c, route.href))} + {@const isChildActive = route.href === page.url.pathname || route.children.some((childItem) => isChildItemActive(childItem, route.href))} {#snippet child({ props: collapsibleProps })} @@ -104,7 +118,7 @@ {#each route.children as savedItem (savedItem.href)} - + {#snippet child({ props: subProps })}
+ {#if accountRoutes.length > 0} + + + + {#snippet child({ props })} + + + {#snippet child({ props })} + + + Account + + + {/snippet} + + + + {#each accountRoutes as subItem (subItem.href)} + + + {#snippet child({ props })} + + {#if subItem.icon} + {@const Icon = subItem.icon} + + {/if} + {subItem.title} + + {/snippet} +
+
+ {/each} + + + + {/snippet} + + + + {/if} + {#if systemRoutes.length > 0} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index dcad5d2abe..bf3f4772cc 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -292,7 +292,32 @@ const filteredRoutes = $derived.by(() => { const context: NavigationItemContext = { authenticated: isAuthenticated, impersonating: isImpersonating, user: meQuery.data }; - const allRoutes = routes().filter((route) => (route.show ? route.show(context) : true)); + const allRoutes = routes() + .filter((route) => (route.show ? route.show(context) : true)) + .map((route) => { + if (route.group !== 'Dashboards') { + return route; + } + + if (route.href === resolve('/(app)') && page.params.eventId) { + return { + ...route, + children: [...(route.children ?? []), { href: resolve('/(app)/event/[eventId]', { 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' } + ] + }; + } + + return route; + }); const savedViews = savedViewsQuery.data ?? []; if (savedViews.length === 0) { @@ -319,7 +344,7 @@ // Only show submenu if there are non-default views if (nonDefaultViews.length === 0) { - return { ...route, defaultViewId: defaultView?.id, view: viewKey }; + return { ...route, children: route.children, defaultViewId: defaultView?.id, view: viewKey }; } // Show all views sorted: default first, then alphabetically by name @@ -335,11 +360,14 @@ return a.name.localeCompare(b.name); }); - const children = sortedViews.map((savedView) => ({ - href: buildSavedViewHref(route.href, savedView), - isDefault: savedView.is_default, - title: savedView.name - })); + const children = [ + ...sortedViews.map((savedView) => ({ + href: buildSavedViewHref(route.href, savedView), + isDefault: savedView.is_default, + title: savedView.name + })), + ...(route.children ?? []) + ]; return { ...route, diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/routes.svelte.ts index 18a6e2fbe4..d9c5f168b6 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/routes.svelte.ts @@ -16,7 +16,7 @@ export function routes(): NavigationItem[] { group: 'My Account', href: resolve('/(app)/account/manage'), icon: Account, - title: 'Account' + title: 'General' }, { group: 'My Account', diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/+layout.svelte index e2c7c9a356..7f8a1acc18 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/+layout.svelte @@ -3,17 +3,23 @@ import { resolve } from '$app/paths'; import { page } from '$app/state'; import { A, H3 } from '$comp/typography'; + import { accessToken } from '$features/auth/index.svelte'; import { getOrganizationQuery } from '$features/organizations/api.svelte'; import OrganizationAdminActionsDropdownMenu from '$features/organizations/components/organization-admin-actions-dropdown-menu.svelte'; import { organization } from '$features/organizations/context.svelte'; + import { getMeQuery } from '$features/users/api.svelte'; import GlobalUser from '$features/users/components/global-user.svelte'; import { toast } from 'svelte-sonner'; + import type { NavigationItemContext } from '../../../routes.svelte'; + import { routes } from './routes.svelte'; let { children } = $props(); const organizationId = $derived(page.params.organizationId || ''); + const meQuery = getMeQuery(); + const isAuthenticated = $derived(accessToken.current !== null); const organizationQuery = getOrganizationQuery({ route: { get id() { @@ -22,7 +28,10 @@ } }); - const filteredRoutes = $derived(routes().filter((route) => route.group === 'Organization Settings')); + const filteredRoutes = $derived.by(() => { + const context: NavigationItemContext = { authenticated: isAuthenticated, user: meQuery.data }; + return routes().filter((route) => route.group === 'Organization Settings' && (route.show ? route.show(context) : true)); + }); const currentPath = $derived(page.url.pathname); $effect(() => { diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/+page.svelte index 72f6ca0bf8..0e27bc10e2 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/+page.svelte @@ -7,7 +7,7 @@ import DataTableViewOptions from '$comp/data-table/data-table-view-options.svelte'; import * as FacetedFilter from '$comp/faceted-filter'; import RefreshButton from '$comp/refresh-button.svelte'; - import { H3, Muted } from '$comp/typography'; + import { Muted } from '$comp/typography'; import { showBillingDialogOnUpgradeProblem } from '$features/billing/upgrade-required.svelte'; import { StatusFilter, StringFilter, TagFilter } from '$features/events/components/filters'; import { @@ -283,8 +283,7 @@
-

Issue Management

- Defaults to ignored and discarded issues. + Manage project issues, including restoring ignored or discarded issues.
From d3fcb706524abf790403716bd8452e5369b65ead Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 17 May 2026 21:25:23 -0500 Subject: [PATCH 27/42] Move sessions into dashboards navigation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../(app)/(components)/layouts/sidebar.svelte | 22 ------------------- .../ClientApp/src/routes/(app)/+layout.svelte | 2 +- .../src/routes/(app)/routes.svelte.ts | 2 +- 3 files changed, 2 insertions(+), 24 deletions(-) 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 afddfb17cb..8e26a9868f 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 @@ -46,7 +46,6 @@ let { footer, header, routes, ...props }: Props = $props(); const dashboardRoutes = $derived(routes.filter((route) => route.group === 'Dashboards')); - const reportRoutes = $derived(routes.filter((route) => route.group === 'Reports')); const settingsRoutes = $derived(routes.filter((route) => route.group === 'Settings')); const projectSettingsRoutes = $derived(routes.filter((route) => route.group === 'Project Settings')); @@ -154,27 +153,6 @@ - {#if reportRoutes.length > 0} - - Reports - - {#each reportRoutes as route (route.href)} - {@const Icon = route.icon} - - - {#snippet child({ props })} - - - {route.title} - - {/snippet} - - - {/each} - - - {/if} - diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index bf3f4772cc..8152a34ac9 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -325,7 +325,7 @@ } return allRoutes.map((route) => { - if (route.group !== 'Dashboards' && route.group !== 'Reports') { + if (route.group !== 'Dashboards') { return route; } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts index b2d938c7cd..17c33d13ef 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts @@ -38,7 +38,7 @@ export function routes(): NavigationItem[] { title: 'Event Stream' }, { - group: 'Reports', + group: 'Dashboards', href: resolve('/(app)/sessions'), icon: Sessions, title: 'Sessions' From 57b24a816a50c4facd1adf0886b33a2daaa170ce Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 17 May 2026 21:52:29 -0500 Subject: [PATCH 28/42] Add collapsed sidebar hover flyouts for child nav Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../(app)/(components)/layouts/sidebar.svelte | 581 +++++++++++++----- 1 file changed, 416 insertions(+), 165 deletions(-) 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 8e26a9868f..7dce7bde6c 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 @@ -1,14 +1,17 @@ @@ -84,168 +151,218 @@ - {#each dashboardRoutes as route (route.href)} - {@const Icon = route.icon} - {#if route.children?.length} - {@const isChildActive = route.href === page.url.pathname || route.children.some((childItem) => isChildItemActive(childItem, route.href))} - - {#snippet child({ props: collapsibleProps })} - - - {#snippet child({ props: triggerProps })} - - {#snippet child({ props: buttonProps })} - - - {route.title} - - - {/snippet} - - {/snippet} - - - + {#if isIconCollapsed} + {@const menuId = 'section:dashboards'} + onHoverMenuOpenChange(menuId, open)}> + + {#snippet child({ props })} + openHoverMenu(menuId)} onmouseleave={() => closeHoverMenu(menuId)}> + + + Dashboards + + + {/snippet} + + openHoverMenu(menuId)} + onmouseleave={() => closeHoverMenu(menuId)} + > + {#each dashboardRoutes as route (route.href)} + {#if route.children?.length} + + openHoverMenu(menuId)} + onmouseleave={() => closeHoverMenu(menuId)} + > + {route.title} + + openHoverMenu(menuId)} + onmouseleave={() => closeHoverMenu(menuId)} + > + + + {route.title} + + + {#each route.children as savedItem (savedItem.href)} + + + {savedItem.title} + + + {/each} + + + {:else} + + + {route.title} + + + {/if} + {/each} + + + {:else} + + {#snippet child({ props })} + + + {#snippet child({ props })} + + + Dashboards + + + {/snippet} + + + + {#each dashboardRoutes as route (route.href)} + {@const Icon = route.icon} + {#if route.children?.length} + {@const isChildActive = route.href === page.url.pathname || route.children.some((childItem) => isChildItemActive(childItem, route.href))} + + {#snippet child({ props: collapsibleProps })} + + + {#snippet child({ props: triggerProps })} + + {#snippet child({ props: buttonProps })} + + + {route.title} + + + {/snippet} + + {/snippet} + + + + {#each route.children as savedItem (savedItem.href)} + + + {#snippet child({ props: subProps })} + + {savedItem.title} + + {/snippet} + + + {/each} + + + + {/snippet} + + {:else} - - {#snippet child({ props: subProps })} - - {savedItem.title} + + {#snippet child({ props })} + + + {route.title} {/snippet} - {/each} - - - - {/snippet} - - {:else} - - - {#snippet child({ props })} - - - {route.title} - - {/snippet} - - - {/if} - {/each} + {/if} + {/each} + + + + {/snippet} + + {/if} - - {#snippet child({ props })} - - - {#snippet child({ props })} + {#if isIconCollapsed} + {@const menuId = 'section:settings'} + onHoverMenuOpenChange(menuId, open)}> + + {#snippet child({ props })} + openHoverMenu(menuId)} onmouseleave={() => closeHoverMenu(menuId)}> Settings - - {/snippet} - - - - {#each settingsRoutes as subItem, index (subItem.href)} - {#if index > 0 && settingsRoutes[index - 1]?.title === 'Organizations'} - -
-
- {/if} - - - {#snippet child({ props })} - - {#if subItem.icon} - {@const Icon = subItem.icon} - - {/if} - {subItem.title} - - {/snippet} - - - {#if subItem.title === 'Projects' && projectSettingsRoutes.length > 0} - -
- {currentProjectName} -
-
- {#each projectSettingsRoutes as projectSubItem (projectSubItem.href)} - - - {#snippet child({ props })} - - {#if projectSubItem.icon} - {@const Icon = projectSubItem.icon} - - {/if} - {projectSubItem.title} - - {/snippet} - - - {/each} - {/if} +
+ {/snippet} + + openHoverMenu(menuId)} + onmouseleave={() => closeHoverMenu(menuId)} + > + {#each settingsRoutes as subItem (subItem.href)} + + + {subItem.title} + + + {#if subItem.title === 'Projects' && projectSettingsRoutes.length > 0} + + {currentProjectName} + {#each projectSettingsRoutes as projectSubItem (projectSubItem.href)} + + + {projectSubItem.title} + + {/each} - - - - {/snippet} -
-
-
- - {#if accountRoutes.length > 0} - - - + {/if} + {/each} + + + {:else} + {#snippet child({ props })} {#snippet child({ props })} - - Account + + Settings {/snippet} - {#each accountRoutes as subItem (subItem.href)} + {#each settingsRoutes as subItem, index (subItem.href)} + {#if index > 0 && settingsRoutes[index - 1]?.title === 'Organizations'} + +
+
+ {/if} {#snippet child({ props })} @@ -259,12 +376,115 @@ {/snippet} + {#if subItem.title === 'Projects' && projectSettingsRoutes.length > 0} + +
+ {currentProjectName} +
+
+ {#each projectSettingsRoutes as projectSubItem (projectSubItem.href)} + + + {#snippet child({ props })} + + {#if projectSubItem.icon} + {@const Icon = projectSubItem.icon} + + {/if} + {projectSubItem.title} + + {/snippet} + + + {/each} + {/if} {/each}
{/snippet}
+ {/if} +
+
+ + {#if accountRoutes.length > 0} + + + {#if isIconCollapsed} + {@const menuId = 'section:account'} + onHoverMenuOpenChange(menuId, open)}> + + {#snippet child({ props })} + openHoverMenu(menuId)} onmouseleave={() => closeHoverMenu(menuId)}> + + + Account + + + {/snippet} + + openHoverMenu(menuId)} + onmouseleave={() => closeHoverMenu(menuId)} + > + {#each accountRoutes as subItem (subItem.href)} + + + {subItem.title} + + + {/each} + + + {:else} + + {#snippet child({ props })} + + + {#snippet child({ props })} + + + Account + + + {/snippet} + + + + {#each accountRoutes as subItem (subItem.href)} + + + {#snippet child({ props })} + + {#if subItem.icon} + {@const Icon = subItem.icon} + + {/if} + {subItem.title} + + {/snippet} + + + {/each} + + + + {/snippet} + + {/if} {/if} @@ -272,40 +492,71 @@ {#if systemRoutes.length > 0} - - {#snippet child({ props })} - - - {#snippet child({ props })} + {#if isIconCollapsed} + {@const menuId = 'section:system'} + onHoverMenuOpenChange(menuId, open)}> + + {#snippet child({ props })} + openHoverMenu(menuId)} onmouseleave={() => closeHoverMenu(menuId)}> System - - {/snippet} - - - - {#each systemRoutes as subItem (subItem.href)} - - - {#snippet child({ props })} - - {#if subItem.icon} - {@const Icon = subItem.icon} - - {/if} - {subItem.title} - - {/snippet} - - - {/each} - - - - {/snippet} - + + {/snippet} + + openHoverMenu(menuId)} + onmouseleave={() => closeHoverMenu(menuId)} + > + {#each systemRoutes as subItem (subItem.href)} + + + {subItem.title} + + + {/each} + + + {:else} + + {#snippet child({ props })} + + + {#snippet child({ props })} + + + System + + + {/snippet} + + + + {#each systemRoutes as subItem (subItem.href)} + + + {#snippet child({ props })} + + {#if subItem.icon} + {@const Icon = subItem.icon} + + {/if} + {subItem.title} + + {/snippet} + + + {/each} + + + + {/snippet} + + {/if} {/if} From e5588e9445bbca0c5e9f8a63b17d94a719d383e9 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 18 May 2026 08:41:34 -0500 Subject: [PATCH 29/42] Revert out-of-scope changes per PR review Reverted: Startup.cs (next build path), SavedView model/tests, organization-features extraction, events-overview pager, saved-view 422 error handling hack, issues page filter changes, project manage page link change. --- .../events/components/events-overview.svelte | 173 +----------------- .../organizations/organization-features.ts | 8 - .../components/save-view-dialog.svelte | 11 +- .../components/saved-view-picker.svelte | 32 +--- .../src/routes/(app)/issues/+page.svelte | 96 ++++------ .../[organizationId]/features/+page.svelte | 80 ++++---- .../[organizationId]/routes.svelte.ts | 28 +-- .../project/[projectId]/manage/+page.svelte | 14 +- src/Exceptionless.Web/Startup.cs | 27 +-- .../Controllers/SavedViewControllerTests.cs | 63 +------ 10 files changed, 115 insertions(+), 417 deletions(-) delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/organizations/organization-features.ts diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte index 23e271f783..0e093f0c52 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte @@ -5,19 +5,15 @@ import DateTime from '$comp/formatters/date-time.svelte'; import TimeAgo from '$comp/formatters/time-ago.svelte'; - import { Button } from '$comp/ui/button'; import { Skeleton } from '$comp/ui/skeleton'; import * as Table from '$comp/ui/table'; import * as Tabs from '$comp/ui/tabs'; - import { getEventQuery, getStackEventsQuery } from '$features/events/api.svelte'; + import { getEventQuery } from '$features/events/api.svelte'; import * as EventsFacetedFilter from '$features/events/components/filters'; import { getExtendedDataItems, hasErrorOrSimpleError } from '$features/events/persistent-event'; import { getOrganizationQuery } from '$features/organizations/api.svelte'; import { getProjectQuery } from '$features/projects/api.svelte'; import StackCard from '$features/stacks/components/stack-card.svelte'; - import ChevronLeft from '@lucide/svelte/icons/chevron-left'; - import ChevronRight from '@lucide/svelte/icons/chevron-right'; - import { onMount, tick } from 'svelte'; import type { PersistentEvent } from '../models/index'; @@ -35,12 +31,9 @@ filterChanged: (filter: IFilter) => void; handleError: (problem: ProblemDetails) => void; id: string; - onEventChange?: (eventId: string) => void; - onSessionFilter?: () => void; - showStackPager?: boolean; } - let { filterChanged, handleError, id, onEventChange, onSessionFilter, showStackPager = false }: Props = $props(); + let { filterChanged, handleError, id }: Props = $props(); function getTabs(event?: null | PersistentEvent, project?: ViewProject): TabType[] { if (!event) { @@ -65,7 +58,7 @@ } if (getSessionId(event)) { - tabs.push('Session'); + tabs.push('Session Events'); } if (!project) { @@ -98,30 +91,6 @@ } }); - const stackEventsQuery = getStackEventsQuery({ - params: { - limit: 100, - sort: '-date' - }, - route: { - get stackId() { - return eventQuery.data?.stack_id; - } - } - }); - - const currentEventIndex = $derived(stackEventsQuery.data?.findIndex((event) => event.id === id) ?? -1); - const previousEvent = $derived(currentEventIndex > 0 ? stackEventsQuery.data?.[currentEventIndex - 1] : null); - const nextEvent = $derived(currentEventIndex >= 0 ? (stackEventsQuery.data?.[currentEventIndex + 1] ?? null) : null); - - function changeEvent(eventId: string | undefined): void { - if (!eventId) { - return; - } - - onEventChange?.(eventId); - } - const projectQuery = getProjectQuery({ route: { get id() { @@ -143,60 +112,8 @@ type TabType = 'Environment' | 'Exception' | 'Extended Data' | 'Overview' | 'Request' | 'Trace Log' | string; let activeTab = $state('Overview'); - let areTabsScrollable = $state(false); - let canScrollTabsLeft = $state(false); - let canScrollTabsRight = $state(false); - let shouldRoundLastTab = $state(false); - let tabsListElement = $state(null); let tabs = $derived(getTabs(eventQuery.data, projectQuery.data)); - function updateTabScrollState(): void { - if (!tabsListElement) { - areTabsScrollable = false; - canScrollTabsLeft = false; - canScrollTabsRight = false; - shouldRoundLastTab = false; - return; - } - - const maxScrollLeft = tabsListElement.scrollWidth - tabsListElement.clientWidth; - const lastTabElement = tabsListElement.querySelector('[data-slot="tabs-trigger"]:last-child'); - areTabsScrollable = maxScrollLeft > 1; - canScrollTabsLeft = tabsListElement.scrollLeft > 1; - canScrollTabsRight = tabsListElement.scrollLeft < maxScrollLeft - 1; - shouldRoundLastTab = areTabsScrollable || (lastTabElement?.getBoundingClientRect().right ?? 0) >= tabsListElement.getBoundingClientRect().right - 1; - } - - function scrollTabs(direction: -1 | 1): void { - if (!tabsListElement) { - return; - } - - tabsListElement.scrollBy({ - behavior: 'smooth', - left: direction * Math.max(tabsListElement.clientWidth * 0.75, 160) - }); - } - - async function refreshTabScrollState(currentTabs: TabType[]): Promise { - await tick(); - if (currentTabs !== tabs) { - return; - } - - updateTabScrollState(); - } - - async function scrollActiveTabIntoView(currentTab: TabType): Promise { - await tick(); - if (currentTab !== activeTab) { - return; - } - - tabsListElement?.querySelector('[data-state="active"]')?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - updateTabScrollState(); - } - function onPromoted(title: string): void { activeTab = title; } @@ -214,38 +131,10 @@ handleError(eventQuery.error); } }); - - $effect(() => { - void refreshTabScrollState(tabs); - }); - - $effect(() => { - void scrollActiveTabIntoView(activeTab); - }); - - onMount(() => { - updateTabScrollState(); - window.addEventListener('resize', updateTabScrollState); - - return () => { - window.removeEventListener('resize', updateTabScrollState); - }; - }); -{#if showStackPager && eventQuery.data?.stack_id} -
- - -
-{/if} - @@ -278,53 +167,11 @@ {#if eventQuery.isSuccess} -
- {#if areTabsScrollable} - - {/if} - - - {#each tabs as tab (tab)} - - {tab} - - {/each} - - - {#if areTabsScrollable} - - {/if} -
+ + {#each tabs as tab (tab)} + {tab} + {/each} + {#each tabs as tab (tab)} @@ -338,8 +185,8 @@ {:else if tab === 'Trace Log'} - {:else if tab === 'Session'} - + {:else if tab === 'Session Events'} + {:else if tab === 'Extended Data'} {:else} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/organization-features.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/organization-features.ts deleted file mode 100644 index a72eed1521..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/organization-features.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface OrganizationFeatureDefinition { - description: string; - id: string; - name: string; -} - -// Keep this list empty unless there are active, organization-scoped feature toggles. -export const organizationFeatureDefinitions: OrganizationFeatureDefinition[] = []; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte index f7077d728d..28f34f51cd 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte @@ -1,5 +1,4 @@
- General project settings +
+

General

+ Manage your project name. +
+
{ @@ -152,7 +157,7 @@
-
@@ -160,8 +165,7 @@
- - Delete + diff --git a/src/Exceptionless.Web/Startup.cs b/src/Exceptionless.Web/Startup.cs index fe99245685..4a37df1963 100644 --- a/src/Exceptionless.Web/Startup.cs +++ b/src/Exceptionless.Web/Startup.cs @@ -21,7 +21,6 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; -using Microsoft.Extensions.FileProviders; using Microsoft.Net.Http.Headers; using Scalar.AspNetCore; using Serilog; @@ -284,16 +283,6 @@ ApplicationException applicationException when applicationException.Message.Cont }); app.UseStaticFiles(); - string nextBuildPath = Path.Combine(app.ApplicationServices.GetRequiredService().ContentRootPath, "ClientApp", "build"); - if (Directory.Exists(nextBuildPath)) - { - app.UseStaticFiles(new StaticFileOptions - { - FileProvider = new PhysicalFileProvider(nextBuildPath), - RequestPath = "/next" - }); - } - app.UseDefaultFiles(); app.UseFileServer(); app.UseRouting(); @@ -340,11 +329,9 @@ ApplicationException applicationException when applicationException.Message.Cont private static RequestDelegate CreateRequestDelegate(IEndpointRouteBuilder endpoints, string filePath) { var app = endpoints.CreateApplicationBuilder(); - var webHostEnvironment = endpoints.ServiceProvider.GetRequiredService(); var apiPathSegment = new PathString("/api"); var docsPathSegment = new PathString("/docs"); var nextPathSegment = new PathString("/next"); - var normalizedFilePath = filePath.StartsWith('/') ? filePath : $"/{filePath}"; app.Use(next => context => { bool isApiRequest = context.Request.Path.StartsWithSegments(apiPathSegment); @@ -352,9 +339,9 @@ private static RequestDelegate CreateRequestDelegate(IEndpointRouteBuilder endpo bool isNextRequest = context.Request.Path.StartsWithSegments(nextPathSegment); if (!isApiRequest && !isDocsRequest && !isNextRequest) - context.Request.Path = normalizedFilePath; + context.Request.Path = "/" + filePath; else if (!isApiRequest && !isDocsRequest) - context.Request.Path = nextPathSegment.Add(new PathString(normalizedFilePath)); + context.Request.Path = "/next/" + filePath; // Set endpoint to null so the static files middleware will handle the request. context.SetEndpoint(null); @@ -362,16 +349,6 @@ private static RequestDelegate CreateRequestDelegate(IEndpointRouteBuilder endpo return next(context); }); - string nextBuildPath = Path.Combine(webHostEnvironment.ContentRootPath, "ClientApp", "build"); - if (Directory.Exists(nextBuildPath)) - { - app.UseStaticFiles(new StaticFileOptions - { - FileProvider = new PhysicalFileProvider(nextBuildPath), - RequestPath = "/next" - }); - } - app.UseStaticFiles(); return app.Build(); } diff --git a/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs index 75a2773c6f..7ba3768a49 100644 --- a/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs @@ -185,57 +185,6 @@ public async Task PostAsync_AsOrganizationUser_CanCreateSavedView() Assert.Equal("Organization User View", result.Name); } - [Fact] - public async Task PostAsync_WithStacksViewType_ReturnsCreated() - { - var newView = new NewSavedView - { - OrganizationId = SampleDataService.TEST_ORG_ID, - Name = "Stacks - Snoozed", - Filter = "status:snoozed", - ViewType = "stacks" - }; - - var result = await SendRequestAsAsync(r => r - .Post() - .AsTestOrganizationUser() - .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") - .Content(newView) - .StatusCodeShouldBeCreated() - ); - - Assert.NotNull(result); - Assert.Equal("stacks", result.ViewType); - } - - [Fact] - public async Task PostAsync_WithStacksColumns_ReturnsCreated() - { - var newView = new NewSavedView - { - OrganizationId = SampleDataService.TEST_ORG_ID, - Name = "Stacks - Custom Columns", - ViewType = "stacks", - Columns = new Dictionary - { - ["critical"] = true, - ["fixed_in_version"] = false, - ["type"] = true - } - }; - - var result = await SendRequestAsAsync(r => r - .Post() - .AsGlobalAdminUser() - .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") - .Content(newView) - .StatusCodeShouldBeCreated() - ); - - Assert.NotNull(result); - Assert.Equal("stacks", result.ViewType); - } - [Fact] public Task PostAsync_WithEmptyName_ReturnsUnprocessableEntity() { @@ -660,7 +609,7 @@ await SendRequestAsync(r => r } [Fact] - public async Task PostAsync_WhenFeatureDisabled_ReturnsCreated() + public async Task PostAsync_WhenFeatureDisabled_ReturnsUnprocessableEntity() { // Arrange — disable the saved views feature var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); @@ -681,12 +630,12 @@ await SendRequestAsync(r => r .AsTestOrganizationUser() .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") .Content(newView) - .StatusCodeShouldBeCreated() + .StatusCodeShouldBeUnprocessableEntity() ); } [Fact] - public async Task PatchAsync_WhenFeatureDisabled_ReturnsOk() + public async Task PatchAsync_WhenFeatureDisabled_ReturnsUnprocessableEntity() { // Arrange — create a view directly (bypassing feature check), then disable feature var savedView = await _savedViewRepository.AddAsync(new SavedView @@ -711,12 +660,12 @@ await SendRequestAsync(r => r .AsGlobalAdminUser() .AppendPaths("saved-views", savedView.Id) .Content(new UpdateSavedView { Name = "Updated Name" }) - .StatusCodeShouldBeOk() + .StatusCodeShouldBeUnprocessableEntity() ); } [Fact] - public async Task DeleteAsync_WhenFeatureDisabled_ReturnsAccepted() + public async Task DeleteAsync_WhenFeatureDisabled_ReturnsUnprocessableEntity() { // Arrange — create a view directly, then disable feature var savedView = await _savedViewRepository.AddAsync(new SavedView @@ -740,7 +689,7 @@ await SendRequestAsync(r => r .Delete() .AsGlobalAdminUser() .AppendPaths("saved-views", savedView.Id) - .StatusCodeShouldBeAccepted() + .StatusCodeShouldBeUnprocessableEntity() ); } From ea96bea09489b7201ce5b26154fae73474cef12c Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 18 May 2026 08:41:42 -0500 Subject: [PATCH 30/42] Remove stacks page and navigation link per PR review The stacks dashboard was not ready for inclusion. Removed the route and its sidebar link from the layout. --- .../ClientApp/src/routes/(app)/+layout.svelte | 6 +- .../src/routes/(app)/stacks/+page.svelte | 482 ------------------ 2 files changed, 1 insertion(+), 487 deletions(-) delete mode 100644 src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 8152a34ac9..68d68d757c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -272,7 +272,6 @@ const viewToHref: Record = { events: resolve('/(app)'), issues: resolve('/(app)/issues'), - stacks: resolve('/(app)/stacks'), stream: resolve('/(app)/stream') }; @@ -309,10 +308,7 @@ 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]', { stackId: page.params.stackId }), title: 'Details' }] }; } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte deleted file mode 100644 index cabcccdd39..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte +++ /dev/null @@ -1,482 +0,0 @@ - - -
-
-

Stacks

-
- - - -
-
- - - -
-
- - - {#snippet footerChildren()} -
- -
- - - -
- - -
- {/snippet} -
-
- - (selectedStackId = undefined)} onError={handleStackError} /> From 3f35fd90c8a7d5953c8aeee212dbc7232e2b06fc Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 18 May 2026 08:41:52 -0500 Subject: [PATCH 31/42] Improve stacks table: rename severity to critical, fix tags cell UX - Renamed stack-severity-cell to stack-critical-cell (matches column name) - Added tooltip with Kbd for platform-agnostic shortcut hints on tags - Made overflow tags clickable in tooltip dropdown - Added getProjectStacksQuery tanstack wrapper in stacks API - Moved defaultColumnVisibility and getTableOptions to options file --- .../src/lib/features/stacks/api.svelte.ts | 39 +++++++++++++++ .../stacks/components/table/options.svelte.ts | 47 +++++++++++++++++-- ...cell.svelte => stack-critical-cell.svelte} | 0 .../components/table/stack-tags-cell.svelte | 40 +++++++++++----- 4 files changed, 110 insertions(+), 16 deletions(-) rename src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/{stack-severity-cell.svelte => stack-critical-cell.svelte} (100%) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/api.svelte.ts index c4693d0fc8..1c9b74e284 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/api.svelte.ts @@ -28,6 +28,7 @@ export const queryKeys = { postMarkSnoozed: (ids: string[] | undefined) => [...queryKeys.ids(ids), 'mark-snoozed'] as const, postPromote: (ids: string[] | undefined) => [...queryKeys.ids(ids), 'promote'] as const, postRemoveLink: (id: string | undefined) => [...queryKeys.id(id), 'remove-link'] as const, + project: (projectId: string | undefined, params?: GetProjectStacksParams) => [...queryKeys.type, 'project', projectId, { params }] as const, type: ['Stack'] as const }; @@ -37,6 +38,20 @@ export interface DeleteStackRequest { }; } +export interface GetProjectStacksParams { + filter?: string; + limit?: number; + page?: number; + sort?: string; +} + +export interface GetProjectStacksRequest { + params?: GetProjectStacksParams; + route: { + projectId: string | undefined; + }; +} + export interface GetStackRequest { route: { id: string | undefined; @@ -123,6 +138,30 @@ export function deleteStack(request: DeleteStackRequest) { })); } +export function getProjectStacksQuery(request: GetProjectStacksRequest) { + const queryClient = useQueryClient(); + + return createQuery, ProblemDetails>(() => ({ + enabled: () => !!accessToken.current && !!request.route.projectId, + onSuccess: (data: FetchClientResponse) => { + data.data?.forEach((stack) => { + queryClient.setQueryData(queryKeys.id(stack.id!), stack); + }); + }, + queryClient, + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON(`projects/${request.route.projectId}/stacks`, { + params: request.params as Record, + signal + }); + + return response; + }, + queryKey: queryKeys.project(request.route.projectId, request.params) + })); +} + export function getStackQuery(request: GetStackRequest) { return createQuery(() => ({ enabled: () => !!accessToken.current && !!request.route.id, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/options.svelte.ts index 9056019772..e856624f56 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/options.svelte.ts @@ -1,16 +1,33 @@ +import type { GetProjectStacksParams } from '$features/stacks/api.svelte'; import type { Stack } from '$features/stacks/models'; +import type { FetchClientResponse, ProblemDetails } from '@exceptionless/fetchclient'; +import type { CreateQueryResult } from '@tanstack/svelte-query'; import NumberFormatter from '$comp/formatters/number.svelte'; import TimeAgo from '$comp/formatters/time-ago.svelte'; import { Checkbox } from '$comp/ui/checkbox'; +import { getSharedTableOptions } from '$features/shared/table.svelte'; import { nameof } from '$lib/utils'; -import { type ColumnDef, renderComponent, type StockFeatures } from '@tanstack/svelte-table'; +import { type ColumnDef, type ColumnVisibilityState, renderComponent, type StockFeatures } from '@tanstack/svelte-table'; -import StackSeverityCell from './stack-severity-cell.svelte'; +import StackCriticalCell from './stack-critical-cell.svelte'; import StackStatusCell from './stack-status-cell.svelte'; import StackTagsCell from './stack-tags-cell.svelte'; import StackTypeBadge from './stack-type-badge.svelte'; +export const defaultColumnVisibility: ColumnVisibilityState = { + critical: false, + events: true, + first: false, + fixed_in_version: false, + last: true, + select: true, + status: false, + tags: false, + title: true, + type: true +}; + export function getColumns(onTagClick?: (tag: string) => void): ColumnDef[] { return [ { @@ -62,7 +79,7 @@ export function getColumns(onTagClick?: (tag: string) => void): ColumnDef('occurrences_are_critical'), - cell: (prop) => renderComponent(StackSeverityCell, { isCritical: prop.getValue() }), + cell: (prop) => renderComponent(StackCriticalCell, { isCritical: prop.getValue() }), header: 'Critical', id: 'critical', meta: { @@ -115,3 +132,27 @@ export function getColumns(onTagClick?: (tag: string) => void): ColumnDef, ProblemDetails>, + onTagClick?: (tag: string) => void +) { + return getSharedTableOptions({ + columnPersistenceKey: 'project-issues-v2-column-visibility', + get columns() { + return getColumns(onTagClick); + }, + defaultColumnVisibility, + paginationStrategy: 'offset', + get queryData() { + return queryResponse.data?.data ?? []; + }, + get queryMeta() { + return queryResponse.data?.meta; + }, + get queryParameters() { + return queryParameters; + } + }); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-severity-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-critical-cell.svelte similarity index 100% rename from src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-severity-cell.svelte rename to src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-critical-cell.svelte diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte index 1e12aee798..6a70576d00 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte @@ -2,6 +2,7 @@ import { Muted } from '$comp/typography'; import { Badge } from '$comp/ui/badge'; import { Button } from '$comp/ui/button'; + import { Kbd } from '$comp/ui/kbd'; import * as Tooltip from '$comp/ui/tooltip'; import { toast } from 'svelte-sonner'; @@ -41,7 +42,7 @@ event.preventDefault(); event.stopPropagation(); - if (event.altKey) { + if (event.altKey || event.metaKey) { try { await navigator.clipboard.writeText(tag); toast.success(`Copied tag "${tag}" to clipboard.`); @@ -56,29 +57,42 @@ } +{#snippet tagButton(tag: string)} + + + {#snippet child({ props })} + + {/snippet} + + + Click to filter. click to copy. + + +{/snippet} + {#if tags && tags.length > 0}
{#each tags.slice(0, 3) as tag (tag)} - + {@render tagButton(tag)} {/each} {#if tags.length > 3} {#snippet child({ props })} - +{tags.length - 3} + +{tags.length - 3} {/snippet} - {tags.slice(3).join(', ')} +
+ {#each tags.slice(3) as tag (tag)} + + {/each} +
+ Click to filter. click to copy.
{/if} From 57e8e8b4083088f2a57f170cec68f390251faf82 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 18 May 2026 08:42:02 -0500 Subject: [PATCH 32/42] Refactor project issues page to use TanStack Query Replaced manual useFetchClient calls with getProjectStacksQuery and getTableOptions from the options file. Removed unused showStackPager and onEventChange props from stack/event detail pages. --- .../routes/(app)/event/[eventId]/+page.svelte | 10 +- .../(app)/issues/[stackId]/+page.svelte | 2 +- .../project/[projectId]/issues/+page.svelte | 99 +++++-------------- .../[projectId]/issues/[stackId]/+page.svelte | 2 +- 4 files changed, 26 insertions(+), 87 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId]/+page.svelte index faf51df535..737bec4301 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId]/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/[eventId]/+page.svelte @@ -35,17 +35,9 @@ toast.error(`The event "${page.params.eventId}" could not be found.`); await goto(resolve('/(app)')); } - - async function handleEventChange(eventId: string) { - if (eventId === page.params.eventId) { - return; - } - - await goto(resolve('/(app)/event/[eventId]', { eventId })); - }

Event Details

- +
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/[stackId]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/[stackId]/+page.svelte index 149b0d067c..6da88362e2 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/[stackId]/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/[stackId]/+page.svelte @@ -64,6 +64,6 @@ This issue has no events to display. {:else if eventId} - (eventId = nextEventId)} /> + {/if}
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/+page.svelte index 0e27bc10e2..173f5d7051 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/+page.svelte @@ -8,7 +8,6 @@ import * as FacetedFilter from '$comp/faceted-filter'; import RefreshButton from '$comp/refresh-button.svelte'; import { Muted } from '$comp/typography'; - import { showBillingDialogOnUpgradeProblem } from '$features/billing/upgrade-required.svelte'; import { StatusFilter, StringFilter, TagFilter } from '$features/events/components/filters'; import { buildFilterCacheKey, @@ -19,23 +18,25 @@ toFilter, updateFilterCache } from '$features/events/components/filters/helpers.svelte'; - import { getSharedTableOptions, removeTableSelection } from '$features/shared/table.svelte'; + import { removeTableSelection } from '$features/shared/table.svelte'; + import { type GetProjectStacksParams, getProjectStacksQuery } from '$features/stacks/api.svelte'; import StackFacetedFilterBuilder from '$features/stacks/components/filters/stack-faceted-filter-builder.svelte'; import TableStacksBulkActionsDropdownMenu from '$features/stacks/components/stacks-bulk-actions-dropdown-menu.svelte'; - import { getColumns } from '$features/stacks/components/table/options.svelte'; + import { getTableOptions } from '$features/stacks/components/table/options.svelte'; import StacksDataTable from '$features/stacks/components/table/stacks-data-table.svelte'; import { StackStatus } from '$features/stacks/models'; import { describeStackFilter, isStackFilterSupported, splitSupportedStackFilters } from '$features/stacks/stack-filter-support'; import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models'; - import { DEFAULT_LIMIT, useFetchClientStatus } from '$shared/api/api.svelte'; - import { type FetchClientResponse, useFetchClient } from '@exceptionless/fetchclient'; + import { DEFAULT_LIMIT } from '$shared/api/api.svelte'; + import { useQueryClient } from '@tanstack/svelte-query'; import { createTable } from '@tanstack/svelte-table'; import { queryParamsState } from 'kit-query-params'; import { useEventListener, watch } from 'runed'; import { toast } from 'svelte-sonner'; - import { throttle } from 'throttle-debounce'; const projectId = $derived(page.params.projectId); + const queryClient = useQueryClient(); + const DEFAULT_PARAMS = { filter: '(status:ignored OR status:discarded)', limit: DEFAULT_LIMIT, @@ -157,14 +158,7 @@ onFilterChanged(new TagFilter([tag])); } - interface StacksQueryParameters { - filter?: string; - limit?: number; - page?: number; - sort?: string; - } - - const stacksQueryParameters: StacksQueryParameters = $state({ + const stacksQueryParameters: GetProjectStacksParams = $state({ get filter() { return queryParams.filter!; }, @@ -191,44 +185,22 @@ } }); - const client = useFetchClient(); - const clientStatus = useFetchClientStatus(client); - let clientResponse = $state>(); + const stacksQuery = getProjectStacksQuery({ + get params() { + return stacksQueryParameters; + }, + route: { + get projectId() { + return projectId; + } + } + }); function rowHref(row: Stack): string { return resolve('/(app)/project/[projectId]/issues/[stackId]', { projectId: projectId ?? '', stackId: row.id }); } - const table = createTable( - getSharedTableOptions({ - columnPersistenceKey: 'project-issues-v2-column-visibility', - get columns() { - return getColumns(handleTagClick); - }, - defaultColumnVisibility: { - critical: false, - events: true, - first: false, - fixed_in_version: false, - last: true, - select: true, - status: false, - tags: false, - title: true, - type: true - }, - paginationStrategy: 'offset', - get queryData() { - return clientResponse?.data ?? []; - }, - get queryMeta() { - return clientResponse?.meta; - }, - get queryParameters() { - return stacksQueryParameters; - } - }) - ); + const table = createTable(getTableOptions(stacksQueryParameters, stacksQuery, handleTagClick)); const canRefresh = $derived(!table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() && table.store.state.pagination.pageIndex === 0); @@ -242,43 +214,18 @@ reset(); } - await loadData(); + await stacksQuery.refetch(); } - async function loadData(filter = queryParams.filter, limit = queryParams.limit, pageNumber = queryParams.page, sort = queryParams.sort) { - if (!projectId) { - return; - } - - clientResponse = await client.getJSON(`projects/${projectId}/stacks`, { - params: { - filter, - limit, - page: pageNumber, - sort - } - }); - - showBillingDialogOnUpgradeProblem(clientResponse.problem, undefined); - } - - const throttledLoadData = throttle(5000, loadData); - async function onStackChanged(message: WebSocketMessageValue<'StackChanged'>) { if (message.id && message.change_type === ChangeType.Removed) { removeTableSelection(table, message.id); - await loadData(); - return; } - await throttledLoadData(); + await queryClient.invalidateQueries({ queryKey: ['Stack', 'project', projectId] }); } useEventListener(document, 'StackChanged', async (event) => await onStackChanged((event as CustomEvent).detail)); - - $effect(() => { - loadData(queryParams.filter, queryParams.limit, queryParams.page, queryParams.sort); - });
@@ -292,7 +239,7 @@
@@ -300,7 +247,7 @@
- + {#snippet footerChildren()}
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/[stackId]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/[stackId]/+page.svelte index b079503476..6aea4c860c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/[stackId]/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/issues/[stackId]/+page.svelte @@ -64,6 +64,6 @@ This issue has no events to display. {:else if eventId} - (eventId = nextEventId)} /> + {/if}
From df8aa15dd82854a31e658c34b0e8ad9a660af63f Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 18 May 2026 08:42:13 -0500 Subject: [PATCH 33/42] Cosmetic fixes: remove double newlines, simplify margin, extract \ - Removed extra blank lines in events and stream pages - Simplified account layout margin from mx-6 my-6 to m-6 - Extracted detailsHref into a \ in event-detail-sheet --- .../events/components/event-detail-sheet.svelte | 13 ++++--------- .../ClientApp/src/routes/(app)/+page.svelte | 1 - .../src/routes/(app)/account/+layout.svelte | 4 ++-- .../ClientApp/src/routes/(app)/stream/+page.svelte | 1 - 4 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-detail-sheet.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-detail-sheet.svelte index a161add6e0..7fed760c38 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-detail-sheet.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/event-detail-sheet.svelte @@ -19,6 +19,8 @@ let { detailsHref, eventId = $bindable(), filterChanged, onClose, onError }: Props = $props(); + const resolvedHref = $derived(detailsHref ?? (eventId ? resolve('/(app)/event/[eventId]', { eventId }) : '#')); + function handleOpenChange() { onClose(); } @@ -35,18 +37,11 @@ - Event Details + Event Details
{#if eventId} - + {/if}
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte index 0149f6ec9c..3d0ad70478 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte @@ -60,7 +60,6 @@ return resolve('/(app)/event/[eventId]', { eventId: row.id }); } - const DEFAULT_TIME_RANGE = '[now-7d TO now]'; const DEFAULT_FILTERS = [new DateFilter('date', DEFAULT_TIME_RANGE), new ProjectFilter([]), new StatusFilter([StackStatus.Open, StackStatus.Regressed])]; const DEFAULT_PARAMS = { diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/+layout.svelte index 1a0af66e6b..cf517798b9 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/+layout.svelte @@ -25,10 +25,10 @@

Settings

Manage your account settings and set e-mail preferences. - + - + {@render children()} 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 c652d62c34..096d54fc82 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte @@ -56,7 +56,6 @@ return resolve('/(app)/event/[eventId]', { eventId: row.id }); } - const DEFAULT_FILTERS = [new ProjectFilter([]), new StatusFilter([StackStatus.Open, StackStatus.Regressed])]; const DEFAULT_PARAMS = { filter: '(status:open OR status:regressed)', From f05ae778d3b0c25ad07c028b14e2f49d097fa86f Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 18 May 2026 08:42:50 -0500 Subject: [PATCH 34/42] Apply code formatting (npm run format) --- .../data-table/data-table-body.svelte | 7 ++++- .../src/lib/features/shared/table.svelte.ts | 4 +-- .../(app)/(components)/layouts/sidebar.svelte | 28 ++++++++++--------- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte index 15ef4fdcee..bbecb52b2f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte @@ -117,7 +117,12 @@ {:else} - onCellClick(event, cell)} onkeydown={(event) => onCellKeydown(event, cell)} tabindex={cell.column.id === 'select' ? undefined : 0}> + onCellClick(event, cell)} + onkeydown={(event) => onCellKeydown(event, cell)} + tabindex={cell.column.id === 'select' ? undefined : 0} + > {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts index 8150ddacd7..128203c43a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts @@ -175,9 +175,7 @@ export function getSharedTableOptions import type { ComponentProps, Snippet } from 'svelte'; - import { onDestroy } from 'svelte'; import { resolve } from '$app/paths'; import { page } from '$app/state'; @@ -15,6 +14,7 @@ import Settings from '@lucide/svelte/icons/settings-2'; import User from '@lucide/svelte/icons/user'; import Wrench from '@lucide/svelte/icons/wrench'; + import { onDestroy } from 'svelte'; import type { NavigationItem } from '../../../routes.svelte'; @@ -53,7 +53,9 @@ const settingsRoutes = $derived(routes.filter((route) => route.group === 'Settings')); const projectSettingsRoutes = $derived(routes.filter((route) => route.group === 'Project Settings')); - const settingsIsActive = $derived(settingsRoutes.some((route) => isRouteActive(String(route.href))) || projectSettingsRoutes.some((route) => isRouteActive(String(route.href)))); + const settingsIsActive = $derived( + settingsRoutes.some((route) => isRouteActive(String(route.href))) || projectSettingsRoutes.some((route) => isRouteActive(String(route.href))) + ); const currentProjectId = $derived(page.params.projectId); const currentProjectQuery = getProjectQuery({ route: { @@ -174,10 +176,7 @@ {#each dashboardRoutes as route (route.href)} {#if route.children?.length} - openHoverMenu(menuId)} - onmouseleave={() => closeHoverMenu(menuId)} - > + openHoverMenu(menuId)} onmouseleave={() => closeHoverMenu(menuId)}> {route.title} isChildItemActive(childItem, route.href))} + {@const isChildActive = + route.href === page.url.pathname || + route.children.some((childItem) => isChildItemActive(childItem, route.href))} {#snippet child({ props: collapsibleProps })} @@ -377,16 +378,17 @@ {#if subItem.title === 'Projects' && projectSettingsRoutes.length > 0} - -
+ +
{currentProjectName}
{#each projectSettingsRoutes as projectSubItem (projectSubItem.href)} - + {#snippet child({ props })} Date: Mon, 18 May 2026 08:55:13 -0500 Subject: [PATCH 35/42] Revert stacks view type additions from saved view models The stacks page was removed per review. These model changes adding 'stacks' as a valid ViewType are no longer needed. --- src/Exceptionless.Core/Models/SavedView.cs | 4 ++-- src/Exceptionless.Web/Models/SavedView/NewSavedView.cs | 3 +-- tests/Exceptionless.Tests/Controllers/Data/openapi.json | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Exceptionless.Core/Models/SavedView.cs b/src/Exceptionless.Core/Models/SavedView.cs index b15c641113..d152e0c76e 100644 --- a/src/Exceptionless.Core/Models/SavedView.cs +++ b/src/Exceptionless.Core/Models/SavedView.cs @@ -59,9 +59,9 @@ public record SavedView : IOwnedByOrganizationWithIdentity, IHaveDates /// Schema version for future filter definition migrations. public int Version { get; set; } = 1; - /// Dashboard page identifier: "events", "issues", "stacks", or "stream". + /// Dashboard page identifier: "events", "issues", or "stream". [Required] - [RegularExpression("^(events|issues|stacks|stream)$")] + [RegularExpression("^(events|issues|stream)$")] public string ViewType { get; set; } = null!; // Timestamps diff --git a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs index f203c88cbc..eceea2728e 100644 --- a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs +++ b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs @@ -9,7 +9,7 @@ namespace Exceptionless.Web.Models; public record NewSavedView : IOwnedByOrganization, IValidatableObject { /// The set of valid dashboard view type identifiers. - public static readonly string[] ValidViewTypes = ["events", "issues", "stacks", "stream"]; + public static readonly string[] ValidViewTypes = ["events", "issues", "stream"]; /// Valid column IDs per view, matching the TanStack Table column definitions. public static readonly IReadOnlyDictionary> ValidColumnIds = @@ -17,7 +17,6 @@ public record NewSavedView : IOwnedByOrganization, IValidatableObject { ["events"] = new HashSet { "user", "date" }, ["issues"] = new HashSet { "status", "users", "events", "first", "last" }, - ["stacks"] = new HashSet { "critical", "events", "first", "fixed_in_version", "last", "status", "tags", "type" }, ["stream"] = new HashSet { "user", "date" } }; diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index e2b9d14ced..579e976ad8 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -150,7 +150,7 @@ { "name": "viewType", "in": "path", - "description": "The dashboard view type (events, issues, stacks, stream).", + "description": "The dashboard view type (events, issues, stream).", "required": true, "schema": { "type": "string" From 76dc7231b1caf00e8da8843fbb442f71cc45ea7c Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 18 May 2026 08:55:21 -0500 Subject: [PATCH 36/42] Fix data-table click handling and stacks.http filter syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Don't intercept clicks on links/buttons in table cells (was breaking link navigation when rowHref not set) - Added keyboard accessibility (Enter/Space) to table cells - Fixed stacks.http filter: tags: → tag: (correct filter term) --- .../shared/components/data-table/data-table-body.svelte | 7 +++++-- tests/http/stacks.http | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte index bbecb52b2f..403ee0f58f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte @@ -59,11 +59,14 @@ const target = event.target as HTMLElement | null; + // Don't intercept clicks on interactive elements (links, buttons) + if (target?.closest('a, button')) { + return; + } + // For regular clicks with href, prevent default navigation if (rowHref) { event.preventDefault(); - } else if (target?.closest('a')) { - event.preventDefault(); } // Call the row click handler, passing the event so consumer can override if needed diff --git a/tests/http/stacks.http b/tests/http/stacks.http index 3b3fd685d3..d5801d108e 100644 --- a/tests/http/stacks.http +++ b/tests/http/stacks.http @@ -36,7 +36,7 @@ GET {{apiUrl}}/stacks?organization={{organizationId}}&filter=status:discarded Authorization: Bearer {{token}} ### Get By Organization Id with Filter (e.g., stacks with specific tag) -GET {{apiUrl}}/stacks?organization={{organizationId}}&filter=tags:production +GET {{apiUrl}}/stacks?organization={{organizationId}}&filter=tag:production Authorization: Bearer {{token}} ### Get By Organization Id with Filter (e.g., stacks with type and status) From 3db88a947f023d1fe2d3b7d2196a9162829ce412 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 18 May 2026 09:16:16 -0500 Subject: [PATCH 37/42] Make org settings routes consistent, fix stacks viewType doc - Settings group now has all the same entries as Organization Settings (General, Usage, Billing, Features) for navigation consistency - Removed stale 'stacks' from SavedViewController viewType doc comment to match the reverted model validation --- .../[organizationId]/routes.svelte.ts | 25 +++++++++++++++++++ .../Controllers/SavedViewController.cs | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts index 5c215a6cd3..efdb9a7389 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts @@ -55,6 +55,18 @@ export function routes(): NavigationItem[] { show: (ctx) => !!ctx.user?.roles?.includes('global'), title: 'Features' }, + { + group: 'Settings', + href: resolve('/(app)/organization/[organizationId]/manage', { organizationId }), + icon: Settings, + title: 'General' + }, + { + group: 'Settings', + href: resolve('/(app)/organization/[organizationId]/usage', { organizationId }), + icon: Usage, + title: 'Usage' + }, { group: 'Settings', href: resolve('/(app)/organization/[organizationId]/projects', { organizationId }), @@ -66,6 +78,19 @@ export function routes(): NavigationItem[] { href: resolve('/(app)/organization/[organizationId]/users', { organizationId }), icon: Users, title: 'Users' + }, + { + group: 'Settings', + href: resolve('/(app)/organization/[organizationId]/billing', { organizationId }), + icon: Billing, + title: 'Billing' + }, + { + group: 'Settings', + href: resolve('/(app)/organization/[organizationId]/features', { organizationId }), + icon: Zap, + show: (ctx) => !!ctx.user?.roles?.includes('global'), + title: 'Features' } ]; } diff --git a/src/Exceptionless.Web/Controllers/SavedViewController.cs b/src/Exceptionless.Web/Controllers/SavedViewController.cs index 4f92d913ac..344b08a4b2 100644 --- a/src/Exceptionless.Web/Controllers/SavedViewController.cs +++ b/src/Exceptionless.Web/Controllers/SavedViewController.cs @@ -59,7 +59,7 @@ public async Task>> GetByOrganiz /// Get by organization and view /// /// The identifier of the organization. - /// The dashboard view type (events, issues, stacks, stream). + /// The dashboard view type (events, issues, stream). /// The page parameter is used for pagination. This value must be greater than 0. /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. /// The organization could not be found. From c4f4d75205364db8aec7aee18c382c8593a0e4da Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 18 May 2026 09:19:22 -0500 Subject: [PATCH 38/42] Revert tag filter from AND back to OR semantics Multi-tag filter should use OR (show events with any selected tag) not AND (require all tags). AND was an accidental breaking change. --- .../src/lib/features/events/components/filters/models.svelte.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/models.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/models.svelte.ts index ad0db0affd..a9706ab8b2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/models.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/models.svelte.ts @@ -362,7 +362,7 @@ export class TagFilter implements IFilter { return `tag:${this.value[0]}`; } - return `(${this.value.map((val) => `tag:${val}`).join(' AND ')})`; + return `(${this.value.map((val) => `tag:${val}`).join(' OR ')})`; } } From 206992f1f218a1bd365cdb34a302c8a93308e429 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 18 May 2026 09:32:38 -0500 Subject: [PATCH 39/42] Fix data-table keyboard a11y and stacks.http date format - Move tabindex from individual cells to row level (reduces tab stops) - Remove per-cell keydown handler, use row-level Enter/Space instead - Fix snoozeUntilUtc to use ISO-8601 format (2030-12-31T00:00:00Z) --- .../data-table/data-table-body.svelte | 30 +++++++------------ tests/http/stacks.http | 4 +-- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte index 403ee0f58f..df9cbd64ae 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte @@ -72,23 +72,6 @@ // Call the row click handler, passing the event so consumer can override if needed rowClick(cell.row.original, event); } - - function onCellKeydown(event: KeyboardEvent, cell: Cell): void { - if (cell.column.id === 'select') { - return; - } - - if (!rowClick) { - return; - } - - if (event.key !== 'Enter' && event.key !== ' ') { - return; - } - - event.preventDefault(); - rowClick(cell.row.original); - }
@@ -110,7 +93,16 @@ {@render children()} {/if} {#each table.getRowModel().rows as row (row.id)} - + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + const firstCell = row.getVisibleCells()[0]; + if (firstCell) rowClick(firstCell.row.original); + } + } : undefined} + > {#each row.getVisibleCells() as cell (cell.id)} {#if rowHref && cell.row.original} {@const href = rowHref(cell.row.original)} @@ -123,8 +115,6 @@ onCellClick(event, cell)} - onkeydown={(event) => onCellKeydown(event, cell)} - tabindex={cell.column.id === 'select' ? undefined : 0} > diff --git a/tests/http/stacks.http b/tests/http/stacks.http index d5801d108e..db3f18b9cf 100644 --- a/tests/http/stacks.http +++ b/tests/http/stacks.http @@ -58,7 +58,7 @@ Authorization: Bearer {{token}} Content-Type: application/json ### Mark Snoozed -POST {{apiUrl}}/stacks/{{stackId}}/mark-snoozed?snoozeUntilUtc=12-31-2030 +POST {{apiUrl}}/stacks/{{stackId}}/mark-snoozed?snoozeUntilUtc=2030-12-31T00:00:00Z Authorization: Bearer {{token}} Content-Type: application/json @@ -99,7 +99,7 @@ Authorization: Bearer {{token}} Content-Type: application/json ### Bulk Mark Snoozed (multiple stack IDs) -POST {{apiUrl}}/stacks/{{stackId}},{{stackId}}/mark-snoozed?snoozeUntilUtc=12-31-2030 +POST {{apiUrl}}/stacks/{{stackId}},{{stackId}}/mark-snoozed?snoozeUntilUtc=2030-12-31T00:00:00Z Authorization: Bearer {{token}} Content-Type: application/json From 5acd81aeb0a1d3ef64392013730f227b45c9e928 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 18 May 2026 11:13:59 -0500 Subject: [PATCH 40/42] Fix CI: prettier formatting and remove obsolete feature-gate tests - Fix prettier formatting in data-table-body.svelte (multiline onkeydown) - Remove 3 WhenFeatureDisabled tests since feature gate was removed from SavedViewController (saved views are now available to all orgs) --- .../data-table/data-table-body.svelte | 21 ++--- .../Controllers/SavedViewControllerTests.cs | 91 ------------------- 2 files changed, 10 insertions(+), 102 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte index df9cbd64ae..a8ac45f1ff 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte @@ -95,13 +95,15 @@ {#each table.getRowModel().rows as row (row.id)} { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - const firstCell = row.getVisibleCells()[0]; - if (firstCell) rowClick(firstCell.row.original); - } - } : undefined} + onkeydown={rowClick + ? (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + const firstCell = row.getVisibleCells()[0]; + if (firstCell) rowClick(firstCell.row.original); + } + } + : undefined} > {#each row.getVisibleCells() as cell (cell.id)} {#if rowHref && cell.row.original} @@ -112,10 +114,7 @@ {:else} - onCellClick(event, cell)} - > + onCellClick(event, cell)}> {/if} diff --git a/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs index 7ba3768a49..7fc09f3c11 100644 --- a/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs @@ -14,14 +14,12 @@ namespace Exceptionless.Tests.Controllers; public sealed class SavedViewControllerTests : IntegrationTestsBase { private readonly ISavedViewRepository _savedViewRepository; - private readonly IOrganizationRepository _organizationRepository; private readonly IUserRepository _userRepository; private readonly OrganizationService _organizationService; public SavedViewControllerTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { _savedViewRepository = GetService(); - _organizationRepository = GetService(); _userRepository = GetService(); _organizationService = GetService(); } @@ -31,12 +29,6 @@ protected override async Task ResetDataAsync() await base.ResetDataAsync(); var service = GetService(); await service.CreateDataAsync(); - - // Enable saved views feature for all tests in this class - var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); - Assert.NotNull(organization); - organization.Features.Add(OrganizationFeatures.SavedViews); - await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); } [Fact] @@ -608,90 +600,7 @@ await SendRequestAsync(r => r ); } - [Fact] - public async Task PostAsync_WhenFeatureDisabled_ReturnsUnprocessableEntity() - { - // Arrange — disable the saved views feature - var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); - Assert.NotNull(organization); - organization.Features.Remove(OrganizationFeatures.SavedViews); - await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); - - var newView = new NewSavedView - { - OrganizationId = SampleDataService.TEST_ORG_ID, - Name = "Blocked View", - Filter = "status:open", - ViewType = "events" - }; - await SendRequestAsync(r => r - .Post() - .AsTestOrganizationUser() - .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") - .Content(newView) - .StatusCodeShouldBeUnprocessableEntity() - ); - } - - [Fact] - public async Task PatchAsync_WhenFeatureDisabled_ReturnsUnprocessableEntity() - { - // Arrange — create a view directly (bypassing feature check), then disable feature - var savedView = await _savedViewRepository.AddAsync(new SavedView - { - OrganizationId = SampleDataService.TEST_ORG_ID, - Name = "Existing View", - Filter = "status:open", - ViewType = "events", - Version = 1, - CreatedByUserId = "537650f3b77efe23a47914f0", - CreatedUtc = DateTime.UtcNow, - UpdatedUtc = DateTime.UtcNow - }, o => o.ImmediateConsistency()); - - var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); - Assert.NotNull(organization); - organization.Features.Remove(OrganizationFeatures.SavedViews); - await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); - - await SendRequestAsync(r => r - .Patch() - .AsGlobalAdminUser() - .AppendPaths("saved-views", savedView.Id) - .Content(new UpdateSavedView { Name = "Updated Name" }) - .StatusCodeShouldBeUnprocessableEntity() - ); - } - - [Fact] - public async Task DeleteAsync_WhenFeatureDisabled_ReturnsUnprocessableEntity() - { - // Arrange — create a view directly, then disable feature - var savedView = await _savedViewRepository.AddAsync(new SavedView - { - OrganizationId = SampleDataService.TEST_ORG_ID, - Name = "View To Delete", - Filter = "status:open", - ViewType = "events", - Version = 1, - CreatedByUserId = "537650f3b77efe23a47914f0", - CreatedUtc = DateTime.UtcNow, - UpdatedUtc = DateTime.UtcNow - }, o => o.ImmediateConsistency()); - - var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); - Assert.NotNull(organization); - organization.Features.Remove(OrganizationFeatures.SavedViews); - await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency()); - - await SendRequestAsync(r => r - .Delete() - .AsGlobalAdminUser() - .AppendPaths("saved-views", savedView.Id) - .StatusCodeShouldBeUnprocessableEntity() - ); - } [Fact] public async Task RemoveUser_DeletesPrivateSavedViews_ButPreservesOrganizationWideViews() From d7042e0124a15a0e45fbcb3ddad2102fe15d146b Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 18 May 2026 11:49:02 -0500 Subject: [PATCH 41/42] Fix ESLint curly rule: wrap if body in braces --- .../shared/components/data-table/data-table-body.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte index a8ac45f1ff..8131b80183 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte @@ -100,7 +100,9 @@ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); const firstCell = row.getVisibleCells()[0]; - if (firstCell) rowClick(firstCell.row.original); + if (firstCell) { + rowClick(firstCell.row.original); + } } } : undefined} From 216a3931a22be84c7e3ad4534928a9e45b90c8de Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 18 May 2026 12:38:29 -0500 Subject: [PATCH 42/42] Refactor feature flag documentation: remove specific feature references and clean up comments --- src/Exceptionless.Core/Models/Organization.cs | 11 ++--------- .../routes/(app)/(components)/layouts/sidebar.svelte | 4 ++++ .../[organizationId]/features/+page.svelte | 8 +------- .../Controllers/OrganizationController.cs | 4 ++-- 4 files changed, 9 insertions(+), 18 deletions(-) diff --git a/src/Exceptionless.Core/Models/Organization.cs b/src/Exceptionless.Core/Models/Organization.cs index 0a8f062829..27b9e3c937 100644 --- a/src/Exceptionless.Core/Models/Organization.cs +++ b/src/Exceptionless.Core/Models/Organization.cs @@ -135,7 +135,7 @@ public Organization() public bool HasPremiumFeatures { get; set; } /// - /// Set of enabled feature flags for this organization (e.g., "feature-saved-views"). + /// Set of enabled feature flags for this organization. /// Feature identifiers are always stored in lowercase. /// public ISet Features { get; set; } = new HashSet(); @@ -277,11 +277,4 @@ public enum BillingStatus Unpaid = 4 } -/// -/// Well-known organization feature flag identifiers. -/// -public static class OrganizationFeatures -{ - /// Enables the Saved Views feature for the organization. - public const string SavedViews = "feature-saved-views"; -} + 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 3c44833dae..3ad3054a5a 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 @@ -341,6 +341,7 @@ {/each} + {/if} {/each} @@ -410,6 +411,9 @@ {/each} + +
+
{/if} {/each} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte index 5bccd12d1b..988ff6e900 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte @@ -24,13 +24,7 @@ const organization = $derived(organizationQuery.data); - const KNOWN_FEATURES: { description: string; id: string; name: string }[] = [ - { - description: 'Allows users to save and reuse filter combinations across dashboard pages.', - id: 'feature-saved-views', - name: 'Saved Views' - } - ]; + const KNOWN_FEATURES: { description: string; id: string; name: string }[] = []; function hasFeature(featureId: string) { return organization?.features?.includes(featureId) ?? false; diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs index 2f6139baf3..2c8157d86e 100644 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ b/src/Exceptionless.Web/Controllers/OrganizationController.cs @@ -806,7 +806,7 @@ public async Task DeleteDataAsync(string id, string key) /// Enable a feature flag /// /// The identifier of the organization. - /// The feature flag identifier (e.g., "feature-saved-views"). + /// The feature flag identifier. /// The feature flag was enabled. /// The organization was not found. [HttpPost] @@ -833,7 +833,7 @@ public async Task SetFeatureAsync(string id, string feature) /// Disable a feature flag /// /// The identifier of the organization. - /// The feature flag identifier (e.g., "feature-saved-views"). + /// The feature flag identifier. /// The feature flag was disabled. /// The organization was not found. [HttpDelete]