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);
+ }
+ }
}