From 113322234db0eae94b7fccefa254565c84ef926d Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 15 May 2026 22:35:04 -0500 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 7/9] 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 @@