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..49324f2342 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/tag-suggestions.e2e.ts @@ -0,0 +1,144 @@ +import { expect, type Page, type Route, test } from '@playwright/test'; + +const ORGANIZATION_ID = '000000000000000000000001'; + +test('complete tag suggestions filter locally without changing selected tags', async ({ page }) => { + const requests: string[] = []; + await page.clock.install(); + await setup(page, async (route, aggregation) => { + requests.push(aggregation); + await route.fulfill({ json: tags(['Alpha', 'Beta']) }); + }); + await page.goto('/next/event?tag=Selected&time=%5Bnow-24h%20TO%20now%5D&project=000000000000000000000003'); + await page.getByRole('button', { name: /^Tag\s+Selected/ }).click(); + const input = page.getByPlaceholder('Tag', { exact: true }); + 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 input.press('Escape'); + await page.getByRole('button', { name: /^Tag\s+Selected/ }).click(); + expect(requests).toHaveLength(1); + await input.press('Escape'); + 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).toHaveURL(/[?&]time=/); + await page + .getByRole('button', { name: /^Project/ }) + .first() + .click(); + 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[] = []; + let searchFailed = false; + 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 page.goto('/next/event?tag=Selected'); + await page.getByRole('button', { name: /^Tag\s+Selected/ }).click(); + const input = page.getByPlaceholder('Tag', { exact: true }); + 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 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 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); +}); + +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'); + 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(url.pathname).toBe(`/api/v2/organizations/${ORGANIZATION_ID}/events/count`); + 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], + organization_preferences: [], + roles: [] + } + }); + } else if (url.pathname === '/api/v2/organizations' || url.pathname === `/api/v2/organizations/${ORGANIZATION_ID}`) { + const organization = { features: [], id: ORGANIZATION_ID, name: 'Test Organization', plan_id: 'EX_UNLIMITED', plan_name: 'Unlimited' }; + await route.fulfill({ json: url.pathname === '/api/v2/organizations' ? [organization] : organization }); + } 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/src/lib/features/events/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts index 6948cfde0a..c2b236f002 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; @@ -647,6 +649,32 @@ export function getStackEventsQuery(request: GetStackEventsRequest) { })); } +export function getTagSuggestionsQuery(request: { enabled: () => boolean; organizationId: string | undefined; search: string }) { + return createQuery(() => { + const organizationId = request.organizationId; + const search = request.search; + const session = tagSuggestionSession(accessToken.current); + + return { + enabled: !!accessToken.current && !!organizationId && request.enabled(), + queryFn: async ({ signal }) => { + const response = await useFetchClient().getJSON(`/organizations/${organizationId}/events/count`, { + params: { + aggregations: tagSuggestionAggregation(search), + time: 'all' + }, + signal + }); + return response.data!; + }, + queryKey: ['EventTagSuggestions', session, organizationId, search], + 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..ada447ea3d 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,63 +2,99 @@ 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(); + let search = $state(''); + let debouncedSearch = $state(''); + const normalizedSearch = $derived(search.trim().toLowerCase()); - function toggleHidden() { - filter.hidden = !filter.hidden; - filterChanged(filter); - } - - // Store the organizationId to prevent loading when switching organizations. - const organizationId = organization.current; - - // Create query with conditional enabled - only fetch when dropdown is open - const countQuery = getOrganizationCountQuery({ + const initialQuery = getTagSuggestionsQuery({ enabled: () => open, - params: { - aggregations: 'terms:tags' + get organizationId() { + return organization.current; }, - route: { - get organizationId() { - return organizationId; - } + search: '' + }); + const initial = $derived(tagSuggestions(initialQuery.data)); + const searchQuery = getTagSuggestionsQuery({ + enabled: () => open && initialQuery.isSuccess && !initial.complete && debouncedSearch.length >= 2 && debouncedSearch === normalizedSearch, + get organizationId() { + return organization.current; + }, + get search() { + return debouncedSearch; } }); - - const tags = $derived(Array.from(new Set(['Critical', ...(terms(countQuery.data?.aggregations, 'terms_tags')?.buckets?.map((tag) => tag.key) ?? [])]))); + const remoteSearch = $derived(!initial.complete && normalizedSearch.length >= 2); + const currentSearch = $derived(debouncedSearch === normalizedSearch); + 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)); - $effect(() => { - if (!countQuery.isSuccess || filter.value.length === 0) { - return; + 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.'; } - const selectedTags = tags.filter((tag) => filter.value.includes(tag)); - if (filter.value.length !== selectedTags.length) { - filter.value = selectedTags.map((tag) => tag); - filterChanged(filter); + 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(() => { + const value = normalizedSearch; + const organizationId = organization.current; + if (!open) { + debouncedSearch = ''; + return; + } + const timer = setTimeout(() => { + if (organization.current === organizationId) { + debouncedSearch = value; + } + }, 300); + return () => clearTimeout(timer); + }); + + function toggleHidden() { + filter.hidden = !filter.hidden; + filterChanged(filter); + } { filter.value = values; filterChanged(filter); }} - loading={countQuery.isLoading} + {loading} {options} remove={() => { filter.value = []; @@ -69,4 +105,23 @@ {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..893a7640e8 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte.test.ts @@ -0,0 +1,27 @@ +import { render } from '@testing-library/svelte'; +import { tick } from 'svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import { TagFilter } from './models.svelte'; +import TagFacetedFilter from './tag-faceted-filter.svelte'; + +vi.mock('$features/organizations/context.svelte', () => ({ organization: { current: 'organization-id' } })); +vi.mock('$features/events/api.svelte', () => ({ + getTagSuggestionsQuery: () => ({ + data: { aggregations: { terms_tags: { data: { '@type': 'bucket', SumOtherDocCount: 1 }, items: [{ key: 'common', total: 2 }] } } }, + isError: false, + isFetching: false, + isSuccess: true + }) +})); + +describe('tag suggestions', () => { + it('preserves a selected tag absent from the returned aggregation', async () => { + const filter = new TagFilter(['rare']); + const filterChanged = vi.fn(); + render(TagFacetedFilter, { filter, filterChanged, filterRemoved: vi.fn(), open: false, title: 'Tag' }); + await tick(); + expect(filter.value).toEqual(['rare']); + expect(filterChanged).not.toHaveBeenCalled(); + }); +}); 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..baed5e2e86 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/tag-suggestions-api.svelte.test.ts @@ -0,0 +1,93 @@ +import type { CountResult } from '$shared/models'; + +import { QueryClient, QueryObserver, type QueryObserverOptions } from '@tanstack/svelte-query'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getTagSuggestionsQuery } 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, organizationId, search }); + return mocks.createQuery.mock.calls.at(-1)![0](); + } + + it('does not query a closed picker or an unauthenticated session', () => { + expect(options('organization-a', '', false).enabled).toBe(false); + mocks.accessToken.current = null; + expect(options().enabled).toBe(false); + }); + + it('reuses fresh results for five minutes and scopes keys to organization, search and session', async () => { + const initial = options(); + const first = new QueryObserver(client, initial); + const stop = first.subscribe(() => {}); + await vi.waitFor(() => expect(first.getCurrentResult().isSuccess).toBe(true)); + stop(); + const again = new QueryObserver(client, options()); + const stopAgain = again.subscribe(() => {}); + expect(again.getCurrentResult().data).toEqual(data); + expect(mocks.getJSON).toHaveBeenCalledTimes(1); + expect(initial.staleTime).toBe(300000); + expect(options('organization-b').queryKey).not.toEqual(initial.queryKey); + expect(options('organization-a', 'rare').queryKey).not.toEqual(initial.queryKey); + mocks.accessToken.current = 'session-b'; + expect(options().queryKey).not.toEqual(initial.queryKey); + expect(JSON.stringify(initial.queryKey)).not.toContain('session-a'); + stopAgain(); + }); + + it('cancels stale requests and never applies their response to a newer search', async () => { + 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; + observer.setOptions(options('organization-a', 'newer')); + await vi.waitFor(() => expect(observer.getCurrentResult().isSuccess).toBe(true)); + expect(signal.aborted).toBe(true); + pending.resolve({ data: { total: 999 } }); + await Promise.resolve(); + expect(observer.getCurrentResult().data).toEqual(data); + stop(); + }); + + it('retains failures for explicit retry and sends no dashboard filters', async () => { + 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)); + expect(mocks.getJSON).toHaveBeenCalledTimes(1); + expect(mocks.getJSON.mock.calls[0]![1].params).toEqual({ aggregations: 'terms:(tags~251)', time: 'all' }); + await observer.refetch(); + 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..d1fdbb8264 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/tag-suggestions.test.ts @@ -0,0 +1,52 @@ +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('requests an overflow bucket initially and bounds matching searches', () => { + expect(tagSuggestionAggregation('')).toBe('terms:(tags~251)'); + expect(tagSuggestionAggregation('Ab')).toBe('terms:(tags~250 @include:/.*[aA][bB].*/)'); + }); + + it('only treats known repository metadata with no omitted terms as complete', () => { + expect(tagSuggestions(result(250)).complete).toBe(true); + expect(tagSuggestions(result(0)).complete).toBe(true); + expect(tagSuggestions(result(1, { '@type': 'bucket', DocCountErrorUpperBound: 0, SumOtherDocCount: 0 })).complete).toBe(true); + expect(tagSuggestions(result(251)).complete).toBe(false); + expect(tagSuggestions(result(1, {})).complete).toBe(false); + expect(tagSuggestions(undefined).complete).toBe(false); + for (const counter of ['SumOtherDocCount', 'DocCountErrorUpperBound']) { + for (const value of [1, -1, null, '0']) { + expect(tagSuggestions(result(1, { '@type': 'bucket', [counter]: value })).complete).toBe(false); + } + } + }); + + it('never exposes the overflow bucket', () => { + expect(tagSuggestions(result(251)).tags).toHaveLength(250); + }); + + it('escapes literal regex syntax and slash delimiters through both parsers', () => { + expect(tagSuggestionAggregation('a.b+c/d\\e')).toBe(String.raw`terms:(tags~250 @include:/.*[aA]\\.[bB]\\+[cC]\/[dD]\\\\[eE].*/)`); + expect(tagSuggestionAggregation('@#&<>~"(){}[]?*|')).toBe(String.raw`terms:(tags~250 @include:/.*\\@\\#\\&\\<\\>\\~\\"\\(\\)\\{\\}\\[\\]\\?\\*\\|.*/)`); + }); + + it('preserves non-ASCII letters without introducing multicharacter case expansions', () => { + expect(tagSuggestionAggregation('éß')).toBe('terms:(tags~250 @include:/.*[éÉ]ß.*/)'); + }); + + it('partitions cache sessions without placing credentials into keys', () => { + const first = tagSuggestionSession('session-a'); + expect(tagSuggestionSession('session-a')).toBe(first); + expect(tagSuggestionSession('session-b')).not.toBe(first); + tagSuggestionSession(null); + expect(tagSuggestionSession('session-a')).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..cc5cb92126 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/tag-suggestions.ts @@ -0,0 +1,52 @@ +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) }; +} + +// Keep credentials out of query keys while preventing cached suggestions crossing sessions. +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/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 05f38e3246..bdf8020836 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 @@