Compare commits

..

4 Commits

Author SHA1 Message Date
Ashwin Bhatkal
212054786f Merge branch 'main' into fix/alerts-views-test-timeout 2026-11-09 16:55:54 +05:30
Swapnil Nakade
313ec2d657 chore: bump go version to 1.25 in github ci (#13480)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- bump go version in ci to 1.25

---------

Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
2026-11-09 10:08:41 +00:00
Ashwin Bhatkal
0d6a308179 test(alerts): stop the saved views tests timing out on CI 2026-11-09 14:41:11 +05:30
Vinicius Lourenço
87bcd1b702 test(saved-views): cover behaviour the current tests miss (#13301)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

Stacked on #13287, so that one merges first.

The saved views tests touch about 98% of the lines, yet a lot of
behaviour could break without any of them failing. This adds tests for
that behaviour:

- The row link and Copy link follow the selected time range.
- Saving from the header: a failed update, blank names, pressing Enter
twice, keeping the view color, and saving the query that was run.
- The hover card while it loads, when it fails, and when it closes.
- Restoring the last used view, per source.
- The unsaved changes check and spec conversion against what the server
stores.

It also adds the first saved views e2e specs, run against a real
backend:

- Open, create, update, discard, delete and rename a view on the logs
explorer, and check the header is still clean after a reload.
- Row links and Copy link carry the time range, and a Ctrl/Cmd or middle
click opens the view in a new tab.

While adding the tests I found that renaming a view from the row menu
sent the list's copy of the view back as a full replacement, so a stale
list could undo someone else's edit. Rename now reads the stored view
first, and falls back to the list's copy when the user cannot read the
view.

#### Issues closed by this PR

Closes https://github.com/SigNoz/uforge-pod/issues/6397

#### Additional Information

- Each new test fails when the code it covers is broken on purpose.
- Jest: `pnpm jest src/container/SavedViews
src/utils/__tests__/buildExplorerNavigationUrl.test.ts`
- E2E, with the stack up (`pnpm env:start` in `tests/e2e`): `pnpm
playwright test tests/saved-views --project=chromium`
- Opening a view the user cannot read still sends the GET, from
`usePreferenceSync`, which has no read check. The server answers 403 and
the header shows "No view access".
2026-11-08 17:53:07 +00:00
32 changed files with 2189 additions and 155 deletions

View File

@@ -63,7 +63,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.24
GO_VERSION: 1.25
GO_NAME: signoz-community
GO_INPUT_ARTIFACT_CACHE_KEY: community-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build

View File

@@ -89,7 +89,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.24
GO_VERSION: 1.25
GO_INPUT_ARTIFACT_CACHE_KEY: enterprise-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build
GO_BUILD_CONTEXT: ./cmd/enterprise

View File

@@ -95,7 +95,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.24
GO_VERSION: 1.25
GO_INPUT_ARTIFACT_CACHE_KEY: staging-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build
GO_BUILD_CONTEXT: ./cmd/enterprise

View File

@@ -22,7 +22,7 @@ jobs:
with:
PRIMUS_REF: main
GO_TEST_CONTEXT: ./...
GO_VERSION: 1.24
GO_VERSION: 1.25
fmt:
if: |
github.event_name == 'merge_group' ||
@@ -32,7 +32,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.24
GO_VERSION: 1.25
lint:
if: |
github.event_name == 'merge_group' ||
@@ -42,7 +42,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.24
GO_VERSION: 1.25
deps:
if: |
github.event_name == 'merge_group' ||
@@ -52,7 +52,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.24
GO_VERSION: 1.25
semconv-generated:
if: |
github.event_name == 'merge_group' ||
@@ -65,7 +65,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: check-semconv-generated-files
run: make semconv-check
build:
@@ -80,7 +80,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: qemu-install
uses: docker/setup-qemu-action@v3
- name: aarch64-install
@@ -116,7 +116,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: generate-openapi
run: |
go run cmd/enterprise/*.go generate openapi
@@ -133,7 +133,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: generate-authz
run: |
go run cmd/enterprise/*.go generate authz
@@ -150,7 +150,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: generate-web-settings
run: |
go run cmd/enterprise/*.go generate config web-settings
@@ -167,7 +167,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: generate-transaction-groups
run: |
go run cmd/enterprise/*.go generate config transaction-groups

View File

@@ -66,7 +66,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: cross-compilation-tools
if: matrix.os == 'ubuntu-latest'
run: |
@@ -130,7 +130,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
# copy the caches from build
- name: get-sha

View File

@@ -75,7 +75,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: cross-compilation-tools
if: matrix.os == 'ubuntu-latest'
run: |
@@ -138,7 +138,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
# copy the caches from build
- name: get-sha

View File

@@ -31,6 +31,8 @@ jest.mock('@floating-ui/dom', () => ({
Promise.resolve({ x: 0, y: 0, placement, strategy, middlewareData: {} }),
}));
jest.setTimeout(15_000);
const VIEW_1_ORDER_BY = JSON.stringify({ columnName: 'name', order: 'asc' });
const VIEW_1_QUERY = "severity = 'critical'";

View File

@@ -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),
});
});
});

View File

@@ -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) =>

View File

@@ -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)));

View File

@@ -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');
});
});

View File

@@ -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' });

View File

@@ -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();

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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,

View File

@@ -11,6 +11,7 @@ import (
"log/slog"
"net"
"net/http"
"net/textproto"
"net/url"
"os"
"strconv"
@@ -706,7 +707,9 @@ func TestEmailRejected(t *testing.T) {
// Send the alert to mock SMTP server.
retry, err := e.Notify(context.Background(), firingAlert)
require.ErrorContains(t, err, "501 5.5.4 Rejected!")
var smtpErr *textproto.Error
require.ErrorAs(t, err, &smtpErr)
assert.Equal(t, 501, smtpErr.Code)
assert.True(t, retry)
require.NoError(t, srv.Shutdown(ctx))

View File

@@ -60,7 +60,7 @@ func (bc *bucketCache) GetMissRanges(
) (cached *qbtypes.Result, missing []*qbtypes.TimeRange) {
// Get query window
startMs, endMs := cacheWindow(q, uint64(step.Milliseconds()))
startMs, endMs := q.Window()
bc.logger.DebugContext(ctx, "getting miss ranges", slog.String("fingerprint", q.Fingerprint()), slog.Uint64("start", startMs), slog.Uint64("end", endMs))
@@ -127,7 +127,7 @@ func (bc *bucketCache) GetMissRanges(
// Put stores fresh query results in the cache.
func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Query, step qbtypes.Step, fresh *qbtypes.Result) {
// Get query window
startMs, endMs := cacheWindow(q, uint64(step.Milliseconds()))
startMs, endMs := q.Window()
// Calculate the flux boundary - data after this point should not be cached
currentMs := uint64(time.Now().UnixMilli())
@@ -152,8 +152,7 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
}
// Trim the result to exclude data within flux interval
requestStartMs, _ := q.Window()
trimmedResult := bc.trimResultToFluxBoundary(fresh, requestStartMs, cachableEndMs)
trimmedResult := bc.trimResultToFluxBoundary(fresh, cachableEndMs)
if trimmedResult == nil {
// Result type is not cacheable (raw or scalar)
return
@@ -223,30 +222,6 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
}
}
// lookbackSteps is how many steps before its window a query's statement
// reads. runningDiff drops its first point, so the metrics statement reads
// one step before the window to give the first interval a difference.
func lookbackSteps(q qbtypes.Query) int {
bq, ok := q.(*builderQuery[qbtypes.MetricAggregation])
if !ok {
return 0
}
for _, fn := range bq.spec.Functions {
if fn.Name == qbtypes.FunctionNameRunningDiff {
return 1
}
}
return 0
}
// cacheWindow is the query window widened by its lookback, so a hit carries
// the steps the statement reads before the window.
func cacheWindow(q qbtypes.Query, stepMs uint64) (uint64, uint64) {
startMs, endMs := q.Window()
lookbackMs := uint64(lookbackSteps(q)) * stepMs
return startMs - min(lookbackMs, startMs), endMs
}
// generateCacheKey names the cache entry holding one grid cell of a query.
func (bc *bucketCache) generateCacheKey(q qbtypes.Query, cellIdx uint64) string {
return fmt.Sprintf("v6:query:%s:cellidx:%d", q.Fingerprint(), cellIdx)
@@ -785,7 +760,7 @@ func (bc *bucketCache) deduplicateWarnings(warnings []string) []string {
}
// trimResultToFluxBoundary trims the result to exclude data points beyond the flux boundary.
func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, requestStartMs, fluxBoundary uint64) *qbtypes.Result {
func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, fluxBoundary uint64) *qbtypes.Result {
trimmedResult := &qbtypes.Result{
Type: result.Type,
Stats: result.Stats,
@@ -815,10 +790,8 @@ func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, requestS
// Filter values to exclude those beyond flux boundary and partial values
for _, value := range series.Values {
// Skip partial values - they cannot be cached. A lookback
// step before the request window is flagged partial
// against that window but is a whole step, so it stays.
if value.Partial && uint64(value.Timestamp) >= requestStartMs {
// Skip partial values - they cannot be cached
if value.Partial {
continue
}
if uint64(value.Timestamp) <= fluxBoundary {

View File

@@ -71,7 +71,7 @@ func TestTrimResultToFluxBoundaryKeepsTheHeatmapAxis(t *testing.T) {
},
}
trimmed := cache.trimResultToFluxBoundary(result, 0, 1710000060000)
trimmed := cache.trimResultToFluxBoundary(result, 1710000060000)
tsData, ok := trimmed.Value.(*qbtypes.TimeSeriesData)
require.True(t, ok)

View File

@@ -814,7 +814,7 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
// If entire range is missing, execute normally
if cachedResult == nil && len(missingRanges) == 1 {
startMs, endMs := cacheWindow(query, uint64(step.Milliseconds()))
startMs, endMs := query.Window()
if missingRanges[0].From == startMs && missingRanges[0].To == endMs {
sem <- struct{}{}
result, err := query.Execute(ctx)
@@ -898,11 +898,6 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
// Merge cached and fresh results
mergedResult := q.mergeResults(cachedResult, freshResults)
// A gap statement at the start of the cache window reads its own lookback
// step before it, which the request's statement would not read.
if cacheStartMs, _ := cacheWindow(query, uint64(step.Milliseconds())); lookbackSteps(query) > 0 {
dropValuesBefore(mergedResult, cacheStartMs)
}
mergedResult.Stats.RowsScanned += totalStats.RowsScanned
mergedResult.Stats.BytesScanned += totalStats.BytesScanned
mergedResult.Stats.DurationMS += totalStats.DurationMS
@@ -913,18 +908,6 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
return mergedResult, nil
}
func dropValuesBefore(result *qbtypes.Result, fromMs uint64) {
data, ok := result.Value.(*qbtypes.TimeSeriesData)
if !ok || data == nil {
return
}
for _, aggBucket := range data.Aggregations {
for _, series := range aggBucket.Series {
series.Values = slices.DeleteFunc(series.Values, func(value *qbtypes.TimeSeriesValue) bool { return uint64(value.Timestamp) < fromMs })
}
}
}
// createRangedQuery creates a copy of the query with a different time range.
func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query, timeRange qbtypes.TimeRange) qbtypes.Query {
// this is called in a goroutine, so we create a copy of the query to avoid race conditions

481
tests/e2e/drivers/saved-views.ts generated Normal file
View 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
View 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
View 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
View 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,
};
}

View 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;
}

View 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();
});
});

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

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

View File

@@ -1,69 +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.metrics import Metrics
from fixtures.querier import (
build_builder_query,
build_function,
get_all_series,
index_series_by_label,
make_query_request,
)
MINUTE = timedelta(minutes=1)
def test_running_diff_full_hit_matches_no_cache(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
"""
Setup:
A gauge that grows by 10 every minute over four minutes.
Tests:
runningDiff over the last three of those minutes, requested twice. The
metrics statement reads one step before the window so the first
interval has a difference. The second request, a full cache hit, must
return the same three differences as the request with noCache.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
# the cache outlives the run, so a fixed name would serve the previous
# run's points back to this one
metric_name = f"cache_running_diff_{uuid4().hex[:8]}"
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "svc-a"},
timestamp=now - (14 - i) * MINUTE,
value=100.0 + 10 * i,
type_="Gauge",
is_monotonic=False,
)
for i in range(4)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_builder_query("A", metric_name, "avg", "avg", group_by=["service"], functions=[build_function("runningDiff")])
start_ms = int((now - 13 * MINUTE).timestamp() * 1000)
end_ms = int((now - 10 * MINUTE).timestamp() * 1000)
warm = make_query_request(signoz, token, start_ms, end_ms, [query], no_cache=False)
assert warm.status_code == HTTPStatus.OK, warm.text
cached = make_query_request(signoz, token, start_ms, end_ms, [query], no_cache=False)
assert cached.status_code == HTTPStatus.OK, cached.text
fresh = make_query_request(signoz, token, start_ms, end_ms, [query], no_cache=True)
assert fresh.status_code == HTTPStatus.OK, fresh.text
fresh_points = {point["timestamp"]: point["value"] for point in index_series_by_label(get_all_series(fresh.json(), "A"), "service")["svc-a"]["values"]}
assert sorted(fresh_points.values()) == [10, 10, 10], fresh_points
cached_points = {point["timestamp"]: point["value"] for point in index_series_by_label(get_all_series(cached.json(), "A"), "service")["svc-a"]["values"]}
assert cached_points == fresh_points