Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<table
class="table table-striped table-bordered table-selectable table-fixed b-t table-hover table-clickable"
refresh-on="StackChanged PlanChanged"
refresh-always="WebSocketReconnected"
refresh-if="vm.canRefresh(data)"
refresh-action="vm.get(vm.currentOptions)"
refresh-throttle="10000"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
function changePassword(changePasswordModel) {
function onSuccess(response) {
$auth.setToken(response.data.token);
$rootScope.$emit("auth:tokenChanged");
return response;
}

Expand Down Expand Up @@ -100,6 +101,7 @@
function unlink(providerName, providerUserId) {
function onSuccess(response) {
$auth.setToken(response.data.token);
$rootScope.$emit("auth:tokenChanged");
return response;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<table
class="table table-striped table-bordered table-selectable table-fixed b-t table-hover table-clickable"
refresh-on="StackChanged PlanChanged"
refresh-always="WebSocketReconnected"
refresh-if="vm.canRefresh(data)"
refresh-action="vm.get(vm.currentOptions)"
refresh-throttle="10000"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<table
class="table table-striped table-bordered table-selectable table-fixed b-t table-hover table-clickable"
refresh-on="StackChanged PlanChanged"
refresh-always="WebSocketReconnected"
refresh-if="vm.canRefresh(data)"
refresh-action="vm.get(vm.currentOptions)"
refresh-throttle="10000"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
protocols = [];
}
this.reconnectInterval = 1000;
this.reconnectTimeout = null;
this.timeoutInterval = 2000;
this.forcedClose = false;
this.timedOut = false;
Expand All @@ -27,7 +28,8 @@

ResilientWebSocket.prototype.connect = function (reconnectAttempt) {
var _this = this;
this.ws = new WebSocket(this.url, this.protocols);
var url = typeof this.url === "function" ? this.url() : this.url;
this.ws = new WebSocket(url, this.protocols);
this.onconnecting();
var localWs = this.ws;
var timeout = setTimeout(function () {
Expand All @@ -38,22 +40,26 @@
this.ws.onopen = function (event) {
clearTimeout(timeout);
_this.readyState = WebSocket.OPEN;
_this.onopen(event, reconnectAttempt);
reconnectAttempt = false;
_this.onopen(event);
};
this.ws.onclose = function (event) {
clearTimeout(timeout);
_this.ws = null;
if (_this.forcedClose) {
_this.readyState = WebSocket.CLOSED;
_this.onclose(event);
} else if (event.code === 1008 || (event.code >= 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);
}
Expand All @@ -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 () {
Expand All @@ -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) {
Expand Down Expand Up @@ -153,6 +168,12 @@
return pushUrl.replace(protoMatch, "ws://");
}

$rootScope.$on("auth:tokenChanged", function () {
if (_connection) {
startDelayed(1);
}
});

var service = {
start: start,
startDelayed: startDelayed,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -210,6 +215,7 @@ export function deleteEvent(request: DeleteEventsRequest) {
},
onSuccess: () => {
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id) }));
schedulePersistentEventDeleteReconciliation(queryClient);
}
}));
}
Expand Down Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
filterChanged,
quoteIfSpecialCharacters,
serializeFilters,
shouldRefreshPersistentEventRemoval,
toFilter,
toFilterFromSerializedFilters
} from './helpers.svelte';
Expand Down Expand Up @@ -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('[]');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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.');
});
});
Loading
Loading