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/lib/features/events/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts index 4f61847c40..49e84037de 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts @@ -42,6 +42,7 @@ export const queryKeys = { sessionEvents: (id: string | undefined, params?: GetSessionEventsRequest['params']) => [...queryKeys.type, 'sessions', 'session', id, params] as const, sessions: (id: string | undefined) => [...queryKeys.type, 'sessions', 'organizations', id] as const, sessionsCount: (id: string | undefined, params?: GetOrganizationSessionsCountRequest['params']) => [...queryKeys.sessions(id), 'count', params] as const, + stackEvents: (id: string | undefined, params?: GetStackEventsRequest['params']) => [...queryKeys.stacks(id), 'events', params] as const, stacks: (id: string | undefined) => [...queryKeys.type, 'stacks', id] as const, stacksCount: (id: string | undefined, params?: GetStackCountRequest['params']) => [...queryKeys.stacks(id), 'count', params] as const, type: ['PersistentEvent'] as const @@ -342,6 +343,6 @@ export function getStackEventsQuery(request: GetStackEventsRequest) { return response.data!; }, - queryKey: queryKeys.stacks(request.route.stackId) + queryKey: queryKeys.stackEvents(request.route.stackId, request.params) })); } 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..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 @@ -10,13 +10,16 @@ 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(); + + const resolvedHref = $derived(detailsHref ?? (eventId ? resolve('/(app)/event/[eventId]', { eventId }) : '#')); function handleOpenChange() { onClose(); @@ -34,18 +37,11 @@ - Event Details + Event Details
{#if eventId} - + {/if}
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 8e824487fb..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,7 +5,6 @@ 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'; @@ -15,9 +14,6 @@ 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,10 +31,9 @@ filterChanged: (filter: IFilter) => void; handleError: (problem: ProblemDetails) => void; id: string; - onSessionFilter?: () => void; } - let { filterChanged, handleError, id, onSessionFilter }: Props = $props(); + let { filterChanged, handleError, id }: Props = $props(); function getTabs(event?: null | PersistentEvent, project?: ViewProject): TabType[] { if (!event) { @@ -63,7 +58,7 @@ } if (getSessionId(event)) { - tabs.push('Session'); + tabs.push('Session Events'); } if (!project) { @@ -117,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; } @@ -188,23 +131,6 @@ handleError(eventQuery.error); } }); - - $effect(() => { - void refreshTabScrollState(tabs); - }); - - $effect(() => { - void scrollActiveTabIntoView(activeTab); - }); - - onMount(() => { - updateTabScrollState(); - window.addEventListener('resize', updateTabScrollState); - - return () => { - window.removeEventListener('resize', updateTabScrollState); - }; - }); @@ -241,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)} @@ -301,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/saved-views/use-saved-views.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts index 8491fdbe24..f220eb6025 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts @@ -2,7 +2,6 @@ import type { IFilter } from '$comp/faceted-filter'; import type { ColumnVisibilityState } from '@tanstack/svelte-table'; import { deserializeFilters } from '$features/events/components/filters/helpers.svelte'; -import { getOrganizationQuery } from '$features/organizations/api.svelte'; import { organization } from '$features/organizations/context.svelte'; import { untrack } from 'svelte'; @@ -10,8 +9,6 @@ import type { SavedView } from './models'; import { getSavedViewsByViewQuery } from './api.svelte'; -const SAVED_VIEWS_FEATURE = 'feature-saved-views'; - export interface SavedViewQueryParams { filter: null | string; saved: null | string | undefined; @@ -35,6 +32,7 @@ export interface UseSavedViewsReturn { handleLoadView: (id: string) => void; handleResetToSaved: () => void; isEnabled: boolean; + isLoading: boolean; isModified: boolean; savedViews: SavedView[]; } @@ -60,16 +58,7 @@ export function supportsTimeQueryParam(queryParams: SavedViewQueryParams): query } export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsReturn { - const organizationQuery = getOrganizationQuery({ - route: { - get id() { - return organization.current; - } - } - }); - - // Feature flag gate: only enable saved views if the organization has the feature - const isEnabled = $derived(organizationQuery.data?.features?.includes(SAVED_VIEWS_FEATURE) ?? false); + const isEnabled = $derived(!!organization.current); // Some routes, such as stream, do not declare every saved-view query parameter. const supportsSort = supportsSortQueryParam(options.queryParams); @@ -78,7 +67,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur const savedViewsListQuery = getSavedViewsByViewQuery({ route: { get organizationId() { - return isEnabled ? organization.current : undefined; + return organization.current; }, get view() { return options.view; @@ -269,6 +258,9 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur get isEnabled() { return isEnabled; }, + get isLoading() { + return savedViewsListQuery.isLoading; + }, get isModified() { return isModified; }, 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 a1a3adc820..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 @@ -57,6 +57,13 @@ return; } + 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(); @@ -86,7 +93,20 @@ {@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)} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-pagination.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-pagination.svelte index 26ab8c61dc..1e3f233e0c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-pagination.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-pagination.svelte @@ -12,12 +12,11 @@ } let { table }: Props = $props(); - let page = $state(1); + const page = $derived(table.store.state.pagination.pageIndex + 1); const pageCount = $derived(Math.max(1, table.getPageCount() || 1)); function handlePageChange(nextPage: number) { - page = nextPage; table.setPageIndex(nextPage - 1); } 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 4452f34634..abbf4293bf 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,12 @@ 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/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/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 new file mode 100644 index 0000000000..e856624f56 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/options.svelte.ts @@ -0,0 +1,158 @@ +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, type ColumnVisibilityState, renderComponent, type StockFeatures } from '@tanstack/svelte-table'; + +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 [ + { + 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() }), + 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(StackCriticalCell, { isCritical: prop.getValue() }), + header: 'Critical', + id: 'critical', + meta: { + class: 'w-24' + } + }, + { + accessorKey: nameof('tags'), + cell: (prop) => renderComponent(StackTagsCell, { onTagClick, tags: prop.getValue() }), + 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' + } + }, + { + accessorKey: nameof('fixed_in_version'), + header: 'Fixed In', + id: 'fixed_in_version', + meta: { + class: 'w-28' + } + } + ]; +} + +export function getTableOptions( + queryParameters: GetProjectStacksParams, + queryResponse: CreateQueryResult, 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-critical-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-critical-cell.svelte new file mode 100644 index 0000000000..80cab43601 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-critical-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 new file mode 100644 index 0000000000..86a162b57e --- /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..6a70576d00 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-tags-cell.svelte @@ -0,0 +1,100 @@ + + +{#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} + {/snippet} + + +
+ {#each tags.slice(3) as tag (tag)} + + {/each} +
+ Click to filter. click to copy. +
+
+ {/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/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/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-user.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte index 8417ea9823..c04f31b43b 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)/(components)/layouts/sidebar.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte index d9b919bb4b..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 @@ -5,11 +5,16 @@ import { page } from '$app/state'; import { A } from '$comp/typography'; import * as Collapsible from '$comp/ui/collapsible'; + import * as DropdownMenu from '$comp/ui/dropdown-menu'; 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 LayoutDashboard from '@lucide/svelte/icons/layout-dashboard'; 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'; @@ -29,6 +34,17 @@ return group === 'Settings' || group.endsWith(' Settings'); } + 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 isPathActive(childUrl.pathname); + } + type Props = ComponentProps & { footer?: Snippet; header?: Snippet; @@ -37,22 +53,97 @@ 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 dashboardsIsActive = $derived(dashboardRoutes.some((route) => isPathActive(String(route.href)))); const settingsRoutes = $derived(routes.filter((route) => route.group === 'Settings')); + const projectSettingsRoutes = $derived(routes.filter((route) => route.group === 'Project Settings')); const settingsIsActive = $derived(routes.some((route) => isSettingsGroup(route.group) && isPathActive(route.href))); + 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'); 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) => isPathActive(String(route.href)))); const sidebar = useSidebar(); + const isIconCollapsed = $derived(sidebar.state === 'collapsed' && !sidebar.isMobile); + let hoverMenuId = $state(undefined); + let hoverMenuCloseTimeout = $state | undefined>(undefined); function onMenuClick() { if (sidebar.isMobile) { sidebar.toggle(); } } + + function openHoverMenu(menuId: string) { + if (!isIconCollapsed) { + return; + } + + if (hoverMenuCloseTimeout) { + clearTimeout(hoverMenuCloseTimeout); + hoverMenuCloseTimeout = undefined; + } + + hoverMenuId = menuId; + } + + function closeHoverMenu(menuId: string) { + if (!isIconCollapsed) { + return; + } + + if (hoverMenuCloseTimeout) { + clearTimeout(hoverMenuCloseTimeout); + } + + hoverMenuCloseTimeout = setTimeout(() => { + if (hoverMenuId === menuId) { + hoverMenuId = undefined; + } + }, 220); + } + + function isHoverMenuOpen(menuId: string): boolean { + return isIconCollapsed && hoverMenuId === menuId; + } + + function onHoverMenuOpenChange(menuId: string, open: boolean): void { + if (!isIconCollapsed) { + hoverMenuId = undefined; + return; + } + + if (open) { + openHoverMenu(menuId); + return; + } + + if (hoverMenuId === menuId) { + hoverMenuId = undefined; + } + } + + function onFlyoutLinkClick(): void { + hoverMenuId = undefined; + onMenuClick(); + } + + onDestroy(() => { + if (hoverMenuCloseTimeout) { + clearTimeout(hoverMenuCloseTimeout); + } + }); @@ -64,155 +155,220 @@ - {#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))} - - {#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} - {#if reportRoutes.length > 0} - - Reports - - {#each reportRoutes as route (route.href)} - {@const Icon = route.icon} - - - {#snippet child({ props })} - - - {route.title} - - {/snippet} - - - {/each} - - - {/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 (subItem.href)} - - - {#snippet child({ props })} - - {#if subItem.icon} - {@const Icon = subItem.icon} - - {/if} - {subItem.title} - - {/snippet} - - + + {/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 systemRoutes.length > 0} - - - + + {/if} + {/each} + + + {:else} + {#snippet child({ props })} {#snippet child({ props })} - - System + + Settings {/snippet} - {#each systemRoutes as subItem (subItem.href)} + {#each settingsRoutes as subItem, index (subItem.href)} + {#if index > 0 && settingsRoutes[index - 1]?.title === 'Organizations'} + +
+
+ {/if} - + {#snippet child({ props })} {#if subItem.icon} @@ -224,12 +380,191 @@ {/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} + + {#if systemRoutes.length > 0} + + + {#if isIconCollapsed} + {@const menuId = 'section:system'} + onHoverMenuOpenChange(menuId, open)}> + + {#snippet child({ props })} + openHoverMenu(menuId)} onmouseleave={() => closeHoverMenu(menuId)}> + + + System + + + {/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} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index a3778dcd0f..13f22e3ec7 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 @@ + +
+

Issue Details

+ {#if stackEventsQuery.isSuccess && !eventId} + + This issue has no events to display. + {:else if eventId} + + {/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 56dcecded7..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(() => { @@ -32,7 +41,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)/organization/[organizationId]/features/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte index 0c8fea9dcd..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 @@ -1,7 +1,8 @@ + +
+
+ Manage project issues, including restoring ignored or discarded issues. +
+ + + +
+
+ + +
+
+ + + {#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..6aea4c860c --- /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} + + {/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 76634a72b1..2e0671f0d0 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 @@ -5,11 +5,12 @@ import { resolve } from '$app/paths'; import { page } from '$app/state'; import ErrorMessage from '$comp/error-message.svelte'; - import { Muted } from '$comp/typography'; + import { H3, Muted } from '$comp/typography'; import { Button, buttonVariants } from '$comp/ui/button'; import * as DropdownMenu from '$comp/ui/dropdown-menu'; import * as Field from '$comp/ui/field'; import { Input } from '$comp/ui/input'; + import { Separator } from '$comp/ui/separator'; import { Spinner } from '$comp/ui/spinner'; import { organization } from '$features/organizations/context.svelte'; import { deleteProject, getProjectQuery, resetData, updateProject } from '$features/projects/api.svelte'; @@ -112,7 +113,11 @@
- General project settings +
+

General

+ Manage your project name. +
+
{ @@ -160,8 +165,7 @@
- - Delete + 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 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' 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] diff --git a/src/Exceptionless.Web/Controllers/SavedViewController.cs b/src/Exceptionless.Web/Controllers/SavedViewController.cs index e15f40d935..34f93a0856 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); @@ -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)) @@ -308,15 +298,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/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs index 5644155e7e..f2f5c51455 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] @@ -632,90 +624,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() diff --git a/tests/http/stacks.http b/tests/http/stacks.http index 85e1cb745e..db3f18b9cf 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=tag: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}} @@ -46,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 @@ -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=2030-12-31T00:00:00Z +Authorization: Bearer {{token}} +Content-Type: application/json + +### Bulk Delete (multiple stack IDs) +DELETE {{apiUrl}}/stacks/{{stackId}},{{stackId}} +Authorization: Bearer {{token}} +Content-Type: application/json