diff --git a/src/Exceptionless.Web/ClientApp.angular/app/session/sessions-directive.tpl.html b/src/Exceptionless.Web/ClientApp.angular/app/session/sessions-directive.tpl.html index 734eb6a6e6..b349d8b70d 100644 --- a/src/Exceptionless.Web/ClientApp.angular/app/session/sessions-directive.tpl.html +++ b/src/Exceptionless.Web/ClientApp.angular/app/session/sessions-directive.tpl.html @@ -2,6 +2,7 @@ = 4400 && event.code < 4500)) { + _this.readyState = WebSocket.CLOSED; + _this.onclose(event); } else { _this.readyState = WebSocket.CONNECTING; _this.onconnecting(); if (!reconnectAttempt && !_this.timedOut) { _this.onclose(event); } - setTimeout(function () { + _this.reconnectTimeout = setTimeout(function () { + _this.reconnectTimeout = null; _this.connect(true); }, _this.reconnectInterval); } @@ -72,11 +78,15 @@ throw new Error("INVALID_STATE_ERR : Pausing to reconnect websocket"); }; ResilientWebSocket.prototype.close = function () { + this.forcedClose = true; + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; if (this.ws) { - this.forcedClose = true; this.ws.close(); return true; } + + this.readyState = WebSocket.CLOSED; return false; }; ResilientWebSocket.prototype.refresh = function () { @@ -101,7 +111,12 @@ function startDelayed(delay) { function startImpl() { - _connection = new ResilientWebSocket(getPushUrl()); + _connection = new ResilientWebSocket(getPushUrl); + _connection.onopen = function (event, isReconnect) { + if (isReconnect) { + $rootScope.$emit("WebSocketReconnected"); + } + }; _connection.onmessage = function (ev) { var data = ev.data ? JSON.parse(ev.data) : null; if (!data || !data.type) { @@ -153,6 +168,12 @@ return pushUrl.replace(protoMatch, "ws://"); } + $rootScope.$on("auth:tokenChanged", function () { + if (_connection) { + startDelayed(1); + } + }); + var service = { start: start, startDelayed: startDelayed, 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 9a57dd1a2a..fd54c1e078 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 @@ -2,6 +2,7 @@ import type { WebSocketMessageValue } from '$features/websockets/models'; import type { CountResult, WorkInProgressResult } from '$shared/models'; import { accessToken } from '$features/auth/index.svelte'; +import { queryKeys as stackQueryKeys } from '$features/stacks/api.svelte'; import { DEFAULT_OFFSET } from '$shared/api/api.svelte'; import { type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; import { createMutation, createQuery, keepPreviousData, QueryClient, useQueryClient } from '@tanstack/svelte-query'; @@ -51,6 +52,10 @@ export const queryKeys = { type: ['PersistentEvent'] as const }; +export const PERSISTENT_EVENT_DELETE_RECONCILE_EVENT = 'PersistentEventDeleteReconcile'; +export const PERSISTENT_EVENT_DELETE_RECONCILE_DELAY = 1500; +export const PERSISTENT_EVENT_DELETE_RECONCILE_RETRY_DELAY = 5000; + export interface DeleteEventsRequest { route: { ids: string[] | undefined; @@ -210,6 +215,7 @@ export function deleteEvent(request: DeleteEventsRequest) { }, onSuccess: () => { request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id) })); + schedulePersistentEventDeleteReconciliation(queryClient); } })); } @@ -439,3 +445,17 @@ export function getStackEventsQuery(request: GetStackEventsRequest) { queryKey: queryKeys.stackEvents(request.route.stackId, request.params) })); } + +export function schedulePersistentEventDeleteReconciliation(queryClient: QueryClient, eventTarget: EventTarget = document) { + eventTarget.dispatchEvent(new Event(PERSISTENT_EVENT_DELETE_RECONCILE_EVENT)); + void queryClient.invalidateQueries({ queryKey: stackQueryKeys.type }); + setTimeout(() => { + void queryClient.invalidateQueries({ queryKey: queryKeys.type }); + void queryClient.invalidateQueries({ queryKey: stackQueryKeys.type }); + }, PERSISTENT_EVENT_DELETE_RECONCILE_DELAY); + setTimeout(() => { + eventTarget.dispatchEvent(new Event(PERSISTENT_EVENT_DELETE_RECONCILE_EVENT)); + void queryClient.invalidateQueries({ queryKey: queryKeys.type }); + void queryClient.invalidateQueries({ queryKey: stackQueryKeys.type }); + }, PERSISTENT_EVENT_DELETE_RECONCILE_RETRY_DELAY); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.test.ts index a94b4a70aa..690acfba39 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.test.ts @@ -1,12 +1,24 @@ import { ChangeType } from '$features/websockets/models'; import { QueryClient } from '@tanstack/svelte-query'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; vi.mock('$features/auth/index.svelte', () => ({ accessToken: { current: 'test-token' } })); -import { invalidatePersistentEventQueries, queryKeys } from './api.svelte'; +import { queryKeys as stackQueryKeys } from '../stacks/api.svelte'; +import { + invalidatePersistentEventQueries, + PERSISTENT_EVENT_DELETE_RECONCILE_DELAY, + PERSISTENT_EVENT_DELETE_RECONCILE_EVENT, + PERSISTENT_EVENT_DELETE_RECONCILE_RETRY_DELAY, + queryKeys, + schedulePersistentEventDeleteReconciliation +} from './api.svelte'; + +afterEach(() => { + vi.useRealTimers(); +}); describe('invalidatePersistentEventQueries', () => { it('does not invalidate nested count aggregation queries for event updates', async () => { @@ -33,3 +45,30 @@ describe('invalidatePersistentEventQueries', () => { expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: queryKeys.stacks('stack-id') }); }); }); + +describe('schedulePersistentEventDeleteReconciliation', () => { + it('notifies manual grids immediately and invalidates query grids after the consistency delay', async () => { + vi.useFakeTimers(); + const queryClient = new QueryClient(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries').mockImplementation(async () => {}); + const reconcileListener = vi.fn(); + const eventTarget = new EventTarget(); + eventTarget.addEventListener(PERSISTENT_EVENT_DELETE_RECONCILE_EVENT, reconcileListener); + + schedulePersistentEventDeleteReconciliation(queryClient, eventTarget); + + expect(reconcileListener).toHaveBeenCalledOnce(); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: stackQueryKeys.type }); + + await vi.advanceTimersByTimeAsync(PERSISTENT_EVENT_DELETE_RECONCILE_DELAY); + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.type }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: stackQueryKeys.type }); + expect(reconcileListener).toHaveBeenCalledOnce(); + + await vi.advanceTimersByTimeAsync(PERSISTENT_EVENT_DELETE_RECONCILE_RETRY_DELAY - PERSISTENT_EVENT_DELETE_RECONCILE_DELAY); + + expect(reconcileListener).toHaveBeenCalledTimes(2); + expect(invalidateSpy).toHaveBeenCalledTimes(5); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts index 7d4ac9bf5a..7d63ed90aa 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts @@ -6,6 +6,7 @@ import { filterChanged, quoteIfSpecialCharacters, serializeFilters, + shouldRefreshPersistentEventRemoval, toFilter, toFilterFromSerializedFilters } from './helpers.svelte'; @@ -144,6 +145,26 @@ describe('filterChanged', () => { }); }); +describe('shouldRefreshPersistentEventRemoval', () => { + it('refreshes after removing a visible row even when the message does not match the current filter', () => { + const filters = [new ProjectFilter(['project-1'])]; + + expect(shouldRefreshPersistentEventRemoval(true, filters, 'project:project-1', undefined, 'project-2')).toBe(true); + }); + + it('refreshes an off-page result when the removal matches the current filter', () => { + const filters = [new ProjectFilter(['project-1'])]; + + expect(shouldRefreshPersistentEventRemoval(false, filters, 'project:project-1', undefined, 'project-1')).toBe(true); + }); + + it('ignores an off-page removal that does not match the current filter', () => { + const filters = [new ProjectFilter(['project-1'])]; + + expect(shouldRefreshPersistentEventRemoval(false, filters, 'project:project-1', undefined, 'project-2')).toBe(false); + }); +}); + describe('serializeFilters', () => { it('serializes an empty array', () => { expect(serializeFilters([])).toBe('[]'); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts index bbbc9d9842..8a4554c7c7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts @@ -205,6 +205,18 @@ export function shouldRefreshPersistentEventChanged( return true; } +export function shouldRefreshPersistentEventRemoval( + removedFromTable: boolean, + filters: IFilter[], + filter: null | string, + organization_id?: string, + project_id?: string, + stack_id?: string, + id?: string +) { + return removedFromTable || shouldRefreshPersistentEventChanged(filters, filter, organization_id, project_id, stack_id, id); +} + const TYPE_FILTER_REGEX = /\btype:(\w+)\b/g; export function hasSingleTypeFilter(filter: null | string | undefined): boolean { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/events-bulk-actions-dropdown-menu.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/events-bulk-actions-dropdown-menu.svelte index d41cfc2798..31dec91e56 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/events-bulk-actions-dropdown-menu.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/events-bulk-actions-dropdown-menu.svelte @@ -30,12 +30,13 @@ }); async function remove() { + const deletedCount = ids.length; await removeEvents.mutateAsync(); - if (ids.length === 1) { + if (deletedCount === 1) { toast.success('Successfully deleted event.'); } else { - toast.success(`Successfully deleted ${Intl.NumberFormat().format(ids.length)} events.`); + toast.success(`Successfully deleted ${Intl.NumberFormat().format(deletedCount)} events.`); } table.resetRowSelection(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/events-bulk-actions-dropdown-menu.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/events-bulk-actions-dropdown-menu.svelte.test.ts new file mode 100644 index 0000000000..67e51e4f63 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/events-bulk-actions-dropdown-menu.svelte.test.ts @@ -0,0 +1,57 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mutateAsync = vi.hoisted(() => vi.fn()); +const deleteEvent = vi.hoisted(() => vi.fn(() => ({ mutateAsync }))); +const toast = vi.hoisted(() => ({ success: vi.fn() })); + +vi.mock('$features/events/api.svelte', () => ({ deleteEvent })); +vi.mock('svelte-sonner', () => ({ toast })); + +import EventsBulkActionsDropdownMenu from './events-bulk-actions-dropdown-menu.svelte'; + +describe('EventsBulkActionsDropdownMenu', () => { + beforeEach(() => { + mutateAsync.mockResolvedValue(undefined); + deleteEvent.mockClear(); + toast.success.mockClear(); + }); + + it('deletes the selected events and clears the selection', async () => { + // Arrange + const resetRowSelection = vi.fn(); + const table = { + getSelectedRowModel: () => ({ flatRows: [{ id: 'event-id' }] }), + resetRowSelection + } as never; + render(EventsBulkActionsDropdownMenu, { props: { table } }); + + // Act + await fireEvent.click(screen.getByRole('button', { name: /Bulk Actions/ })); + await fireEvent.click(screen.getByRole('menuitem', { name: 'Delete' })); + await fireEvent.click(screen.getByRole('button', { name: 'Delete Event' })); + + // Assert + await waitFor(() => expect(mutateAsync).toHaveBeenCalledOnce()); + expect(resetRowSelection).toHaveBeenCalledOnce(); + expect(toast.success).toHaveBeenCalledWith('Successfully deleted event.'); + }); + + it('uses the selected count for the bulk delete confirmation', async () => { + // Arrange + const table = { + getSelectedRowModel: () => ({ flatRows: [{ id: 'event-id-1' }, { id: 'event-id-2' }] }), + resetRowSelection: vi.fn() + } as never; + render(EventsBulkActionsDropdownMenu, { props: { table } }); + + // Act + await fireEvent.click(screen.getByRole('button', { name: /Bulk Actions/ })); + await fireEvent.click(screen.getByRole('menuitem', { name: 'Delete' })); + await fireEvent.click(screen.getByRole('button', { name: 'Delete 2 Events' })); + + // Assert + await waitFor(() => expect(mutateAsync).toHaveBeenCalledOnce()); + expect(toast.success).toHaveBeenCalledWith('Successfully deleted 2 events.'); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.svelte.ts index c816c5bcb5..be7aa97b10 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.svelte.ts @@ -51,6 +51,7 @@ export class WebSocketClient { private hasConnectedBefore: boolean = false; private reconnectAttempts: number = 0; private reconnectTimeoutId: null | ReturnType = null; + private terminalAuthFailure: boolean = false; private ws: null | WebSocket = null; @@ -68,6 +69,7 @@ export class WebSocketClient { if (this.accessToken !== accessToken.current) { this.accessToken = accessToken.current; this.reconnectAttempts = 0; // Reset backoff on token change + this.terminalAuthFailure = false; this.close(); } else if (!visibility.visible) { this.close(); @@ -75,7 +77,13 @@ export class WebSocketClient { // Only auto-connect if we're fully closed and don't have a pending reconnect attempt // Don't try to connect if we're CONNECTING, OPEN, or CLOSING - if (this.accessToken && visibility.visible && this.readyState === WebSocket.CLOSED && this.reconnectTimeoutId === null) { + if ( + this.accessToken && + !this.terminalAuthFailure && + visibility.visible && + this.readyState === WebSocket.CLOSED && + this.reconnectTimeoutId === null + ) { this.connect(); } }); @@ -93,6 +101,7 @@ export class WebSocketClient { return true; } + this.readyState = WebSocket.CLOSED; return false; } @@ -143,12 +152,11 @@ export class WebSocketClient { return; } - // Don't retry on authentication/authorization failures - // Code 1008 (Policy Violation) is explicit auth failure - // Code 1006 (Abnormal Closure) during handshake could be 401/403 - // Codes 4xxx are custom application codes (e.g., 4401=401, 4403=403) - const isAuthFailure = event.code === 1008 || (event.code === 1006 && event.wasClean === false) || (event.code >= 4400 && event.code < 4500); + // The push endpoint accepts rejected WebSocket handshakes and closes them with 4401, so browser code + // 1006 remains safe to treat as a transient network or service-startup failure. + const isAuthFailure = event.code === 1008 || (event.code >= 4400 && event.code < 4500); if (isAuthFailure) { + this.terminalAuthFailure = true; console.warn('[WebSocketClient] Auth failure detected, not reconnecting', { code: event.code, reason: event.reason diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.test.ts index befba73c09..0bd8e5e7b4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.test.ts @@ -191,25 +191,26 @@ describe('WebSocketClient', () => { expect(client.readyState).toBe(WebSocket.CLOSED); }); - it('should NOT reconnect on abnormal closure (code 1006, wasClean=false) - connection lost unexpectedly', async () => { + it('should reconnect on abnormal closure (code 1006, wasClean=false) - connection lost unexpectedly', async () => { const client = createClient(); - const onClose = vi.fn(); - client.onClose = onClose; + const onConnecting = vi.fn(); + const onOpen = vi.fn(); + client.onConnecting = onConnecting; + client.onOpen = onOpen; client.connect(); await server.connected; + onConnecting.mockClear(); + onOpen.mockClear(); server.close({ code: 1006, reason: 'Abnormal Closure', wasClean: false }); - await new Promise((resolve) => setTimeout(resolve, 50)); + server = new WS('ws://localhost:1234/api/v2/push'); + await server.connected; - expect(onClose).toHaveBeenCalledWith( - expect.objectContaining({ - code: 1006, - reason: 'Abnormal Closure', - wasClean: false - }) - ); - expect(client.readyState).toBe(WebSocket.CLOSED); + expect(onConnecting).toHaveBeenCalledWith(true); + expect(onOpen).toHaveBeenCalledWith(expect.anything(), true); + expect(client.readyState).toBe(WebSocket.OPEN); + client.close(); }); it('should NOT reconnect on unauthorized (code 4401) - 401 HTTP equivalent', async () => { @@ -288,6 +289,18 @@ describe('WebSocketClient', () => { client.close(); }); + it('should become closed when a pending reconnect is canceled', async () => { + const client = createClient(undefined, { reconnectDelay: () => 1000 }); + + client.connect(); + await server.connected; + server.close({ code: 1001, reason: 'Going Away', wasClean: true }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(client.close()).toBe(false); + expect(client.readyState).toBe(WebSocket.CLOSED); + }); + it('should call onConnecting with isReconnect=true on reconnection', async () => { const onConnecting = vi.fn(); const client = createClient(); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte index cce4bb52f5..f65f329d2d 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte @@ -9,7 +9,7 @@ import RefreshButton from '$comp/refresh-button.svelte'; import { H3 } from '$comp/typography'; import { showBillingDialogOnUpgradeProblem } from '$features/billing/upgrade-required.svelte'; - import { getOrganizationCountQuery } from '$features/events/api.svelte'; + import { getOrganizationCountQuery, PERSISTENT_EVENT_DELETE_RECONCILE_EVENT } from '$features/events/api.svelte'; import EventDetailSheet from '$features/events/components/event-detail-sheet.svelte'; import EventsDashboardChart from '$features/events/components/events-dashboard-chart.svelte'; import EventsStatsDashboard from '$features/events/components/events-stats-dashboard.svelte'; @@ -36,6 +36,7 @@ hasSingleTypeFilter, serializeFilters, shouldRefreshPersistentEventChanged, + shouldRefreshPersistentEventRemoval, toFilter, updateFilterCache } from '$features/events/components/filters/helpers.svelte'; @@ -59,8 +60,8 @@ import { createTable } from '@tanstack/svelte-table'; import { queryParamsState } from 'kit-query-params'; import { useEventListener, watch } from 'runed'; - import { untrack } from 'svelte'; - import { debounce, throttle } from 'throttle-debounce'; + import { onDestroy, untrack } from 'svelte'; + import { debounce } from 'throttle-debounce'; import { ALL_TIME_QUERY_VALUE, @@ -699,40 +700,108 @@ await loadData(); } - async function loadData() { + let loadDataRequestId = 0; + async function loadData(reconcileTotal: boolean = false) { + const requestId = ++loadDataRequestId; if (!organization.current || isSavedViewRoutePending) { return; } + const organizationId = organization.current; + const requestParameters = { ...eventsQueryParameters }; + const requestIdentity = JSON.stringify([organizationId, requestParameters]); + const isCurrentRequest = () => + requestId === loadDataRequestId && requestIdentity === JSON.stringify([organization.current, { ...eventsQueryParameters }]); const params = { - ...eventsQueryParameters, - include: !eventsQueryParameters.after && !eventsQueryParameters.before ? 'total' : undefined + ...requestParameters, + include: !requestParameters.after && !requestParameters.before ? ('total' as const) : undefined }; delete params.page; - clientResponse = await client.getJSON[]>(`organizations/${organization.current}/events`, { params }); + const response = await client.getJSON[]>(`organizations/${organizationId}/events`, { params }); + if (!isCurrentRequest()) { + return; + } + + clientResponse = response; if (clientResponse.problem) { showBillingDialogOnUpgradeProblem(clientResponse.problem, organization.current, () => loadData()); } + + if ((requestParameters.after || requestParameters.before) && reconcileTotal) { + const totalParams = { + ...requestParameters, + include: 'total' as const, + limit: 1 + }; + delete totalParams.after; + delete totalParams.before; + delete totalParams.page; + + const totalResponse = await client.getJSON[]>(`organizations/${organizationId}/events`, { + params: totalParams + }); + if (!isCurrentRequest()) { + return; + } + + if (totalResponse.ok) { + const total = totalResponse.meta.total as number | undefined; + const totalPages = total == null ? undefined : Math.ceil(total / (requestParameters.limit ?? 20)); + if (totalPages != null && table.state.pagination.pageIndex >= totalPages) { + table.firstPage(); + return; + } + } + } + + if (response.ok && response.data?.length === 0 && table.state.pagination.pageIndex > 0) { + table.previousPage(); + } + } + + let reconcileTotalRequested = false; + const debouncedLoadData = debounce(1500, () => { + const reconcileTotal = reconcileTotalRequested; + reconcileTotalRequested = false; + return loadData(reconcileTotal); + }); + function scheduleLoadData(reconcileTotal: boolean = false) { + reconcileTotalRequested ||= reconcileTotal; + debouncedLoadData(); } - const throttledLoadData = throttle(10000, loadData); - const debouncedLoadData = debounce(1500, loadData); + onDestroy(() => { + loadDataRequestId++; + debouncedLoadData.cancel(); + }); - async function onPersistentEventChanged(message: WebSocketMessageValue<'PersistentEventChanged'>) { + function onPersistentEventChanged(message: WebSocketMessageValue<'PersistentEventChanged'>) { + let removedFromTable = false; if (message.id && message.change_type === ChangeType.Removed) { removeTableSelection(table, message.id); - if (removeTableData(table, (doc: EventSummaryModel) => doc.id === message.id)) { - // If the grid data is empty from all events being removed, we should refresh the data. - if (isTableEmpty(table)) { - await throttledLoadData(); - return; - } - } + removedFromTable = removeTableData(table, (doc: EventSummaryModel) => doc.id === message.id); } if (message.change_type === ChangeType.Removed) { + // Reconcile rows and cursor metadata after the asynchronous delete completes. The debounce also gives + // Elasticsearch time to make the removal visible to the list query, including when a matching removal + // is not on the visible page. + if ( + shouldRefreshPersistentEventRemoval( + removedFromTable, + filters, + queryParams.filter, + message.organization_id, + message.project_id, + message.stack_id, + message.id + ) + ) { + scheduleLoadData(true); + } + return; } @@ -740,10 +809,12 @@ return; } - await debouncedLoadData(); + scheduleLoadData(); } - useEventListener(document, 'PersistentEventChanged', async (event) => await onPersistentEventChanged((event as CustomEvent).detail)); + useEventListener(document, PERSISTENT_EVENT_DELETE_RECONCILE_EVENT, () => scheduleLoadData(true)); + useEventListener(document, 'refresh', () => loadData()); + useEventListener(document, 'PersistentEventChanged', (event) => onPersistentEventChanged((event as CustomEvent).detail)); $effect(() => { loadData(); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/sessions/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/sessions/+page.svelte index 5d0c2e825e..c130206b0f 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/sessions/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/sessions/+page.svelte @@ -12,7 +12,7 @@ import { Label } from '$comp/ui/label'; import { Switch } from '$comp/ui/switch'; import { showBillingDialogOnUpgradeProblem } from '$features/billing/upgrade-required.svelte'; - import { getOrganizationSessionsCountQuery } from '$features/events/api.svelte'; + import { getOrganizationSessionsCountQuery, PERSISTENT_EVENT_DELETE_RECONCILE_EVENT } from '$features/events/api.svelte'; import EventDetailSheet from '$features/events/components/event-detail-sheet.svelte'; import { DateFilter, ProjectFilter, TypeFilter } from '$features/events/components/filters'; import { @@ -33,7 +33,7 @@ import SessionsDashboardChart from '$features/sessions/components/sessions-dashboard-chart.svelte'; import SessionsStatsDashboard from '$features/sessions/components/sessions-stats-dashboard.svelte'; import * as agg from '$features/shared/api/aggregations'; - import { getSharedTableOptions, isTableEmpty, removeTableData, removeTableSelection } from '$features/shared/table.svelte'; + import { getSharedTableOptions, removeTableData, removeTableSelection } from '$features/shared/table.svelte'; import { fillDateSeries } from '$features/shared/utils/charts.js'; import { parseDateMathRange, toDateMathRange } from '$features/shared/utils/datemath'; import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models'; @@ -42,7 +42,8 @@ import { createTable } from '@tanstack/svelte-table'; import { queryParamsState } from 'kit-query-params'; import { useEventListener, watch } from 'runed'; - import { throttle } from 'throttle-debounce'; + import { onDestroy } from 'svelte'; + import { debounce } from 'throttle-debounce'; let selectedEventId: null | string = $state(null); function rowclick(row: EventSummaryModel) { @@ -215,7 +216,9 @@ await loadData(); } + let loadDataRequestId = 0; async function loadData() { + const requestId = ++loadDataRequestId; if (!organization.current) { return; } @@ -225,31 +228,44 @@ return; } - clientResponse = await client.getJSON[]>(`organizations/${organization.current}/events/sessions`, { + const response = await client.getJSON[]>(`organizations/${organization.current}/events/sessions`, { params: eventsQueryParameters as Record }); + if (requestId !== loadDataRequestId) { + return; + } + + clientResponse = response; if (clientResponse.problem) { showBillingDialogOnUpgradeProblem(clientResponse.problem, organization.current, () => loadData()); } - } - const throttledLoadData = throttle(10000, loadData); + if (clientResponse.ok && clientResponse.data?.length === 0 && table.store.state.pagination.pageIndex > 0) { + table.previousPage(); + } + } - async function onPersistentEventChanged(message: WebSocketMessageValue<'PersistentEventChanged'>) { - if (message.id && message.change_type === ChangeType.Removed) { - removeTableSelection(table, message.id); + const debouncedLoadData = debounce(1500, loadData); + onDestroy(() => { + loadDataRequestId++; + debouncedLoadData.cancel(); + }); - if (removeTableData(table, (doc) => doc.id === message.id)) { - if (isTableEmpty(table)) { - await throttledLoadData(); - return; - } + function onPersistentEventChanged(message: WebSocketMessageValue<'PersistentEventChanged'>) { + if (message.change_type === ChangeType.Removed && (!message.organization_id || message.organization_id === organization.current)) { + if (message.id) { + removeTableSelection(table, message.id); + removeTableData(table, (doc) => doc.id === message.id); } + + debouncedLoadData(); } } - useEventListener(document, 'PersistentEventChanged', async (event) => await onPersistentEventChanged((event as CustomEvent).detail)); + useEventListener(document, PERSISTENT_EVENT_DELETE_RECONCILE_EVENT, () => debouncedLoadData()); + useEventListener(document, 'refresh', () => loadData()); + useEventListener(document, 'PersistentEventChanged', (event) => onPersistentEventChanged((event as CustomEvent).detail)); $effect(() => { loadData(); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte index 9389da988a..88bf75717e 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte @@ -8,7 +8,7 @@ import RefreshButton from '$comp/refresh-button.svelte'; import { H3 } from '$comp/typography'; import { showBillingDialogOnUpgradeProblem } from '$features/billing/upgrade-required.svelte'; - import { type GetEventsParams, getOrganizationCountQuery } from '$features/events/api.svelte'; + import { type GetEventsParams, getOrganizationCountQuery, PERSISTENT_EVENT_DELETE_RECONCILE_EVENT } from '$features/events/api.svelte'; import EventsDashboardChart from '$features/events/components/events-dashboard-chart.svelte'; import EventsStatsDashboard from '$features/events/components/events-stats-dashboard.svelte'; import { @@ -43,7 +43,7 @@ import SavedViewPicker from '$features/saved-views/components/saved-view-picker.svelte'; import { useSavedViews } from '$features/saved-views/use-saved-views.svelte'; import * as agg from '$features/shared/api/aggregations'; - import { createPageSizePreference, getSharedTableOptions, isTableEmpty, removeTableData, removeTableSelection } from '$features/shared/table.svelte'; + import { createPageSizePreference, getSharedTableOptions, removeTableData, removeTableSelection } from '$features/shared/table.svelte'; import { fillDateSeries } from '$features/shared/utils/charts'; import { parseDateMathRange, toDateMathRange } from '$features/shared/utils/datemath'; import StackDetailSheet from '$features/stacks/components/stack-detail-sheet.svelte'; @@ -56,8 +56,8 @@ import { createTable } from '@tanstack/svelte-table'; import { queryParamsState } from 'kit-query-params'; import { useEventListener, watch } from 'runed'; - import { untrack } from 'svelte'; - import { throttle } from 'throttle-debounce'; + import { onDestroy, untrack } from 'svelte'; + import { debounce } from 'throttle-debounce'; import { ALL_TIME_QUERY_VALUE, @@ -667,42 +667,59 @@ await loadData(); } + let loadDataRequestId = 0; async function loadData() { + const requestId = ++loadDataRequestId; if (!organization.current || isSavedViewRoutePending) { return; } - clientResponse = await client.getJSON[]>(`organizations/${organization.current}/events`, { + const response = await client.getJSON[]>(`organizations/${organization.current}/events`, { params: { ...eventsQueryParameters, - include: !eventsQueryParameters.page || eventsQueryParameters.page <= 1 ? 'total' : undefined + include: 'total' } as Record }); + if (requestId !== loadDataRequestId) { + return; + } + + clientResponse = response; showBillingDialogOnUpgradeProblem(clientResponse.problem, organization.current, () => loadData()); + + if (clientResponse.ok && clientResponse.data?.length === 0 && table.state.pagination.pageIndex > 0) { + table.previousPage(); + } } - const throttledLoadData = throttle(5000, loadData); + const debouncedLoadData = debounce(1500, loadData); + onDestroy(() => { + loadDataRequestId++; + debouncedLoadData.cancel(); + }); - async function onStackChanged(message: WebSocketMessageValue<'StackChanged'>) { - if (message.id && message.change_type === ChangeType.Removed) { + function onStackChanged(message: WebSocketMessageValue<'StackChanged'>) { + if (message.change_type !== ChangeType.Removed || (message.organization_id && message.organization_id !== organization.current)) { + return; + } + + if (message.id) { if (message.id === selectedStackId) { selectedStackId = undefined; } removeTableSelection(table, message.id); - if (removeTableData(table, (doc: EventSummaryModel) => doc.id === message.id)) { - // If the grid data is empty from all events being removed, we should refresh the data. - if (isTableEmpty(table)) { - await throttledLoadData(); - return; - } - } + removeTableData(table, (doc: EventSummaryModel) => doc.id === message.id); } + + debouncedLoadData(); } - useEventListener(document, 'StackChanged', async (event) => await onStackChanged((event as CustomEvent).detail)); + useEventListener(document, PERSISTENT_EVENT_DELETE_RECONCILE_EVENT, () => debouncedLoadData()); + useEventListener(document, 'refresh', () => loadData()); + useEventListener(document, 'StackChanged', (event) => onStackChanged((event as CustomEvent).detail)); $effect(() => { loadData(); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte index c858d545c2..a0625fd4cf 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte @@ -11,6 +11,7 @@ import StreamingIndicatorButton from '$comp/streaming-indicator-button.svelte'; import { H3 } from '$comp/typography'; import { showBillingDialogOnUpgradeProblem } from '$features/billing/upgrade-required.svelte'; + import { PERSISTENT_EVENT_DELETE_RECONCILE_EVENT } from '$features/events/api.svelte'; import EventDetailSheet from '$features/events/components/event-detail-sheet.svelte'; import { ProjectFilter, StatusFilter, TagFilter } from '$features/events/components/filters'; import { @@ -37,6 +38,7 @@ import { createTable } from '@tanstack/svelte-table'; import { queryParamsState } from 'kit-query-params'; import { useEventListener, watch } from 'runed'; + import { onDestroy } from 'svelte'; import { debounce } from 'throttle-debounce'; import { getEventsNavigationOptionsForFilter, redirectToEventsWithFilter } from '../redirect-to-events.svelte'; @@ -208,12 +210,17 @@ }) ); + let loadDataRequestId = 0; let paused = $state(false); function handleToggle() { paused = !paused; + if (paused) { + loadDataRequestId++; + } } async function loadData(filterChanged: boolean = false) { + const requestId = ++loadDataRequestId; if (paused) { return; } @@ -230,12 +237,17 @@ before = undefined; } - clientResponse = await client.getJSON[]>(`organizations/${organization.current}/events`, { + const response = await client.getJSON[]>(`organizations/${organization.current}/events`, { params: { ...eventsQueryParameters, before } }); + if (requestId !== loadDataRequestId) { + return; + } + + clientResponse = response; if (clientResponse.problem && showBillingDialogOnUpgradeProblem(clientResponse.problem, organization.current, () => loadData(true))) { return; @@ -256,12 +268,16 @@ } const debouncedLoadData = debounce(5000, loadData); - async function onPersistentEventChanged(message: WebSocketMessageValue<'PersistentEventChanged'>) { + onDestroy(() => { + loadDataRequestId++; + debouncedLoadData.cancel(); + }); + function onPersistentEventChanged(message: WebSocketMessageValue<'PersistentEventChanged'>) { if (message.id && message.change_type === ChangeType.Removed) { if (removeTableData(table, (doc) => doc.id === message.id)) { // If the grid data is empty from all events being removed, we should refresh the data. if (isTableEmpty(table) && !paused) { - await debouncedLoadData(); + debouncedLoadData(); return; } } @@ -276,10 +292,11 @@ return; } - await debouncedLoadData(); + debouncedLoadData(); } - useEventListener(document, 'refresh', async () => await loadData()); + useEventListener(document, PERSISTENT_EVENT_DELETE_RECONCILE_EVENT, () => debouncedLoadData()); + useEventListener(document, 'refresh', () => loadData()); useEventListener(document, 'PersistentEventChanged', (event) => onPersistentEventChanged((event as CustomEvent).detail)); $effect(() => { @@ -288,6 +305,10 @@ }); $effect(() => { + if (paused) { + return; + } + loadData(); }); diff --git a/src/Exceptionless.Web/Hubs/MessageBusBrokerMiddleware.cs b/src/Exceptionless.Web/Hubs/MessageBusBrokerMiddleware.cs index 227a5372ae..b8d40c0fbb 100644 --- a/src/Exceptionless.Web/Hubs/MessageBusBrokerMiddleware.cs +++ b/src/Exceptionless.Web/Hubs/MessageBusBrokerMiddleware.cs @@ -7,6 +7,9 @@ namespace Exceptionless.Web.Hubs; public class MessageBusBrokerMiddleware { + private const int UnauthorizedCloseStatus = 4401; + private static readonly PathString WebSocketPath = new("/api/v2/push"); + private readonly ILogger _logger; private readonly WebSocketConnectionManager _connectionManager; private readonly IConnectionMapping _connectionMapping; @@ -22,12 +25,25 @@ public MessageBusBrokerMiddleware(RequestDelegate next, WebSocketConnectionManag public async Task Invoke(HttpContext context) { - if (!context.WebSockets.IsWebSocketRequest || !context.User.IsAuthenticated()) + if (!context.WebSockets.IsWebSocketRequest) { await _next(context); return; } + if (!context.User.IsAuthenticated()) + { + if (!context.Request.Path.StartsWithSegments(WebSocketPath)) + { + await _next(context); + return; + } + + using var unauthorizedSocket = await context.WebSockets.AcceptWebSocketAsync(); + await unauthorizedSocket.CloseOutputAsync((WebSocketCloseStatus)UnauthorizedCloseStatus, "Unauthorized", context.RequestAborted); + return; + } + using var socket = await context.WebSockets.AcceptWebSocketAsync(); string connectionId = _connectionManager.AddWebSocket(socket); await OnConnected(context, socket, connectionId); diff --git a/tests/Exceptionless.Tests/Hubs/TestWebSocket.cs b/tests/Exceptionless.Tests/Hubs/TestWebSocket.cs index c8343c7b3a..441a1555af 100644 --- a/tests/Exceptionless.Tests/Hubs/TestWebSocket.cs +++ b/tests/Exceptionless.Tests/Hubs/TestWebSocket.cs @@ -14,6 +14,10 @@ public TestWebSocket(WebSocketState state = WebSocketState.Open) public int CloseCount => _closeCount; private int _closeCount; + public int CloseOutputCount => _closeOutputCount; + private int _closeOutputCount; + public WebSocketCloseStatus? RequestedCloseStatus { get; private set; } + public string? RequestedCloseStatusDescription { get; private set; } public List SentMessages { get; } = []; public override WebSocketCloseStatus? CloseStatus { get; } = WebSocketCloseStatus.NormalClosure; public override string? CloseStatusDescription { get; } = "Closed"; @@ -28,12 +32,17 @@ public override void Abort() public override Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken) { Interlocked.Increment(ref _closeCount); + RequestedCloseStatus = closeStatus; + RequestedCloseStatusDescription = statusDescription; _state = WebSocketState.Closed; return Task.CompletedTask; } public override Task CloseOutputAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken) { + Interlocked.Increment(ref _closeOutputCount); + RequestedCloseStatus = closeStatus; + RequestedCloseStatusDescription = statusDescription; _state = WebSocketState.CloseSent; return Task.CompletedTask; } diff --git a/tests/Exceptionless.Tests/Hubs/WebSocketTests.cs b/tests/Exceptionless.Tests/Hubs/WebSocketTests.cs index a34a33ddc7..84e8143540 100644 --- a/tests/Exceptionless.Tests/Hubs/WebSocketTests.cs +++ b/tests/Exceptionless.Tests/Hubs/WebSocketTests.cs @@ -1,8 +1,10 @@ +using System.Net.WebSockets; using Exceptionless.Core.Messaging.Models; using Exceptionless.Core.Models; using Exceptionless.Core.Utility; using Exceptionless.Web.Hubs; using Foundatio.Repositories.Models; +using Microsoft.AspNetCore.Http.Features; using Xunit; namespace Exceptionless.Tests.Hubs; @@ -25,6 +27,35 @@ public WebSocketTests(ITestOutputHelper output) : base(output) _connectionManager = GetService(); } + [Fact] + public async Task Invoke_UnauthenticatedPushRequest_ClosesWithExplicitUnauthorizedStatus() + { + var socket = new TestWebSocket(); + var feature = new TestWebSocketFeature(socket); + var context = new DefaultHttpContext(); + context.Request.Path = "/api/v2/push"; + context.Features.Set(feature); + bool calledNext = false; + var middleware = new MessageBusBrokerMiddleware( + _ => + { + calledNext = true; + return Task.CompletedTask; + }, + _connectionManager, + _connectionMapping, + GetService>()); + + await middleware.Invoke(context); + + Assert.False(calledNext); + Assert.True(feature.WasAccepted); + Assert.Equal(0, socket.CloseCount); + Assert.Equal(1, socket.CloseOutputCount); + Assert.Equal((WebSocketCloseStatus)4401, socket.RequestedCloseStatus); + Assert.Equal("Unauthorized", socket.RequestedCloseStatusDescription); + } + [Fact] public async Task OnEntityChangedAsync_AuthTokenRemoved_ClosesWebSocketsAndClearsUserMapping() { @@ -118,4 +149,16 @@ public async Task OnEntityChangedAsync_NonAuthTokenRemoved_DoesNotCloseWebSocket await _connectionManager.RemoveWebSocketAsync(connectionId); } } + + private sealed class TestWebSocketFeature(WebSocket socket) : IHttpWebSocketFeature + { + public bool IsWebSocketRequest => true; + public bool WasAccepted { get; private set; } + + public Task AcceptAsync(WebSocketAcceptContext context) + { + WasAccepted = true; + return Task.FromResult(socket); + } + } }