mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-09 01:40:41 +00:00
Compare commits
1 Commits
srikanth/c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87bcd1b702 |
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import { tags } from '@lezer/highlight';
|
||||
import { copilotDarkStyle } from '@uiw/codemirror-theme-copilot';
|
||||
import { githubLightStyle } from '@uiw/codemirror-theme-github';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { act, render, screen } from 'tests/test-utils';
|
||||
|
||||
import SavedViewHighlightedExpression from '../SavedViewHighlightedExpression';
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseIsDarkMode = useIsDarkMode as jest.MockedFunction<
|
||||
typeof useIsDarkMode
|
||||
>;
|
||||
|
||||
const EXPRESSION = "service.name = 'frontend'";
|
||||
|
||||
function stringColor(styles: typeof copilotDarkStyle): string {
|
||||
return (styles ?? []).find(({ tag }) =>
|
||||
(Array.isArray(tag) ? tag : [tag]).includes(tags.string),
|
||||
)?.color as string;
|
||||
}
|
||||
|
||||
describe('SavedViewHighlightedExpression', () => {
|
||||
it.each([
|
||||
['dark', true, copilotDarkStyle],
|
||||
['light', false, githubLightStyle],
|
||||
])('colours a string value with the %s theme', (_, isDarkMode, styles) => {
|
||||
mockUseIsDarkMode.mockReturnValue(isDarkMode);
|
||||
|
||||
render(<SavedViewHighlightedExpression expression={EXPRESSION} />);
|
||||
|
||||
expect(screen.getByText("'frontend'")).toHaveStyle({
|
||||
color: stringColor(styles),
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the new theme colours when the theme changes', () => {
|
||||
let setIsDarkMode: (isDarkMode: boolean) => void = () => {};
|
||||
function useSwitchableDarkMode(): boolean {
|
||||
const [isDarkMode, setter] = useState(true);
|
||||
setIsDarkMode = setter;
|
||||
return isDarkMode;
|
||||
}
|
||||
mockUseIsDarkMode.mockImplementation(useSwitchableDarkMode);
|
||||
render(<SavedViewHighlightedExpression expression={EXPRESSION} />);
|
||||
|
||||
act(() => setIsDarkMode(false));
|
||||
|
||||
expect(screen.getByText("'frontend'")).toHaveStyle({
|
||||
color: stringColor(githubLightStyle),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,19 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AuthtypesTransactionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { userEvent } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -222,6 +230,31 @@ describe('SavedViewHoverCard', () => {
|
||||
|
||||
expect(card()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not open when the row menu is pressed before the delay', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
await renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS / 2);
|
||||
await user.click(within(row('Errors')).getByTestId('saved-views-row-menu'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
|
||||
expect(card()).toBeNull();
|
||||
});
|
||||
|
||||
it('closes on escape, leaving nothing of the popover behind', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
await renderPanel();
|
||||
await openCard('Errors');
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
expect(card()).toBeNull();
|
||||
expect(
|
||||
document.querySelector('[data-radix-popper-content-wrapper]'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('content', () => {
|
||||
@@ -312,6 +345,78 @@ describe('SavedViewHoverCard', () => {
|
||||
await waitFor(() => expect(fetches).toBe(2));
|
||||
});
|
||||
|
||||
it('shows a skeleton, not the failure, while the view loads', async () => {
|
||||
let requested = false;
|
||||
let release: () => void = () => undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, async (_req, res, ctx) => {
|
||||
requested = true;
|
||||
await gate;
|
||||
return res(ctx.json({ status: 'success', data: errors }));
|
||||
}),
|
||||
);
|
||||
await renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
await waitFor(() => expect(requested).toBe(true));
|
||||
|
||||
expect(
|
||||
screen.getByTestId('saved-view-hover-card-loading'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-view-hover-card-error')).toBeNull();
|
||||
|
||||
release();
|
||||
await screen.findByTestId('saved-view-hover-card-query');
|
||||
});
|
||||
|
||||
it('shows a skeleton while the read check is pending', async () => {
|
||||
let release: () => void = () => undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const isRead = (transaction: AuthtypesTransactionDTO): boolean =>
|
||||
transaction.relation === 'read';
|
||||
const isReadAll = (transaction: AuthtypesTransactionDTO): boolean =>
|
||||
isRead(transaction) && transaction.object.selector === '*';
|
||||
// Read on every view would skip the per-view check, so it is denied.
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
if (
|
||||
payload.some(
|
||||
(transaction) => isRead(transaction) && !isReadAll(transaction),
|
||||
)
|
||||
) {
|
||||
await gate;
|
||||
}
|
||||
return res(
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map((transaction) => !isReadAll(transaction)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
await renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('saved-view-hover-card-loading'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-view-hover-card-query')).toBeNull();
|
||||
|
||||
release();
|
||||
await screen.findByTestId('saved-view-hover-card-query');
|
||||
});
|
||||
|
||||
it('says the view failed to load when its fetch fails', async () => {
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) =>
|
||||
|
||||
@@ -339,6 +339,45 @@ describe('SavedViews - AuthZ', () => {
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
|
||||
it('disables save as new without create, keeping update', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(SavedViewCreatePermission));
|
||||
const { history } = renderHeader(viewUrl(PATH, errors));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('Errors'),
|
||||
);
|
||||
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
);
|
||||
await userEvent.click(await screen.findByTestId('saved-views-save-changes'));
|
||||
|
||||
await expect(
|
||||
settledRow('saved-views-save-changes-item-save-as-new'),
|
||||
).resolves.toHaveAttribute('data-disabled');
|
||||
await expect(
|
||||
settledRow('saved-views-save-changes-item-update'),
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
|
||||
it('offers clear on a view it cannot read', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
'No view access',
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says no access when the check denies read, even if the server returns the view', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { userEvent } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
@@ -75,4 +76,39 @@ describe('SavedViewsHeader last used view', () => {
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not bring the view back once it is cleared', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
await waitForView('Errors');
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-clear'));
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
|
||||
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
|
||||
});
|
||||
|
||||
it('forgets a last used view that no longer loads', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) ?? '{}',
|
||||
),
|
||||
).toStrictEqual({}),
|
||||
);
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
});
|
||||
|
||||
it('starts on a bare explorer when the stored last used views are unreadable', async () => {
|
||||
localStorage.setItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS, '{not json');
|
||||
mockSavedViewsApi([errors]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { act, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
@@ -19,7 +23,7 @@ import {
|
||||
urlParam,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import { API, mockSavedViewsApi } from './savedViewsApiMock';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
@@ -43,6 +47,27 @@ function renderHeader(
|
||||
);
|
||||
}
|
||||
|
||||
// Edits the current query without running it, so it is not the staged one.
|
||||
function EditCurrentQuery(): JSX.Element {
|
||||
const { currentQuery, handleSetQueryData } = useQueryBuilder();
|
||||
return (
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
testId="edit-current-query"
|
||||
onClick={(): void =>
|
||||
handleSetQueryData(0, {
|
||||
...currentQuery.builder.queryData[0],
|
||||
filter: { expression: 'unstaged = true' },
|
||||
})
|
||||
}
|
||||
>
|
||||
Edit query
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const modal = (): HTMLElement => screen.getByTestId('save-view-modal');
|
||||
const isModalOpen = (): boolean => !!screen.queryByTestId('save-view-modal');
|
||||
|
||||
@@ -160,6 +185,76 @@ describe('SavedViewsHeader saving', () => {
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not create a view from a blank name submitted with Enter', async () => {
|
||||
const requests = mockSavedViewsApi([]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await clickCreate();
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), ' {enter}');
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
|
||||
|
||||
expect(requests.created).toHaveLength(0);
|
||||
expect(isModalOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores Enter while the view is being saved', async () => {
|
||||
let creates = 0;
|
||||
mockSavedViewsApi([]);
|
||||
server.use(
|
||||
rest.post(API, (_req, res, ctx) => {
|
||||
creates += 1;
|
||||
return res(
|
||||
ctx.delay(400),
|
||||
ctx.status(201),
|
||||
ctx.json({ status: 'success', data: { id: 'view-new' } }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await clickCreate();
|
||||
await submitName('Slow checkout');
|
||||
await waitFor(() => expect(creates).toBe(1));
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), '{enter}');
|
||||
|
||||
await waitFor(() => expect(isModalOpen()).toBe(false));
|
||||
expect(creates).toBe(1);
|
||||
});
|
||||
|
||||
it('saves the query that was run, not edits made since', async () => {
|
||||
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
const { history } = renderWithExplorerProviders(
|
||||
<>
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
/>
|
||||
<EditCurrentQuery />
|
||||
</>,
|
||||
explorerUrl(PATH, {}),
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('edit-current-query'));
|
||||
await clickCreate();
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() => expect(requests.created).toHaveLength(1));
|
||||
const body = requests.created[0] as {
|
||||
spec: { queries: { spec: { filter?: { expression?: string } } }[] };
|
||||
};
|
||||
expect(body.spec.queries[0].spec.filter?.expression ?? '').not.toContain(
|
||||
'unstaged',
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify('view-new'),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
decodeURIComponent(urlParam(history, QueryParams.compositeQuery) ?? ''),
|
||||
).not.toContain('unstaged');
|
||||
});
|
||||
|
||||
it('cancel closes the modal without saving', async () => {
|
||||
const requests = mockSavedViewsApi([]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
@@ -231,6 +326,37 @@ describe('SavedViewsHeader saving', () => {
|
||||
expect(requests.created).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('update that fails reports the error and never says the view was updated', async () => {
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const requests = mockSavedViewsApi([errors], { updateStatus: 500 });
|
||||
await openDirtyView(errors);
|
||||
|
||||
await chooseFromSaveMenu('Update selected view');
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
expect(requests.updated).toHaveLength(1);
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update keeps the color of the open view', async () => {
|
||||
const base = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const colored = {
|
||||
...base,
|
||||
spec: { ...base.spec, display: { color: '#e5484d' } },
|
||||
};
|
||||
const requests = mockSavedViewsApi([colored]);
|
||||
await openDirtyView(colored);
|
||||
|
||||
await chooseFromSaveMenu('Update selected view');
|
||||
|
||||
await waitFor(() => expect(requests.updated).toHaveLength(1));
|
||||
const { body } = requests.updated[0] as {
|
||||
body: { spec: { display?: { color?: string } } };
|
||||
};
|
||||
expect(body.spec.display?.color).toBe('#e5484d');
|
||||
});
|
||||
|
||||
describe('view name toggle with unsaved changes', () => {
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
@@ -219,6 +221,75 @@ describe('SavedViewsPanel list', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('lists only the views of its own source', async () => {
|
||||
mockSavedViewsApi([
|
||||
mine,
|
||||
makeView({
|
||||
id: 'view-9',
|
||||
displayName: 'Noisy logs',
|
||||
source: SavedviewtypesSourceDTO.logs,
|
||||
}),
|
||||
]);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByTestId('saved-views-list');
|
||||
expect(rowNames('saved-views-created-by-me')).toStrictEqual(['Errors']);
|
||||
});
|
||||
|
||||
describe('last used view', () => {
|
||||
const lastUsedViews = (): unknown =>
|
||||
JSON.parse(localStorage.getItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) ?? '{}');
|
||||
const logsEntry = { logs: { key: 'logs-view', value: 'Noisy logs' } };
|
||||
|
||||
it('is remembered under the panel source, keeping the other sources', async () => {
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
|
||||
JSON.stringify(logsEntry),
|
||||
);
|
||||
mockSavedViewsApi([mine, alsoMine]);
|
||||
renderPanel();
|
||||
await screen.findByTestId('saved-views-list');
|
||||
await waitFor(() => expectUsable(rowName('Slow spans')));
|
||||
|
||||
await userEvent.click(rowName('Slow spans'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(lastUsedViews()).toStrictEqual({
|
||||
...logsEntry,
|
||||
traces: { key: alsoMine.id, value: 'Slow spans' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('is filed under the source of the panel it was opened from', async () => {
|
||||
const logsView = makeView({
|
||||
id: 'view-9',
|
||||
displayName: 'Noisy logs',
|
||||
source: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
});
|
||||
mockSavedViewsApi([logsView]);
|
||||
renderWithExplorerProviders(
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.logs}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
explorerUrl(ROUTES.LOGS_EXPLORER, {}),
|
||||
DataSource.LOGS,
|
||||
);
|
||||
await screen.findByTestId('saved-views-list');
|
||||
await waitFor(() => expectUsable(rowName('Noisy logs')));
|
||||
|
||||
await userEvent.click(rowName('Noisy logs'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(lastUsedViews()).toStrictEqual({
|
||||
logs: { key: logsView.id, value: 'Noisy logs' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('offers to create a view when there are none, and opens it', async () => {
|
||||
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
const { history } = renderPanel();
|
||||
|
||||
@@ -4,11 +4,13 @@ import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import { API, mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectRowUsable,
|
||||
explorerUrl,
|
||||
@@ -181,6 +183,87 @@ describe('SavedViewsPanel row menu', () => {
|
||||
await waitFor(() => expect(row('Slow checkout spans')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renames from the stored view, not a stale list row', async () => {
|
||||
const views = [errors, slow];
|
||||
const requests = mockSavedViewsApi(views);
|
||||
renderPanelWithHeader();
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const stored = makeView({
|
||||
id: slow.id,
|
||||
displayName: 'Slow spans',
|
||||
expression: 'duration_nano > 5000',
|
||||
});
|
||||
views[1] = stored;
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Edit details');
|
||||
const name = screen.getByTestId('save-view-name');
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, 'Slow checkout spans');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
await waitFor(() => expect(requests.updated).toHaveLength(1));
|
||||
expect(requests.updated[0].body).toMatchObject({
|
||||
spec: { ...stored.spec, displayName: 'Slow checkout spans' },
|
||||
});
|
||||
});
|
||||
|
||||
it('renames from the list row when the view cannot be read', async () => {
|
||||
const requests = mockSavedViewsApi([errors, slow]);
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(403), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Edit details');
|
||||
const name = screen.getByTestId('save-view-name');
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, 'Slow checkout spans');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
await waitFor(() => expect(requests.updated).toHaveLength(1));
|
||||
expect(requests.updated[0].body).toMatchObject({
|
||||
spec: { ...slow.spec, displayName: 'Slow checkout spans' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not rename when the stored view fails to load', async () => {
|
||||
const requests = mockSavedViewsApi([errors, slow]);
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Edit details');
|
||||
const name = screen.getByTestId('save-view-name');
|
||||
await userEvent.type(name, ' 2');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(requests.updated).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores Enter while the stored view loads', async () => {
|
||||
const requests = mockSavedViewsApi([errors, slow], { getDelay: 300 });
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Edit details');
|
||||
const name = screen.getByTestId('save-view-name');
|
||||
await userEvent.type(name, ' 2{enter}');
|
||||
await userEvent.type(name, '{enter}');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
'View updated',
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
expect(requests.updated).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows the new name in the header when the open view is renamed', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderPanelWithHeader(viewUrl(PATH, errors));
|
||||
@@ -236,4 +319,19 @@ describe('SavedViewsPanel row menu', () => {
|
||||
);
|
||||
await waitFor(() => expect(chip()).toHaveTextContent('My view'));
|
||||
});
|
||||
|
||||
it('keeps the open view when another view is deleted', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
const { history } = renderPanelWithHeader(viewUrl(PATH, errors));
|
||||
await waitFor(() => expect(chip()).toHaveTextContent('Errors'));
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Delete');
|
||||
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
|
||||
|
||||
await waitFor(() => expect(row('Slow spans')).toBeUndefined());
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify(errors.id),
|
||||
);
|
||||
expect(chip()).toHaveTextContent('Errors');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import store from 'store';
|
||||
import { act, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectRowUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
renderWithExplorerProviders,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const mockCopy: jest.MockedFunction<(text: string) => void> = jest.fn();
|
||||
|
||||
// copy-to-clipboard falls back to window.prompt in jsdom.
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: (): [unknown, typeof mockCopy] => [null, mockCopy],
|
||||
}));
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
const slow = makeView({ id: 'view-2', displayName: 'Slow spans' });
|
||||
|
||||
// A store whose global time can change while the panel stays mounted.
|
||||
function renderPanelWithTime(selectedTime: GlobalReducer['selectedTime']): {
|
||||
setTime: (time: Partial<GlobalReducer>) => void;
|
||||
} {
|
||||
let globalTime: GlobalReducer = {
|
||||
...store.getState().globalTime,
|
||||
selectedTime,
|
||||
};
|
||||
const timeStore = configureStore([thunk])(() => ({
|
||||
...store.getState(),
|
||||
globalTime,
|
||||
}));
|
||||
|
||||
renderWithExplorerProviders(
|
||||
<Provider store={timeStore}>
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
/>
|
||||
</Provider>,
|
||||
explorerUrl(PATH, {}),
|
||||
);
|
||||
|
||||
return {
|
||||
setTime: (time): void => {
|
||||
globalTime = { ...globalTime, ...time };
|
||||
act(() => {
|
||||
timeStore.dispatch({ type: 'TEST_TIME_CHANGED' });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const row = (name: string): HTMLElement =>
|
||||
screen
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((element) => element.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
const relativeTimeOf = (link: string): string | null =>
|
||||
new URL(link, window.location.origin).searchParams.get(
|
||||
QueryParams.relativeTime,
|
||||
);
|
||||
|
||||
describe('SavedViewsPanel time range', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockCopy.mockClear();
|
||||
mockSavedViewsApi([slow]);
|
||||
});
|
||||
|
||||
it('moves the row link to the new time range when it changes', async () => {
|
||||
const { setTime } = renderPanelWithTime('1h');
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const name = within(row('Slow spans')).getByTestId('saved-views-row-name');
|
||||
expect(relativeTimeOf(name.getAttribute('href') as string)).toBe('1h');
|
||||
|
||||
setTime({ selectedTime: '6h' });
|
||||
|
||||
expect(relativeTimeOf(name.getAttribute('href') as string)).toBe('6h');
|
||||
});
|
||||
|
||||
it('copies a link in the time range that is selected when it is copied', async () => {
|
||||
const { setTime } = renderPanelWithTime('1h');
|
||||
await screen.findByTestId('saved-views-list');
|
||||
setTime({ selectedTime: '6h' });
|
||||
|
||||
await userEvent.click(
|
||||
within(row('Slow spans')).getByTestId('saved-views-row-menu'),
|
||||
);
|
||||
const copyLink = await screen.findByTestId(
|
||||
'saved-views-row-menu-item-copyLink',
|
||||
);
|
||||
await waitFor(() => expectRowUsable(copyLink), MENU_CHECK_TIMEOUT);
|
||||
await userEvent.click(copyLink);
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledTimes(1);
|
||||
expect(relativeTimeOf(mockCopy.mock.calls[0][0])).toBe('6h');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
getGetSavedViewQueryOptions,
|
||||
updateSavedView,
|
||||
useDeleteSavedView,
|
||||
useUpdateSavedView,
|
||||
} from 'api/generated/services/saved-view';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
@@ -45,8 +46,39 @@ export function useSavedViewRowActions(
|
||||
const timeSearch = useTimeSearchParams();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const logSavedViewEvent = useSavedViewLogEvent(source);
|
||||
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
|
||||
useUpdateSavedView();
|
||||
// The update replaces the whole spec, so it starts from the stored view, not
|
||||
// the list row, which can be stale. Without read on the view, the list row is
|
||||
// the only copy there is.
|
||||
const { mutateAsync: renameSavedView, isLoading: isRenaming } = useMutation(
|
||||
async ({
|
||||
view,
|
||||
displayName,
|
||||
}: {
|
||||
view: SavedviewtypesSavedViewDTO;
|
||||
displayName: string;
|
||||
}) => {
|
||||
let { spec } = view;
|
||||
try {
|
||||
({
|
||||
data: { spec },
|
||||
} = await queryClient.fetchQuery(
|
||||
getGetSavedViewQueryOptions({ id: view.id }),
|
||||
));
|
||||
} catch (error) {
|
||||
if ((error as ErrorType<unknown>).response?.status !== 403) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await updateSavedView(
|
||||
{ id: view.id },
|
||||
{
|
||||
source,
|
||||
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
|
||||
spec: { ...spec, displayName },
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
const { mutateAsync: deleteSavedView, isLoading: isDeleting } =
|
||||
useDeleteSavedView();
|
||||
|
||||
@@ -77,14 +109,7 @@ export function useSavedViewRowActions(
|
||||
displayName: string,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await updateSavedView({
|
||||
pathParams: { id: view.id },
|
||||
data: {
|
||||
source,
|
||||
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
|
||||
spec: { ...view.spec, displayName },
|
||||
},
|
||||
});
|
||||
await renameSavedView({ view, displayName });
|
||||
await invalidateSavedViews(queryClient, source, view.id);
|
||||
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
logSavedViewEvent(SavedViewEvents.ViewDetailsEdited, {
|
||||
@@ -96,7 +121,7 @@ export function useSavedViewRowActions(
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[updateSavedView, source, queryClient, logSavedViewEvent],
|
||||
[renameSavedView, source, queryClient, logSavedViewEvent],
|
||||
);
|
||||
|
||||
// The deleted view's own query is left alone: refetching it would fail
|
||||
|
||||
@@ -127,6 +127,48 @@ describe('hasUnsavedViewChanges', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is clean while no query is staged', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery: null,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is dirty when the columns are reordered', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery: staged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options: { ...options, selectColumns: [columns[1], columns[0]] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is dirty when a column is swapped for another', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery: staged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options: {
|
||||
...options,
|
||||
selectColumns: [
|
||||
columns[0],
|
||||
{ name: 'duration_nano', key: 'duration_nano' } as TelemetryFieldKey,
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a traces view without columns as the default columns', () => {
|
||||
const bare = viewFrom(
|
||||
toSavedViewSpec({
|
||||
@@ -184,6 +226,21 @@ describe('hasUnsavedViewChanges', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['format', { format: 'table' as LogViewMode }],
|
||||
['font size', { fontSize: FontSize.LARGE }],
|
||||
])('is dirty when only the %s changes', (_, change) => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: logsView,
|
||||
stagedQuery: logsStaged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
options: { selectColumns: shownColumns, ...formatting, ...change },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a view without formatting as the default formatting', () => {
|
||||
const bare = viewFrom(
|
||||
toSavedViewSpec({
|
||||
@@ -295,6 +352,137 @@ describe('hasUnsavedViewChanges', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['point', 'attribute'],
|
||||
['spanfield', 'span'],
|
||||
['logfield', 'log'],
|
||||
['tracefield', 'trace'],
|
||||
])(
|
||||
'reads a group-by staged as %s and stored as %s as the same field',
|
||||
(legacyContext, storedContext) => {
|
||||
const base = viewFrom(
|
||||
toSavedViewSpec({
|
||||
query: query(DataSource.METRICS, ''),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
displayName: 'By os',
|
||||
}),
|
||||
);
|
||||
const [storedQuery] = base.spec.queries;
|
||||
const storedView = {
|
||||
...base,
|
||||
spec: {
|
||||
...base.spec,
|
||||
queries: [
|
||||
{
|
||||
...storedQuery,
|
||||
spec: {
|
||||
...storedQuery.spec,
|
||||
groupBy: [
|
||||
{
|
||||
name: 'os.type',
|
||||
fieldContext: storedContext,
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as SavedviewtypesSavedViewDTO;
|
||||
const opened = stagedFrom(storedView, DataSource.METRICS);
|
||||
const stagedLegacy = {
|
||||
...opened,
|
||||
builder: {
|
||||
...opened.builder,
|
||||
queryData: opened.builder.queryData.map((queryData) => ({
|
||||
...queryData,
|
||||
groupBy: [{ key: 'os.type', dataType: 'string', type: legacyContext }],
|
||||
})),
|
||||
},
|
||||
} as Query;
|
||||
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: storedView,
|
||||
stagedQuery: stagedLegacy,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
sourcePage: SavedviewtypesSourceDTO.metrics,
|
||||
}),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
// The query still staged after a save keeps the builder's values, while
|
||||
// the server returns the view without them.
|
||||
describe('right after saving a query that carries builder defaults', () => {
|
||||
const withPatch = (
|
||||
dataSource: DataSource,
|
||||
patch: Partial<Query['builder']['queryData'][number]>,
|
||||
): Query => {
|
||||
const base = query(dataSource, '');
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: base.builder.queryData.map((queryData) => ({
|
||||
...queryData,
|
||||
...patch,
|
||||
})),
|
||||
},
|
||||
} as Query;
|
||||
};
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a zero offset on a list',
|
||||
DataSource.LOGS,
|
||||
SavedviewtypesSourceDTO.logs,
|
||||
PANEL_TYPES.LIST,
|
||||
{ offset: 0 },
|
||||
],
|
||||
[
|
||||
'an empty group-by description',
|
||||
DataSource.TRACES,
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
{
|
||||
groupBy: [
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
'a function without arguments',
|
||||
DataSource.METRICS,
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
{ functions: [{ name: 'absolute', args: [], namedArgs: {} }] },
|
||||
],
|
||||
])('is clean with %s', (_, dataSource, sourcePage, panelType, patch) => {
|
||||
const saved = withPatch(
|
||||
dataSource,
|
||||
patch as Partial<Query['builder']['queryData'][number]>,
|
||||
);
|
||||
const savedView = viewFrom(
|
||||
toSavedViewSpec({ query: saved, panelType, displayName: 'Saved' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: savedView,
|
||||
stagedQuery: saved,
|
||||
panelType,
|
||||
sourcePage,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list order', () => {
|
||||
const withOrderBy = (
|
||||
staged: Query,
|
||||
|
||||
@@ -80,6 +80,66 @@ describe('toSavedViewSpec', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the description and unit of a column, naming it by name before key', () => {
|
||||
const spec = toSavedViewSpec({
|
||||
query: tracesQuery(),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName: 'Errors',
|
||||
options: {
|
||||
selectColumns: [
|
||||
{
|
||||
name: 'duration_nano',
|
||||
key: 'durationNano',
|
||||
description: 'Span duration',
|
||||
unit: 'ns',
|
||||
},
|
||||
] as unknown as TelemetryFieldKey[],
|
||||
format: 'raw' as LogViewMode,
|
||||
maxLines: 2,
|
||||
fontSize: FontSize.SMALL,
|
||||
},
|
||||
});
|
||||
|
||||
expect(spec.selectedFields).toStrictEqual([
|
||||
{ name: 'duration_nano', description: 'Span duration', unit: 'ns' },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
PANEL_TYPES.LIST,
|
||||
SavedviewtypesPanelTypeDTO.list,
|
||||
Querybuildertypesv5RequestTypeDTO.raw,
|
||||
],
|
||||
[
|
||||
PANEL_TYPES.TABLE,
|
||||
SavedviewtypesPanelTypeDTO.table,
|
||||
Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
],
|
||||
[
|
||||
PANEL_TYPES.TRACE,
|
||||
SavedviewtypesPanelTypeDTO.trace,
|
||||
Querybuildertypesv5RequestTypeDTO.trace,
|
||||
],
|
||||
[
|
||||
PANEL_TYPES.PIE,
|
||||
SavedviewtypesPanelTypeDTO.graph,
|
||||
Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
],
|
||||
])(
|
||||
'stores the %s panel as panel type %s with request type %s',
|
||||
(panelType, storedPanelType, requestType) => {
|
||||
const spec = toSavedViewSpec({
|
||||
query: tracesQuery(),
|
||||
panelType,
|
||||
displayName: 'Errors',
|
||||
});
|
||||
|
||||
expect(spec.panelType).toBe(storedPanelType);
|
||||
expect(spec.requestType).toBe(requestType);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the colour in display', () => {
|
||||
const spec = toSavedViewSpec({
|
||||
query: tracesQuery(),
|
||||
|
||||
@@ -21,6 +21,20 @@ describe('buildExplorerNavigationUrl', () => {
|
||||
expect(url).toContain(`${QueryParams.viewKey}=`);
|
||||
});
|
||||
|
||||
it('encodes the composite query for a reader that decodes it, even with a percent sign', () => {
|
||||
const query = {
|
||||
queryType: 'builder',
|
||||
filter: { expression: "body LIKE '%timeout%'" },
|
||||
} as never;
|
||||
|
||||
const url = buildExplorerNavigationUrl(ROUTES.LOGS_EXPLORER, query, {});
|
||||
|
||||
const param = new URL(url, window.location.origin).searchParams.get(
|
||||
QueryParams.compositeQuery,
|
||||
) as string;
|
||||
expect(JSON.parse(decodeURIComponent(param))).toStrictEqual(query);
|
||||
});
|
||||
|
||||
it('JSON-encodes panelTypes so the explorer opens the right view', () => {
|
||||
const url = buildExplorerNavigationUrl(
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
|
||||
@@ -922,25 +922,32 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
|
||||
copy(argsCopy, qt.args)
|
||||
return newchSQLQuery(q.logger, q.telemetryStore, queryCopy, argsCopy, timeRange, qt.kind, qt.vars)
|
||||
|
||||
// A builder query's window was moved into the shifted clock when it was
|
||||
// built, so a range taken from that window must not be shifted again.
|
||||
case *builderQuery[qbtypes.TraceAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
// reuse the original query's statement builder and type so an AI query
|
||||
// keeps its AI builder and cache key
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, qt.builderConfig)
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, qt.builderConfig)
|
||||
|
||||
case *builderQuery[qbtypes.LogAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
shiftStmtBuilder := q.logStmtBuilder
|
||||
if qt.spec.Source == telemetrytypes.SourceAudit {
|
||||
shiftStmtBuilder = q.auditStmtBuilder
|
||||
}
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, shiftStmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, q.builderConfig)
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, shiftStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, q.builderConfig)
|
||||
|
||||
case *builderQuery[qbtypes.MetricAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
if qt.spec.Source == telemetrytypes.SourceMeter {
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.meterStmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, builderConfig{})
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.meterStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
}
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.metricStmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, builderConfig{})
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.metricStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
case *traceOperatorQuery:
|
||||
specCopy := qt.spec.Copy()
|
||||
return &traceOperatorQuery{
|
||||
|
||||
481
tests/e2e/drivers/saved-views.ts
generated
Normal file
481
tests/e2e/drivers/saved-views.ts
generated
Normal file
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
type Locator,
|
||||
type Page,
|
||||
type Request,
|
||||
type Response,
|
||||
} from '@playwright/test';
|
||||
|
||||
import { BASE_PATH } from './base-path';
|
||||
import { collectRequests } from './console';
|
||||
import { dismissQuickFiltersAnnouncement } from './logs/explorer';
|
||||
import {
|
||||
filterExpressions,
|
||||
isQueryRangeRequest,
|
||||
queryRangeBody,
|
||||
} from './saved-views/query';
|
||||
import { SAVED_VIEWS_API, seededView } from './saved-views/spec';
|
||||
import type {
|
||||
GettableSavedView,
|
||||
PostableSavedView,
|
||||
SavedViewSource,
|
||||
SeededView,
|
||||
} from './saved-views/spec.types';
|
||||
|
||||
import { driver, kit } from './kit';
|
||||
import {
|
||||
createViaApi,
|
||||
getViaApi,
|
||||
removeTrackedSavedViews,
|
||||
statusViaApi,
|
||||
trackSavedView,
|
||||
} from './saved-views/api';
|
||||
|
||||
// The saved views header and list on the explorers: the sliding list on logs,
|
||||
// traces and meter, the pinned one on metrics.
|
||||
|
||||
export const MY_VIEW = 'My view';
|
||||
|
||||
const EXPLORER_PATH: Record<SavedViewSource, string> = {
|
||||
logs: '/logs/logs-explorer',
|
||||
traces: '/traces-explorer',
|
||||
metrics: '/metrics-explorer/explorer',
|
||||
meter: '/meter/explorer',
|
||||
};
|
||||
|
||||
export function explorerPathname(source: SavedViewSource): string {
|
||||
return `${BASE_PATH}${EXPLORER_PATH[source]}`;
|
||||
}
|
||||
|
||||
/** Row-menu items by the suffix of their `saved-views-row-menu-item-<key>` test id. */
|
||||
export type RowAction = 'copyLink' | 'copyId' | 'editDetails' | 'delete';
|
||||
|
||||
/** Save-menu items by the suffix of their `saved-views-save-changes-item-<key>` test id. */
|
||||
export type SaveAction = 'update' | 'save-as-new';
|
||||
|
||||
export type LinkGesture = 'modifier' | 'middle';
|
||||
|
||||
export interface OpenedTab {
|
||||
page: Page;
|
||||
savedViews: SavedViews;
|
||||
}
|
||||
|
||||
export type ExplorerTab = 'list' | 'timeseries' | 'table';
|
||||
|
||||
export interface SavedViews {
|
||||
/** Created over the page's own session; deleted when the test ends. */
|
||||
seed(view: PostableSavedView): Promise<SeededView>;
|
||||
/** The stored view, read straight from the API. */
|
||||
get(id: string): Promise<GettableSavedView>;
|
||||
/** The status of `GET /api/v2/saved_views/<id>`, for asserting a view is gone. */
|
||||
status(id: string): Promise<number>;
|
||||
|
||||
/** On a bare url by default, which is `relativeTime=1h` and nothing else. */
|
||||
openExplorer(
|
||||
source?: SavedViewSource,
|
||||
params?: Record<string, string>,
|
||||
): Promise<void>;
|
||||
/** Reloads and waits for the explorer to send the filter's query. */
|
||||
reload(filter?: string, requestType?: string): Promise<void>;
|
||||
/** The explorer's next query that carries `filter`, of `requestType` when given. */
|
||||
queryRan(filter: string, requestType?: string): Promise<Response>;
|
||||
/** The current url. */
|
||||
url(): URL;
|
||||
/** A query param of the current url, where the open view and time range live. */
|
||||
param(name: string): string | null;
|
||||
/** The id in the `viewKey` param, or null while no view is open. */
|
||||
activeViewId(): string | null;
|
||||
|
||||
header(): Locator;
|
||||
name(): Locator;
|
||||
createButton(): Locator;
|
||||
clearButton(): Locator;
|
||||
discardButton(): Locator;
|
||||
saveChangesButton(): Locator;
|
||||
saveAction(action: SaveAction): Locator;
|
||||
/** The view is open and nothing differs from what is stored. */
|
||||
expectClean(displayName: string): Promise<void>;
|
||||
/** The view is open and the explorer holds changes to it. */
|
||||
expectDirty(displayName: string): Promise<void>;
|
||||
/** No view is open. */
|
||||
expectNoView(): Promise<void>;
|
||||
toast(text: string): Locator;
|
||||
|
||||
openListButton(): Locator;
|
||||
panel(): Locator;
|
||||
searchInput(): Locator;
|
||||
openList(): Promise<void>;
|
||||
/** Narrows the list, and waits for exactly `rows` rows when given. */
|
||||
search(text: string, rows?: number): Promise<void>;
|
||||
rows(): Locator;
|
||||
row(id: string): Locator;
|
||||
rowLink(id: string): Locator;
|
||||
/** The row link's href, resolved against the current page. */
|
||||
rowHref(id: string): Promise<URL>;
|
||||
/**
|
||||
* Searches for the view, opens it from its row and waits for the explorer's
|
||||
* query for it. Resolves with that query's response.
|
||||
*/
|
||||
open(view: SeededView): Promise<Response>;
|
||||
|
||||
rowMenuButton(id: string): Locator;
|
||||
rowAction(action: RowAction): Locator;
|
||||
openRowMenu(id: string): Promise<void>;
|
||||
/** Opens the row's menu and picks an action once its permission check settles. */
|
||||
chooseRowAction(id: string, action: RowAction): Promise<void>;
|
||||
/** Opens the row's menu and picks Copy link, returning the copied text. */
|
||||
copyLink(id: string): Promise<string>;
|
||||
/**
|
||||
* Ctrl or Cmd click, or a middle click, on the row link. Resolves once the
|
||||
* tab exists, not once it has loaded; the driver is bound to the new tab.
|
||||
*/
|
||||
openInNewTab(id: string, gesture?: LinkGesture): Promise<OpenedTab>;
|
||||
/**
|
||||
* Opens `url` in a new tab of the same browser context and waits for the
|
||||
* explorer's query for `view`.
|
||||
*/
|
||||
openLink(url: URL | string, view: SeededView): Promise<OpenedTab>;
|
||||
/** Tabs the browser context has opened since this driver was built. */
|
||||
newTabCount(): number;
|
||||
rename(id: string, displayName: string): Promise<void>;
|
||||
/** Confirms the delete dialog and waits for the DELETE. */
|
||||
deleteView(id: string): Promise<void>;
|
||||
|
||||
modal(): Locator;
|
||||
modalNameInput(): Locator;
|
||||
modalSubmit(): Locator;
|
||||
deleteConfirm(): Locator;
|
||||
/** Create from the header: names the view, saves it and returns its id. */
|
||||
createFromHeader(displayName: string): Promise<string>;
|
||||
/** Types into the query bar, runs it and waits for the query to carry it. */
|
||||
applyFilter(filter: string): Promise<void>;
|
||||
queryBar(): Locator;
|
||||
/** Header cells of the logs table with a title: the state indicator has none. */
|
||||
logsColumnHeaders(): Locator;
|
||||
openLogsFormatMenu(): Promise<void>;
|
||||
closeLogsFormatMenu(): Promise<void>;
|
||||
/** The checked format in the open menu: Raw, Default or Column. */
|
||||
selectedLogsFormat(): Locator;
|
||||
logsMaxLines(): Locator;
|
||||
logsFontSize(): Locator;
|
||||
explorerTab(tab: ExplorerTab): Locator;
|
||||
/** Changes when the document reloads and not when the app navigates in place. */
|
||||
documentId(): Promise<number>;
|
||||
/** Update selected view, waiting for the PUT and the toast. */
|
||||
update(): Promise<void>;
|
||||
discard(): Promise<void>;
|
||||
|
||||
/** `GET /api/v2/saved_views/<id>` requests the page has made so far. */
|
||||
viewGets(id: string): Request[];
|
||||
}
|
||||
|
||||
/** The driver on any page of the same context, such as a tab a link opened. */
|
||||
export function savedViewsFor(page: Page): SavedViews {
|
||||
const requests = collectRequests(page);
|
||||
let newTabs = 0;
|
||||
page.context().on('page', () => {
|
||||
newTabs += 1;
|
||||
});
|
||||
const tid = (id: string): Locator => page.getByTestId(id);
|
||||
const formatMenu = (): Locator => page.locator('.format-options-popover');
|
||||
const viewPath = (id: string): string =>
|
||||
`${BASE_PATH}${SAVED_VIEWS_API}/${id}`;
|
||||
|
||||
// A menu row, like a header button, ignores clicks until its permission
|
||||
// check settles, and stays enabled-looking meanwhile.
|
||||
const settle = async (control: Locator): Promise<void> => {
|
||||
await expect(control).toBeVisible();
|
||||
await expect(control).not.toHaveAttribute('aria-busy');
|
||||
await expect(control).not.toHaveAttribute('data-loading');
|
||||
await expect(control).not.toHaveAttribute('data-disabled');
|
||||
};
|
||||
const press = async (control: Locator): Promise<void> => {
|
||||
await settle(control);
|
||||
await control.click();
|
||||
};
|
||||
|
||||
const savedViews: SavedViews = {
|
||||
async seed(view) {
|
||||
const id = await createViaApi(page, view);
|
||||
trackSavedView(page, id);
|
||||
return seededView(id, view);
|
||||
},
|
||||
get: (id) => getViaApi(page, id),
|
||||
status: (id) => statusViaApi(page, id),
|
||||
|
||||
async openExplorer(source = 'logs', params = { relativeTime: '1h' }) {
|
||||
await dismissQuickFiltersAnnouncement(page);
|
||||
const search = new URLSearchParams(params).toString();
|
||||
await page.goto(`${EXPLORER_PATH[source]}${search ? `?${search}` : ''}`);
|
||||
await expect(savedViews.header()).toBeVisible();
|
||||
},
|
||||
async reload(filter, requestType) {
|
||||
await Promise.all([
|
||||
filter === undefined ? undefined : savedViews.queryRan(filter, requestType),
|
||||
page.reload(),
|
||||
]);
|
||||
await expect(savedViews.header()).toBeVisible();
|
||||
},
|
||||
queryRan: (filter, requestType) =>
|
||||
page.waitForResponse((res) => {
|
||||
const request = res.request();
|
||||
return (
|
||||
isQueryRangeRequest(request) &&
|
||||
filterExpressions(request).includes(filter) &&
|
||||
(requestType === undefined ||
|
||||
queryRangeBody(request).requestType === requestType)
|
||||
);
|
||||
}),
|
||||
url: () => new URL(page.url()),
|
||||
param: (name) => new URL(page.url()).searchParams.get(name),
|
||||
activeViewId() {
|
||||
const raw = savedViews.param('viewKey');
|
||||
return raw === null ? null : (JSON.parse(raw) as string);
|
||||
},
|
||||
|
||||
header: () => tid('saved-views-header'),
|
||||
name: () => tid('saved-views-name'),
|
||||
createButton: () => tid('saved-views-create'),
|
||||
clearButton: () => tid('saved-views-clear'),
|
||||
discardButton: () => tid('saved-views-discard'),
|
||||
saveChangesButton: () => tid('saved-views-save-changes'),
|
||||
saveAction: (action) => tid(`saved-views-save-changes-item-${action}`),
|
||||
async expectClean(displayName) {
|
||||
await expect(savedViews.name()).toHaveText(displayName);
|
||||
await expect(savedViews.clearButton()).toBeVisible();
|
||||
await expect(savedViews.discardButton()).toBeHidden();
|
||||
await expect(savedViews.saveChangesButton()).toBeHidden();
|
||||
},
|
||||
async expectDirty(displayName) {
|
||||
await expect(savedViews.name()).toHaveText(displayName);
|
||||
await expect(savedViews.discardButton()).toBeVisible();
|
||||
await expect(savedViews.saveChangesButton()).toBeVisible();
|
||||
await expect(savedViews.clearButton()).toBeHidden();
|
||||
},
|
||||
async expectNoView() {
|
||||
await expect(savedViews.name()).toHaveText(MY_VIEW);
|
||||
await expect(savedViews.createButton()).toBeVisible();
|
||||
await expect(savedViews.clearButton()).toBeHidden();
|
||||
await expect.poll(() => savedViews.param('viewKey')).toBeNull();
|
||||
},
|
||||
toast: (text) =>
|
||||
page.locator('[data-sonner-toast]').filter({ hasText: text }),
|
||||
|
||||
openListButton: () => tid('saved-views-open'),
|
||||
panel: () => tid('saved-views-panel'),
|
||||
searchInput: () => tid('saved-views-search'),
|
||||
async openList() {
|
||||
await expect(
|
||||
savedViews.openListButton().or(savedViews.panel()),
|
||||
).toBeVisible();
|
||||
if (!(await savedViews.panel().isVisible())) {
|
||||
await press(savedViews.openListButton());
|
||||
}
|
||||
await expect(savedViews.panel()).toBeVisible();
|
||||
},
|
||||
async search(text, rows) {
|
||||
await savedViews.searchInput().fill(text);
|
||||
if (rows !== undefined) {
|
||||
await expect(savedViews.rows()).toHaveCount(rows);
|
||||
}
|
||||
},
|
||||
rows: () => tid('saved-views-row'),
|
||||
row: (id) =>
|
||||
page.locator(`[data-testid="saved-views-row"][data-view-id="${id}"]`),
|
||||
rowLink: (id) => savedViews.row(id).getByTestId('saved-views-row-name'),
|
||||
async rowHref(id) {
|
||||
const href = await savedViews.rowLink(id).getAttribute('href');
|
||||
expect(href, `row ${id} has no href`).not.toBeNull();
|
||||
return new URL(href as string, page.url());
|
||||
},
|
||||
async open(view) {
|
||||
await savedViews.openList();
|
||||
await savedViews.search(view.displayName);
|
||||
const [response] = await Promise.all([
|
||||
savedViews.queryRan(view.filter, view.requestType),
|
||||
savedViews.rowLink(view.id).click(),
|
||||
]);
|
||||
await expect.poll(() => savedViews.activeViewId()).toBe(view.id);
|
||||
await expect(savedViews.name()).toHaveText(view.displayName);
|
||||
return response;
|
||||
},
|
||||
|
||||
rowMenuButton: (id) => savedViews.row(id).getByTestId('saved-views-row-menu'),
|
||||
rowAction: (action) => tid(`saved-views-row-menu-item-${action}`),
|
||||
async openRowMenu(id) {
|
||||
// The menu button has no width until the row is hovered.
|
||||
await savedViews.row(id).hover();
|
||||
await savedViews.rowMenuButton(id).click();
|
||||
},
|
||||
async chooseRowAction(id, action) {
|
||||
await savedViews.openRowMenu(id);
|
||||
await press(savedViews.rowAction(action));
|
||||
},
|
||||
async copyLink(id) {
|
||||
// Clipboard permissions are Chromium-only, so the copy is caught in the
|
||||
// page: react-use copies through a selection and execCommand, whose
|
||||
// `copy` event a capture listener sees first.
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as { __copied?: string };
|
||||
delete w.__copied;
|
||||
document.addEventListener(
|
||||
'copy',
|
||||
() => {
|
||||
w.__copied = document.getSelection()?.toString() ?? '';
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
);
|
||||
});
|
||||
await savedViews.chooseRowAction(id, 'copyLink');
|
||||
const copied = (): Promise<string | undefined> =>
|
||||
page.evaluate(() => (window as unknown as { __copied?: string }).__copied);
|
||||
await expect.poll(copied).toBeTruthy();
|
||||
return (await copied()) as string;
|
||||
},
|
||||
async openInNewTab(id, gesture = 'modifier') {
|
||||
const [tab] = await Promise.all([
|
||||
page.context().waitForEvent('page'),
|
||||
savedViews
|
||||
.rowLink(id)
|
||||
.click(
|
||||
gesture === 'modifier'
|
||||
? { modifiers: ['ControlOrMeta'] }
|
||||
: { button: 'middle' },
|
||||
),
|
||||
]);
|
||||
return { page: tab, savedViews: savedViewsFor(tab) };
|
||||
},
|
||||
async openLink(url, view) {
|
||||
const tab = await page.context().newPage();
|
||||
const opened = savedViewsFor(tab);
|
||||
await Promise.all([
|
||||
opened.queryRan(view.filter, view.requestType),
|
||||
tab.goto(url.toString()),
|
||||
]);
|
||||
return { page: tab, savedViews: opened };
|
||||
},
|
||||
newTabCount: () => newTabs,
|
||||
async rename(id, displayName) {
|
||||
await savedViews.chooseRowAction(id, 'editDetails');
|
||||
await expect(savedViews.modalNameInput()).toBeVisible();
|
||||
await savedViews.modalNameInput().fill(displayName);
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.request().method() === 'PUT' &&
|
||||
new URL(res.url()).pathname === viewPath(id),
|
||||
),
|
||||
savedViews.modalSubmit().click(),
|
||||
]);
|
||||
expect(response.ok(), `PUT ${SAVED_VIEWS_API}/${id}`).toBe(true);
|
||||
await expect(savedViews.modal()).toBeHidden();
|
||||
},
|
||||
async deleteView(id) {
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.request().method() === 'DELETE' &&
|
||||
new URL(res.url()).pathname === viewPath(id),
|
||||
),
|
||||
press(savedViews.deleteConfirm()),
|
||||
]);
|
||||
await expect(savedViews.deleteConfirm()).toBeHidden();
|
||||
},
|
||||
|
||||
modal: () => tid('save-view-modal'),
|
||||
modalNameInput: () => tid('save-view-name'),
|
||||
modalSubmit: () => tid('save-view-submit'),
|
||||
deleteConfirm: () => tid('delete-saved-view-confirm'),
|
||||
async createFromHeader(displayName) {
|
||||
await press(savedViews.createButton());
|
||||
await savedViews.modalNameInput().fill(displayName);
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.request().method() === 'POST' &&
|
||||
new URL(res.url()).pathname === `${BASE_PATH}${SAVED_VIEWS_API}`,
|
||||
),
|
||||
savedViews.modalSubmit().click(),
|
||||
]);
|
||||
expect(response.status(), `POST ${SAVED_VIEWS_API}`).toBe(201);
|
||||
const { id } = ((await response.json()) as { data: { id: string } }).data;
|
||||
trackSavedView(page, id);
|
||||
await expect(savedViews.modal()).toBeHidden();
|
||||
return id;
|
||||
},
|
||||
logsColumnHeaders: () => page.locator('thead th').filter({ hasText: /\S/ }),
|
||||
async openLogsFormatMenu() {
|
||||
await tid('periscope-btn-format-options').click();
|
||||
await expect(formatMenu()).toBeVisible();
|
||||
},
|
||||
async closeLogsFormatMenu() {
|
||||
await tid('periscope-btn-format-options').click();
|
||||
await expect(formatMenu()).toBeHidden();
|
||||
},
|
||||
selectedLogsFormat: () =>
|
||||
formatMenu()
|
||||
.locator('.menu-items .item')
|
||||
.filter({ has: page.locator('svg') }),
|
||||
logsMaxLines: () => formatMenu().getByTestId('max-lines-per-row'),
|
||||
logsFontSize: () => formatMenu().getByTestId('font-size-select'),
|
||||
explorerTab: (tab) => page.locator(`.${tab}-view-tab`),
|
||||
documentId: () => page.evaluate(() => performance.timeOrigin),
|
||||
queryBar: () =>
|
||||
page.locator('.query-where-clause-editor .cm-content').first(),
|
||||
async applyFilter(filter) {
|
||||
await Promise.all([
|
||||
savedViews.queryRan(filter),
|
||||
(async (): Promise<void> => {
|
||||
await savedViews.queryBar().click();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await page.keyboard.insertText(filter);
|
||||
await expect(savedViews.queryBar()).toHaveText(filter);
|
||||
await page.locator('[data-run-query-btn]').click();
|
||||
})(),
|
||||
]);
|
||||
},
|
||||
async update() {
|
||||
const id = savedViews.activeViewId();
|
||||
await savedViews.saveChangesButton().click();
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.request().method() === 'PUT' &&
|
||||
new URL(res.url()).pathname === viewPath(id as string),
|
||||
),
|
||||
press(savedViews.saveAction('update')),
|
||||
]);
|
||||
expect(response.ok(), `PUT ${SAVED_VIEWS_API}/${id}`).toBe(true);
|
||||
await expect(savedViews.toast('View updated')).toBeVisible();
|
||||
},
|
||||
async discard() {
|
||||
await press(savedViews.discardButton());
|
||||
},
|
||||
|
||||
viewGets: (id) =>
|
||||
requests.filter(
|
||||
(request) =>
|
||||
request.method() === 'GET' &&
|
||||
new URL(request.url()).pathname === viewPath(id),
|
||||
),
|
||||
};
|
||||
|
||||
return savedViews;
|
||||
}
|
||||
|
||||
const withSavedViews = driver(
|
||||
'savedViews',
|
||||
({ page }) => savedViewsFor(page),
|
||||
(_, { page }) => removeTrackedSavedViews(page),
|
||||
);
|
||||
|
||||
export const test = withSavedViews(kit);
|
||||
|
||||
export { expect } from './kit';
|
||||
export * from './saved-views/spec';
|
||||
export type * from './saved-views/spec.types';
|
||||
export { queryRangeBody } from './saved-views/query';
|
||||
78
tests/e2e/drivers/saved-views/api.ts
generated
Normal file
78
tests/e2e/drivers/saved-views/api.ts
generated
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { authToken } from '../api';
|
||||
import { SAVED_VIEWS_API } from './spec';
|
||||
import type { GettableSavedView, PostableSavedView } from './spec.types';
|
||||
|
||||
async function bearer(page: Page): Promise<{ Authorization: string }> {
|
||||
return { Authorization: `Bearer ${await authToken(page)}` };
|
||||
}
|
||||
|
||||
export async function createViaApi(
|
||||
page: Page,
|
||||
view: PostableSavedView,
|
||||
): Promise<string> {
|
||||
const res = await page.request.post(SAVED_VIEWS_API, {
|
||||
data: view,
|
||||
headers: await bearer(page),
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`POST ${SAVED_VIEWS_API} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return ((await res.json()) as { data: { id: string } }).data.id;
|
||||
}
|
||||
|
||||
export async function getViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
): Promise<GettableSavedView> {
|
||||
const res = await page.request.get(`${SAVED_VIEWS_API}/${id}`, {
|
||||
headers: await bearer(page),
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`GET ${SAVED_VIEWS_API}/${id} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return ((await res.json()) as { data: GettableSavedView }).data;
|
||||
}
|
||||
|
||||
/** The status alone, for asserting a view is gone. */
|
||||
export async function statusViaApi(page: Page, id: string): Promise<number> {
|
||||
const res = await page.request.get(`${SAVED_VIEWS_API}/${id}`, {
|
||||
headers: await bearer(page),
|
||||
});
|
||||
return res.status();
|
||||
}
|
||||
|
||||
/** Not asserted: a test that deleted its own view must not fail on cleanup. */
|
||||
export async function deleteViaApi(page: Page, id: string): Promise<void> {
|
||||
try {
|
||||
await page.request.delete(`${SAVED_VIEWS_API}/${id}`, {
|
||||
headers: await bearer(page),
|
||||
});
|
||||
} catch {
|
||||
/* the view outlives the run rather than hiding why it failed */
|
||||
}
|
||||
}
|
||||
|
||||
const trackedByPage = new WeakMap<Page, string[]>();
|
||||
|
||||
/** Registers a view for deletion when the test owning `page` ends. */
|
||||
export function trackSavedView(page: Page, id: string): void {
|
||||
trackedByPage.set(page, [...(trackedByPage.get(page) ?? []), id]);
|
||||
}
|
||||
|
||||
export async function removeTrackedSavedViews(page: Page): Promise<void> {
|
||||
const ids = trackedByPage.get(page) ?? [];
|
||||
trackedByPage.delete(page);
|
||||
for (const id of ids) {
|
||||
await deleteViaApi(page, id);
|
||||
}
|
||||
}
|
||||
40
tests/e2e/drivers/saved-views/query.ts
generated
Normal file
40
tests/e2e/drivers/saved-views/query.ts
generated
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import type { Request } from '@playwright/test';
|
||||
|
||||
import { isQueryRange } from '../query-range-mock';
|
||||
|
||||
interface QueryEnvelope {
|
||||
type: string;
|
||||
spec: {
|
||||
name?: string;
|
||||
signal?: string;
|
||||
filter?: { expression?: string };
|
||||
aggregations?: { expression?: string }[];
|
||||
groupBy?: { name: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface QueryRangeRequest {
|
||||
start: number;
|
||||
end: number;
|
||||
requestType: string;
|
||||
compositeQuery: { queries: QueryEnvelope[] };
|
||||
}
|
||||
|
||||
export function isQueryRangeRequest(request: Request): boolean {
|
||||
return request.method() === 'POST' && isQueryRange(request.url());
|
||||
}
|
||||
|
||||
export function queryRangeBody(request: Request): QueryRangeRequest {
|
||||
return request.postDataJSON() as QueryRangeRequest;
|
||||
}
|
||||
|
||||
/** The filter of every query in the request, in order. */
|
||||
export function filterExpressions(request: Request): string[] {
|
||||
return queryRangeBody(request).compositeQuery.queries.map(
|
||||
(query) => query.spec.filter?.expression ?? '',
|
||||
);
|
||||
}
|
||||
121
tests/e2e/drivers/saved-views/spec.ts
generated
Normal file
121
tests/e2e/drivers/saved-views/spec.ts
generated
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
// Typed builders for the saved view spec: pure data, no Playwright. The types
|
||||
// live in `spec.types.ts`.
|
||||
//
|
||||
// `/api/v2/saved_views`: create and update take the whole spec, and an update
|
||||
// replaces it, so a field left out is reset.
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import type {
|
||||
LogsViewOptions,
|
||||
PostableSavedView,
|
||||
SavedViewColumn,
|
||||
SavedViewPanelType,
|
||||
SavedViewRequestType,
|
||||
SeededView,
|
||||
} from './spec.types';
|
||||
|
||||
export const SAVED_VIEWS_API = '/api/v2/saved_views';
|
||||
|
||||
const REQUEST_TYPE: Record<SavedViewPanelType, SavedViewRequestType> = {
|
||||
list: 'raw',
|
||||
graph: 'time_series',
|
||||
table: 'scalar',
|
||||
};
|
||||
|
||||
/** Workers and browser projects share one org, so every view name is unique. */
|
||||
export function uniqueViewName(label: string): string {
|
||||
return `e2e sv ${label} ${randomBytes(4).toString('hex')}`;
|
||||
}
|
||||
|
||||
export const LOG_COLUMNS: Record<string, SavedViewColumn> = {
|
||||
timestamp: {
|
||||
name: 'timestamp',
|
||||
signal: 'logs',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: '',
|
||||
},
|
||||
body: {
|
||||
name: 'body',
|
||||
signal: 'logs',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: '',
|
||||
},
|
||||
serviceName: {
|
||||
name: 'service.name',
|
||||
signal: 'logs',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
severityText: {
|
||||
name: 'severity_text',
|
||||
signal: 'logs',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
};
|
||||
|
||||
export function logsView({
|
||||
displayName,
|
||||
filter = '',
|
||||
panelType = 'list',
|
||||
groupBy = [],
|
||||
columns = [LOG_COLUMNS.timestamp, LOG_COLUMNS.body],
|
||||
display,
|
||||
}: LogsViewOptions): PostableSavedView {
|
||||
const isList = panelType === 'list';
|
||||
return {
|
||||
generateName: true,
|
||||
source: 'logs',
|
||||
schemaVersion: 'v2',
|
||||
spec: {
|
||||
displayName,
|
||||
panelType,
|
||||
requestType: REQUEST_TYPE[panelType],
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'logs',
|
||||
disabled: false,
|
||||
filter: { expression: filter },
|
||||
...(isList
|
||||
? {
|
||||
order: [
|
||||
{ key: { name: 'timestamp' }, direction: 'desc' },
|
||||
{ key: { name: 'id' }, direction: 'desc' },
|
||||
],
|
||||
}
|
||||
: {
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
groupBy: groupBy.map((name) => ({
|
||||
name,
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
})),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
selectedFields: isList ? columns : [],
|
||||
...(display && { display }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function seededView(id: string, view: PostableSavedView): SeededView {
|
||||
const [query] = view.spec.queries as {
|
||||
spec?: { filter?: { expression?: string } };
|
||||
}[];
|
||||
return {
|
||||
id,
|
||||
displayName: view.spec.displayName,
|
||||
filter: query?.spec?.filter?.expression ?? '',
|
||||
requestType: view.spec.requestType,
|
||||
};
|
||||
}
|
||||
69
tests/e2e/drivers/saved-views/spec.types.ts
generated
Normal file
69
tests/e2e/drivers/saved-views/spec.types.ts
generated
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
// The saved view spec of `/api/v2/saved_views`.
|
||||
|
||||
export type SavedViewSource = 'logs' | 'traces' | 'metrics' | 'meter';
|
||||
export type SavedViewPanelType = 'list' | 'graph' | 'table';
|
||||
|
||||
export type SavedViewRequestType = 'raw' | 'time_series' | 'scalar';
|
||||
|
||||
export interface SavedViewColumn {
|
||||
name: string;
|
||||
signal?: string;
|
||||
fieldContext?: string;
|
||||
fieldDataType?: string;
|
||||
}
|
||||
|
||||
export interface SavedViewDisplay {
|
||||
format?: string;
|
||||
maxLines?: number;
|
||||
fontSize?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface SavedViewSpec {
|
||||
displayName: string;
|
||||
panelType: SavedViewPanelType;
|
||||
requestType: SavedViewRequestType;
|
||||
queries: Record<string, unknown>[];
|
||||
selectedFields?: SavedViewColumn[];
|
||||
display?: SavedViewDisplay;
|
||||
}
|
||||
|
||||
export interface PostableSavedView {
|
||||
generateName: true;
|
||||
source: SavedViewSource;
|
||||
schemaVersion: 'v2';
|
||||
spec: SavedViewSpec;
|
||||
}
|
||||
|
||||
export interface GettableSavedView {
|
||||
id: string;
|
||||
name: string;
|
||||
source: SavedViewSource;
|
||||
createdBy: string;
|
||||
updatedBy?: string;
|
||||
spec: SavedViewSpec;
|
||||
}
|
||||
|
||||
export interface LogsViewOptions {
|
||||
displayName: string;
|
||||
filter?: string;
|
||||
panelType?: SavedViewPanelType;
|
||||
/** Group by attributes of a graph or table view, as resource attributes. */
|
||||
groupBy?: string[];
|
||||
/** Defaults to timestamp and body, which a list view always shows. */
|
||||
columns?: SavedViewColumn[];
|
||||
display?: SavedViewDisplay;
|
||||
}
|
||||
|
||||
export interface SeededView {
|
||||
id: string;
|
||||
displayName: string;
|
||||
/** The first query's filter, which the explorer sends once the view is open. */
|
||||
filter: string;
|
||||
/** What the explorer's own query for the view asks for: raw, time_series or scalar. */
|
||||
requestType: string;
|
||||
}
|
||||
176
tests/e2e/tests/saved-views/lifecycle.spec.ts
Normal file
176
tests/e2e/tests/saved-views/lifecycle.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
expect,
|
||||
LOG_COLUMNS,
|
||||
logsView,
|
||||
test,
|
||||
uniqueViewName,
|
||||
} from '../../drivers/saved-views';
|
||||
|
||||
const SAVED_FILTER = "service.name = 'frontend'";
|
||||
const EDITED_FILTER = "service.name = 'cartservice'";
|
||||
|
||||
test.describe('Saved views: lifecycle on the logs explorer', () => {
|
||||
test('TC-01 a view created from the header survives a reload clean', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const displayName = uniqueViewName('create');
|
||||
await savedViews.openExplorer();
|
||||
await savedViews.expectNoView();
|
||||
await savedViews.applyFilter(SAVED_FILTER);
|
||||
|
||||
const id = await savedViews.createFromHeader(displayName);
|
||||
await expect(savedViews.toast('View created')).toBeVisible();
|
||||
await expect.poll(() => savedViews.activeViewId()).toBe(id);
|
||||
await savedViews.expectClean(displayName);
|
||||
|
||||
const stored = await savedViews.get(id);
|
||||
expect(stored.source).toBe('logs');
|
||||
expect(stored.spec.displayName).toBe(displayName);
|
||||
expect(stored.spec.panelType).toBe('list');
|
||||
expect(JSON.stringify(stored.spec.queries)).toContain(SAVED_FILTER);
|
||||
|
||||
await savedViews.reload(SAVED_FILTER);
|
||||
await savedViews.expectClean(displayName);
|
||||
await expect(savedViews.queryBar()).toHaveText(SAVED_FILTER);
|
||||
|
||||
await savedViews.openList();
|
||||
await savedViews.search(displayName);
|
||||
await expect(savedViews.row(id)).toBeVisible();
|
||||
await expect(savedViews.rowLink(id)).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
test('TC-02 an edited view turns dirty, updates, and opens with the new filter after a reload', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const view = await savedViews.seed(
|
||||
logsView({
|
||||
displayName: uniqueViewName('update'),
|
||||
filter: SAVED_FILTER,
|
||||
}),
|
||||
);
|
||||
await savedViews.openExplorer();
|
||||
await savedViews.open(view);
|
||||
await savedViews.expectClean(view.displayName);
|
||||
|
||||
await savedViews.applyFilter(EDITED_FILTER);
|
||||
await savedViews.expectDirty(view.displayName);
|
||||
|
||||
await savedViews.update();
|
||||
await savedViews.expectClean(view.displayName);
|
||||
|
||||
const stored = await savedViews.get(view.id);
|
||||
expect(JSON.stringify(stored.spec.queries)).toContain(EDITED_FILTER);
|
||||
expect(JSON.stringify(stored.spec.queries)).not.toContain(SAVED_FILTER);
|
||||
|
||||
await savedViews.reload(EDITED_FILTER);
|
||||
await savedViews.expectClean(view.displayName);
|
||||
await expect(savedViews.queryBar()).toHaveText(EDITED_FILTER);
|
||||
});
|
||||
|
||||
test('TC-03 discarding changes brings back the stored filter and tab', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const view = await savedViews.seed(
|
||||
logsView({
|
||||
displayName: uniqueViewName('discard'),
|
||||
filter: SAVED_FILTER,
|
||||
}),
|
||||
);
|
||||
await savedViews.openExplorer();
|
||||
await savedViews.open(view);
|
||||
await expect(savedViews.explorerTab('list')).toHaveClass(/active-tab/);
|
||||
|
||||
await savedViews.applyFilter(EDITED_FILTER);
|
||||
await savedViews.explorerTab('timeseries').click();
|
||||
await expect(savedViews.explorerTab('timeseries')).toHaveClass(/active-tab/);
|
||||
await savedViews.expectDirty(view.displayName);
|
||||
|
||||
await savedViews.discard();
|
||||
await savedViews.expectClean(view.displayName);
|
||||
await expect(savedViews.queryBar()).toHaveText(SAVED_FILTER);
|
||||
await expect(savedViews.explorerTab('list')).toHaveClass(/active-tab/);
|
||||
expect(savedViews.param('panelTypes')).toBe('"list"');
|
||||
|
||||
const stored = await savedViews.get(view.id);
|
||||
expect(JSON.stringify(stored.spec.queries)).toContain(SAVED_FILTER);
|
||||
});
|
||||
|
||||
test('TC-04 deleting the open view clears it without trying to load it again', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const view = await savedViews.seed(
|
||||
logsView({
|
||||
displayName: uniqueViewName('delete'),
|
||||
filter: SAVED_FILTER,
|
||||
}),
|
||||
);
|
||||
await savedViews.openExplorer();
|
||||
await savedViews.open(view);
|
||||
|
||||
await savedViews.chooseRowAction(view.id, 'delete');
|
||||
const getsBeforeDelete = savedViews.viewGets(view.id).length;
|
||||
expect(getsBeforeDelete).toBeGreaterThan(0);
|
||||
await savedViews.deleteView(view.id);
|
||||
|
||||
await expect(savedViews.toast('View deleted')).toBeVisible();
|
||||
await expect(savedViews.row(view.id)).toHaveCount(0);
|
||||
await savedViews.expectNoView();
|
||||
await expect.poll(() => savedViews.param('panelTypes')).toBeNull();
|
||||
expect(savedViews.param('viewName')).toBeNull();
|
||||
expect(savedViews.param('compositeQuery') ?? '').not.toContain('frontend');
|
||||
expect(savedViews.viewGets(view.id)).toHaveLength(getsBeforeDelete);
|
||||
expect(await savedViews.status(view.id)).toBe(404);
|
||||
});
|
||||
|
||||
test('TC-05 renaming a view keeps its columns and display settings', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const columns = [
|
||||
LOG_COLUMNS.timestamp,
|
||||
LOG_COLUMNS.body,
|
||||
LOG_COLUMNS.serviceName,
|
||||
LOG_COLUMNS.severityText,
|
||||
];
|
||||
const headers = ['Timestamp', 'Body', 'service.name', 'severity_text'];
|
||||
const display = { format: 'table', maxLines: 3, fontSize: 'medium' };
|
||||
const view = await savedViews.seed(
|
||||
logsView({
|
||||
displayName: uniqueViewName('rename'),
|
||||
filter: SAVED_FILTER,
|
||||
columns,
|
||||
display,
|
||||
}),
|
||||
);
|
||||
const renamed = uniqueViewName('renamed');
|
||||
|
||||
const expectDisplay = async (): Promise<void> => {
|
||||
await expect(savedViews.logsColumnHeaders()).toHaveText(headers);
|
||||
await savedViews.openLogsFormatMenu();
|
||||
await expect(savedViews.selectedLogsFormat()).toHaveText('Column');
|
||||
await expect(savedViews.logsMaxLines()).toHaveValue('3');
|
||||
await expect(savedViews.logsFontSize()).toHaveText('medium');
|
||||
await savedViews.closeLogsFormatMenu();
|
||||
};
|
||||
|
||||
await savedViews.openExplorer('logs', { relativeTime: '1d' });
|
||||
await savedViews.open(view);
|
||||
await expectDisplay();
|
||||
|
||||
await savedViews.openList();
|
||||
await savedViews.search(view.displayName);
|
||||
await savedViews.rename(view.id, renamed);
|
||||
await expect(savedViews.toast('View updated')).toBeVisible();
|
||||
await expect(savedViews.name()).toHaveText(renamed);
|
||||
|
||||
const stored = await savedViews.get(view.id);
|
||||
expect(stored.spec.displayName).toBe(renamed);
|
||||
expect(stored.spec.selectedFields?.map((column) => column.name)).toEqual(
|
||||
columns.map((column) => column.name),
|
||||
);
|
||||
expect(stored.spec.display).toMatchObject(display);
|
||||
|
||||
await savedViews.reload(SAVED_FILTER);
|
||||
await savedViews.expectClean(renamed);
|
||||
await expectDisplay();
|
||||
});
|
||||
});
|
||||
79
tests/e2e/tests/saved-views/open.spec.ts
Normal file
79
tests/e2e/tests/saved-views/open.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
type ExplorerTab,
|
||||
expect,
|
||||
logsView,
|
||||
queryRangeBody,
|
||||
type SavedViewPanelType,
|
||||
test,
|
||||
uniqueViewName,
|
||||
} from '../../drivers/saved-views';
|
||||
|
||||
const FILTER = "service.name = 'frontend'";
|
||||
|
||||
const PANELS: {
|
||||
panelType: SavedViewPanelType;
|
||||
tab: ExplorerTab;
|
||||
requestType: string;
|
||||
aggregation: string | undefined;
|
||||
groupBy: string[] | undefined;
|
||||
}[] = [
|
||||
{
|
||||
panelType: 'list',
|
||||
tab: 'list',
|
||||
requestType: 'raw',
|
||||
aggregation: undefined,
|
||||
groupBy: undefined,
|
||||
},
|
||||
{
|
||||
panelType: 'graph',
|
||||
tab: 'timeseries',
|
||||
requestType: 'time_series',
|
||||
aggregation: 'count()',
|
||||
groupBy: ['service.name'],
|
||||
},
|
||||
{
|
||||
panelType: 'table',
|
||||
tab: 'table',
|
||||
requestType: 'scalar',
|
||||
aggregation: 'count()',
|
||||
groupBy: ['service.name'],
|
||||
},
|
||||
];
|
||||
|
||||
test.describe('Saved views: opening from the list', () => {
|
||||
for (const [
|
||||
index,
|
||||
{ panelType, tab, requestType, aggregation, groupBy },
|
||||
] of PANELS.entries()) {
|
||||
test(`TC-0${index + 1} a ${panelType} view opens with its query, tab and a clean header`, async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const view = await savedViews.seed(
|
||||
logsView({
|
||||
displayName: uniqueViewName(`open ${panelType}`),
|
||||
filter: FILTER,
|
||||
panelType,
|
||||
groupBy,
|
||||
}),
|
||||
);
|
||||
await savedViews.openExplorer();
|
||||
const response = await savedViews.open(view);
|
||||
|
||||
expect(savedViews.param('panelTypes')).toBe(JSON.stringify(panelType));
|
||||
await expect(savedViews.explorerTab(tab)).toHaveClass(/active-tab/);
|
||||
await expect(savedViews.queryBar()).toHaveText(FILTER);
|
||||
await expect(savedViews.rowLink(view.id)).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page',
|
||||
);
|
||||
await savedViews.expectClean(view.displayName);
|
||||
|
||||
const body = queryRangeBody(response.request());
|
||||
expect(body.requestType).toBe(requestType);
|
||||
const [query] = body.compositeQuery.queries;
|
||||
expect(query.spec.filter?.expression).toBe(FILTER);
|
||||
expect(query.spec.aggregations?.[0]?.expression).toBe(aggregation);
|
||||
expect(query.spec.groupBy?.map((group) => group.name)).toEqual(groupBy);
|
||||
});
|
||||
}
|
||||
});
|
||||
169
tests/e2e/tests/saved-views/row-link.spec.ts
Normal file
169
tests/e2e/tests/saved-views/row-link.spec.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
expect,
|
||||
explorerPathname,
|
||||
logsView,
|
||||
type SavedViews,
|
||||
test,
|
||||
uniqueViewName,
|
||||
} from '../../drivers/saved-views';
|
||||
|
||||
const LOGS_PATH = explorerPathname('logs');
|
||||
const FRONTEND_FILTER = "service.name = 'frontend'";
|
||||
const CART_FILTER = "service.name = 'cartservice'";
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
/** Whole milliseconds, as the links carry them. */
|
||||
function customRange(): { startTime: string; endTime: string } {
|
||||
const end = Math.floor(Date.now() / 60_000) * 60_000;
|
||||
return {
|
||||
startTime: String(end - 2 * HOUR_MS),
|
||||
endTime: String(end - HOUR_MS),
|
||||
};
|
||||
}
|
||||
|
||||
async function seedTwoViews(savedViews: SavedViews) {
|
||||
const target = await savedViews.seed(
|
||||
logsView({ displayName: uniqueViewName('link'), filter: FRONTEND_FILTER }),
|
||||
);
|
||||
const other = await savedViews.seed(
|
||||
logsView({ displayName: uniqueViewName('other'), filter: CART_FILTER }),
|
||||
);
|
||||
return { target, other };
|
||||
}
|
||||
|
||||
test.describe('Saved views: row links', () => {
|
||||
test('TC-01 a row links to its view in the selected relative time range', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const { target } = await seedTwoViews(savedViews);
|
||||
await savedViews.openExplorer('logs', { relativeTime: '1h' });
|
||||
await savedViews.openList();
|
||||
await savedViews.search(target.displayName);
|
||||
|
||||
const href = await savedViews.rowHref(target.id);
|
||||
expect(href.pathname).toBe(LOGS_PATH);
|
||||
expect(href.searchParams.get('viewKey')).toBe(JSON.stringify(target.id));
|
||||
expect(href.searchParams.get('panelTypes')).toBe('"list"');
|
||||
expect(href.searchParams.get('relativeTime')).toBe('1h');
|
||||
expect(href.searchParams.has('startTime')).toBe(false);
|
||||
expect(href.searchParams.has('endTime')).toBe(false);
|
||||
expect(href.searchParams.get('compositeQuery')).toContain(
|
||||
encodeURIComponent(FRONTEND_FILTER),
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-02 a row links to its view in a custom time range, to the millisecond', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const { target } = await seedTwoViews(savedViews);
|
||||
const range = customRange();
|
||||
await savedViews.openExplorer('logs', range);
|
||||
await savedViews.openList();
|
||||
await savedViews.search(target.displayName);
|
||||
|
||||
const href = await savedViews.rowHref(target.id);
|
||||
expect(href.searchParams.get('startTime')).toBe(range.startTime);
|
||||
expect(href.searchParams.get('endTime')).toBe(range.endTime);
|
||||
expect(href.searchParams.has('relativeTime')).toBe(false);
|
||||
expect(href.searchParams.get('viewKey')).toBe(JSON.stringify(target.id));
|
||||
});
|
||||
|
||||
test('TC-03 a plain click opens the view in place and marks its row', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const { target, other } = await seedTwoViews(savedViews);
|
||||
await savedViews.openExplorer('logs', { relativeTime: '1h' });
|
||||
const documentId = await savedViews.documentId();
|
||||
|
||||
await savedViews.openList();
|
||||
await savedViews.search(target.displayName);
|
||||
await savedViews.open(target);
|
||||
|
||||
await savedViews.expectClean(target.displayName);
|
||||
await expect(savedViews.rowLink(target.id)).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page',
|
||||
);
|
||||
expect(savedViews.param('relativeTime')).toBe('1h');
|
||||
expect(await savedViews.documentId()).toBe(documentId);
|
||||
expect(savedViews.newTabCount()).toBe(0);
|
||||
|
||||
await savedViews.search(other.displayName);
|
||||
await expect(savedViews.rowLink(other.id)).not.toHaveAttribute(
|
||||
'aria-current',
|
||||
'page',
|
||||
);
|
||||
});
|
||||
|
||||
for (const gesture of ['modifier', 'middle'] as const) {
|
||||
test(`TC-04 a ${gesture} click opens the view in a new tab and leaves this one alone`, async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const { target } = await seedTwoViews(savedViews);
|
||||
await savedViews.openExplorer('logs', { relativeTime: '1h' });
|
||||
await savedViews.openList();
|
||||
await savedViews.search(target.displayName);
|
||||
const urlBefore = savedViews.url().href;
|
||||
|
||||
const { savedViews: opened } = await savedViews.openInNewTab(
|
||||
target.id,
|
||||
gesture,
|
||||
);
|
||||
await opened.queryRan(target.filter);
|
||||
|
||||
await opened.expectClean(target.displayName);
|
||||
expect(opened.activeViewId()).toBe(target.id);
|
||||
expect(opened.param('relativeTime')).toBe('1h');
|
||||
await expect(opened.queryBar()).toHaveText(target.filter);
|
||||
expect(savedViews.url().href).toBe(urlBefore);
|
||||
await expect(savedViews.name()).toHaveText('My view');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Saved views: copied links', () => {
|
||||
test('TC-05 a copied link reopens the view in the same relative time range', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const { target } = await seedTwoViews(savedViews);
|
||||
await savedViews.openExplorer('logs', { relativeTime: '1h' });
|
||||
await savedViews.openList();
|
||||
await savedViews.search(target.displayName);
|
||||
|
||||
const copied = new URL(await savedViews.copyLink(target.id));
|
||||
await expect(savedViews.toast('Link copied')).toBeVisible();
|
||||
expect(copied.origin).toBe(savedViews.url().origin);
|
||||
expect(copied.pathname).toBe(LOGS_PATH);
|
||||
expect(copied.searchParams.get('relativeTime')).toBe('1h');
|
||||
expect(copied.searchParams.get('viewKey')).toBe(JSON.stringify(target.id));
|
||||
|
||||
const { savedViews: reopened } = await savedViews.openLink(copied, target);
|
||||
|
||||
await reopened.expectClean(target.displayName);
|
||||
expect(reopened.activeViewId()).toBe(target.id);
|
||||
expect(reopened.param('relativeTime')).toBe('1h');
|
||||
await expect(reopened.queryBar()).toHaveText(target.filter);
|
||||
});
|
||||
|
||||
test('TC-06 a copied link reopens the view in the same custom time range', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const { target } = await seedTwoViews(savedViews);
|
||||
const range = customRange();
|
||||
await savedViews.openExplorer('logs', range);
|
||||
await savedViews.openList();
|
||||
await savedViews.search(target.displayName);
|
||||
|
||||
const copied = new URL(await savedViews.copyLink(target.id));
|
||||
expect(copied.searchParams.get('startTime')).toBe(range.startTime);
|
||||
expect(copied.searchParams.get('endTime')).toBe(range.endTime);
|
||||
expect(copied.searchParams.has('relativeTime')).toBe(false);
|
||||
|
||||
const { savedViews: reopened } = await savedViews.openLink(copied, target);
|
||||
|
||||
await reopened.expectClean(target.displayName);
|
||||
expect(reopened.param('startTime')).toBe(range.startTime);
|
||||
expect(reopened.param('endTime')).toBe(range.endTime);
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
from uuid import uuid4
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.querier import (
|
||||
build_aggregation,
|
||||
build_function,
|
||||
build_group_by_field,
|
||||
build_scalar_query,
|
||||
get_all_series,
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
)
|
||||
|
||||
MINUTE = timedelta(minutes=1)
|
||||
|
||||
|
||||
def test_time_shift_partial_hit_matches_no_cache(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
svc-a: 4 logs and svc-b: 2 logs in every minute of the hour before the
|
||||
requested window, so a timeShift(3600) query reads them. The hour before
|
||||
that holds 9 and 7 logs per minute, so a gap read one shift too far back
|
||||
returns points with those counts instead of nothing.
|
||||
|
||||
Tests:
|
||||
A timeShift query whose first 30 minutes are cached returns the same
|
||||
points for the next 20 minutes as the request with noCache. The missing
|
||||
range is already in the shifted clock, so the gap query must not shift
|
||||
it again.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
# the cache outlives the run, so a fixed filter would serve the previous
|
||||
# run's points back to this one
|
||||
run = uuid4().hex[:8]
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=now - 120 * MINUTE + minute * MINUTE + timedelta(seconds=1 + i),
|
||||
resources={"service.name": service},
|
||||
attributes={"run": run},
|
||||
body=f"{service} {minute} {i}",
|
||||
)
|
||||
for minute in range(60)
|
||||
for service, count in (("svc-a", 4), ("svc-b", 2))
|
||||
for i in range(count)
|
||||
]
|
||||
+ [
|
||||
Logs(
|
||||
timestamp=now - 180 * MINUTE + minute * MINUTE + timedelta(seconds=1 + i),
|
||||
resources={"service.name": service},
|
||||
attributes={"run": run},
|
||||
body=f"{service} earlier {minute} {i}",
|
||||
)
|
||||
for minute in range(60)
|
||||
for service, count in (("svc-a", 9), ("svc-b", 7))
|
||||
for i in range(count)
|
||||
]
|
||||
)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query = build_scalar_query(
|
||||
name="A",
|
||||
signal="logs",
|
||||
aggregations=[build_aggregation("count()")],
|
||||
group_by=[build_group_by_field("service.name", "string", "resource")],
|
||||
filter_expression=f"run = '{run}'",
|
||||
step_interval=60,
|
||||
functions=[build_function("timeShift", 3600)],
|
||||
)
|
||||
start_ms = int((now - 60 * MINUTE).timestamp() * 1000)
|
||||
first_end_ms = int((now - 30 * MINUTE).timestamp() * 1000)
|
||||
second_end_ms = int((now - 10 * MINUTE).timestamp() * 1000)
|
||||
|
||||
warm = make_query_request(signoz, token, start_ms, first_end_ms, [query], no_cache=False)
|
||||
assert warm.status_code == HTTPStatus.OK, warm.text
|
||||
cached = make_query_request(signoz, token, start_ms, second_end_ms, [query], no_cache=False)
|
||||
assert cached.status_code == HTTPStatus.OK, cached.text
|
||||
fresh = make_query_request(signoz, token, start_ms, second_end_ms, [query], no_cache=True)
|
||||
assert fresh.status_code == HTTPStatus.OK, fresh.text
|
||||
|
||||
fresh_points = {service: {point["timestamp"]: point["value"] for point in series["values"]} for service, series in index_series_by_label(get_all_series(fresh.json(), "A"), "service.name").items()}
|
||||
assert set(fresh_points) == {"svc-a", "svc-b"}, fresh_points
|
||||
assert {service: len(points) for service, points in fresh_points.items()} == {"svc-a": 50, "svc-b": 50}
|
||||
|
||||
cached_points = {service: {point["timestamp"]: point["value"] for point in series["values"]} for service, series in index_series_by_label(get_all_series(cached.json(), "A"), "service.name").items()}
|
||||
assert cached_points == fresh_points
|
||||
Reference in New Issue
Block a user