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 000000000..f38678fcc --- /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 000000000..7f915e960 --- /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 000000000..36e01bc9f --- /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)} + {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 000000000..55b7ceda3 --- /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)/(components)/layouts/sidebar-user.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte index 8417ea982..c04f31b43 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte @@ -48,6 +48,7 @@ let { gravatar, intercomUnreadCount = 0, isChatEnabled, isImpersonating = false, isLoading, openChat, organizations = [], user }: Props = $props(); const sidebar = useSidebar(); + const currentOrganizationId = $derived(organizations.find((organizationItem) => organizationItem.id === organization.current)?.id); let openImpersonateDialog = $state(false); function getUnreadCountLabel(unreadCount: number): string { @@ -165,12 +166,12 @@ Notifications ⇧⌘gn - {#if organization.current} + {#if currentOrganizationId} @@ -182,7 +183,7 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 78dff88d4..dc691194d 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -231,8 +231,10 @@ }); const intercomOrganization = $derived(shouldFetchIntercomOrganization ? currentOrganizationQuery.data : undefined); - // Simple organization selection - pick first available if none selected + // Keep selected organization synchronized with current memberships. $effect(() => { + void page.url.pathname; + if (!organizationsQuery.isSuccess) { return; } @@ -243,14 +245,17 @@ // Redirect non-admins to add organization page if (!isGlobalAdmin && !organizationsQuery.isLoading) { - goto(resolve(`/(app)/organization/add`)); + const addOrganizationPath = resolve('/(app)/organization/add'); + if (page.url.pathname !== addOrganizationPath) { + goto(addOrganizationPath); + } } return; } - // Select first organization if none selected - if (!organization.current) { + const hasSelectedOrganization = !!organization.current && organizations.some((organizationItem) => organizationItem.id === organization.current); + if (!hasSelectedOrganization && !impersonatingOrganizationId) { organization.current = organizations[0]!.id; } }); 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 6658067b8..705de1b7a 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 @@ -34,7 +34,7 @@ return; } - if (organizationQuery.isSuccess && organizationId !== organization.current) { + if (organizationQuery.isSuccess && organization.current && organizationId !== organization.current) { goto(page.url.pathname.replace(`/organization/${organizationId}`, `/organization/${organization.current}`)); return; } 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 b2d938c7c..ac93bd4cf 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/routes.svelte.ts @@ -7,6 +7,7 @@ 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'; @@ -37,6 +38,12 @@ 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 new file mode 100644 index 000000000..e8030d23d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stacks/+page.svelte @@ -0,0 +1,234 @@ + + +
+
+

Stacks

+
+ + + +
+
+ + +
+
+ + + {#snippet footerChildren()} +
+ +
+ + + +
+ + +
+ {/snippet} +
+
diff --git a/tests/http/stacks.http b/tests/http/stacks.http index 85e1cb745..3b3fd685d 100644 --- a/tests/http/stacks.http +++ b/tests/http/stacks.http @@ -31,6 +31,18 @@ Authorization: Bearer {{token}} GET {{apiUrl}}/stacks?organization={{organizationId}} Authorization: Bearer {{token}} +### Get By Organization Id with Filter (e.g., discarded stacks) +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 +Authorization: Bearer {{token}} + +### Get By Organization Id with Filter (e.g., stacks with type and status) +GET {{apiUrl}}/stacks?organization={{organizationId}}&filter=type:error%20status:open +Authorization: Bearer {{token}} + ### Get By Id GET {{apiUrl}}/stacks/{{stackId}} Authorization: Bearer {{token}} @@ -75,3 +87,23 @@ Content-Type: application/json ### Delete DELETE {{apiUrl}}/stacks/{{stackId}} Authorization: Bearer {{token}} + +### Bulk Change Status (multiple stack IDs) +POST {{apiUrl}}/stacks/{{stackId}},{{stackId}}/change-status?status=open +Authorization: Bearer {{token}} +Content-Type: application/json + +### Bulk Mark Fixed (multiple stack IDs) +POST {{apiUrl}}/stacks/{{stackId}},{{stackId}}/mark-fixed +Authorization: Bearer {{token}} +Content-Type: application/json + +### Bulk Mark Snoozed (multiple stack IDs) +POST {{apiUrl}}/stacks/{{stackId}},{{stackId}}/mark-snoozed?snoozeUntilUtc=12-31-2030 +Authorization: Bearer {{token}} +Content-Type: application/json + +### Bulk Delete (multiple stack IDs) +DELETE {{apiUrl}}/stacks/{{stackId}},{{stackId}} +Authorization: Bearer {{token}} +Content-Type: application/json