Compare commits

..

8 Commits

Author SHA1 Message Date
srikanthccv
848fad021f chore(querybuilder): group the request validation test imports
Assisted-by: Claude Opus 5.5
2026-11-07 16:46:25 +05:30
srikanthccv
b5136b744a Merge branch 'main' into fix/query-range-validation-gaps
Assisted-by: Claude Opus 5.5
2026-11-07 16:41:28 +05:30
Srikanth Chekuri
38f4b85a78 Merge branch 'main' into fix/query-range-validation-gaps 2026-11-05 21:04:47 +05:30
srikanthccv
7268d5ebf5 test(querier): name the new validation cases in the PascalCase form
Assisted-by: Claude Opus 5.5
2026-11-05 15:47:33 +05:30
srikanthccv
9fc012d819 chore: generate openapi spec
Assisted-by: Claude Opus 5.5
2026-11-05 02:40:45 +05:30
srikanthccv
e17252420c fix(querier): keep distribution in the request type description
Assisted-by: Claude Opus 5.5
2026-11-05 02:40:44 +05:30
srikanthccv
ac153c34f3 chore: generate openapi spec
Assisted-by: Claude Opus 5.5
2026-11-05 02:29:38 +05:30
srikanthccv
f234da5983 fix(querier): reject negative formula limits and empty raw order keys, and name the valid request types
distribution still decodes, because saved views and dashboards can store it.
Validate rejects it and lists the request types a query can use. Rules and
saved views validate on write only, so stored ones still load.

Assisted-by: Claude Opus 5.5
2026-11-05 02:29:38 +05:30
30 changed files with 242 additions and 2769 deletions

View File

@@ -32729,301 +32729,6 @@ paths:
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_anomaly_request_rate:
description: 'anomaly is the one function with named arguments. seasonality
says which past periods predict the current value: hourly, daily
or weekly, daily when absent. z_score_threshold is how many standard
deviations from the prediction count as an anomaly, 3 when absent.
The response carries, next to the values, a predicted series, an
upper and a lower bound series and an anomaly score series. The
scoring runs in the enterprise build; the community build returns
the series unchanged.'
summary: 'anomaly: request rate scored against its daily pattern'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: http.server.duration.count
spaceAggregation: sum
timeAggregation: rate
functions:
- args:
- name: seasonality
value: daily
- name: z_score_threshold
value: 3
name: anomaly
groupBy:
- fieldContext: resource
name: service.name
name: A
signal: metrics
stepInterval: 300s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_cumulativesum_errors_so_far:
description: cumulativeSum replaces each point with the sum of every
point up to it, so a per-step count becomes a running total that
starts at zero at the window start. It takes no arguments. The total
resets with the window, so a longer time range gives a larger final
value; it is a view of the window, not a counter.
summary: 'cumulativeSum: 5xx responses since the start of the window'
value:
compositeQuery:
queries:
- spec:
aggregations:
- alias: server_errors
expression: count()
filter:
expression: http.response.status_code >= 500
functions:
- name: cumulativeSum
groupBy:
- fieldContext: resource
name: service.name
name: A
signal: traces
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_cutoffmin_hide_idle_hosts:
description: The four threshold functions take one number. cutOffMin
and cutOffMax remove the points below or above the threshold, so
an idle host disappears from the chart instead of drawing a line
along the bottom. clampMin and clampMax keep the points and replace
the value with the threshold, which caps spikes without hiding them.
system.cpu.utilization is a ratio, so 0.05 is 5%.
summary: 'cutOffMin: drop hosts below 5% CPU from the chart'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: system.cpu.utilization
spaceAggregation: avg
timeAggregation: avg
functions:
- args:
- value: 0.05
name: cutOffMin
groupBy:
- fieldContext: resource
name: host.name
name: A
signal: metrics
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_ewma_smoothed_latency:
description: 'ewma3, ewma5 and ewma7 replace each point with an exponentially
weighted average of the point and the points before it. The number
in the name is the window the default weight is derived from: alpha
= 2 / (n + 1), so 0.5, 0.333 and 0.25. One optional argument overrides
alpha; a value close to 1 follows the raw series, a value close
to 0 smooths harder. 0.3 here smooths a little more than ewma5''s
default.'
summary: 'ewma5: smoothed p95 latency'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: http.server.duration.bucket
spaceAggregation: p95
functions:
- args:
- value: 0.3
name: ewma5
groupBy:
- fieldContext: resource
name: service.name
name: A
signal: metrics
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_fillzero_sparse_errors:
description: A count over sparse events returns a point only for steps
that had a match, so a chart of failed spans has gaps where nothing
failed. fillZero adds a zero at every missing step. With no arguments
the server fills the request window at the query's step, which is
the common case. The three optional arguments are start, end and
step in milliseconds, for callers that want to fill a different
grid.
summary: 'fillZero: a zero at every step with no failed spans'
value:
compositeQuery:
queries:
- spec:
aggregations:
- alias: failed_spans
expression: count()
filter:
expression: has_error = true
functions:
- name: fillZero
groupBy:
- fieldContext: resource
name: service.name
name: A
signal: traces
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_log10_network_bytes:
description: log2 and log10 replace each value with its logarithm,
so a loopback device moving kilobytes and an uplink moving gigabytes
fit on one chart. They take no arguments. Zero and negative values
have no logarithm and are not drawn. absolute, also without arguments,
is the usual companion for a signed series before a logarithm.
summary: 'log10: network throughput across devices that differ by
orders of magnitude'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: system.network.io
spaceAggregation: sum
timeAggregation: rate
filter:
expression: direction = 'transmit'
functions:
- name: log10
groupBy:
- fieldContext: attribute
name: device
name: A
signal: metrics
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_median_despiked_request_rate:
description: median3, median5 and median7 replace each point with
the median of the surrounding 3, 5 or 7 points. A spike that lasts
one step vanishes, a shift that lasts longer than half the window
stays. They take no arguments. Use them when the raw series is noisy
and the chart is for a trend, not for catching the spike.
summary: 'median5: request rate without one-step spikes'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: http.server.duration.count
spaceAggregation: sum
timeAggregation: rate
functions:
- name: median5
groupBy:
- fieldContext: resource
name: service.name
name: A
signal: metrics
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_runningdiff_disk_growth:
description: 'runningDiff turns a gauge into the change between consecutive
points, so a filesystem usage gauge becomes bytes written per step.
It takes no arguments and drops the first point of the window, since
that point has nothing to difference against; the server reads one
step before the window to keep the first interval. Functions apply
in order: clampMin with 0 runs on the differences and replaces the
negative value a cleanup or a volume reset produces with zero.'
summary: 'runningDiff then clampMin: bytes written to disk per step'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: system.filesystem.usage
spaceAggregation: sum
timeAggregation: latest
filter:
expression: state = 'used'
functions:
- name: runningDiff
- args:
- value: 0
name: clampMin
groupBy:
- fieldContext: resource
name: host.name
name: A
signal: metrics
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_timeshift_week_over_week:
description: timeShift moves every point of its query forward by the
argument, in seconds, so a series read from the past lands on the
current timestamps. B here reads the hour exactly seven days (604800
s) before the requested window and comes back on this week's timestamps,
so it overlays A on a chart and the formula can subtract it point
by point. The argument is positive; it says how far back to read.
summary: 'timeShift: this week''s error logs next to last week''s'
value:
compositeQuery:
queries:
- spec:
aggregations:
- alias: errors
expression: count()
filter:
expression: severity_text = 'ERROR'
name: A
signal: logs
stepInterval: 60s
type: builder_query
- spec:
aggregations:
- alias: errors
expression: count()
filter:
expression: severity_text = 'ERROR'
functions:
- args:
- value: 604800
name: timeShift
name: B
signal: logs
stepInterval: 60s
type: builder_query
- spec:
expression: A - B
name: F1
type: builder_formula
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
logs_raw:
summary: 'Raw: fetch raw log records'
value:

View File

@@ -1,56 +0,0 @@
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,19 +5,11 @@ import {
waitFor,
within,
} from '@testing-library/react';
import {
AuthtypesTransactionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { 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';
@@ -230,31 +222,6 @@ 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', () => {
@@ -345,78 +312,6 @@ 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,45 +339,6 @@ 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,7 +3,6 @@ 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';
@@ -76,39 +75,4 @@ 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,14 +1,10 @@
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';
@@ -23,7 +19,7 @@ import {
urlParam,
viewUrl,
} from './savedViewsTestUtils';
import { API, mockSavedViewsApi } from './savedViewsApiMock';
import { mockSavedViewsApi } from './savedViewsApiMock';
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
@@ -47,27 +43,6 @@ 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');
@@ -185,76 +160,6 @@ 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, {}));
@@ -326,37 +231,6 @@ 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,6 +1,5 @@
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 {
@@ -10,7 +9,6 @@ import {
waitFor,
within,
} from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
@@ -221,75 +219,6 @@ 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,13 +4,11 @@ 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 { API, mockSavedViewsApi } from './savedViewsApiMock';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
expectRowUsable,
explorerUrl,
@@ -183,87 +181,6 @@ 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));
@@ -319,19 +236,4 @@ 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

@@ -1,111 +0,0 @@
// 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,11 +1,10 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from 'react-query';
import { 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,
@@ -46,39 +45,8 @@ export function useSavedViewRowActions(
const timeSearch = useTimeSearchParams();
const [, copyToClipboard] = useCopyToClipboard();
const logSavedViewEvent = useSavedViewLogEvent(source);
// 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: updateSavedView, isLoading: isRenaming } =
useUpdateSavedView();
const { mutateAsync: deleteSavedView, isLoading: isDeleting } =
useDeleteSavedView();
@@ -109,7 +77,14 @@ export function useSavedViewRowActions(
displayName: string,
): Promise<boolean> => {
try {
await renameSavedView({ view, displayName });
await updateSavedView({
pathParams: { id: view.id },
data: {
source,
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
spec: { ...view.spec, displayName },
},
});
await invalidateSavedViews(queryClient, source, view.id);
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.ViewDetailsEdited, {
@@ -121,7 +96,7 @@ export function useSavedViewRowActions(
return false;
}
},
[renameSavedView, source, queryClient, logSavedViewEvent],
[updateSavedView, source, queryClient, logSavedViewEvent],
);
// The deleted view's own query is left alone: refetching it would fail

View File

@@ -127,48 +127,6 @@ 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({
@@ -226,21 +184,6 @@ 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({
@@ -352,137 +295,6 @@ 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,66 +80,6 @@ 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,20 +21,6 @@ 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

@@ -28,7 +28,6 @@ import (
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -111,44 +110,42 @@ func TestAggrGroup(t *testing.T) {
}
)
type notification struct {
alerts alertmanagertypes.AlertSlice
notifiedAt time.Time
}
alertsCh := make(chan notification)
var (
last = time.Now()
current = time.Now()
lastCurMtx = &sync.Mutex{}
alertsCh = make(chan alertmanagertypes.AlertSlice)
)
ntfy := func(ctx context.Context, alerts ...*alertmanagertypes.Alert) bool {
// Validate that the context is properly populated.
notifiedAt, ok := notify.Now(ctx)
assert.True(t, ok, "now missing")
_, ok = notify.GroupKey(ctx)
assert.True(t, ok, "group key missing")
lbls, ok := notify.GroupLabels(ctx)
if assert.True(t, ok, "group labels missing") {
assert.Equal(t, lset, lbls, "wrong group labels")
if _, ok := notify.Now(ctx); !ok {
t.Errorf("now missing")
}
rcv, ok := notify.ReceiverName(ctx)
if assert.True(t, ok, "receiver missing") {
assert.Equal(t, opts.Receiver, rcv, "wrong receiver")
if _, ok := notify.GroupKey(ctx); !ok {
t.Errorf("group key missing")
}
ri, ok := notify.RepeatInterval(ctx)
if assert.True(t, ok, "repeat interval missing") {
assert.Equal(t, notificationConfig.Renotify.RenotifyInterval, ri, "wrong repeat interval")
if lbls, ok := notify.GroupLabels(ctx); !ok || !reflect.DeepEqual(lbls, lset) {
t.Errorf("wrong group labels: %q", lbls)
}
if rcv, ok := notify.ReceiverName(ctx); !ok || rcv != opts.Receiver {
t.Errorf("wrong receiver: %q", rcv)
}
if ri, ok := notify.RepeatInterval(ctx); !ok || ri != notificationConfig.Renotify.RenotifyInterval {
t.Errorf("wrong repeat interval: %q", ri)
}
alertsCh <- notification{
alerts: alertmanagertypes.AlertSlice(alerts),
notifiedAt: notifiedAt,
}
lastCurMtx.Lock()
last = current
// Subtract a millisecond to allow for races.
current = time.Now().Add(-time.Millisecond)
lastCurMtx.Unlock()
alertsCh <- alertmanagertypes.AlertSlice(alerts)
return true
}
assertNotifiedAfter := func(previous, current time.Time, interval time.Duration) {
t.Helper()
require.GreaterOrEqual(t, current.Sub(previous), interval, "received batch too early")
}
removeEndsAt := func(as alertmanagertypes.AlertSlice) alertmanagertypes.AlertSlice {
for i, a := range as {
ac := *a
@@ -159,26 +156,29 @@ func TestAggrGroup(t *testing.T) {
}
// Test regular situation where we wait for group_wait to send out alerts.
groupStartedAt := time.Now()
ag := newAggrGroup(context.Background(), lset, route, nil, promslog.NewNopLogger(), notificationConfig.Renotify.RenotifyInterval)
go ag.run(ntfy)
ag.insert(a1)
var lastNotificationAt time.Time
select {
case <-time.After(2 * opts.GroupWait):
require.FailNow(t, "expected initial batch after group_wait")
t.Fatalf("expected initial batch after group_wait")
case notification := <-alertsCh:
assertNotifiedAfter(groupStartedAt, notification.notifiedAt, opts.GroupWait)
lastNotificationAt = notification.notifiedAt
batch := notification.alerts
case batch := <-alertsCh:
lastCurMtx.Lock()
s := time.Since(last)
lastCurMtx.Unlock()
if s < opts.GroupWait {
t.Fatalf("received batch too early after %v", s)
}
exp := removeEndsAt(alertmanagertypes.AlertSlice{a1})
sort.Sort(batch)
require.Equal(t, exp, batch)
if !reflect.DeepEqual(batch, exp) {
t.Fatalf("expected alerts %v but got %v", exp, batch)
}
}
for i := 0; i < 3; i++ {
@@ -187,16 +187,21 @@ func TestAggrGroup(t *testing.T) {
select {
case <-time.After(2 * opts.GroupInterval):
require.FailNow(t, "expected new batch after group interval but received none")
t.Fatalf("expected new batch after group interval but received none")
case notification := <-alertsCh:
assertNotifiedAfter(lastNotificationAt, notification.notifiedAt, opts.GroupInterval)
lastNotificationAt = notification.notifiedAt
batch := notification.alerts
case batch := <-alertsCh:
lastCurMtx.Lock()
s := time.Since(last)
lastCurMtx.Unlock()
if s < opts.GroupInterval {
t.Fatalf("received batch too early after %v", s)
}
exp := removeEndsAt(alertmanagertypes.AlertSlice{a1, a3})
sort.Sort(batch)
require.Equal(t, exp, batch)
if !reflect.DeepEqual(batch, exp) {
t.Fatalf("expected alerts %v but got %v", exp, batch)
}
}
}
@@ -215,15 +220,15 @@ func TestAggrGroup(t *testing.T) {
// a2 lies way in the past so the initial group_wait should be skipped.
select {
case <-time.After(opts.GroupWait / 2):
require.FailNow(t, "expected immediate alert but received none")
t.Fatalf("expected immediate alert but received none")
case notification := <-alertsCh:
lastNotificationAt = notification.notifiedAt
batch := notification.alerts
case batch := <-alertsCh:
exp := removeEndsAt(alertmanagertypes.AlertSlice{a1, a2})
sort.Sort(batch)
require.Equal(t, exp, batch)
if !reflect.DeepEqual(batch, exp) {
t.Fatalf("expected alerts %v but got %v", exp, batch)
}
}
for i := 0; i < 3; i++ {
@@ -232,16 +237,21 @@ func TestAggrGroup(t *testing.T) {
select {
case <-time.After(2 * opts.GroupInterval):
require.FailNow(t, "expected new batch after group interval but received none")
t.Fatalf("expected new batch after group interval but received none")
case notification := <-alertsCh:
assertNotifiedAfter(lastNotificationAt, notification.notifiedAt, opts.GroupInterval)
lastNotificationAt = notification.notifiedAt
batch := notification.alerts
case batch := <-alertsCh:
lastCurMtx.Lock()
s := time.Since(last)
lastCurMtx.Unlock()
if s < opts.GroupInterval {
t.Fatalf("received batch too early after %v", s)
}
exp := removeEndsAt(alertmanagertypes.AlertSlice{a1, a2, a3})
sort.Sort(batch)
require.Equal(t, exp, batch)
if !reflect.DeepEqual(batch, exp) {
t.Fatalf("expected alerts %v but got %v", exp, batch)
}
}
}
@@ -253,14 +263,19 @@ func TestAggrGroup(t *testing.T) {
select {
case <-time.After(2 * opts.GroupInterval):
require.FailNow(t, "expected new batch after group interval but received none")
case notification := <-alertsCh:
assertNotifiedAfter(lastNotificationAt, notification.notifiedAt, opts.GroupInterval)
lastNotificationAt = notification.notifiedAt
batch := notification.alerts
t.Fatalf("expected new batch after group interval but received none")
case batch := <-alertsCh:
lastCurMtx.Lock()
s := time.Since(last)
lastCurMtx.Unlock()
if s < opts.GroupInterval {
t.Fatalf("received batch too early after %v", s)
}
sort.Sort(batch)
require.Equal(t, exp, batch)
if !reflect.DeepEqual(batch, exp) {
t.Fatalf("expected alerts %v but got %v", exp, batch)
}
}
// Resolve all remaining alerts, they should be removed after the next batch was sent.
@@ -274,16 +289,24 @@ func TestAggrGroup(t *testing.T) {
select {
case <-time.After(2 * opts.GroupInterval):
require.FailNow(t, "expected new batch after group interval but received none")
t.Fatalf("expected new batch after group interval but received none")
case notification := <-alertsCh:
assertNotifiedAfter(lastNotificationAt, notification.notifiedAt, opts.GroupInterval)
batch := notification.alerts
case batch := <-alertsCh:
lastCurMtx.Lock()
s := time.Since(last)
lastCurMtx.Unlock()
if s < opts.GroupInterval {
t.Fatalf("received batch too early after %v", s)
}
sort.Sort(batch)
require.Equal(t, resolved, batch)
if !reflect.DeepEqual(batch, resolved) {
t.Fatalf("expected alerts %v but got %v", resolved, batch)
}
require.Eventually(t, ag.empty, 2*opts.GroupInterval, 10*time.Millisecond, "expected aggregation group to be empty after resolving alerts: %v", ag)
if !ag.empty() {
t.Fatalf("Expected aggregation group to be empty after resolving alerts: %v", ag)
}
}
ag.stop()

View File

@@ -30,7 +30,7 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
Description: "Execute a composite query over a time range. Supports builder queries (traces, logs, metrics), formulas, trace operators, PromQL, and ClickHouse SQL.",
Request: new(qbtypes.QueryRangeRequest),
RequestContentType: "application/json",
RequestExamples: append([]handler.OpenAPIExample{
RequestExamples: []handler.OpenAPIExample{
{
Name: "traces_time_series",
Summary: "Time series: count spans grouped by service",
@@ -598,7 +598,7 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
},
},
},
}, queryRangeFunctionExamples()...),
},
Response: new(qbtypes.QueryRangeResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,

View File

@@ -1,202 +0,0 @@
package signozapiserver
import "github.com/SigNoz/signoz/pkg/http/handler"
// timeSeriesExample wraps builder query specs in a one hour time series
// request, the shape every function example shares.
func timeSeriesExample(name, summary, description string, queries ...map[string]any) handler.OpenAPIExample {
list := make([]any, 0, len(queries))
for _, query := range queries {
list = append(list, query)
}
return handler.OpenAPIExample{
Name: name,
Summary: summary,
Description: description,
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{"queries": list},
},
}
}
func builderQueryEnvelope(spec map[string]any) map[string]any {
return map[string]any{"type": "builder_query", "spec": spec}
}
// function builds a function entry with positional arguments; anomaly is the
// one function that takes named arguments and is written out in full.
func function(name string, args ...any) map[string]any {
list := make([]any, 0, len(args))
for _, arg := range args {
list = append(list, map[string]any{"value": arg})
}
entry := map[string]any{"name": name}
if len(list) > 0 {
entry["args"] = list
}
return entry
}
// queryRangeFunctionExamples shows every function a builder query can carry,
// what its arguments mean and the values they take. Functions run in the
// order listed, after the aggregation and before formulas.
func queryRangeFunctionExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
timeSeriesExample(
"function_timeshift_week_over_week",
"timeShift: this week's error logs next to last week's",
"timeShift moves every point of its query forward by the argument, in seconds, so a series read from the past lands on the current timestamps. B here reads the hour exactly seven days (604800 s) before the requested window and comes back on this week's timestamps, so it overlays A on a chart and the formula can subtract it point by point. The argument is positive; it says how far back to read.",
builderQueryEnvelope(map[string]any{
"name": "A",
"signal": "logs",
"aggregations": []any{map[string]any{"expression": "count()", "alias": "errors"}},
"stepInterval": "60s",
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
}),
builderQueryEnvelope(map[string]any{
"name": "B",
"signal": "logs",
"aggregations": []any{map[string]any{"expression": "count()", "alias": "errors"}},
"stepInterval": "60s",
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
"functions": []any{function("timeShift", 604800)},
}),
map[string]any{
"type": "builder_formula",
"spec": map[string]any{"name": "F1", "expression": "A - B"},
},
),
timeSeriesExample(
"function_fillzero_sparse_errors",
"fillZero: a zero at every step with no failed spans",
"A count over sparse events returns a point only for steps that had a match, so a chart of failed spans has gaps where nothing failed. fillZero adds a zero at every missing step. With no arguments the server fills the request window at the query's step, which is the common case. The three optional arguments are start, end and step in milliseconds, for callers that want to fill a different grid.",
builderQueryEnvelope(map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{map[string]any{"expression": "count()", "alias": "failed_spans"}},
"stepInterval": "60s",
"filter": map[string]any{"expression": "has_error = true"},
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
"functions": []any{function("fillZero")},
}),
),
timeSeriesExample(
"function_cutoffmin_hide_idle_hosts",
"cutOffMin: drop hosts below 5% CPU from the chart",
"The four threshold functions take one number. cutOffMin and cutOffMax remove the points below or above the threshold, so an idle host disappears from the chart instead of drawing a line along the bottom. clampMin and clampMax keep the points and replace the value with the threshold, which caps spikes without hiding them. system.cpu.utilization is a ratio, so 0.05 is 5%.",
builderQueryEnvelope(map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{
map[string]any{"metricName": "system.cpu.utilization", "timeAggregation": "avg", "spaceAggregation": "avg"},
},
"stepInterval": "60s",
"groupBy": []any{map[string]any{"name": "host.name", "fieldContext": "resource"}},
"functions": []any{function("cutOffMin", 0.05)},
}),
),
timeSeriesExample(
"function_runningdiff_disk_growth",
"runningDiff then clampMin: bytes written to disk per step",
"runningDiff turns a gauge into the change between consecutive points, so a filesystem usage gauge becomes bytes written per step. It takes no arguments and drops the first point of the window, since that point has nothing to difference against; the server reads one step before the window to keep the first interval. Functions apply in order: clampMin with 0 runs on the differences and replaces the negative value a cleanup or a volume reset produces with zero.",
builderQueryEnvelope(map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{
map[string]any{"metricName": "system.filesystem.usage", "timeAggregation": "latest", "spaceAggregation": "sum"},
},
"stepInterval": "60s",
"filter": map[string]any{"expression": "state = 'used'"},
"groupBy": []any{map[string]any{"name": "host.name", "fieldContext": "resource"}},
"functions": []any{function("runningDiff"), function("clampMin", 0)},
}),
),
timeSeriesExample(
"function_ewma_smoothed_latency",
"ewma5: smoothed p95 latency",
"ewma3, ewma5 and ewma7 replace each point with an exponentially weighted average of the point and the points before it. The number in the name is the window the default weight is derived from: alpha = 2 / (n + 1), so 0.5, 0.333 and 0.25. One optional argument overrides alpha; a value close to 1 follows the raw series, a value close to 0 smooths harder. 0.3 here smooths a little more than ewma5's default.",
builderQueryEnvelope(map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{
map[string]any{"metricName": "http.server.duration.bucket", "spaceAggregation": "p95"},
},
"stepInterval": "60s",
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
"functions": []any{function("ewma5", 0.3)},
}),
),
timeSeriesExample(
"function_median_despiked_request_rate",
"median5: request rate without one-step spikes",
"median3, median5 and median7 replace each point with the median of the surrounding 3, 5 or 7 points. A spike that lasts one step vanishes, a shift that lasts longer than half the window stays. They take no arguments. Use them when the raw series is noisy and the chart is for a trend, not for catching the spike.",
builderQueryEnvelope(map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{
map[string]any{"metricName": "http.server.duration.count", "timeAggregation": "rate", "spaceAggregation": "sum"},
},
"stepInterval": "60s",
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
"functions": []any{function("median5")},
}),
),
timeSeriesExample(
"function_cumulativesum_errors_so_far",
"cumulativeSum: 5xx responses since the start of the window",
"cumulativeSum replaces each point with the sum of every point up to it, so a per-step count becomes a running total that starts at zero at the window start. It takes no arguments. The total resets with the window, so a longer time range gives a larger final value; it is a view of the window, not a counter.",
builderQueryEnvelope(map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{map[string]any{"expression": "count()", "alias": "server_errors"}},
"stepInterval": "60s",
"filter": map[string]any{"expression": "http.response.status_code >= 500"},
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
"functions": []any{function("cumulativeSum")},
}),
),
timeSeriesExample(
"function_log10_network_bytes",
"log10: network throughput across devices that differ by orders of magnitude",
"log2 and log10 replace each value with its logarithm, so a loopback device moving kilobytes and an uplink moving gigabytes fit on one chart. They take no arguments. Zero and negative values have no logarithm and are not drawn. absolute, also without arguments, is the usual companion for a signed series before a logarithm.",
builderQueryEnvelope(map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{
map[string]any{"metricName": "system.network.io", "timeAggregation": "rate", "spaceAggregation": "sum"},
},
"stepInterval": "60s",
"filter": map[string]any{"expression": "direction = 'transmit'"},
"groupBy": []any{map[string]any{"name": "device", "fieldContext": "attribute"}},
"functions": []any{function("log10")},
}),
),
timeSeriesExample(
"function_anomaly_request_rate",
"anomaly: request rate scored against its daily pattern",
"anomaly is the one function with named arguments. seasonality says which past periods predict the current value: hourly, daily or weekly, daily when absent. z_score_threshold is how many standard deviations from the prediction count as an anomaly, 3 when absent. The response carries, next to the values, a predicted series, an upper and a lower bound series and an anomaly score series. The scoring runs in the enterprise build; the community build returns the series unchanged.",
builderQueryEnvelope(map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{
map[string]any{"metricName": "http.server.duration.count", "timeAggregation": "rate", "spaceAggregation": "sum"},
},
"stepInterval": "300s",
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
"functions": []any{
map[string]any{
"name": "anomaly",
"args": []any{
map[string]any{"name": "seasonality", "value": "daily"},
map[string]any{"name": "z_score_threshold", "value": 3},
},
},
},
}),
),
}
}

View File

@@ -1,21 +0,0 @@
package signozapiserver
import (
"encoding/json"
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/require"
)
func TestQueryRangeFunctionExamplesAreValidRequests(t *testing.T) {
for _, ex := range queryRangeFunctionExamples() {
t.Run(ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var req qbtypes.QueryRangeRequest
require.NoError(t, json.Unmarshal(raw, &req))
require.NoError(t, req.Validate())
})
}
}

View File

@@ -9,12 +9,13 @@ import (
// NewValidReferences / NewSuggestionsOnLevenshteinDistance to phrase the
// "valid <noun> are ..." list consistently across the codebase.
const (
NounFields = "fields"
NounKeys = "keys"
NounServices = "services"
NounQueryTypes = "query types"
NounSignals = "signals"
NounReferences = "references"
NounFields = "fields"
NounKeys = "keys"
NounServices = "services"
NounQueryTypes = "query types"
NounRequestTypes = "request types"
NounSignals = "signals"
NounReferences = "references"
)
const (

View File

@@ -118,6 +118,10 @@ func (f QueryBuilderFormula) Validate() error {
)
}
if f.Limit < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be non-negative, got %d", f.Limit)
}
// Validate functions if present
for i, fn := range f.Functions {
if err := fn.Validate(); err != nil {

View File

@@ -23,7 +23,8 @@ func (r *RequestType) UnmarshalJSON(data []byte) error {
*r = v
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q; allowed values: %s", s, "`scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `distribution`, `heatmap`")
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q", s).
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
}
}

View File

@@ -499,6 +499,18 @@ func (q *QueryBuilderQuery[T]) validateOrderBy(cfg validationConfig) error {
return q.validateOrderByForAggregation()
}
// a raw order key resolves like a select field, so only an empty one is
// invalid before resolution
for i, order := range q.Order {
if order.Key.Name == "" {
orderID := fmt.Sprintf("order by clause #%d", i+1)
if q.Name != "" {
orderID = fmt.Sprintf("order by clause #%d in query '%s'", i+1, q.Name)
}
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid empty key name for %s", orderID)
}
}
return nil
}
@@ -586,19 +598,10 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
return err
}
// Validate request type
switch r.RequestType {
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap, RequestTypeDistribution:
opts = append(opts, GetValidationOptions(r.RequestType)...)
default:
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid request type: %s",
r.RequestType,
).WithAdditional(
"Valid request types are: raw, timeseries, scalar, heatmap, distribution",
)
if err := r.validateRequestType(); err != nil {
return err
}
opts = append(opts, GetValidationOptions(r.RequestType)...)
if err := r.validateBucketCounting(); err != nil {
return err
@@ -639,14 +642,10 @@ func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
return nil, err
}
var opts []ValidationOption
switch r.RequestType {
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap, RequestTypeDistribution:
opts = GetValidationOptions(r.RequestType)
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid request type: %s", r.RequestType).
WithAdditional("Valid request types are: raw, timeseries, scalar, heatmap, distribution")
if err := r.validateRequestType(); err != nil {
return nil, err
}
opts := GetValidationOptions(r.RequestType)
if err := r.validateBucketCounting(); err != nil {
return nil, err
@@ -703,6 +702,20 @@ func (e QueryEnvelope) Validate(opts ...ValidationOption) error {
return validateQueryEnvelope(e, opts...)
}
// Enum is the one list of request types a query can use, so the spec and the
// suggestions agree with what is accepted.
func (r *QueryRangeRequest) validateRequestType() error {
if slices.Contains(RequestType{}.Enum(), any(r.RequestType)) {
return nil
}
if r.RequestType == RequestTypeUnknown {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "requestType is required").
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
}
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request type %q is not supported", r.RequestType.StringValue()).
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
}
func (r *QueryRangeRequest) validateTimeRange() error {
if r.RequestType == RequestTypeRawStream {
return nil

View File

@@ -1,9 +1,15 @@
package querybuildertypesv5
import (
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -521,6 +527,28 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
wantErr: true,
errMsg: "expression cannot be blank",
},
{
name: "Formula_NegativeLimit_Rejected",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeFormula,
Spec: QueryBuilderFormula{
Name: "F1",
Expression: "A * 2",
Limit: -5,
},
},
},
},
},
wantErr: true,
errMsg: "invalid formula 'F1': limit must be non-negative, got -5",
},
{
name: "promql with empty query should return error",
request: QueryRangeRequest{
@@ -696,6 +724,31 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
wantErr: true,
errMsg: "raw request type is not supported for metric queries",
},
{
name: "Raw_EmptyOrderKey_Rejected",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeRaw,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[LogAggregation]{
Name: "B",
Signal: telemetrytypes.SignalLogs,
Order: []OrderBy{
{Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "timestamp"}}, Direction: OrderDirectionDesc},
{Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: ""}}, Direction: OrderDirectionAsc},
},
},
},
},
},
},
wantErr: true,
errMsg: "invalid query 'B': invalid empty key name for order by clause #2 in query 'B'",
},
{
name: "timeseries request with group by timestamp should return error",
request: QueryRangeRequest{
@@ -1407,6 +1460,44 @@ func TestRequestType_IsAggregation(t *testing.T) {
}
}
func TestQueryRangeRequest_ValidateRequestType(t *testing.T) {
validRequestTypes := []string{"valid request types are `scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `heatmap`, `distribution`"}
testCases := []struct {
name string
requestType string
message string
}{
{
name: "Missing_Required",
requestType: "",
message: "requestType is required",
},
{
name: "Timeseries_Unknown",
requestType: `"requestType": "timeseries",`,
message: `unknown request type "timeseries"`,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
body := fmt.Sprintf(`{"schemaVersion": "v1", "start": 1640995200000, "end": 1640998800000, %s "compositeQuery": {"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs"}}]}}`, testCase.requestType)
var request QueryRangeRequest
err := json.Unmarshal([]byte(body), &request)
if err == nil {
err = request.Validate()
}
require.Error(t, err)
got := errors.AsJSON(err)
assert.Equal(t, testCase.message, got.Message)
assert.Equal(t, validRequestTypes, got.Suggestions)
})
}
}
func TestNonAggregationFieldsSkipped(t *testing.T) {
// Fields that only apply to aggregation queries (groupBy, having, aggregations)
// should be silently skipped for non-aggregation request types.

View File

@@ -1,481 +0,0 @@
/**
* 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';

View File

@@ -1,78 +0,0 @@
/**
* 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);
}
}

View File

@@ -1,40 +0,0 @@
/**
* 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 ?? '',
);
}

View File

@@ -1,121 +0,0 @@
/**
* 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

@@ -1,69 +0,0 @@
/**
* 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

@@ -1,176 +0,0 @@
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

@@ -1,79 +0,0 @@
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

@@ -1,169 +0,0 @@
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);
});
});