diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/tag-suggestions-live.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/tag-suggestions-live.e2e.ts new file mode 100644 index 0000000000..2e3cbe385c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/tag-suggestions-live.e2e.ts @@ -0,0 +1,34 @@ +import { expect, test } from '../fixtures/e2e-test'; +import { getVisibleText } from '../support/page-helpers'; +import { createRepresentativeEvent } from '../support/synthetic-event'; + +test('tag suggestions from indexed events can be selected and survive reload', async ({ e2eApi, e2eScenario, page }, testInfo) => { + await e2eApi.submitEvent(e2eScenario.projectId, e2eScenario.projectToken, { + ...createRepresentativeEvent({ + appUrl: e2eApi.environment.appUrl, + message: e2eScenario.message, + referenceId: e2eScenario.referenceId, + runId: e2eScenario.run + }), + tags: ['AlphaTag', 'BetaTag'] + }); + await e2eApi.pollForEventByReference(e2eScenario.userToken, e2eScenario.projectId, e2eScenario.referenceId); + + await page.goto('/next/event?tag=AlphaTag&time=all'); + await expect(getVisibleText(page, e2eScenario.message)).toBeVisible({ timeout: 30_000 }); + await page.getByRole('button', { name: /^Tag\s+AlphaTag/ }).click(); + await expect(page.getByRole('option', { exact: true, name: 'BetaTag' })).toBeVisible(); + await page.getByPlaceholder('Tag', { exact: true }).fill('Beta'); + await page.getByRole('option', { exact: true, name: 'BetaTag' }).click(); + await expect(page).toHaveURL(/BetaTag/); + await expect(page).toHaveURL(/AlphaTag/); + await page.getByPlaceholder('Tag', { exact: true }).press('Escape'); + await page.reload(); + + await expect(getVisibleText(page, e2eScenario.message)).toBeVisible({ timeout: 30_000 }); + await page.getByRole('button', { name: /^Tag\s+AlphaTag\s+BetaTag/ }).click(); + await expect(page.getByRole('option', { exact: true, name: 'AlphaTag' })).toBeVisible(); + await expect(page.getByRole('option', { exact: true, name: 'BetaTag' })).toBeVisible(); + await expect(page.getByPlaceholder('Tag', { exact: true })).toHaveValue(''); + await page.screenshot({ path: testInfo.outputPath('tag-suggestions.png') }); +}); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/tag-suggestions.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/tag-suggestions.e2e.ts new file mode 100644 index 0000000000..2c193fe300 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/tag-suggestions.e2e.ts @@ -0,0 +1,246 @@ +import { expect, type Page, type Route, test } from '@playwright/test'; + +const ORGANIZATION_ID = '000000000000000000000001'; +const OTHER_ORGANIZATION_ID = '000000000000000000000004'; + +test('complete tag suggestions filter locally without changing selected tags', async ({ page }) => { + const requests: string[] = []; + const input = page.getByPlaceholder('Tag', { exact: true }); + await test.step('arrange complete suggestions and an existing selection', async () => { + await page.clock.install(); + await setup(page, async (route, aggregation) => { + requests.push(aggregation); + await route.fulfill({ json: tags(['Alpha', 'Beta']) }); + }); + }); + await test.step('filter a complete cache locally while preserving the selection', async () => { + await page.goto('/next/event?tag=Selected&time=%5Bnow-24h%20TO%20now%5D&project=000000000000000000000003'); + await page.getByRole('button', { name: /^Tag\s+Selected/ }).click(); + await expect(page.getByRole('option', { exact: true, name: 'Alpha' })).toBeVisible(); + await input.fill('Be'); + await expect(page.getByRole('option', { exact: true, name: 'Beta' })).toBeVisible(); + await expect(page.getByRole('option', { exact: true, name: 'Alpha' })).toHaveCount(0); + await page.clock.fastForward(450); + expect(requests).toEqual(['terms:(tags~251)']); + await expect(page).toHaveURL(/[?&]tag=Selected/); + }); + await test.step('reopen with an empty search and cached options', async () => { + await input.press('Escape'); + await page.getByRole('button', { name: /^Tag\s+Selected/ }).click(); + await expect(input).toHaveValue(''); + await expect(page.getByRole('option', { exact: true, name: 'Alpha' })).toBeVisible(); + expect(requests).toHaveLength(1); + await input.press('Escape'); + }); + await test.step('reuse suggestions after date and project filters change', async () => { + await page.getByRole('button', { exact: true, name: 'Date Last 24 hours' }).click(); + await page.getByRole('button', { exact: true, name: 'Last 7 days' }).click(); + await expect(page.getByRole('button', { exact: true, name: 'Date Last 7 days' })).toBeVisible(); + await expect(page).not.toHaveURL(/[?&]time=/); + await page + .getByRole('button', { name: /^Project/ }) + .first() + .click(); + const projectSearch = page.getByPlaceholder('Project', { exact: true }); + await projectSearch.fill('One'); + await projectSearch.press('Escape'); + await page + .getByRole('button', { name: /^Project/ }) + .first() + .click(); + await expect(projectSearch).toHaveValue(''); + await page.getByRole('option', { exact: true, name: 'Project One' }).click(); + await expect(page).not.toHaveURL(/[?&]project=/); + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: /^Tag\s+Selected/ }).click(); + await page.clock.fastForward(450); + expect(requests).toHaveLength(1); + }); +}); + +test('incomplete suggestions debounce remote search, reuse cache and preserve selections through failure', async ({ page }) => { + const requests: string[] = []; + const input = page.getByPlaceholder('Tag', { exact: true }); + let searchFailed = false; + await test.step('arrange truncated suggestions and one transient search failure', async () => { + await page.clock.install(); + await setup(page, async (route, aggregation) => { + requests.push(aggregation); + if (aggregation === 'terms:(tags~251)') { + await route.fulfill({ json: tags(['Common'], 1) }); + } else if (aggregation.includes('[fF][aA][iI][lL]')) { + if (!searchFailed) { + searchFailed = true; + await route.fulfill({ json: { status: 503, title: 'Unavailable' }, status: 503 }); + } else { + await route.fulfill({ json: tags(['Failover']) }); + } + } else { + await route.fulfill({ json: tags(['RareTag']) }); + } + }); + }); + await test.step('debounce searches and add a tag without dropping the selection', async () => { + await page.goto('/next/event?tag=Selected'); + await page.getByRole('button', { name: /^Tag\s+Selected/ }).click(); + await expect(page.getByRole('option', { exact: true, name: 'Common' })).toBeVisible(); + await input.fill('R'); + await page.clock.fastForward(350); + expect(requests).toHaveLength(1); + await input.fill('Ra'); + await input.fill('Rar'); + await input.fill('Rare'); + await page.clock.fastForward(350); + await expect(page.getByRole('option', { exact: true, name: 'RareTag' })).toBeVisible(); + expect(requests).toHaveLength(2); + await page.getByRole('option', { exact: true, name: 'RareTag' }).click(); + await expect(page).toHaveURL(/RareTag/); + expect(requests).toHaveLength(2); + }); + await test.step('preserve selections through failure and explicit retry', async () => { + await input.fill('fail'); + await page.clock.fastForward(350); + await expect(page.getByText('Could not load tags.')).toBeVisible(); + await expect(page.getByRole('button', { exact: true, name: 'Retry' })).toBeVisible(); + await expect(page).toHaveURL(/Selected/); + await expect(page).toHaveURL(/RareTag/); + await page.getByRole('button', { exact: true, name: 'Retry' }).click(); + await expect(page.getByRole('option', { exact: true, name: 'Failover' })).toBeVisible(); + }); + await test.step('reuse the previously fetched search', async () => { + await input.fill('Rare'); + await page.clock.fastForward(350); + await expect(page.getByRole('option', { exact: true, name: 'RareTag' })).toBeVisible(); + await page.clock.fastForward(350); + expect(requests).toHaveLength(4); + }); +}); + +test('initial failure retries the initial query and keeps saved tag selections', async ({ page }) => { + const requests: string[] = []; + await setup(page, async (route, aggregation) => { + requests.push(aggregation); + if (requests.length === 1) { + await route.fulfill({ json: { status: 503, title: 'Unavailable' }, status: 503 }); + } else { + await route.fulfill({ json: tags(['Alpha']) }); + } + }); + await page.goto('/next/event?tag=Selected'); + await page.getByRole('button', { name: /^Tag\s+Selected/ }).click(); + await expect(page.getByText('Could not load tags.')).toBeVisible(); + await page.getByRole('button', { exact: true, name: 'Retry' }).click(); + await expect(page.getByRole('option', { exact: true, name: 'Alpha' })).toBeVisible(); + await expect(page).toHaveURL(/[?&]tag=Selected/); + expect(requests).toEqual(['terms:(tags~251)', 'terms:(tags~251)']); +}); + +test('closing during debounce cancels the search and tag actions keep working', async ({ page }) => { + const requests: string[] = []; + await page.clock.install(); + await setup(page, async (route, aggregation) => { + requests.push(aggregation); + await route.fulfill({ json: tags(['Common'], 1) }); + }); + await page.goto('/next/event?tag=Selected'); + await page.getByRole('button', { name: /^Tag\s+Selected/ }).click(); + await expect(page.getByRole('option', { exact: true, name: 'Common' })).toBeVisible(); + const input = page.getByPlaceholder('Tag', { exact: true }); + await input.fill('rare'); + await input.press('Escape'); + await page.clock.fastForward(450); + expect(requests).toHaveLength(1); + await page.getByRole('button', { name: /^Tag\s+Selected/ }).click(); + await expect(input).toHaveValue(''); + await page.getByRole('button', { exact: true, name: 'Clear filter value' }).click(); + await expect(page).not.toHaveURL(/[?&]tag=Selected/); + await page.getByRole('option', { exact: true, name: 'Common' }).click(); + await expect(page).toHaveURL(/[?&]tag=Common/); + await page.getByRole('button', { exact: true, name: 'Remove filter' }).click(); + await expect(page.getByRole('button', { name: /^Tag\s/ })).toHaveCount(0); + await expect(page).not.toHaveURL(/[?&]tag=Common/); +}); + +test('switching organizations discards the old picker and loads only the new organization tags', async ({ page }) => { + const requestedOrganizations: string[] = []; + await setup(page, async (route) => { + const organizationId = new URL(route.request().url()).pathname.split('/')[4]!; + requestedOrganizations.push(organizationId); + await route.fulfill({ json: tags([organizationId === ORGANIZATION_ID ? 'FirstOrganizationTag' : 'SecondOrganizationTag']) }); + }); + await page.goto('/next/event?tag=Selected'); + await page.getByRole('button', { name: /^Tag\s+Selected/ }).click(); + await expect(page.getByRole('option', { exact: true, name: 'FirstOrganizationTag' })).toBeVisible(); + await page.getByPlaceholder('Tag', { exact: true }).press('Escape'); + await page.getByRole('button', { name: /Test Organization.*Unlimited/ }).click(); + await page.getByRole('menuitem', { name: /Other Organization/ }).click(); + await expect(page.getByRole('button', { name: /Other Organization.*Unlimited/ })).toBeVisible(); + await page.goto('/next/event?tag=OtherSelected'); + await page.getByRole('button', { name: /^Tag\s+OtherSelected/ }).click(); + await expect(page.getByRole('option', { exact: true, name: 'SecondOrganizationTag' })).toBeVisible(); + await expect(page.getByRole('option', { exact: true, name: 'FirstOrganizationTag' })).toHaveCount(0); + await expect(page.getByRole('option', { exact: true, name: 'Selected' })).toHaveCount(0); + expect(requestedOrganizations).toEqual([ORGANIZATION_ID, OTHER_ORGANIZATION_ID]); +}); + +async function setup(page: Page, handleTags: (route: Route, aggregation: string) => Promise) { + page.setDefaultTimeout(10000); + await page.addInitScript((organizationId) => { + localStorage.setItem('satellizer_token', 'synthetic-tag-test-token'); + if (!localStorage.getItem('organization')) { + localStorage.setItem('organization', JSON.stringify(organizationId)); + } + }, ORGANIZATION_ID); + await page.route('**/health', (route) => route.fulfill({ body: 'OK' })); + await page.route('**/api/v2/**', async (route) => { + const url = new URL(route.request().url()); + const aggregation = url.searchParams.get('aggregations'); + if (aggregation?.startsWith('terms:(tags~')) { + expect([ORGANIZATION_ID, OTHER_ORGANIZATION_ID]).toContain(url.pathname.split('/')[4]); + expect(url.searchParams.get('filter')).toBeNull(); + expect(url.searchParams.get('time')).toBe('all'); + await handleTags(route, aggregation); + } else if (url.pathname === '/api/v2/users/me') { + await route.fulfill({ + json: { + email_address: 'tags@example.test', + full_name: 'Test User', + id: '000000000000000000000002', + is_active: true, + is_email_address_verified: true, + organization_ids: [ORGANIZATION_ID, OTHER_ORGANIZATION_ID], + organization_preferences: [], + roles: [] + } + }); + } else if (url.pathname === '/api/v2/organizations' || /^\/api\/v2\/organizations\/[^/]+$/.test(url.pathname)) { + const organizations = [ + { features: [], id: ORGANIZATION_ID, name: 'Test Organization', plan_id: 'EX_UNLIMITED', plan_name: 'Unlimited' }, + { features: [], id: OTHER_ORGANIZATION_ID, name: 'Other Organization', plan_id: 'EX_UNLIMITED', plan_name: 'Unlimited' } + ]; + await route.fulfill({ + json: url.pathname === '/api/v2/organizations' ? organizations : organizations.find((item) => url.pathname.endsWith(item.id)) + }); + } else if (url.pathname.endsWith('/projects')) { + await route.fulfill({ json: [{ id: '000000000000000000000003', name: 'Project One', organization_id: ORGANIZATION_ID }] }); + } else if (url.pathname === '/api/v2/assistant/access') { + await route.fulfill({ json: { enabled: false, has_access: false } }); + } else if (url.pathname.endsWith('/count')) { + await route.fulfill({ json: { aggregations: {}, total: 0 } }); + } else { + await route.fulfill({ json: [] }); + } + }); +} + +function tags(values: string[], omitted = 0) { + return { + aggregations: { + terms_tags: { + data: { '@type': 'bucket', ...(omitted ? { SumOtherDocCount: omitted } : {}) }, + items: values.map((key) => ({ key, total: 1 })) + } + }, + total: values.length + }; +} diff --git a/src/Exceptionless.Web/ClientApp/eslint.config.js b/src/Exceptionless.Web/ClientApp/eslint.config.js index 0e7fe79173..5f27156b84 100644 --- a/src/Exceptionless.Web/ClientApp/eslint.config.js +++ b/src/Exceptionless.Web/ClientApp/eslint.config.js @@ -53,7 +53,7 @@ export default ts.config( '@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }], '@stylistic/object-curly-newline': ['error', { ObjectExpression: { minProperties: 1 } }], curly: ['error', 'all'], - 'padding-line-between-statements': ['error', { blankLine: 'always', next: ['if', 'while', 'for', 'do'], prev: 'block-like' }] + 'padding-line-between-statements': ['error', { blankLine: 'always', next: ['if', 'while', 'for', 'do', 'return'], prev: 'block-like' }] } }, { 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 6948cfde0a..99f95fdc8e 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 @@ -11,6 +11,8 @@ import { SvelteSet } from 'svelte/reactivity'; import type { EventSummaryModel, SummaryTemplateKeys } from './components/summary/index'; import type { PersistentEvent } from './models'; +import { TAG_SUGGESTION_STALE_TIME, tagSuggestionAggregation, tagSuggestionSession } from './tag-suggestions'; + export interface OrganizationEventNotificationRefresher { cancel: () => void; schedule: (organizationId?: string, refreshImmediately?: boolean) => void; @@ -114,6 +116,7 @@ export const queryKeys = { 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, + tagSuggestions: (organizationId: string | undefined, search: string, session: number) => ['EventTagSuggestions', organizationId, search, session] as const, type: ['PersistentEvent'] as const }; @@ -283,6 +286,12 @@ export interface GetStackEventsRequest { }; } +export interface GetTagSuggestionsRequest { + enabled?: () => boolean; + params: { search: string }; + route: { organizationId: string | undefined }; +} + export function createEventWithNavigationQueryOptions(request: GetEventRequest, queryClient: QueryClient) { const eventId = request.route.id; const params = request.params @@ -647,6 +656,33 @@ export function getStackEventsQuery(request: GetStackEventsRequest) { })); } +export function getTagSuggestionsQuery(request: GetTagSuggestionsRequest) { + return createQuery(() => { + const organizationId = request.route.organizationId; + const search = request.params.search; + const session = tagSuggestionSession(accessToken.current); + + return { + enabled: !!accessToken.current && !!organizationId && (request.enabled?.() ?? true), + queryFn: async ({ signal }) => { + const client = useFetchClient(); + const response = await client.getJSON(`/organizations/${organizationId}/events/count`, { + params: { + aggregations: tagSuggestionAggregation(search), + time: 'all' + }, + signal + }); + return response.data!; + }, + queryKey: queryKeys.tagSuggestions(organizationId, search, session), + refetchOnWindowFocus: false, + retry: false, + staleTime: TAG_SUGGESTION_STALE_TIME + }; + }); +} + export function retainPreviousOrganizationQueryData( previousData: T | undefined, previousQueryKey: readonly unknown[] | undefined, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte index c686d92e84..0fdf8dcc0e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte @@ -2,71 +2,142 @@ import type { FacetedFilterProps } from '$comp/faceted-filter'; import * as FacetedFilter from '$comp/faceted-filter'; - import { getOrganizationCountQuery } from '$features/events/api.svelte'; + import { Button } from '$comp/ui/button'; + import { getTagSuggestionsQuery } from '$features/events/api.svelte'; + import { TAG_SUGGESTION_LIMIT, tagSuggestions } from '$features/events/tag-suggestions'; import { organization } from '$features/organizations/context.svelte'; - import { terms } from '$features/shared/api/aggregations'; import { TagFilter } from './models.svelte'; let { filter, filterChanged, filterRemoved, open = $bindable(false), title = 'Tag', ...props }: FacetedFilterProps = $props(); - - function toggleHidden() { - filter.hidden = !filter.hidden; - filterChanged(filter); - } + let search = $state(''); + let debouncedSearch = $state(''); + const normalizedSearch = $derived(search.trim().toLowerCase()); // Store the organizationId to prevent loading when switching organizations. const organizationId = organization.current; + const enabled = $derived(open && organization.current === organizationId); - // Create query with conditional enabled - only fetch when dropdown is open - const countQuery = getOrganizationCountQuery({ - enabled: () => open, + const initialQuery = getTagSuggestionsQuery({ + enabled: () => enabled, params: { - aggregations: 'terms:tags' + search: '' }, route: { - get organizationId() { - return organizationId; + organizationId + } + }); + const initial = $derived(tagSuggestions(initialQuery.data)); + const remoteSearch = $derived(!initial.complete && normalizedSearch.length >= 2); + const currentSearch = $derived(debouncedSearch === normalizedSearch); + const searchQuery = getTagSuggestionsQuery({ + enabled: () => enabled && initialQuery.isSuccess && remoteSearch && currentSearch, + params: { + get search() { + return debouncedSearch; } + }, + route: { + organizationId } }); - - const tags = $derived(Array.from(new Set(['Critical', ...(terms(countQuery.data?.aggregations, 'terms_tags')?.buckets?.map((tag) => tag.key) ?? [])]))); + const result = $derived(remoteSearch && currentSearch && searchQuery.isSuccess ? tagSuggestions(searchQuery.data) : initial); const options = $derived( - tags.map((tag) => ({ - label: tag, - value: tag - })) ?? [] + Array.from(new Set(['Critical', ...filter.value, ...result.tags])) + .filter((tag) => tag.toLowerCase().includes(normalizedSearch)) + .slice(0, TAG_SUGGESTION_LIMIT) + .map((tag) => ({ + label: tag, + value: tag + })) ); + const loading = $derived(open && (initialQuery.isFetching || (remoteSearch && (!currentSearch || searchQuery.isFetching)))); + const failed = $derived(initialQuery.isError || (remoteSearch && currentSearch && searchQuery.isError)); + + const statusMessage = $derived.by(() => { + if (loading) { + return 'Searching tags…'; + } + + if (!initial.complete && normalizedSearch.length < 2) { + return 'Showing up to 250 tags. Type at least two characters to search all tags.'; + } + + if (options.length === 0) { + return 'No matching tags found.'; + } + + if (remoteSearch && result.tags.length === TAG_SUGGESTION_LIMIT) { + return 'Showing up to 250 tags. Type more to narrow.'; + } + + return undefined; + }); $effect(() => { - if (!countQuery.isSuccess || filter.value.length === 0) { + const value = normalizedSearch; + if (!enabled) { + debouncedSearch = ''; + return; + } + const timer = setTimeout(() => { + debouncedSearch = value; + }, 300); + return () => clearTimeout(timer); + }); + + function onChanged(values: string[]) { + filter.value = values; + filterChanged(filter); + } + + function onRemove() { + filter.value = []; + filterRemoved(filter); + } + + async function retry() { + if (!enabled) { return; } - const selectedTags = tags.filter((tag) => filter.value.includes(tag)); - if (filter.value.length !== selectedTags.length) { - filter.value = selectedTags.map((tag) => tag); - filterChanged(filter); + if (initialQuery.isError) { + await initialQuery.refetch(); + } else { + await searchQuery.refetch(); } - }); + } + + function toggleHidden() { + filter.hidden = !filter.hidden; + filterChanged(filter); + } { - filter.value = values; - filterChanged(filter); - }} - loading={countQuery.isLoading} + bind:search + shouldFilter={false} + changed={onChanged} + {loading} {options} - remove={() => { - filter.value = []; - filterRemoved(filter); - }} + remove={onRemove} hidden={filter.hidden} {title} {toggleHidden} values={filter.value} {...props} -> +> + {#snippet status()} + {#if failed || statusMessage} +
+ {#if failed} + Could not load tags. + + {:else} + {statusMessage} + {/if} +
+ {/if} + {/snippet} + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte.test.ts new file mode 100644 index 0000000000..369b9b9295 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte.test.ts @@ -0,0 +1,69 @@ +import type { CreateQueryOptions, QueryClient as QueryClientType } from '@tanstack/svelte-query'; + +import * as Tooltip from '$comp/ui/tooltip'; +import { organization } from '$features/organizations/context.svelte'; +import { QueryClient } from '@tanstack/svelte-query'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { tick } from 'svelte'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TagFilter } from './models.svelte'; +import TagFacetedFilter from './tag-faceted-filter.svelte'; + +const mocks = vi.hoisted(() => ({ + client: undefined as QueryClientType | undefined, + getJSON: vi.fn() +})); +vi.mock('$env/dynamic/public', () => ({ env: {} })); +vi.mock('$features/auth/index.svelte', () => ({ accessToken: { current: 'test-session' } })); +vi.mock('@foundatiofx/fetchclient', async (importOriginal) => ({ + ...(await importOriginal()), + useFetchClient: () => ({ getJSON: mocks.getJSON }) +})); +vi.mock('@tanstack/svelte-query', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createQuery: (options: () => CreateQueryOptions) => actual.createQuery(options, () => mocks.client!) }; +}); + +describe('tag picker organization lifecycle', () => { + beforeEach(() => { + mocks.client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mocks.getJSON.mockResolvedValue({ data: { aggregations: {}, total: 0 } }); + organization.current = 'organization-a'; + vi.stubGlobal( + 'ResizeObserver', + class { + disconnect() {} + observe() {} + unobserve() {} + } + ); + Element.prototype.scrollIntoView = vi.fn(); + }); + + afterEach(() => { + cleanup(); + mocks.client?.clear(); + vi.unstubAllGlobals(); + }); + + it('keeps the outgoing picker scoped to its original organization until it is replaced', async () => { + const props = { filter: new TagFilter(['Selected']), filterChanged: vi.fn(), filterRemoved: vi.fn(), open: true, title: 'Tag' }; + const picker = render(TagFacetedFilter, props, { wrapper: Tooltip.Provider }); + await waitFor(() => expect(mocks.getJSON).toHaveBeenCalledTimes(1)); + + await fireEvent.input(screen.getByPlaceholderText('Tag'), { target: { value: 'rare' } }); + organization.current = 'organization-b'; + await tick(); + await new Promise((resolve) => setTimeout(resolve, 350)); + + expect(mocks.getJSON).toHaveBeenCalledTimes(1); + expect(props.filterChanged).not.toHaveBeenCalled(); + expect(props.filter.value).toEqual(['Selected']); + + picker.unmount(); + render(TagFacetedFilter, { ...props, filter: new TagFilter() }, { wrapper: Tooltip.Provider }); + await waitFor(() => expect(mocks.getJSON).toHaveBeenCalledTimes(2)); + expect(mocks.getJSON.mock.calls[1]![0]).toBe('/organizations/organization-b/events/count'); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/tag-suggestions-api.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/tag-suggestions-api.svelte.test.ts new file mode 100644 index 0000000000..a20ffa9a82 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/tag-suggestions-api.svelte.test.ts @@ -0,0 +1,177 @@ +import type { CountResult } from '$shared/models'; + +import { ChangeType } from '$features/websockets/models'; +import { QueryClient, QueryObserver, type QueryObserverOptions } from '@tanstack/svelte-query'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getTagSuggestionsQuery, invalidatePersistentEventQueries } from './api.svelte'; + +const mocks = vi.hoisted(() => ({ + accessToken: { current: 'session-a' as null | string }, + createQuery: vi.fn<(options: () => QueryObserverOptions) => void>(), + getJSON: vi.fn() +})); +vi.mock('$env/dynamic/public', () => ({ env: {} })); +vi.mock('$features/auth/index.svelte', () => ({ accessToken: mocks.accessToken })); +vi.mock('@foundatiofx/fetchclient', async (importOriginal) => ({ + ...(await importOriginal()), + useFetchClient: () => ({ getJSON: mocks.getJSON }) +})); +vi.mock('@tanstack/svelte-query', async (importOriginal) => ({ + ...(await importOriginal()), + createQuery: mocks.createQuery +})); + +describe('tag suggestion query lifecycle', () => { + let client: QueryClient; + const data: CountResult = { aggregations: {}, total: 0 }; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.accessToken.current = 'session-a'; + mocks.getJSON.mockResolvedValue({ data }); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + }); + afterEach(() => client.clear()); + + function options(organizationId = 'organization-a', search = '', enabled = true) { + getTagSuggestionsQuery({ enabled: () => enabled, params: { search }, route: { organizationId } }); + return mocks.createQuery.mock.calls.at(-1)![0](); + } + + it.each([ + { enabled: false, token: 'session-a' }, + { enabled: true, token: null } + ])('does not fetch when disabled or unauthenticated: %j', async ({ enabled, token }) => { + // Arrange + mocks.accessToken.current = token; + const observer = new QueryObserver(client, options('organization-a', '', enabled)); + + // Act + const stop = observer.subscribe(() => {}); + await Promise.resolve(); + + // Assert + expect(observer.getCurrentResult().fetchStatus).toBe('idle'); + expect(mocks.getJSON).not.toHaveBeenCalled(); + stop(); + }); + + it('reuses fresh results for five minutes across observers', async () => { + // Arrange + const initial = options(); + await client.fetchQuery(initial); + const observer = new QueryObserver(client, options()); + + // Act + const stop = observer.subscribe(() => {}); + + // Assert + expect(observer.getCurrentResult().data).toEqual(data); + expect(mocks.getJSON).toHaveBeenCalledTimes(1); + expect(initial.staleTime).toBe(300000); + stop(); + }); + + it('keeps fresh suggestions cached through event notifications', async () => { + // Arrange + const initial = options(); + await client.fetchQuery(initial); + + // Act + await invalidatePersistentEventQueries(client, { change_type: ChangeType.Saved, data: {}, organization_id: 'organization-a', type: 'PersistentEvent' }); + + // Assert + expect(client.getQueryState(initial.queryKey)?.isInvalidated).toBe(false); + expect(client.getQueryData(initial.queryKey)).toEqual(data); + }); + + it('loads distinct organization data and reuses it only in its own organization', async () => { + // Arrange + const otherData: CountResult = { total: 27 }; + const firstOptions = options(); + await client.fetchQuery(firstOptions); + mocks.getJSON.mockResolvedValueOnce({ data: otherData }); + const observer = new QueryObserver(client, options('organization-b')); + + // Act + const stop = observer.subscribe(() => {}); + const beforeResponse = observer.getCurrentResult().data; + await vi.waitFor(() => expect(observer.getCurrentResult().isSuccess).toBe(true)); + const otherResult = observer.getCurrentResult().data; + observer.setOptions(firstOptions); + + // Assert + expect(beforeResponse).toBeUndefined(); + expect(otherResult).toEqual(otherData); + expect(observer.getCurrentResult().data).toEqual(data); + expect(mocks.getJSON).toHaveBeenCalledTimes(2); + expect(mocks.getJSON.mock.calls[1]![0]).toBe('/organizations/organization-b/events/count'); + stop(); + }); + + it('does not reuse a previous session after logout and login with the same token', async () => { + // Arrange + const initial = options(); + await client.fetchQuery(initial); + mocks.accessToken.current = null; + options(); + mocks.accessToken.current = 'session-a'; + const nextData: CountResult = { total: 42 }; + mocks.getJSON.mockResolvedValueOnce({ data: nextData }); + const nextOptions = options(); + const observer = new QueryObserver(client, nextOptions); + + // Act + const stop = observer.subscribe(() => {}); + const beforeResponse = observer.getCurrentResult().data; + await vi.waitFor(() => expect(observer.getCurrentResult().isSuccess).toBe(true)); + + // Assert + expect(beforeResponse).toBeUndefined(); + expect(observer.getCurrentResult().data).toEqual(nextData); + expect(mocks.getJSON).toHaveBeenCalledTimes(2); + expect(nextOptions.queryKey).not.toEqual(initial.queryKey); + expect(JSON.stringify(nextOptions.queryKey)).not.toContain('session-a'); + stop(); + }); + + it('cancels stale requests and never applies their response to a newer search', async () => { + // Arrange + const pending = Promise.withResolvers<{ data: CountResult }>(); + mocks.getJSON.mockReturnValueOnce(pending.promise); + const observer = new QueryObserver(client, options('organization-a', 'older')); + const stop = observer.subscribe(() => {}); + await vi.waitFor(() => expect(mocks.getJSON).toHaveBeenCalledTimes(1)); + const signal = mocks.getJSON.mock.calls[0]![1].signal as AbortSignal; + + // Act + observer.setOptions(options('organization-a', 'newer')); + await vi.waitFor(() => expect(observer.getCurrentResult().isSuccess).toBe(true)); + pending.resolve({ data: { total: 999 } }); + await Promise.resolve(); + + // Assert + expect(signal.aborted).toBe(true); + expect(observer.getCurrentResult().data).toEqual(data); + stop(); + }); + + it('retains failures for explicit retry and sends no dashboard filters', async () => { + // Arrange + mocks.getJSON.mockRejectedValueOnce(new Error('unavailable')); + const observer = new QueryObserver(client, options()); + const stop = observer.subscribe(() => {}); + await vi.waitFor(() => expect(observer.getCurrentResult().isError).toBe(true)); + const requestsAfterFailure = mocks.getJSON.mock.calls.length; + + // Act + await observer.refetch(); + + // Assert + expect(requestsAfterFailure).toBe(1); + expect(mocks.getJSON.mock.calls[0]![1].params).toEqual({ aggregations: 'terms:(tags~251)', time: 'all' }); + expect(observer.getCurrentResult().isSuccess).toBe(true); + stop(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/tag-suggestions.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/tag-suggestions.test.ts new file mode 100644 index 0000000000..d932886587 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/tag-suggestions.test.ts @@ -0,0 +1,90 @@ +import type { CountResult } from '$shared/models'; + +import { describe, expect, it } from 'vitest'; + +import { tagSuggestionAggregation, tagSuggestions, tagSuggestionSession } from './tag-suggestions'; + +function result(size: number, data: Record = { '@type': 'bucket' }): CountResult { + const aggregate = { data, items: Array.from({ length: size }, (_, index) => ({ key: `tag-${index}`, total: 1 })) }; + return { aggregations: { terms_tags: aggregate }, total: size }; +} + +describe('tag aggregation bounds and completeness', () => { + it.each([ + ['', 'terms:(tags~251)'], + ['Ab', 'terms:(tags~250 @include:/.*[aA][bB].*/)'], + ['a.b+c/d\\e', String.raw`terms:(tags~250 @include:/.*[aA]\\.[bB]\\+[cC]\/[dD]\\\\[eE].*/)`], + ['@#&<>~"(){}[]?*|', String.raw`terms:(tags~250 @include:/.*\\@\\#\\&\\<\\>\\~\\"\\(\\)\\{\\}\\[\\]\\?\\*\\|.*/)`], + ['éß', 'terms:(tags~250 @include:/.*[éÉ]ß.*/)'] + ])('bounds aggregation and preserves literal search %s', (search, expected) => { + // Arrange + const input = search; + + // Act + const aggregation = tagSuggestionAggregation(input); + + // Assert + expect(aggregation).toBe(expected); + }); + + it.each([ + { complete: true, data: { '@type': 'bucket' }, size: 250 }, + { complete: true, data: { '@type': 'bucket' }, size: 0 }, + { complete: true, data: { '@type': 'bucket', DocCountErrorUpperBound: 0, SumOtherDocCount: 0 }, size: 1 }, + { complete: false, data: { '@type': 'bucket' }, size: 251 }, + { complete: false, data: {}, size: 1 }, + { complete: false, data: { '@type': 'bucket', SumOtherDocCount: 1 }, size: 1 }, + { complete: false, data: { '@type': 'bucket', DocCountErrorUpperBound: 1 }, size: 1 }, + { complete: false, data: { '@type': 'bucket', SumOtherDocCount: -1 }, size: 1 }, + { complete: false, data: { '@type': 'bucket', DocCountErrorUpperBound: -1 }, size: 1 }, + { complete: false, data: { '@type': 'bucket', SumOtherDocCount: null }, size: 1 }, + { complete: false, data: { '@type': 'bucket', DocCountErrorUpperBound: null }, size: 1 }, + { complete: false, data: { '@type': 'bucket', SumOtherDocCount: '0' }, size: 1 }, + { complete: false, data: { '@type': 'bucket', DocCountErrorUpperBound: '0' }, size: 1 } + ])('reports completeness only with authoritative metadata: %j', ({ complete, data, size }) => { + // Arrange + const response = result(size, data); + + // Act + const suggestions = tagSuggestions(response); + + // Assert + expect(suggestions.complete).toBe(complete); + }); + + it('does not treat a missing response as complete', () => { + // Arrange + const response = undefined; + + // Act + const suggestions = tagSuggestions(response); + + // Assert + expect(suggestions.complete).toBe(false); + }); + + it('never exposes the overflow bucket', () => { + // Arrange + const response = result(251); + + // Act + const suggestions = tagSuggestions(response); + + // Assert + expect(suggestions.tags).toHaveLength(250); + expect(suggestions.tags).not.toContain('tag-250'); + }); + + it('partitions changed credentials without placing them into keys', () => { + // Arrange + const first = tagSuggestionSession('session-a'); + + // Act + const repeated = tagSuggestionSession('session-a'); + const changed = tagSuggestionSession('session-b'); + + // Assert + expect(repeated).toBe(first); + expect(changed).not.toBe(first); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/tag-suggestions.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/tag-suggestions.ts new file mode 100644 index 0000000000..45770c7898 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/tag-suggestions.ts @@ -0,0 +1,53 @@ +import type { CountResult } from '$shared/models'; + +import { terms } from '$shared/api/aggregations'; + +export const TAG_SUGGESTION_LIMIT = 250; +export const TAG_SUGGESTION_STALE_TIME = 5 * 60 * 1000; + +export function tagSuggestionAggregation(search: string): string { + if (!search) { + return `terms:(tags~${TAG_SUGGESTION_LIMIT + 1})`; + } + + // Terms include uses Lucene regex, inside the aggregation parser's slash-delimited term. + const literal = Array.from(search, (character) => { + const lower = character.toLowerCase(); + const upper = character.toUpperCase(); + if (lower !== upper && Array.from(lower).length === 1 && Array.from(upper).length === 1) { + return `[${lower}${upper}]`; + } + + return /[.\\?+*|{}[\]()"#@&<>~]/u.test(character) ? `\\${character}` : character; + }).join(''); + const pattern = literal.replaceAll('\\', '\\\\').replaceAll('/', '\\/'); + return `terms:(tags~${TAG_SUGGESTION_LIMIT} @include:/.*${pattern}.*/)`; +} + +export function tagSuggestions(result: CountResult | undefined) { + const aggregate = terms(result?.aggregations, 'terms_tags'); + const buckets = aggregate?.buckets ?? []; + const data = aggregate?.data; + // The repository adapter omits zero counters, but always marks its bucket metadata. + const complete = + !!aggregate && + data?.['@type'] === 'bucket' && + buckets.length <= TAG_SUGGESTION_LIMIT && + (data.SumOtherDocCount === undefined || data.SumOtherDocCount === 0) && + (data.DocCountErrorUpperBound === undefined || data.DocCountErrorUpperBound === 0); + + return { complete, tags: buckets.slice(0, TAG_SUGGESTION_LIMIT).map((bucket) => bucket.key) }; +} + +// Authentication redirects invalidate queries but do not always clear the shared cache. +// Partition suggestions without including credentials in query keys; retain only the latest token. +let lastToken: null | string | undefined; +let session = 0; +export function tagSuggestionSession(token: null | string): number { + if (token !== lastToken) { + lastToken = token; + session++; + } + + return session; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/column-management-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/column-management-dialog.svelte index 3e86b8d5fa..9f568b1630 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/column-management-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/column-management-dialog.svelte @@ -178,6 +178,7 @@ if (!wrappedColumnIds.includes(columnId)) { setWrappedColumnIds([...wrappedColumnIds, columnId]); } + return; } 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 7a9d2d4737..84f5c75351 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 @@ -998,6 +998,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur pendingDraftKey = ''; pendingDraftGeneration = -1; } + return; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-multi-select.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-multi-select.svelte index fb98a5deef..d563b0de30 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-multi-select.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-multi-select.svelte @@ -1,4 +1,6 @@