mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-12 11:20:42 +00:00
Compare commits
16 Commits
srikanth/c
...
feat/distr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1b97a1404 | ||
|
|
1b37430401 | ||
|
|
0d69faf6ae | ||
|
|
4f802cd077 | ||
|
|
5a491bd258 | ||
|
|
51b6570221 | ||
|
|
fd6dca4658 | ||
|
|
d8d64c5c1f | ||
|
|
02ed7d6a9c | ||
|
|
f5b1d71675 | ||
|
|
eb92d8dee0 | ||
|
|
083500731e | ||
|
|
6cc664d954 | ||
|
|
dfb906fe0a | ||
|
|
4b6f040e8a | ||
|
|
87bcd1b702 |
@@ -4526,6 +4526,99 @@ components:
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesDistributionAxes:
|
||||
properties:
|
||||
x:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionXAxis'
|
||||
description: The bucket axis.
|
||||
"y":
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionYAxis'
|
||||
description: The count axis.
|
||||
type: object
|
||||
DashboardtypesDistributionChartAppearance:
|
||||
properties:
|
||||
percentiles:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionPercentiles'
|
||||
description: Percentile markers drawn across the bucket axis.
|
||||
stack:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesStackMode'
|
||||
description: 'How groups combine in a bucket: normal (default) stacks them,
|
||||
none draws them side by side, percent stacks them as shares of the bucket.'
|
||||
required:
|
||||
- stack
|
||||
type: object
|
||||
DashboardtypesDistributionPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionAxes'
|
||||
description: The X (bucket) axis range and Y (count) axis scale.
|
||||
chartAppearance:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionChartAppearance'
|
||||
description: Stacking and percentile markers.
|
||||
formatting:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelFormatting'
|
||||
description: Unit and decimal precision of the bucket bounds; counts take
|
||||
no unit.
|
||||
legend:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesLegend'
|
||||
description: Legend placement and series colors.
|
||||
visualization:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
description: The time range the bucket counts are summed over.
|
||||
type: object
|
||||
DashboardtypesDistributionPercentiles:
|
||||
properties:
|
||||
enabled:
|
||||
description: Whether percentile markers are drawn. Defaults to true.
|
||||
type: boolean
|
||||
values:
|
||||
description: The percentiles to mark, each in (0, 100), at most 5, no duplicates.
|
||||
Defaults to [50, 90, 99].
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- enabled
|
||||
- values
|
||||
type: object
|
||||
DashboardtypesDistributionXAxis:
|
||||
properties:
|
||||
max:
|
||||
description: Buckets wholly above this value are hidden. Null shows them
|
||||
all.
|
||||
nullable: true
|
||||
type: number
|
||||
min:
|
||||
description: Buckets wholly below this value are hidden. Null shows them
|
||||
all.
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesDistributionYAxis:
|
||||
properties:
|
||||
scale:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionYScale'
|
||||
description: 'How counts are spaced: linear (default) or log.'
|
||||
required:
|
||||
- scale
|
||||
type: object
|
||||
DashboardtypesDistributionYScale:
|
||||
enum:
|
||||
- linear
|
||||
- log
|
||||
type: string
|
||||
DashboardtypesDynamicVariableSignal:
|
||||
enum:
|
||||
- traces
|
||||
@@ -5296,6 +5389,7 @@ components:
|
||||
mapping:
|
||||
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
|
||||
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
signoz/DistributionPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec'
|
||||
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
@@ -5318,6 +5412,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -5332,6 +5427,7 @@ components:
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
- signoz/DistributionPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -5363,6 +5459,21 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
description: The panel kind.
|
||||
enum:
|
||||
- signoz/DistributionPanel
|
||||
type: string
|
||||
spec:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionPanelSpec'
|
||||
description: The options for this panel kind. {} accepts every default.
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -5516,9 +5627,9 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPlugin'
|
||||
description: 'The visualization: kind is one of signoz/TimeSeriesPanel,
|
||||
signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel,
|
||||
signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel
|
||||
or signoz/HeatmapPanel, and spec holds that kind''s options ({} accepts
|
||||
every default).'
|
||||
signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel,
|
||||
signoz/HeatmapPanel or signoz/DistributionPanel, and spec holds that kind''s
|
||||
options ({} accepts every default).'
|
||||
queries:
|
||||
description: The queries the panel runs. Use exactly one entry for a data
|
||||
panel and [] for signoz/TextPanel. To run several named queries or formulas,
|
||||
@@ -5654,8 +5765,8 @@ components:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5RequestType'
|
||||
description: 'The result shape the panel requests: time_series for time
|
||||
series, bar, area and histogram panels, scalar for number, table and pie
|
||||
panels, raw for list panels, heatmap for heatmap panels, trace for trace
|
||||
panels.'
|
||||
panels, raw for list panels, heatmap for heatmap panels, distribution
|
||||
for distribution panels, trace for trace panels.'
|
||||
spec:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesQuerySpec'
|
||||
@@ -5812,9 +5923,9 @@ components:
|
||||
(builder query over AI observability traces), signoz/CompositeQuery (several
|
||||
named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL
|
||||
or signoz/TraceOperator, and spec holds that kind''s query. Pie and table
|
||||
panels reject signoz/PromQLQuery; heatmap panels reject signoz/AIBuilderQuery
|
||||
and signoz/TraceOperator; list panels accept only signoz/BuilderQuery
|
||||
and signoz/AIBuilderQuery.'
|
||||
panels reject signoz/PromQLQuery; heatmap and distribution panels reject
|
||||
signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only
|
||||
signoz/BuilderQuery and signoz/AIBuilderQuery.'
|
||||
required:
|
||||
- plugin
|
||||
type: object
|
||||
|
||||
@@ -11,7 +11,7 @@ const config: Config.InitialOptions = {
|
||||
coverageReporters: ['text', 'cobertura', 'html', 'json-summary'],
|
||||
collectCoverageFrom: ['src/**/*.{ts,tsx}'],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
|
||||
modulePathIgnorePatterns: ['dist'],
|
||||
modulePathIgnorePatterns: ['<rootDir>/dist/'],
|
||||
moduleNameMapper: {
|
||||
'\\.(png|jpg|jpeg|gif|svg|webp|avif|ico|bmp|tiff)$':
|
||||
'<rootDir>/__mocks__/fileMock.ts',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import { tags } from '@lezer/highlight';
|
||||
import { copilotDarkStyle } from '@uiw/codemirror-theme-copilot';
|
||||
import { githubLightStyle } from '@uiw/codemirror-theme-github';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { act, render, screen } from 'tests/test-utils';
|
||||
|
||||
import SavedViewHighlightedExpression from '../SavedViewHighlightedExpression';
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseIsDarkMode = useIsDarkMode as jest.MockedFunction<
|
||||
typeof useIsDarkMode
|
||||
>;
|
||||
|
||||
const EXPRESSION = "service.name = 'frontend'";
|
||||
|
||||
function stringColor(styles: typeof copilotDarkStyle): string {
|
||||
return (styles ?? []).find(({ tag }) =>
|
||||
(Array.isArray(tag) ? tag : [tag]).includes(tags.string),
|
||||
)?.color as string;
|
||||
}
|
||||
|
||||
describe('SavedViewHighlightedExpression', () => {
|
||||
it.each([
|
||||
['dark', true, copilotDarkStyle],
|
||||
['light', false, githubLightStyle],
|
||||
])('colours a string value with the %s theme', (_, isDarkMode, styles) => {
|
||||
mockUseIsDarkMode.mockReturnValue(isDarkMode);
|
||||
|
||||
render(<SavedViewHighlightedExpression expression={EXPRESSION} />);
|
||||
|
||||
expect(screen.getByText("'frontend'")).toHaveStyle({
|
||||
color: stringColor(styles),
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the new theme colours when the theme changes', () => {
|
||||
let setIsDarkMode: (isDarkMode: boolean) => void = () => {};
|
||||
function useSwitchableDarkMode(): boolean {
|
||||
const [isDarkMode, setter] = useState(true);
|
||||
setIsDarkMode = setter;
|
||||
return isDarkMode;
|
||||
}
|
||||
mockUseIsDarkMode.mockImplementation(useSwitchableDarkMode);
|
||||
render(<SavedViewHighlightedExpression expression={EXPRESSION} />);
|
||||
|
||||
act(() => setIsDarkMode(false));
|
||||
|
||||
expect(screen.getByText("'frontend'")).toHaveStyle({
|
||||
color: stringColor(githubLightStyle),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,19 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AuthtypesTransactionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { userEvent } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -222,6 +230,31 @@ describe('SavedViewHoverCard', () => {
|
||||
|
||||
expect(card()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not open when the row menu is pressed before the delay', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
await renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS / 2);
|
||||
await user.click(within(row('Errors')).getByTestId('saved-views-row-menu'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
|
||||
expect(card()).toBeNull();
|
||||
});
|
||||
|
||||
it('closes on escape, leaving nothing of the popover behind', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
await renderPanel();
|
||||
await openCard('Errors');
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
expect(card()).toBeNull();
|
||||
expect(
|
||||
document.querySelector('[data-radix-popper-content-wrapper]'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('content', () => {
|
||||
@@ -312,6 +345,78 @@ describe('SavedViewHoverCard', () => {
|
||||
await waitFor(() => expect(fetches).toBe(2));
|
||||
});
|
||||
|
||||
it('shows a skeleton, not the failure, while the view loads', async () => {
|
||||
let requested = false;
|
||||
let release: () => void = () => undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, async (_req, res, ctx) => {
|
||||
requested = true;
|
||||
await gate;
|
||||
return res(ctx.json({ status: 'success', data: errors }));
|
||||
}),
|
||||
);
|
||||
await renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
await waitFor(() => expect(requested).toBe(true));
|
||||
|
||||
expect(
|
||||
screen.getByTestId('saved-view-hover-card-loading'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-view-hover-card-error')).toBeNull();
|
||||
|
||||
release();
|
||||
await screen.findByTestId('saved-view-hover-card-query');
|
||||
});
|
||||
|
||||
it('shows a skeleton while the read check is pending', async () => {
|
||||
let release: () => void = () => undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const isRead = (transaction: AuthtypesTransactionDTO): boolean =>
|
||||
transaction.relation === 'read';
|
||||
const isReadAll = (transaction: AuthtypesTransactionDTO): boolean =>
|
||||
isRead(transaction) && transaction.object.selector === '*';
|
||||
// Read on every view would skip the per-view check, so it is denied.
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
if (
|
||||
payload.some(
|
||||
(transaction) => isRead(transaction) && !isReadAll(transaction),
|
||||
)
|
||||
) {
|
||||
await gate;
|
||||
}
|
||||
return res(
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map((transaction) => !isReadAll(transaction)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
await renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('saved-view-hover-card-loading'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-view-hover-card-query')).toBeNull();
|
||||
|
||||
release();
|
||||
await screen.findByTestId('saved-view-hover-card-query');
|
||||
});
|
||||
|
||||
it('says the view failed to load when its fetch fails', async () => {
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) =>
|
||||
|
||||
@@ -339,6 +339,45 @@ describe('SavedViews - AuthZ', () => {
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
|
||||
it('disables save as new without create, keeping update', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(SavedViewCreatePermission));
|
||||
const { history } = renderHeader(viewUrl(PATH, errors));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('Errors'),
|
||||
);
|
||||
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
);
|
||||
await userEvent.click(await screen.findByTestId('saved-views-save-changes'));
|
||||
|
||||
await expect(
|
||||
settledRow('saved-views-save-changes-item-save-as-new'),
|
||||
).resolves.toHaveAttribute('data-disabled');
|
||||
await expect(
|
||||
settledRow('saved-views-save-changes-item-update'),
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
|
||||
it('offers clear on a view it cannot read', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
'No view access',
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says no access when the check denies read, even if the server returns the view', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { userEvent } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
@@ -75,4 +76,39 @@ describe('SavedViewsHeader last used view', () => {
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not bring the view back once it is cleared', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
await waitForView('Errors');
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-clear'));
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
|
||||
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
|
||||
});
|
||||
|
||||
it('forgets a last used view that no longer loads', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) ?? '{}',
|
||||
),
|
||||
).toStrictEqual({}),
|
||||
);
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
});
|
||||
|
||||
it('starts on a bare explorer when the stored last used views are unreadable', async () => {
|
||||
localStorage.setItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS, '{not json');
|
||||
mockSavedViewsApi([errors]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { act, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
@@ -19,7 +23,7 @@ import {
|
||||
urlParam,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import { API, mockSavedViewsApi } from './savedViewsApiMock';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
@@ -43,6 +47,27 @@ function renderHeader(
|
||||
);
|
||||
}
|
||||
|
||||
// Edits the current query without running it, so it is not the staged one.
|
||||
function EditCurrentQuery(): JSX.Element {
|
||||
const { currentQuery, handleSetQueryData } = useQueryBuilder();
|
||||
return (
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
testId="edit-current-query"
|
||||
onClick={(): void =>
|
||||
handleSetQueryData(0, {
|
||||
...currentQuery.builder.queryData[0],
|
||||
filter: { expression: 'unstaged = true' },
|
||||
})
|
||||
}
|
||||
>
|
||||
Edit query
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const modal = (): HTMLElement => screen.getByTestId('save-view-modal');
|
||||
const isModalOpen = (): boolean => !!screen.queryByTestId('save-view-modal');
|
||||
|
||||
@@ -160,6 +185,76 @@ describe('SavedViewsHeader saving', () => {
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not create a view from a blank name submitted with Enter', async () => {
|
||||
const requests = mockSavedViewsApi([]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await clickCreate();
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), ' {enter}');
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
|
||||
|
||||
expect(requests.created).toHaveLength(0);
|
||||
expect(isModalOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores Enter while the view is being saved', async () => {
|
||||
let creates = 0;
|
||||
mockSavedViewsApi([]);
|
||||
server.use(
|
||||
rest.post(API, (_req, res, ctx) => {
|
||||
creates += 1;
|
||||
return res(
|
||||
ctx.delay(400),
|
||||
ctx.status(201),
|
||||
ctx.json({ status: 'success', data: { id: 'view-new' } }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await clickCreate();
|
||||
await submitName('Slow checkout');
|
||||
await waitFor(() => expect(creates).toBe(1));
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), '{enter}');
|
||||
|
||||
await waitFor(() => expect(isModalOpen()).toBe(false));
|
||||
expect(creates).toBe(1);
|
||||
});
|
||||
|
||||
it('saves the query that was run, not edits made since', async () => {
|
||||
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
const { history } = renderWithExplorerProviders(
|
||||
<>
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
/>
|
||||
<EditCurrentQuery />
|
||||
</>,
|
||||
explorerUrl(PATH, {}),
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('edit-current-query'));
|
||||
await clickCreate();
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() => expect(requests.created).toHaveLength(1));
|
||||
const body = requests.created[0] as {
|
||||
spec: { queries: { spec: { filter?: { expression?: string } } }[] };
|
||||
};
|
||||
expect(body.spec.queries[0].spec.filter?.expression ?? '').not.toContain(
|
||||
'unstaged',
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify('view-new'),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
decodeURIComponent(urlParam(history, QueryParams.compositeQuery) ?? ''),
|
||||
).not.toContain('unstaged');
|
||||
});
|
||||
|
||||
it('cancel closes the modal without saving', async () => {
|
||||
const requests = mockSavedViewsApi([]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
@@ -231,6 +326,37 @@ describe('SavedViewsHeader saving', () => {
|
||||
expect(requests.created).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('update that fails reports the error and never says the view was updated', async () => {
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const requests = mockSavedViewsApi([errors], { updateStatus: 500 });
|
||||
await openDirtyView(errors);
|
||||
|
||||
await chooseFromSaveMenu('Update selected view');
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
expect(requests.updated).toHaveLength(1);
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update keeps the color of the open view', async () => {
|
||||
const base = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const colored = {
|
||||
...base,
|
||||
spec: { ...base.spec, display: { color: '#e5484d' } },
|
||||
};
|
||||
const requests = mockSavedViewsApi([colored]);
|
||||
await openDirtyView(colored);
|
||||
|
||||
await chooseFromSaveMenu('Update selected view');
|
||||
|
||||
await waitFor(() => expect(requests.updated).toHaveLength(1));
|
||||
const { body } = requests.updated[0] as {
|
||||
body: { spec: { display?: { color?: string } } };
|
||||
};
|
||||
expect(body.spec.display?.color).toBe('#e5484d');
|
||||
});
|
||||
|
||||
describe('view name toggle with unsaved changes', () => {
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
@@ -219,6 +221,75 @@ describe('SavedViewsPanel list', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('lists only the views of its own source', async () => {
|
||||
mockSavedViewsApi([
|
||||
mine,
|
||||
makeView({
|
||||
id: 'view-9',
|
||||
displayName: 'Noisy logs',
|
||||
source: SavedviewtypesSourceDTO.logs,
|
||||
}),
|
||||
]);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByTestId('saved-views-list');
|
||||
expect(rowNames('saved-views-created-by-me')).toStrictEqual(['Errors']);
|
||||
});
|
||||
|
||||
describe('last used view', () => {
|
||||
const lastUsedViews = (): unknown =>
|
||||
JSON.parse(localStorage.getItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) ?? '{}');
|
||||
const logsEntry = { logs: { key: 'logs-view', value: 'Noisy logs' } };
|
||||
|
||||
it('is remembered under the panel source, keeping the other sources', async () => {
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
|
||||
JSON.stringify(logsEntry),
|
||||
);
|
||||
mockSavedViewsApi([mine, alsoMine]);
|
||||
renderPanel();
|
||||
await screen.findByTestId('saved-views-list');
|
||||
await waitFor(() => expectUsable(rowName('Slow spans')));
|
||||
|
||||
await userEvent.click(rowName('Slow spans'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(lastUsedViews()).toStrictEqual({
|
||||
...logsEntry,
|
||||
traces: { key: alsoMine.id, value: 'Slow spans' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('is filed under the source of the panel it was opened from', async () => {
|
||||
const logsView = makeView({
|
||||
id: 'view-9',
|
||||
displayName: 'Noisy logs',
|
||||
source: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
});
|
||||
mockSavedViewsApi([logsView]);
|
||||
renderWithExplorerProviders(
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.logs}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
explorerUrl(ROUTES.LOGS_EXPLORER, {}),
|
||||
DataSource.LOGS,
|
||||
);
|
||||
await screen.findByTestId('saved-views-list');
|
||||
await waitFor(() => expectUsable(rowName('Noisy logs')));
|
||||
|
||||
await userEvent.click(rowName('Noisy logs'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(lastUsedViews()).toStrictEqual({
|
||||
logs: { key: logsView.id, value: 'Noisy logs' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('offers to create a view when there are none, and opens it', async () => {
|
||||
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
const { history } = renderPanel();
|
||||
|
||||
@@ -4,11 +4,13 @@ import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import { API, mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectRowUsable,
|
||||
explorerUrl,
|
||||
@@ -181,6 +183,87 @@ describe('SavedViewsPanel row menu', () => {
|
||||
await waitFor(() => expect(row('Slow checkout spans')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renames from the stored view, not a stale list row', async () => {
|
||||
const views = [errors, slow];
|
||||
const requests = mockSavedViewsApi(views);
|
||||
renderPanelWithHeader();
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const stored = makeView({
|
||||
id: slow.id,
|
||||
displayName: 'Slow spans',
|
||||
expression: 'duration_nano > 5000',
|
||||
});
|
||||
views[1] = stored;
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Edit details');
|
||||
const name = screen.getByTestId('save-view-name');
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, 'Slow checkout spans');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
await waitFor(() => expect(requests.updated).toHaveLength(1));
|
||||
expect(requests.updated[0].body).toMatchObject({
|
||||
spec: { ...stored.spec, displayName: 'Slow checkout spans' },
|
||||
});
|
||||
});
|
||||
|
||||
it('renames from the list row when the view cannot be read', async () => {
|
||||
const requests = mockSavedViewsApi([errors, slow]);
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(403), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Edit details');
|
||||
const name = screen.getByTestId('save-view-name');
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, 'Slow checkout spans');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
await waitFor(() => expect(requests.updated).toHaveLength(1));
|
||||
expect(requests.updated[0].body).toMatchObject({
|
||||
spec: { ...slow.spec, displayName: 'Slow checkout spans' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not rename when the stored view fails to load', async () => {
|
||||
const requests = mockSavedViewsApi([errors, slow]);
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Edit details');
|
||||
const name = screen.getByTestId('save-view-name');
|
||||
await userEvent.type(name, ' 2');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(requests.updated).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores Enter while the stored view loads', async () => {
|
||||
const requests = mockSavedViewsApi([errors, slow], { getDelay: 300 });
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Edit details');
|
||||
const name = screen.getByTestId('save-view-name');
|
||||
await userEvent.type(name, ' 2{enter}');
|
||||
await userEvent.type(name, '{enter}');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
'View updated',
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
expect(requests.updated).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows the new name in the header when the open view is renamed', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderPanelWithHeader(viewUrl(PATH, errors));
|
||||
@@ -236,4 +319,19 @@ describe('SavedViewsPanel row menu', () => {
|
||||
);
|
||||
await waitFor(() => expect(chip()).toHaveTextContent('My view'));
|
||||
});
|
||||
|
||||
it('keeps the open view when another view is deleted', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
const { history } = renderPanelWithHeader(viewUrl(PATH, errors));
|
||||
await waitFor(() => expect(chip()).toHaveTextContent('Errors'));
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Delete');
|
||||
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
|
||||
|
||||
await waitFor(() => expect(row('Slow spans')).toBeUndefined());
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify(errors.id),
|
||||
);
|
||||
expect(chip()).toHaveTextContent('Errors');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import store from 'store';
|
||||
import { act, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectRowUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
renderWithExplorerProviders,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const mockCopy: jest.MockedFunction<(text: string) => void> = jest.fn();
|
||||
|
||||
// copy-to-clipboard falls back to window.prompt in jsdom.
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: (): [unknown, typeof mockCopy] => [null, mockCopy],
|
||||
}));
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
const slow = makeView({ id: 'view-2', displayName: 'Slow spans' });
|
||||
|
||||
// A store whose global time can change while the panel stays mounted.
|
||||
function renderPanelWithTime(selectedTime: GlobalReducer['selectedTime']): {
|
||||
setTime: (time: Partial<GlobalReducer>) => void;
|
||||
} {
|
||||
let globalTime: GlobalReducer = {
|
||||
...store.getState().globalTime,
|
||||
selectedTime,
|
||||
};
|
||||
const timeStore = configureStore([thunk])(() => ({
|
||||
...store.getState(),
|
||||
globalTime,
|
||||
}));
|
||||
|
||||
renderWithExplorerProviders(
|
||||
<Provider store={timeStore}>
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
/>
|
||||
</Provider>,
|
||||
explorerUrl(PATH, {}),
|
||||
);
|
||||
|
||||
return {
|
||||
setTime: (time): void => {
|
||||
globalTime = { ...globalTime, ...time };
|
||||
act(() => {
|
||||
timeStore.dispatch({ type: 'TEST_TIME_CHANGED' });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const row = (name: string): HTMLElement =>
|
||||
screen
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((element) => element.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
const relativeTimeOf = (link: string): string | null =>
|
||||
new URL(link, window.location.origin).searchParams.get(
|
||||
QueryParams.relativeTime,
|
||||
);
|
||||
|
||||
describe('SavedViewsPanel time range', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockCopy.mockClear();
|
||||
mockSavedViewsApi([slow]);
|
||||
});
|
||||
|
||||
it('moves the row link to the new time range when it changes', async () => {
|
||||
const { setTime } = renderPanelWithTime('1h');
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const name = within(row('Slow spans')).getByTestId('saved-views-row-name');
|
||||
expect(relativeTimeOf(name.getAttribute('href') as string)).toBe('1h');
|
||||
|
||||
setTime({ selectedTime: '6h' });
|
||||
|
||||
expect(relativeTimeOf(name.getAttribute('href') as string)).toBe('6h');
|
||||
});
|
||||
|
||||
it('copies a link in the time range that is selected when it is copied', async () => {
|
||||
const { setTime } = renderPanelWithTime('1h');
|
||||
await screen.findByTestId('saved-views-list');
|
||||
setTime({ selectedTime: '6h' });
|
||||
|
||||
await userEvent.click(
|
||||
within(row('Slow spans')).getByTestId('saved-views-row-menu'),
|
||||
);
|
||||
const copyLink = await screen.findByTestId(
|
||||
'saved-views-row-menu-item-copyLink',
|
||||
);
|
||||
await waitFor(() => expectRowUsable(copyLink), MENU_CHECK_TIMEOUT);
|
||||
await userEvent.click(copyLink);
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledTimes(1);
|
||||
expect(relativeTimeOf(mockCopy.mock.calls[0][0])).toBe('6h');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
getGetSavedViewQueryOptions,
|
||||
updateSavedView,
|
||||
useDeleteSavedView,
|
||||
useUpdateSavedView,
|
||||
} from 'api/generated/services/saved-view';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
@@ -45,8 +46,39 @@ export function useSavedViewRowActions(
|
||||
const timeSearch = useTimeSearchParams();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const logSavedViewEvent = useSavedViewLogEvent(source);
|
||||
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
|
||||
useUpdateSavedView();
|
||||
// The update replaces the whole spec, so it starts from the stored view, not
|
||||
// the list row, which can be stale. Without read on the view, the list row is
|
||||
// the only copy there is.
|
||||
const { mutateAsync: renameSavedView, isLoading: isRenaming } = useMutation(
|
||||
async ({
|
||||
view,
|
||||
displayName,
|
||||
}: {
|
||||
view: SavedviewtypesSavedViewDTO;
|
||||
displayName: string;
|
||||
}) => {
|
||||
let { spec } = view;
|
||||
try {
|
||||
({
|
||||
data: { spec },
|
||||
} = await queryClient.fetchQuery(
|
||||
getGetSavedViewQueryOptions({ id: view.id }),
|
||||
));
|
||||
} catch (error) {
|
||||
if ((error as ErrorType<unknown>).response?.status !== 403) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await updateSavedView(
|
||||
{ id: view.id },
|
||||
{
|
||||
source,
|
||||
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
|
||||
spec: { ...spec, displayName },
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
const { mutateAsync: deleteSavedView, isLoading: isDeleting } =
|
||||
useDeleteSavedView();
|
||||
|
||||
@@ -77,14 +109,7 @@ export function useSavedViewRowActions(
|
||||
displayName: string,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await updateSavedView({
|
||||
pathParams: { id: view.id },
|
||||
data: {
|
||||
source,
|
||||
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
|
||||
spec: { ...view.spec, displayName },
|
||||
},
|
||||
});
|
||||
await renameSavedView({ view, displayName });
|
||||
await invalidateSavedViews(queryClient, source, view.id);
|
||||
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
logSavedViewEvent(SavedViewEvents.ViewDetailsEdited, {
|
||||
@@ -96,7 +121,7 @@ export function useSavedViewRowActions(
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[updateSavedView, source, queryClient, logSavedViewEvent],
|
||||
[renameSavedView, source, queryClient, logSavedViewEvent],
|
||||
);
|
||||
|
||||
// The deleted view's own query is left alone: refetching it would fail
|
||||
|
||||
@@ -127,6 +127,48 @@ describe('hasUnsavedViewChanges', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is clean while no query is staged', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery: null,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is dirty when the columns are reordered', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery: staged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options: { ...options, selectColumns: [columns[1], columns[0]] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is dirty when a column is swapped for another', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery: staged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options: {
|
||||
...options,
|
||||
selectColumns: [
|
||||
columns[0],
|
||||
{ name: 'duration_nano', key: 'duration_nano' } as TelemetryFieldKey,
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a traces view without columns as the default columns', () => {
|
||||
const bare = viewFrom(
|
||||
toSavedViewSpec({
|
||||
@@ -184,6 +226,21 @@ describe('hasUnsavedViewChanges', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['format', { format: 'table' as LogViewMode }],
|
||||
['font size', { fontSize: FontSize.LARGE }],
|
||||
])('is dirty when only the %s changes', (_, change) => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: logsView,
|
||||
stagedQuery: logsStaged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
options: { selectColumns: shownColumns, ...formatting, ...change },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a view without formatting as the default formatting', () => {
|
||||
const bare = viewFrom(
|
||||
toSavedViewSpec({
|
||||
@@ -295,6 +352,137 @@ describe('hasUnsavedViewChanges', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['point', 'attribute'],
|
||||
['spanfield', 'span'],
|
||||
['logfield', 'log'],
|
||||
['tracefield', 'trace'],
|
||||
])(
|
||||
'reads a group-by staged as %s and stored as %s as the same field',
|
||||
(legacyContext, storedContext) => {
|
||||
const base = viewFrom(
|
||||
toSavedViewSpec({
|
||||
query: query(DataSource.METRICS, ''),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
displayName: 'By os',
|
||||
}),
|
||||
);
|
||||
const [storedQuery] = base.spec.queries;
|
||||
const storedView = {
|
||||
...base,
|
||||
spec: {
|
||||
...base.spec,
|
||||
queries: [
|
||||
{
|
||||
...storedQuery,
|
||||
spec: {
|
||||
...storedQuery.spec,
|
||||
groupBy: [
|
||||
{
|
||||
name: 'os.type',
|
||||
fieldContext: storedContext,
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as SavedviewtypesSavedViewDTO;
|
||||
const opened = stagedFrom(storedView, DataSource.METRICS);
|
||||
const stagedLegacy = {
|
||||
...opened,
|
||||
builder: {
|
||||
...opened.builder,
|
||||
queryData: opened.builder.queryData.map((queryData) => ({
|
||||
...queryData,
|
||||
groupBy: [{ key: 'os.type', dataType: 'string', type: legacyContext }],
|
||||
})),
|
||||
},
|
||||
} as Query;
|
||||
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: storedView,
|
||||
stagedQuery: stagedLegacy,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
sourcePage: SavedviewtypesSourceDTO.metrics,
|
||||
}),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
// The query still staged after a save keeps the builder's values, while
|
||||
// the server returns the view without them.
|
||||
describe('right after saving a query that carries builder defaults', () => {
|
||||
const withPatch = (
|
||||
dataSource: DataSource,
|
||||
patch: Partial<Query['builder']['queryData'][number]>,
|
||||
): Query => {
|
||||
const base = query(dataSource, '');
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: base.builder.queryData.map((queryData) => ({
|
||||
...queryData,
|
||||
...patch,
|
||||
})),
|
||||
},
|
||||
} as Query;
|
||||
};
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a zero offset on a list',
|
||||
DataSource.LOGS,
|
||||
SavedviewtypesSourceDTO.logs,
|
||||
PANEL_TYPES.LIST,
|
||||
{ offset: 0 },
|
||||
],
|
||||
[
|
||||
'an empty group-by description',
|
||||
DataSource.TRACES,
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
{
|
||||
groupBy: [
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
'a function without arguments',
|
||||
DataSource.METRICS,
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
{ functions: [{ name: 'absolute', args: [], namedArgs: {} }] },
|
||||
],
|
||||
])('is clean with %s', (_, dataSource, sourcePage, panelType, patch) => {
|
||||
const saved = withPatch(
|
||||
dataSource,
|
||||
patch as Partial<Query['builder']['queryData'][number]>,
|
||||
);
|
||||
const savedView = viewFrom(
|
||||
toSavedViewSpec({ query: saved, panelType, displayName: 'Saved' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: savedView,
|
||||
stagedQuery: saved,
|
||||
panelType,
|
||||
sourcePage,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list order', () => {
|
||||
const withOrderBy = (
|
||||
staged: Query,
|
||||
|
||||
@@ -80,6 +80,66 @@ describe('toSavedViewSpec', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the description and unit of a column, naming it by name before key', () => {
|
||||
const spec = toSavedViewSpec({
|
||||
query: tracesQuery(),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName: 'Errors',
|
||||
options: {
|
||||
selectColumns: [
|
||||
{
|
||||
name: 'duration_nano',
|
||||
key: 'durationNano',
|
||||
description: 'Span duration',
|
||||
unit: 'ns',
|
||||
},
|
||||
] as unknown as TelemetryFieldKey[],
|
||||
format: 'raw' as LogViewMode,
|
||||
maxLines: 2,
|
||||
fontSize: FontSize.SMALL,
|
||||
},
|
||||
});
|
||||
|
||||
expect(spec.selectedFields).toStrictEqual([
|
||||
{ name: 'duration_nano', description: 'Span duration', unit: 'ns' },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
PANEL_TYPES.LIST,
|
||||
SavedviewtypesPanelTypeDTO.list,
|
||||
Querybuildertypesv5RequestTypeDTO.raw,
|
||||
],
|
||||
[
|
||||
PANEL_TYPES.TABLE,
|
||||
SavedviewtypesPanelTypeDTO.table,
|
||||
Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
],
|
||||
[
|
||||
PANEL_TYPES.TRACE,
|
||||
SavedviewtypesPanelTypeDTO.trace,
|
||||
Querybuildertypesv5RequestTypeDTO.trace,
|
||||
],
|
||||
[
|
||||
PANEL_TYPES.PIE,
|
||||
SavedviewtypesPanelTypeDTO.graph,
|
||||
Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
],
|
||||
])(
|
||||
'stores the %s panel as panel type %s with request type %s',
|
||||
(panelType, storedPanelType, requestType) => {
|
||||
const spec = toSavedViewSpec({
|
||||
query: tracesQuery(),
|
||||
panelType,
|
||||
displayName: 'Errors',
|
||||
});
|
||||
|
||||
expect(spec.panelType).toBe(storedPanelType);
|
||||
expect(spec.requestType).toBe(requestType);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the colour in display', () => {
|
||||
const spec = toSavedViewSpec({
|
||||
query: tracesQuery(),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { createVariableWidthBarsPathBuilder } from '../variableWidthBars';
|
||||
|
||||
class FakePath2D {
|
||||
rects: number[][] = [];
|
||||
|
||||
rect(x: number, y: number, width: number, height: number): void {
|
||||
this.rects.push([x, y, width, height]);
|
||||
}
|
||||
}
|
||||
|
||||
/** 10 px per unit on both axes; y grows downward from 100. */
|
||||
const fakePlot: Partial<uPlot> = {
|
||||
valToPos: (value: number, scaleKey: string): number =>
|
||||
scaleKey === 'x' ? value * 10 : 100 - value * 10,
|
||||
ctx: {} as CanvasRenderingContext2D,
|
||||
};
|
||||
const plot = fakePlot as uPlot;
|
||||
|
||||
type Fills = Map<CanvasRenderingContext2D['fillStyle'], Path2D>;
|
||||
|
||||
function fillsOf(paths: ReturnType<uPlot.Series.PathBuilder>): Fills {
|
||||
const fill = paths?.fill;
|
||||
if (!(fill instanceof Map)) {
|
||||
throw new Error('expected a fill per style');
|
||||
}
|
||||
return fill;
|
||||
}
|
||||
|
||||
function rectsOf(path: Path2D | undefined): number[][] {
|
||||
if (!(path instanceof FakePath2D)) {
|
||||
throw new Error('expected a FakePath2D');
|
||||
}
|
||||
return path.rects;
|
||||
}
|
||||
|
||||
describe('createVariableWidthBarsPathBuilder', () => {
|
||||
const originalPath2D = globalThis.Path2D;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'Path2D', {
|
||||
value: FakePath2D,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(globalThis, 'Path2D', {
|
||||
value: originalPath2D,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
function build(
|
||||
pattern: CanvasPattern | null = null,
|
||||
): ReturnType<uPlot.Series.PathBuilder> {
|
||||
return createVariableWidthBarsPathBuilder({
|
||||
getBars: () => [
|
||||
{ x0: 0, x1: 1, y0: 0, y1: 2, hatched: false },
|
||||
{ x0: 1, x1: 4, y0: 0, y1: 5, hatched: false },
|
||||
{ x0: 4, x1: 6, y0: 1, y1: 3, hatched: true },
|
||||
],
|
||||
fill: '#ff0000',
|
||||
hatchedFill: '#ff000066',
|
||||
getHatchPattern: () => pattern,
|
||||
gapPx: 2,
|
||||
})(plot, 1, 0, 0);
|
||||
}
|
||||
|
||||
it('draws each bar at its own width, inset by half the gap', () => {
|
||||
const fills = fillsOf(build());
|
||||
|
||||
expect(rectsOf(fills.get('#ff0000'))).toStrictEqual([
|
||||
[1, 80, 8, 20],
|
||||
[11, 50, 28, 50],
|
||||
]);
|
||||
});
|
||||
|
||||
it('draws hatched bars in their own fill, with the pattern on top', () => {
|
||||
const pattern = {} as CanvasPattern;
|
||||
const fills = fillsOf(build(pattern));
|
||||
|
||||
expect([...fills.keys()]).toStrictEqual(['#ff0000', '#ff000066', pattern]);
|
||||
expect(rectsOf(fills.get(pattern))).toStrictEqual([[41, 70, 18, 20]]);
|
||||
});
|
||||
it('reads a fill callback on every draw', () => {
|
||||
let fill = '#ff0000';
|
||||
const builder = createVariableWidthBarsPathBuilder({
|
||||
getBars: () => [{ x0: 0, x1: 1, y0: 0, y1: 1, hatched: false }],
|
||||
fill: () => fill,
|
||||
hatchedFill: '#00ff00',
|
||||
getHatchPattern: () => null,
|
||||
gapPx: 0,
|
||||
});
|
||||
|
||||
const first = fillsOf(builder(plot, 1, 0, 0));
|
||||
fill = '#0000ff';
|
||||
const second = fillsOf(builder(plot, 1, 0, 0));
|
||||
|
||||
expect([...first.keys()]).toStrictEqual(['#ff0000']);
|
||||
expect([...second.keys()]).toStrictEqual(['#0000ff']);
|
||||
});
|
||||
|
||||
it('keeps solid bars when the hatched fill is the same colour', () => {
|
||||
const fills = fillsOf(
|
||||
createVariableWidthBarsPathBuilder({
|
||||
getBars: () => [
|
||||
{ x0: 0, x1: 1, y0: 0, y1: 1, hatched: false },
|
||||
{ x0: 1, x1: 2, y0: 0, y1: 1, hatched: true },
|
||||
],
|
||||
fill: 'red',
|
||||
hatchedFill: 'red',
|
||||
getHatchPattern: () => null,
|
||||
gapPx: 0,
|
||||
})(plot, 1, 0, 0),
|
||||
);
|
||||
|
||||
expect(rectsOf(fills.get('red'))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('hatches only overflow bars when both fills are the same colour', () => {
|
||||
const pattern = {} as CanvasPattern;
|
||||
const fills = fillsOf(
|
||||
createVariableWidthBarsPathBuilder({
|
||||
getBars: () => [
|
||||
{ x0: 0, x1: 1, y0: 0, y1: 1, hatched: false },
|
||||
{ x0: 1, x1: 2, y0: 0, y1: 1, hatched: true },
|
||||
],
|
||||
fill: 'red',
|
||||
hatchedFill: 'red',
|
||||
getHatchPattern: () => pattern,
|
||||
gapPx: 0,
|
||||
})(plot, 1, 0, 0),
|
||||
);
|
||||
|
||||
expect(rectsOf(fills.get(pattern))).toStrictEqual([[10, 90, 10, 10]]);
|
||||
});
|
||||
});
|
||||
88
frontend/src/lib/uPlotV2/paths/variableWidthBars.ts
Normal file
88
frontend/src/lib/uPlotV2/paths/variableWidthBars.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
/** A bar in scale space. Unlike uPlot's own bars, each one carries its own width. */
|
||||
export interface VariableWidthBar {
|
||||
x0: number;
|
||||
x1: number;
|
||||
y0: number;
|
||||
y1: number;
|
||||
/** Drawn in `hatchedFill` with the hatch pattern on top. */
|
||||
hatched: boolean;
|
||||
}
|
||||
|
||||
/** A fill, or one read on every draw (e.g. to dim a series on hover). */
|
||||
type BarFill = string | (() => string);
|
||||
|
||||
/** Bars narrower than this, in CSS pixels, keep no gap between them. */
|
||||
const MIN_WIDTH_FOR_GAP_PX = 3;
|
||||
|
||||
/**
|
||||
* Draws `[x0, x1] × [y0, y1]` rects on the `x` and `y` scales. uPlot's bar builder
|
||||
* assumes one width for every bar, which a bucket axis with uneven bounds breaks.
|
||||
*
|
||||
* `getBars` runs on every redraw, so a caller can derive the bars from the plot's
|
||||
* current state (e.g. which series are shown).
|
||||
*/
|
||||
export function createVariableWidthBarsPathBuilder({
|
||||
getBars,
|
||||
fill,
|
||||
hatchedFill,
|
||||
getHatchPattern,
|
||||
gapPx,
|
||||
}: {
|
||||
getBars: (u: uPlot, seriesIdx: number) => VariableWidthBar[];
|
||||
fill: BarFill;
|
||||
hatchedFill: BarFill;
|
||||
getHatchPattern: (ctx: CanvasRenderingContext2D) => CanvasPattern | null;
|
||||
/** Space between neighbouring bars, in CSS pixels. */
|
||||
gapPx: number;
|
||||
}): uPlot.Series.PathBuilder {
|
||||
return (u, seriesIdx): uPlot.Series.Paths => {
|
||||
const solidFill = resolveFill(fill);
|
||||
const overflowFill = resolveFill(hatchedFill);
|
||||
const solidPath = new Path2D();
|
||||
// The hatch overlays only overflow bars, so they keep a path of their own even
|
||||
// when one colour fill covers both (a Map keyed by fill holds one path a fill).
|
||||
const hatchedPath = new Path2D();
|
||||
const isSharedFill = overflowFill === solidFill;
|
||||
const inset = (gapPx * uPlot.pxRatio) / 2;
|
||||
const minWidthForGap = MIN_WIDTH_FOR_GAP_PX * uPlot.pxRatio;
|
||||
let hasHatched = false;
|
||||
|
||||
getBars(u, seriesIdx).forEach((bar) => {
|
||||
const left = u.valToPos(bar.x0, 'x', true);
|
||||
const right = u.valToPos(bar.x1, 'x', true);
|
||||
const top = u.valToPos(bar.y1, 'y', true);
|
||||
const bottom = u.valToPos(bar.y0, 'y', true);
|
||||
const width = right - left;
|
||||
const gap = width > minWidthForGap ? inset : 0;
|
||||
const rect = [left + gap, top, width - 2 * gap, bottom - top] as const;
|
||||
if (!bar.hatched || isSharedFill) {
|
||||
solidPath.rect(...rect);
|
||||
}
|
||||
if (bar.hatched) {
|
||||
hatchedPath.rect(...rect);
|
||||
hasHatched = true;
|
||||
}
|
||||
});
|
||||
|
||||
const fills = new Map<CanvasRenderingContext2D['fillStyle'], Path2D>([
|
||||
[solidFill, solidPath],
|
||||
]);
|
||||
if (hasHatched) {
|
||||
if (!isSharedFill) {
|
||||
fills.set(overflowFill, hatchedPath);
|
||||
}
|
||||
const pattern = getHatchPattern(u.ctx);
|
||||
if (pattern) {
|
||||
fills.set(pattern, hatchedPath);
|
||||
}
|
||||
}
|
||||
|
||||
return { stroke: null, fill: fills, clip: null, band: null, flags: 0 };
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFill(fill: BarFill): string {
|
||||
return typeof fill === 'function' ? fill() : fill;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.notice {
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import Legend from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import {
|
||||
LegendPosition,
|
||||
type TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import type { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
|
||||
import { resolveBoundaryPrecision } from 'lib/visualization/charts/Heatmap/utils';
|
||||
import { DistributionChartProps } from 'lib/visualization/charts/types';
|
||||
import { useLegendVisibility } from 'lib/visualization/hooks/useLegendVisibility';
|
||||
import type { LegendLayout } from 'lib/visualization/layout/ChartLayout/ChartLayout';
|
||||
|
||||
import { resolveOtherColor, resolveSeriesColor } from './colors';
|
||||
import DistributionTooltip from './components/DistributionTooltip/DistributionTooltip';
|
||||
import { buildDistributionConfig } from './config';
|
||||
import {
|
||||
clampDistributionXAxis,
|
||||
cropDistributionXAxis,
|
||||
resolveDistributionBars,
|
||||
resolveDistributionXAxis,
|
||||
sumVisibleCounts,
|
||||
} from './layout';
|
||||
import { computeDistributionPercentiles } from './percentiles';
|
||||
import { groupIntoOther, resolveMaxDrawnGroups } from './topGroups';
|
||||
import { DistributionYScale } from './types';
|
||||
import { useDistributionBarClick } from './useDistributionBarClick';
|
||||
|
||||
import styles from './Distribution.module.scss';
|
||||
import { createBoundFormatter, prepareDistributionChartData } from './utils';
|
||||
|
||||
/** Rough share of the panel width the plot gets, for fitting side-by-side bars. */
|
||||
const PLOT_SHARE = 0.9;
|
||||
const PLOT_SHARE_WITH_RIGHT_LEGEND = 0.65;
|
||||
/** Vertical space the Other notice takes out of the container. */
|
||||
const NOTICE_HEIGHT = 20;
|
||||
const NO_PERCENTILES: readonly number[] = Object.freeze([]);
|
||||
|
||||
export default function Distribution({
|
||||
id,
|
||||
buckets,
|
||||
series,
|
||||
width,
|
||||
height,
|
||||
isDarkMode,
|
||||
xMin,
|
||||
xMax,
|
||||
yScale = DistributionYScale.Linear,
|
||||
stack = StackMode.Normal,
|
||||
xAxisUnit,
|
||||
decimalPrecision,
|
||||
customColors,
|
||||
percentiles = NO_PERCENTILES,
|
||||
onBarClick,
|
||||
showLegend = true,
|
||||
legendPosition = LegendPosition.BOTTOM,
|
||||
showTooltip = true,
|
||||
canPinTooltip = false,
|
||||
renderTooltipFooter,
|
||||
tooltipPortalRoot,
|
||||
layoutChildren,
|
||||
'data-testid': testId,
|
||||
}: DistributionChartProps): JSX.Element {
|
||||
const labels = useMemo(() => series.map((entry) => entry.label), [series]);
|
||||
const counts = useMemo(() => series.map((entry) => entry.counts), [series]);
|
||||
|
||||
const { visibleKeys, focusedSeriesIndex, onLegendAction } =
|
||||
useLegendVisibility({ keys: labels, indexOffset: 1, id });
|
||||
|
||||
const visible = useMemo(
|
||||
() => labels.map((label) => visibleKeys.includes(label)),
|
||||
[labels, visibleKeys],
|
||||
);
|
||||
|
||||
const colors = useMemo(
|
||||
() =>
|
||||
labels.map((label) => resolveSeriesColor(label, isDarkMode, customColors)),
|
||||
[labels, isDarkMode, customColors],
|
||||
);
|
||||
|
||||
const { xAxis, totals } = useMemo(() => {
|
||||
const full = resolveDistributionXAxis(buckets);
|
||||
const visibleTotals = sumVisibleCounts(counts, visible, full.rows.length);
|
||||
// An explicit max may reach past the last occupied bucket; only auto-crop without one.
|
||||
const fitted =
|
||||
xMax === undefined ? cropDistributionXAxis(full, visibleTotals) : full;
|
||||
return {
|
||||
xAxis: clampDistributionXAxis(fitted, xMin, xMax),
|
||||
totals: visibleTotals,
|
||||
};
|
||||
}, [buckets, xMin, xMax, counts, visible]);
|
||||
|
||||
// Over the visible groups combined, so a legend toggle moves them without a refetch.
|
||||
const markers = useMemo(
|
||||
() => computeDistributionPercentiles({ xAxis, totals, percentiles }),
|
||||
[xAxis, totals, percentiles],
|
||||
);
|
||||
|
||||
const isLogY =
|
||||
yScale === DistributionYScale.Log && stack !== StackMode.Percent;
|
||||
|
||||
// The legend lists every group; the plot draws the largest and sums the rest into
|
||||
// Other, so a toggle re-ranks without a refetch.
|
||||
const drawn = useMemo(
|
||||
() =>
|
||||
groupIntoOther({
|
||||
labels,
|
||||
counts,
|
||||
colors,
|
||||
visible,
|
||||
maxGroups: resolveMaxDrawnGroups({
|
||||
stack,
|
||||
xAxis,
|
||||
plotWidth:
|
||||
width *
|
||||
(legendPosition === LegendPosition.RIGHT
|
||||
? PLOT_SHARE_WITH_RIGHT_LEGEND
|
||||
: PLOT_SHARE),
|
||||
}),
|
||||
otherColor: resolveOtherColor(isDarkMode),
|
||||
}),
|
||||
[
|
||||
labels,
|
||||
counts,
|
||||
colors,
|
||||
visible,
|
||||
stack,
|
||||
xAxis,
|
||||
width,
|
||||
legendPosition,
|
||||
isDarkMode,
|
||||
],
|
||||
);
|
||||
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
resolveDistributionBars({
|
||||
xAxis,
|
||||
counts: drawn.counts,
|
||||
stack,
|
||||
isLogY,
|
||||
}),
|
||||
[xAxis, drawn, stack, isLogY],
|
||||
);
|
||||
|
||||
const boundaryPrecision = useMemo(
|
||||
() =>
|
||||
resolveBoundaryPrecision({
|
||||
yAxis: xAxis,
|
||||
yAxisUnit: xAxisUnit,
|
||||
decimalPrecision,
|
||||
}),
|
||||
[xAxis, xAxisUnit, decimalPrecision],
|
||||
);
|
||||
|
||||
const formatBound = useMemo(
|
||||
() => createBoundFormatter(xAxisUnit, boundaryPrecision),
|
||||
[xAxisUnit, boundaryPrecision],
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildDistributionConfig({
|
||||
id,
|
||||
xAxis,
|
||||
layout,
|
||||
labels: drawn.labels,
|
||||
colors: drawn.colors,
|
||||
stack,
|
||||
isLogY,
|
||||
isDarkMode,
|
||||
markers,
|
||||
xAxisUnit,
|
||||
decimalPrecision: boundaryPrecision,
|
||||
}),
|
||||
[
|
||||
id,
|
||||
xAxis,
|
||||
layout,
|
||||
drawn,
|
||||
stack,
|
||||
isLogY,
|
||||
isDarkMode,
|
||||
markers,
|
||||
xAxisUnit,
|
||||
boundaryPrecision,
|
||||
],
|
||||
);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(args: TooltipRenderArgs): React.ReactNode => (
|
||||
<DistributionTooltip
|
||||
uPlotInstance={args.uPlotInstance}
|
||||
isPinned={args.isPinned}
|
||||
dismiss={args.dismiss}
|
||||
xAxis={xAxis}
|
||||
layout={layout}
|
||||
labels={drawn.labels}
|
||||
colors={drawn.colors}
|
||||
counts={drawn.counts}
|
||||
formatBound={formatBound}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
/>
|
||||
),
|
||||
[xAxis, layout, drawn, formatBound, renderTooltipFooter],
|
||||
);
|
||||
|
||||
const handleClick = useDistributionBarClick({
|
||||
xAxis,
|
||||
layout,
|
||||
drawn,
|
||||
series,
|
||||
onBarClick,
|
||||
});
|
||||
|
||||
const data = useMemo(
|
||||
() => prepareDistributionChartData(xAxis, drawn.counts),
|
||||
[xAxis, drawn],
|
||||
);
|
||||
|
||||
const legendItems = useMemo<LegendItem[]>(
|
||||
() =>
|
||||
labels.map((label, index) => ({
|
||||
seriesIndex: index + 1,
|
||||
label,
|
||||
color: colors[index],
|
||||
show: visible[index],
|
||||
})),
|
||||
[labels, colors, visible],
|
||||
);
|
||||
|
||||
const groupLegend = useCallback(
|
||||
({ averageLegendWidth, showSearch }: LegendLayout): React.ReactNode => (
|
||||
<Legend
|
||||
items={legendItems}
|
||||
position={legendPosition}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
showSearch={showSearch}
|
||||
itemLabel="groups"
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onAction={onLegendAction}
|
||||
/>
|
||||
),
|
||||
[legendItems, legendPosition, focusedSeriesIndex, onLegendAction],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartWrapper
|
||||
config={config}
|
||||
data={data}
|
||||
width={width}
|
||||
height={
|
||||
drawn.otherGroupCount > 0 ? Math.max(0, height - NOTICE_HEIGHT) : height
|
||||
}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
showLegend={showLegend}
|
||||
customLegend={groupLegend}
|
||||
legendLabels={labels}
|
||||
showTooltip={showTooltip}
|
||||
canPinTooltip={canPinTooltip}
|
||||
decimalPrecision={decimalPrecision}
|
||||
customTooltip={renderTooltip}
|
||||
onClick={handleClick}
|
||||
contentFooter={
|
||||
drawn.otherGroupCount > 0 && (
|
||||
<div className={styles.notice} data-testid="distribution-other-notice">
|
||||
Showing top {drawn.labels.length - 1} of {drawn.visibleGroupCount} groups;
|
||||
the rest are summed into Other.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
tooltipPortalRoot={tooltipPortalRoot}
|
||||
layoutChildren={layoutChildren}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type React from 'react';
|
||||
import type { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import Distribution from '../Distribution';
|
||||
import type { DistributionSeries } from '../types';
|
||||
|
||||
// The shared Legend virtualises its items; render them all so they are queryable.
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
itemContent,
|
||||
}: {
|
||||
data: LegendItem[];
|
||||
itemContent: (index: number, item: LegendItem) => React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<div>
|
||||
{data.map((item, index) => (
|
||||
<div key={item.seriesIndex}>{itemContent(index, item)}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const BUCKETS = [5, 10, 100, 1000];
|
||||
|
||||
const SERIES: DistributionSeries[] = [
|
||||
{ label: 'service.name=cart', counts: [3, 40, 12, 1, 0] },
|
||||
{ label: 'service.name=checkout', counts: [1, 10, 30, 6, 2] },
|
||||
];
|
||||
|
||||
function renderDistribution(
|
||||
props: Partial<React.ComponentProps<typeof Distribution>> = {},
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<Distribution
|
||||
id="panel-1"
|
||||
buckets={BUCKETS}
|
||||
series={SERIES}
|
||||
width={800}
|
||||
height={400}
|
||||
isDarkMode
|
||||
data-testid="distribution"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Distribution', () => {
|
||||
// Legend items count from 1, as uPlot's data series do.
|
||||
const CART = 1;
|
||||
const CHECKOUT = 2;
|
||||
|
||||
// The selection persists under the panel id, which every case here shares.
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
function legendItem(seriesIndex: number): HTMLElement {
|
||||
return screen.getByTestId(`legend-item-${seriesIndex}`);
|
||||
}
|
||||
|
||||
it('renders the plot container', () => {
|
||||
renderDistribution();
|
||||
|
||||
expect(screen.getByTestId('distribution')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists every group in the legend, all shown', () => {
|
||||
renderDistribution();
|
||||
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('isolates a group when its row is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderDistribution();
|
||||
|
||||
await user.click(legendItem(CART));
|
||||
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('hides only that group when its marker is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderDistribution();
|
||||
|
||||
await user.click(screen.getByTestId(`legend-marker-${CHECKOUT}`));
|
||||
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'false');
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
it('sums groups past the stacked cap into Other, with a notice', () => {
|
||||
renderDistribution({
|
||||
series: Array.from({ length: 12 }, (_, index) => ({
|
||||
label: `group-${index}`,
|
||||
counts: [index, 1, 1, 0, 0],
|
||||
})),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('distribution-other-notice')).toHaveTextContent(
|
||||
'Showing top 9 of 12 groups; the rest are summed into Other.',
|
||||
);
|
||||
// The legend still lists every group, so any can be isolated.
|
||||
expect(legendItem(1)).toHaveAttribute('aria-label', 'group-0');
|
||||
});
|
||||
|
||||
it('shows no notice when every group fits', () => {
|
||||
renderDistribution();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('distribution-other-notice'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { resolveDistributionBars, resolveDistributionXAxis } from '../layout';
|
||||
import { buildDistributionConfig } from '../config';
|
||||
|
||||
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4]. Only (10, 20] is occupied.
|
||||
const xAxis = resolveDistributionXAxis([10, 20, 40]);
|
||||
const counts = [
|
||||
[0, 4, 0, 0],
|
||||
[0, 6, 0, 0],
|
||||
];
|
||||
|
||||
function setCursorHook(drawnCounts = counts): (u: uPlot) => void {
|
||||
const config = buildDistributionConfig({
|
||||
id: 'panel-1',
|
||||
xAxis,
|
||||
layout: resolveDistributionBars({
|
||||
xAxis,
|
||||
counts: drawnCounts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
}),
|
||||
labels: drawnCounts.map((_, index) => `group-${index}`),
|
||||
colors: drawnCounts.map(() => '#ff0000'),
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
isDarkMode: true,
|
||||
markers: [],
|
||||
}).getConfig();
|
||||
const [hook] = config.hooks?.setCursor ?? [];
|
||||
return hook as (u: uPlot) => void;
|
||||
}
|
||||
|
||||
/** Pixels are values, so the cursor sits at `(left, top)` in scale space. */
|
||||
function plotAt(
|
||||
left: number,
|
||||
top: number,
|
||||
): { u: uPlot; setSeries: jest.Mock; redraw: jest.Mock } {
|
||||
const setSeries = jest.fn();
|
||||
const redraw = jest.fn();
|
||||
const plot: Partial<uPlot> = {
|
||||
cursor: { left, top },
|
||||
posToVal: (position: number) => position,
|
||||
setSeries,
|
||||
redraw,
|
||||
};
|
||||
return { u: plot as uPlot, setSeries, redraw };
|
||||
}
|
||||
|
||||
describe('buildDistributionConfig cursor', () => {
|
||||
it('turns off drag-to-zoom', () => {
|
||||
const config = buildDistributionConfig({
|
||||
id: 'panel-1',
|
||||
xAxis,
|
||||
layout: resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
}),
|
||||
labels: ['group-0', 'group-1'],
|
||||
colors: ['#ff0000', '#ff0000'],
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
isDarkMode: true,
|
||||
markers: [],
|
||||
}).getConfig();
|
||||
|
||||
expect(config.cursor?.drag).toMatchObject({ x: false, y: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDistributionConfig setCursor', () => {
|
||||
// TooltipPlugin only shows a tooltip for a focused series.
|
||||
it('focuses the group under the cursor, 1-based as uPlot counts series', () => {
|
||||
const { u, setSeries, redraw } = plotAt(1.5, 8);
|
||||
|
||||
setCursorHook()(u);
|
||||
|
||||
expect(setSeries).toHaveBeenCalledWith(2, { focus: true });
|
||||
expect(redraw).toHaveBeenCalledWith(true, false);
|
||||
});
|
||||
|
||||
it('focuses the nearest group above the stack, dimming the rest', () => {
|
||||
const { u, setSeries, redraw } = plotAt(1.5, 50);
|
||||
|
||||
setCursorHook()(u);
|
||||
|
||||
expect(setSeries).toHaveBeenCalledWith(2, { focus: true });
|
||||
expect(redraw).toHaveBeenCalledWith(true, false);
|
||||
});
|
||||
|
||||
it('keeps a tooltip over an empty bucket without dimming any group', () => {
|
||||
const hook = setCursorHook();
|
||||
const { u, setSeries, redraw } = plotAt(1.5, 8);
|
||||
hook(u);
|
||||
redraw.mockClear();
|
||||
|
||||
u.cursor.left = 2.5;
|
||||
u.cursor.top = 1;
|
||||
hook(u);
|
||||
|
||||
expect(setSeries).toHaveBeenLastCalledWith(1, { focus: true });
|
||||
expect(redraw).toHaveBeenCalledWith(true, false);
|
||||
});
|
||||
|
||||
it('focuses nothing off the plot', () => {
|
||||
const hook = setCursorHook();
|
||||
const { u, setSeries } = plotAt(1.5, 8);
|
||||
hook(u);
|
||||
|
||||
u.cursor.left = -1;
|
||||
hook(u);
|
||||
|
||||
expect(setSeries).toHaveBeenLastCalledWith(null, { focus: true });
|
||||
});
|
||||
|
||||
it('focuses nothing over an empty bucket when every group is hidden', () => {
|
||||
const { u, setSeries } = plotAt(2.5, 1);
|
||||
|
||||
setCursorHook([])(u);
|
||||
|
||||
expect(setSeries).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
layoutMarkerLabels,
|
||||
resolveHoveredMarker,
|
||||
} from '../drawPercentileMarkers';
|
||||
|
||||
describe('resolveHoveredMarker', () => {
|
||||
const areas = [
|
||||
{
|
||||
x: 100,
|
||||
badges: [
|
||||
{ left: 92, right: 108, top: 0, bottom: 16 },
|
||||
{ left: 92, right: 108, top: 184, bottom: 200 },
|
||||
],
|
||||
},
|
||||
{
|
||||
x: 120,
|
||||
badges: [{ left: 112, right: 128, top: 18, bottom: 34 }],
|
||||
},
|
||||
];
|
||||
|
||||
it('picks the line nearest x within the tolerance', () => {
|
||||
expect(resolveHoveredMarker(areas, 103, 100, 4)).toBe(0);
|
||||
expect(resolveHoveredMarker(areas, 117, 100, 4)).toBe(1);
|
||||
expect(resolveHoveredMarker(areas, 110, 100, 4)).toBeNull();
|
||||
});
|
||||
|
||||
it('picks the marker whose badge is under the cursor', () => {
|
||||
expect(resolveHoveredMarker(areas, 107, 190, 4)).toBe(0);
|
||||
expect(resolveHoveredMarker(areas, 126, 20, 4)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layoutMarkerLabels', () => {
|
||||
it('keeps labels that fit on the first row', () => {
|
||||
expect(
|
||||
layoutMarkerLabels(
|
||||
[
|
||||
{ x: 0, width: 20 },
|
||||
{ x: 40, width: 20 },
|
||||
],
|
||||
4,
|
||||
),
|
||||
).toStrictEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('drops a label that would overlap to the next free row', () => {
|
||||
expect(
|
||||
layoutMarkerLabels(
|
||||
[
|
||||
{ x: 30, width: 20 },
|
||||
{ x: 0, width: 20 },
|
||||
{ x: 10, width: 20 },
|
||||
],
|
||||
4,
|
||||
),
|
||||
).toStrictEqual([0, 0, 1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import {
|
||||
clampDistributionXAxis,
|
||||
cropDistributionXAxis,
|
||||
LOG_Y_BASE,
|
||||
resolveDistributionBars,
|
||||
resolveDistributionXAxis,
|
||||
resolveNearestSeries,
|
||||
sumVisibleCounts,
|
||||
thinLabelledSplits,
|
||||
} from '../layout';
|
||||
|
||||
function widths(edges: number[]): number[] {
|
||||
return edges.slice(1).map((edge, index) => edge - edges[index]);
|
||||
}
|
||||
|
||||
describe('resolveDistributionXAxis', () => {
|
||||
it('lays N bounds out as N + 1 buckets, overflow last', () => {
|
||||
const axis = resolveDistributionXAxis([10, 20, 30]);
|
||||
|
||||
expect(axis.rows).toHaveLength(4);
|
||||
expect(axis.rows[0].isUnderflow).toBe(true);
|
||||
expect(axis.rows[3]).toMatchObject({ lower: 30, isOverflow: true });
|
||||
expect(axis.edges).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('gives every bucket the same width, ticks on the bounds', () => {
|
||||
const axis = resolveDistributionXAxis([0, 5, 10, 25, 1000]);
|
||||
|
||||
expect(widths(axis.edges)).toStrictEqual([1, 1, 1, 1, 1, 1]);
|
||||
expect(axis.splits).toStrictEqual([1, 2, 3, 4, 5]);
|
||||
expect(axis.overflowSplit).toBe(6);
|
||||
expect(axis.toBucketValue(3.5)).toBe(17.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cropDistributionXAxis', () => {
|
||||
it('stops one bucket past the last occupied one', () => {
|
||||
const axis = resolveDistributionXAxis([1, 2, 3, 4]);
|
||||
|
||||
const cropped = cropDistributionXAxis(axis, [1, 5, 0, 0, 0]);
|
||||
|
||||
expect(cropped.max).toBe(3);
|
||||
expect(cropped.overflowSplit).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampDistributionXAxis', () => {
|
||||
// Buckets ≤ 10, (10, 20], (20, 40], (40, 80], > 80 in slots [0, 1] … [4, 5].
|
||||
const axis = resolveDistributionXAxis([10, 20, 40, 80]);
|
||||
|
||||
it('keeps every bucket without a range', () => {
|
||||
expect(clampDistributionXAxis(axis)).toStrictEqual(axis);
|
||||
});
|
||||
|
||||
it('hides buckets wholly outside the range, keeping straddling ones whole', () => {
|
||||
const clamped = clampDistributionXAxis(axis, 20, 50);
|
||||
|
||||
expect([clamped.min, clamped.max]).toStrictEqual([2, 4]);
|
||||
expect(clamped.overflowSplit).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the overflow tick while the overflow bucket shows', () => {
|
||||
const clamped = clampDistributionXAxis(axis, 15);
|
||||
|
||||
expect([clamped.min, clamped.max]).toStrictEqual([1, 5]);
|
||||
expect(clamped.overflowSplit).toBe(5);
|
||||
});
|
||||
|
||||
it('keeps one bucket when the range misses them all', () => {
|
||||
const clamped = clampDistributionXAxis(axis, 1000);
|
||||
|
||||
expect([clamped.min, clamped.max]).toStrictEqual([4, 5]);
|
||||
});
|
||||
|
||||
it('narrows an already cropped axis, never widening it', () => {
|
||||
const cropped = cropDistributionXAxis(axis, [1, 5, 0, 0, 0]);
|
||||
|
||||
expect(clampDistributionXAxis(cropped, 15).max).toBe(cropped.max);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sumVisibleCounts', () => {
|
||||
it('adds only the visible series', () => {
|
||||
expect(
|
||||
sumVisibleCounts(
|
||||
[
|
||||
[1, 2],
|
||||
[10, 20],
|
||||
[100, 200],
|
||||
],
|
||||
[true, false, true],
|
||||
2,
|
||||
),
|
||||
).toStrictEqual([101, 202]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDistributionBars', () => {
|
||||
const xAxis = resolveDistributionXAxis([1, 2]);
|
||||
const counts = [
|
||||
[4, 6, 0],
|
||||
[1, 3, 2],
|
||||
];
|
||||
|
||||
it('sizes the count axis by the buckets left in the window', () => {
|
||||
// Buckets ≤ 1, (1, 2], > 2; a max of 1 keeps only the first.
|
||||
const { yMax } = resolveDistributionBars({
|
||||
xAxis: clampDistributionXAxis(xAxis, undefined, 1),
|
||||
counts: [[4, 1000, 0]],
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(yMax).toBe(4);
|
||||
});
|
||||
|
||||
it('stacks series in each bucket under normal', () => {
|
||||
const { rects, yMax } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[0].map(({ y0, y1 }) => [y0, y1])).toStrictEqual([
|
||||
[0, 4],
|
||||
[0, 6],
|
||||
]);
|
||||
expect(rects[1].map(({ y0, y1 }) => [y0, y1])).toStrictEqual([
|
||||
[4, 5],
|
||||
[6, 9],
|
||||
[0, 2],
|
||||
]);
|
||||
expect(yMax).toBe(9);
|
||||
});
|
||||
|
||||
it('marks the overflow bucket hatched', () => {
|
||||
const { rects } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[1].map(({ hatched }) => hatched)).toStrictEqual([
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('stacks shares of each bucket under percent', () => {
|
||||
const { rects, yMax } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Percent,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[0][0]).toMatchObject({ y0: 0, y1: 80 });
|
||||
expect(rects[1][0]).toMatchObject({ y0: 80, y1: 100 });
|
||||
expect(yMax).toBe(100);
|
||||
});
|
||||
|
||||
it('splits the bucket width between series under none', () => {
|
||||
const { rects, yMax } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.None,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[0][0]).toMatchObject({ x0: 0, x1: 0.5, y0: 0, y1: 4 });
|
||||
expect(rects[1][0]).toMatchObject({ x0: 0.5, x1: 1, y0: 0, y1: 1 });
|
||||
expect(yMax).toBe(6);
|
||||
});
|
||||
|
||||
it('starts bars at the log base and skips zero counts', () => {
|
||||
const { rects } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: true,
|
||||
});
|
||||
|
||||
expect(rects[0].map(({ y0 }) => y0)).toStrictEqual([LOG_Y_BASE, LOG_Y_BASE]);
|
||||
expect(rects[0].map(({ bucketIndex }) => bucketIndex)).toStrictEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('skips bars whose top stays under the log base, never inverting one', () => {
|
||||
const fractional = [
|
||||
[0.5, 3, 0],
|
||||
[0.25, 0.5, 2],
|
||||
];
|
||||
const stacked = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts: fractional,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: true,
|
||||
});
|
||||
const sideBySide = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts: fractional,
|
||||
stack: StackMode.None,
|
||||
isLogY: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
stacked.rects.map((rects) => rects.map(({ y0, y1 }) => [y0, y1])),
|
||||
).toStrictEqual([
|
||||
[[LOG_Y_BASE, 3]],
|
||||
[
|
||||
[3, 3.5],
|
||||
[LOG_Y_BASE, 2],
|
||||
],
|
||||
]);
|
||||
expect(
|
||||
sideBySide.rects.map((rects) => rects.map(({ bucketIndex }) => bucketIndex)),
|
||||
).toStrictEqual([[1], [2]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('thinLabelledSplits', () => {
|
||||
it('keeps labels that clear each other by the gap', () => {
|
||||
expect(
|
||||
thinLabelledSplits({
|
||||
positions: [0, 50, 100],
|
||||
widths: [20, 20, 20],
|
||||
gap: 10,
|
||||
}),
|
||||
).toStrictEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('drops a wide label that would touch its neighbour, keeping the rightmost', () => {
|
||||
// 16.67 mins-wide labels 60 px apart: each needs 70 px of centre distance.
|
||||
expect(
|
||||
thinLabelledSplits({
|
||||
positions: [0, 60, 120, 180],
|
||||
widths: [60, 60, 60, 60],
|
||||
gap: 10,
|
||||
}),
|
||||
).toStrictEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveNearestSeries', () => {
|
||||
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4].
|
||||
const xAxis = resolveDistributionXAxis([10, 20, 40]);
|
||||
const counts = [
|
||||
[0, 4, 0, 0],
|
||||
[0, 6, 0, 0],
|
||||
];
|
||||
const stacked = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
it('names the group whose bar is under the point', () => {
|
||||
expect(resolveNearestSeries(stacked, 1, 1.5, 2)).toBe(0);
|
||||
expect(resolveNearestSeries(stacked, 1, 1.5, 8)).toBe(1);
|
||||
});
|
||||
|
||||
it('takes the vertically nearest group off the bars, as a bar chart does', () => {
|
||||
expect(resolveNearestSeries(stacked, 1, 1.5, 50)).toBe(1);
|
||||
});
|
||||
|
||||
it('is null over an empty bucket', () => {
|
||||
expect(resolveNearestSeries(stacked, 2, 2.5, 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('picks the sub-bar under the cursor side by side', () => {
|
||||
const sideBySide = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.None,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(resolveNearestSeries(sideBySide, 1, 1.25, 50)).toBe(0);
|
||||
expect(resolveNearestSeries(sideBySide, 1, 1.75, 1)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { resolveDistributionXAxis } from '../layout';
|
||||
import {
|
||||
computeDistributionPercentiles,
|
||||
formatPercentileBadge,
|
||||
formatPercentileName,
|
||||
formatPercentileValue,
|
||||
groupPercentileMarkers,
|
||||
} from '../percentiles';
|
||||
|
||||
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4].
|
||||
const xAxis = resolveDistributionXAxis([10, 20, 40]);
|
||||
|
||||
describe('computeDistributionPercentiles', () => {
|
||||
it('interpolates inside the bucket holding the rank, as histogram_quantile does', () => {
|
||||
const markers = computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [10, 20, 10, 0],
|
||||
percentiles: [50, 90],
|
||||
});
|
||||
|
||||
expect(markers.map(({ value }) => value)).toStrictEqual([15, 32]);
|
||||
expect(markers.every(({ isOverflow }) => !isOverflow)).toBe(true);
|
||||
});
|
||||
|
||||
it('places the marker the same fraction across the bucket on the axis', () => {
|
||||
const [p50] = computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [10, 20, 10, 0],
|
||||
percentiles: [50],
|
||||
});
|
||||
|
||||
expect(p50.axisValue).toBe(1.5);
|
||||
});
|
||||
|
||||
it('starts the lowest bucket at zero', () => {
|
||||
const [p10] = computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [10, 20, 10, 0],
|
||||
percentiles: [10],
|
||||
});
|
||||
|
||||
expect(p10.value).toBe(4);
|
||||
});
|
||||
|
||||
it('reports a percentile in overflow as above the last bound, at its left edge', () => {
|
||||
const [p99] = computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [0, 0, 10, 10],
|
||||
percentiles: [99],
|
||||
});
|
||||
|
||||
expect(p99).toMatchObject({ value: 40, axisValue: 3, isOverflow: true });
|
||||
});
|
||||
|
||||
it('marks nothing when every count is zero', () => {
|
||||
expect(
|
||||
computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [0, 0, 0, 0],
|
||||
percentiles: [50],
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPercentileName', () => {
|
||||
it('keeps fractional percentile names short', () => {
|
||||
expect(formatPercentileName(99.9)).toBe('p99.9');
|
||||
expect(formatPercentileName(50)).toBe('p50');
|
||||
});
|
||||
});
|
||||
|
||||
describe('percentile marker groups', () => {
|
||||
const formatBound = (value: number): string => `${value} ms`;
|
||||
const p50 = { percentile: 50, value: 15, axisValue: 1.5, isOverflow: false };
|
||||
const p99 = { percentile: 99, value: 40, axisValue: 3, isOverflow: true };
|
||||
const p90 = { percentile: 90, value: 40, axisValue: 3, isOverflow: true };
|
||||
|
||||
it('groups markers on the same spot, lowest percentile first', () => {
|
||||
expect(groupPercentileMarkers([p50, p99, p90])).toStrictEqual([
|
||||
[p50],
|
||||
[p90, p99],
|
||||
]);
|
||||
});
|
||||
|
||||
it('names a group by its lowest percentile', () => {
|
||||
expect(formatPercentileBadge([p50])).toBe('p50');
|
||||
expect(formatPercentileBadge([p90, p99])).toBe('p90+');
|
||||
});
|
||||
|
||||
it('shows the estimate, or the overflow bound once for the group', () => {
|
||||
expect(formatPercentileValue([p50], formatBound)).toBe('p50 ≈ 15 ms');
|
||||
expect(formatPercentileValue([p90, p99], formatBound)).toBe(
|
||||
'p90, p99 > 40 ms',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { resolveDistributionXAxis } from '../layout';
|
||||
import {
|
||||
groupIntoOther,
|
||||
resolveClickedGroup,
|
||||
MAX_STACKED_GROUPS,
|
||||
OTHER_GROUP_LABEL,
|
||||
resolveMaxDrawnGroups,
|
||||
} from '../topGroups';
|
||||
|
||||
describe('resolveMaxDrawnGroups', () => {
|
||||
// Four equal buckets plus underflow and overflow: six slots of one unit each.
|
||||
const xAxis = resolveDistributionXAxis([1, 2, 3, 4, 5]);
|
||||
|
||||
it('caps stacked and percent at the fixed limit', () => {
|
||||
expect(
|
||||
resolveMaxDrawnGroups({ stack: StackMode.Normal, xAxis, plotWidth: 60 }),
|
||||
).toBe(MAX_STACKED_GROUPS);
|
||||
expect(
|
||||
resolveMaxDrawnGroups({ stack: StackMode.Percent, xAxis, plotWidth: 60 }),
|
||||
).toBe(MAX_STACKED_GROUPS);
|
||||
});
|
||||
|
||||
it('fits side by side so the narrowest bar stays 3 px wide', () => {
|
||||
// 120 px over six slots: 20 px a bucket, six 3 px bars.
|
||||
expect(
|
||||
resolveMaxDrawnGroups({ stack: StackMode.None, xAxis, plotWidth: 120 }),
|
||||
).toBe(6);
|
||||
expect(
|
||||
resolveMaxDrawnGroups({ stack: StackMode.None, xAxis, plotWidth: 6 }),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupIntoOther', () => {
|
||||
const args = {
|
||||
labels: ['a', 'b', 'c', 'd'],
|
||||
counts: [
|
||||
[1, 1],
|
||||
[5, 5],
|
||||
[2, 0],
|
||||
[3, 3],
|
||||
],
|
||||
colors: ['#a', '#b', '#c', '#d'],
|
||||
visible: [true, true, true, true],
|
||||
otherColor: '#999',
|
||||
};
|
||||
|
||||
it('draws every visible group when they fit', () => {
|
||||
const drawn = groupIntoOther({ ...args, maxGroups: 4 });
|
||||
|
||||
expect(drawn.labels).toStrictEqual(['a', 'b', 'c', 'd']);
|
||||
expect(drawn.otherGroupCount).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps the largest, in legend order, and sums the rest exactly', () => {
|
||||
const drawn = groupIntoOther({ ...args, maxGroups: 3 });
|
||||
|
||||
expect(drawn.labels).toStrictEqual(['b', 'd', OTHER_GROUP_LABEL]);
|
||||
expect(drawn.sourceIndexes).toStrictEqual([1, 3, null]);
|
||||
expect(drawn.counts[2]).toStrictEqual([3, 1]);
|
||||
expect(drawn.colors[2]).toBe('#999');
|
||||
expect(drawn).toMatchObject({ otherGroupCount: 2, visibleGroupCount: 4 });
|
||||
});
|
||||
|
||||
it('re-ranks without hidden groups, which never reach Other', () => {
|
||||
const drawn = groupIntoOther({
|
||||
...args,
|
||||
visible: [true, false, true, true],
|
||||
maxGroups: 2,
|
||||
});
|
||||
|
||||
expect(drawn.labels).toStrictEqual(['d', OTHER_GROUP_LABEL]);
|
||||
expect(drawn.sourceIndexes).toStrictEqual([3, null]);
|
||||
expect(drawn.counts[1]).toStrictEqual([3, 1]);
|
||||
expect(drawn.visibleGroupCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveClickedGroup', () => {
|
||||
const series = ['a', 'b', 'c'].map((label) => ({
|
||||
label,
|
||||
counts: [label === 'a' ? 5 : 1],
|
||||
}));
|
||||
const drawn = groupIntoOther({
|
||||
labels: ['a', 'b', 'c'],
|
||||
counts: series.map(({ counts }) => counts),
|
||||
colors: ['#a', '#b', '#c'],
|
||||
visible: [true, true, true],
|
||||
maxGroups: 2,
|
||||
otherColor: '#999',
|
||||
});
|
||||
|
||||
it('maps a drawn bar back to its source group and colour', () => {
|
||||
expect(resolveClickedGroup(drawn, 0, series)).toStrictEqual({
|
||||
series: series[0],
|
||||
color: '#a',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses Other and empty space', () => {
|
||||
expect(resolveClickedGroup(drawn, 1, series)).toBeNull();
|
||||
expect(resolveClickedGroup(drawn, null, series)).toBeNull();
|
||||
});
|
||||
|
||||
it('maps by source index, not label, when groups share a legend', () => {
|
||||
const twins = [
|
||||
{ label: 'Other', groupLabels: { pod: 'a' }, counts: [1] },
|
||||
{ label: 'Other', groupLabels: { pod: 'b' }, counts: [2] },
|
||||
];
|
||||
const drawnTwins = groupIntoOther({
|
||||
labels: twins.map(({ label }) => label),
|
||||
counts: twins.map(({ counts }) => counts),
|
||||
colors: ['#a', '#b'],
|
||||
visible: [true, true],
|
||||
maxGroups: 2,
|
||||
otherColor: '#999',
|
||||
});
|
||||
|
||||
expect(resolveClickedGroup(drawnTwins, 1, twins)).toStrictEqual({
|
||||
series: twins[1],
|
||||
color: '#b',
|
||||
});
|
||||
});
|
||||
});
|
||||
29
frontend/src/lib/visualization/charts/Distribution/colors.ts
Normal file
29
frontend/src/lib/visualization/charts/Distribution/colors.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { toAlphaHex } from 'lib/uPlotV2/utils/fillOpacity';
|
||||
|
||||
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
export function withOpacity(color: string, opacity: number): string {
|
||||
return HEX_COLOR.test(color) ? `${color}${toAlphaHex(opacity)}` : color;
|
||||
}
|
||||
|
||||
/** Neutral, so the summed remainder never reads as one more group. */
|
||||
export function resolveOtherColor(isDarkMode: boolean): string {
|
||||
return isDarkMode ? Color.BG_VANILLA_400 : Color.BG_SLATE_50;
|
||||
}
|
||||
|
||||
export function resolveSeriesColor(
|
||||
label: string,
|
||||
isDarkMode: boolean,
|
||||
customColors?: Record<string, string>,
|
||||
): string {
|
||||
return (
|
||||
customColors?.[label] ??
|
||||
generateColor(
|
||||
label,
|
||||
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
// Same surface as the shared and heatmap tooltips.
|
||||
.container {
|
||||
--gutter: 14px;
|
||||
--row-inset: 6px;
|
||||
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
background: var(--l2-background);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--l2-foreground);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l2-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 240px;
|
||||
|
||||
&.pinned {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
|
||||
// Same as the heatmap tooltip's bucket row.
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-6) var(--gutter);
|
||||
}
|
||||
|
||||
.titleBucket,
|
||||
.titleCount {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--popover-foreground);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.titleSeparator {
|
||||
flex: 1;
|
||||
border-width: 0.5px;
|
||||
border-style: dashed;
|
||||
border-color: currentColor;
|
||||
min-width: 24px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
// One grid for the heading and every row, so their columns line up.
|
||||
.rows {
|
||||
display: grid;
|
||||
grid-template-columns: 12px minmax(0, 1fr) repeat(3, auto);
|
||||
column-gap: var(--spacing-6);
|
||||
row-gap: var(--spacing-1);
|
||||
align-content: start;
|
||||
padding: var(--spacing-4) var(--spacing-4) var(--spacing-6);
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.columnHeader,
|
||||
.rows .row {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
// The shared item's own `gap` would otherwise shift the subgrid off the parent.
|
||||
column-gap: var(--spacing-6);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.columnHeader {
|
||||
padding: 0 6px var(--spacing-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
|
||||
> span:nth-child(n + 2) {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.columnLabel {
|
||||
grid-column: 1 / 3;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rows .row {
|
||||
border-radius: var(--radius);
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&[data-is-active='true'] {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
> :nth-child(n + 3) {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
formatRowLabel,
|
||||
resolveRowIndex,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import TooltipPinnedBadge from 'lib/uPlotV2/components/Tooltip/components/TooltipPinnedBadge/TooltipPinnedBadge';
|
||||
import ItemStyles from 'lib/uPlotV2/components/Tooltip/components/TooltipItem/TooltipItem.module.scss';
|
||||
|
||||
import { resolveNearestSeries } from '../../layout';
|
||||
import {
|
||||
buildDistributionTooltipRows,
|
||||
formatPercent,
|
||||
resolveGroupColumn,
|
||||
} from './distributionTooltipContent';
|
||||
import { DistributionTooltipProps } from './types';
|
||||
|
||||
import Styles from './DistributionTooltip.module.scss';
|
||||
|
||||
/**
|
||||
* The bucket comes from the live cursor, not uPlot's `cursor.idx`: that snaps to the
|
||||
* nearest bucket centre, which on uneven widths is often the neighbour.
|
||||
*/
|
||||
export default function DistributionTooltip({
|
||||
uPlotInstance,
|
||||
xAxis,
|
||||
layout,
|
||||
labels,
|
||||
colors,
|
||||
counts,
|
||||
formatBound,
|
||||
isPinned,
|
||||
dismiss,
|
||||
renderTooltipFooter,
|
||||
}: DistributionTooltipProps): JSX.Element | null {
|
||||
// Read outside the memo: uPlot mutates the same instance on every move.
|
||||
const { left = -10, top = -10 } = uPlotInstance.cursor;
|
||||
|
||||
const hover = useMemo(() => {
|
||||
if (left < 0 || top < 0) {
|
||||
return null;
|
||||
}
|
||||
const x = uPlotInstance.posToVal(left, 'x');
|
||||
const bucket = resolveRowIndex(xAxis.edges, x);
|
||||
if (bucket === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
bucket,
|
||||
series: resolveNearestSeries(
|
||||
layout,
|
||||
bucket,
|
||||
x,
|
||||
uPlotInstance.posToVal(top, 'y'),
|
||||
),
|
||||
};
|
||||
}, [left, top, uPlotInstance, xAxis, layout]);
|
||||
|
||||
const groupColumn = useMemo(() => resolveGroupColumn(labels), [labels]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
hover
|
||||
? buildDistributionTooltipRows({
|
||||
labels,
|
||||
colors,
|
||||
counts,
|
||||
bucket: hover.bucket,
|
||||
hoveredSeries: hover.series,
|
||||
formatLabel: groupColumn.format,
|
||||
})
|
||||
: [],
|
||||
[hover, labels, colors, counts, groupColumn],
|
||||
);
|
||||
|
||||
if (!hover) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bucket = xAxis.rows[hover.bucket];
|
||||
const bucketTotal = rows.reduce((sum, row) => sum + row.count, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
|
||||
data-pinned={isPinned}
|
||||
data-testid="distribution-tooltip"
|
||||
>
|
||||
<div className={Styles.title}>
|
||||
<span
|
||||
className={Styles.titleBucket}
|
||||
data-testid="distribution-tooltip-range"
|
||||
>
|
||||
{formatRowLabel(bucket, formatBound)}
|
||||
</span>
|
||||
<span className={Styles.titleSeparator} />
|
||||
<span
|
||||
className={Styles.titleCount}
|
||||
data-testid="distribution-tooltip-total"
|
||||
>
|
||||
{bucketTotal.toLocaleString()}
|
||||
</span>
|
||||
{isPinned && <TooltipPinnedBadge />}
|
||||
</div>
|
||||
|
||||
<span className={Styles.divider} />
|
||||
|
||||
<div className={Styles.rows}>
|
||||
<div className={Styles.columnHeader}>
|
||||
<span
|
||||
className={Styles.columnLabel}
|
||||
title={groupColumn.heading}
|
||||
data-testid="distribution-tooltip-group-heading"
|
||||
>
|
||||
{groupColumn.heading}
|
||||
</span>
|
||||
<span>Count</span>
|
||||
<span>% Total</span>
|
||||
<span data-testid="distribution-tooltip-cumulative-heading">
|
||||
{bucket.isOverflow ? 'All' : `≤ ${formatBound(bucket.upper)}`}
|
||||
</span>
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.seriesIndex}
|
||||
className={cx(ItemStyles.uplotTooltipItem, Styles.row)}
|
||||
data-is-active={row.isHovered}
|
||||
data-testid="distribution-tooltip-row"
|
||||
data-hovered={row.isHovered}
|
||||
>
|
||||
<span
|
||||
className={ItemStyles.uplotTooltipItemMarker}
|
||||
style={{ borderColor: row.color, backgroundColor: row.color }}
|
||||
/>
|
||||
<span className={ItemStyles.uplotTooltipItemLabel} title={row.label}>
|
||||
{row.label}
|
||||
</span>
|
||||
<span className={ItemStyles.uplotTooltipItemValue}>
|
||||
{row.count.toLocaleString()}
|
||||
</span>
|
||||
<span className={ItemStyles.uplotTooltipItemValue}>
|
||||
{formatPercent(row.share)}
|
||||
</span>
|
||||
<span className={ItemStyles.uplotTooltipItemValue}>
|
||||
{formatPercent(row.cumulativeShare)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{renderTooltipFooter?.({ isPinned, dismiss })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import {
|
||||
resolveDistributionBars,
|
||||
resolveDistributionXAxis,
|
||||
} from 'lib/visualization/charts/Distribution/layout';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import DistributionTooltip from '../DistributionTooltip';
|
||||
|
||||
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4].
|
||||
const xAxis = resolveDistributionXAxis([10, 20, 40]);
|
||||
const counts = [
|
||||
[1, 4, 2, 0],
|
||||
[0, 6, 1, 1],
|
||||
];
|
||||
const layout = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
/** Pixels are values: the cursor at (1.5, 2) is over the first group in (10, 20]. */
|
||||
function plotAt(left: number, top: number): uPlot {
|
||||
const plot: Partial<uPlot> = {
|
||||
cursor: { left, top },
|
||||
posToVal: (position: number) => position,
|
||||
};
|
||||
return plot as uPlot;
|
||||
}
|
||||
|
||||
function renderTooltip(uPlotInstance: uPlot): void {
|
||||
render(
|
||||
<DistributionTooltip
|
||||
uPlotInstance={uPlotInstance}
|
||||
isPinned={false}
|
||||
dismiss={jest.fn()}
|
||||
xAxis={xAxis}
|
||||
layout={layout}
|
||||
labels={['{service.name="cart"}', '{service.name="checkout"}']}
|
||||
colors={['#f00', '#0f0']}
|
||||
counts={counts}
|
||||
formatBound={(value): string => `${value} ms`}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('DistributionTooltip', () => {
|
||||
it('names the bucket under the cursor and its total', () => {
|
||||
renderTooltip(plotAt(1.5, 2));
|
||||
|
||||
expect(screen.getByTestId('distribution-tooltip-range')).toHaveTextContent(
|
||||
'10 ms – 20 ms',
|
||||
);
|
||||
expect(screen.getByTestId('distribution-tooltip-total')).toHaveTextContent(
|
||||
'10',
|
||||
);
|
||||
});
|
||||
|
||||
it('heads the columns with the group-by key and the bucket bound', () => {
|
||||
renderTooltip(plotAt(1.5, 2));
|
||||
|
||||
expect(
|
||||
screen.getByTestId('distribution-tooltip-group-heading'),
|
||||
).toHaveTextContent('service.name');
|
||||
expect(
|
||||
screen.getByTestId('distribution-tooltip-cumulative-heading'),
|
||||
).toHaveTextContent('≤ 20 ms');
|
||||
});
|
||||
|
||||
it('lists every group in legend order, the hovered one active', () => {
|
||||
renderTooltip(plotAt(1.5, 2));
|
||||
|
||||
const rows = screen.getAllByTestId('distribution-tooltip-row');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toHaveTextContent('cart457%71%');
|
||||
expect(rows[0]).toHaveAttribute('data-is-active', 'true');
|
||||
expect(rows[1]).toHaveTextContent('checkout675%75%');
|
||||
expect(rows[1]).toHaveAttribute('data-is-active', 'false');
|
||||
});
|
||||
|
||||
it('renders nothing off the plot', () => {
|
||||
renderTooltip(plotAt(-1, -1));
|
||||
|
||||
expect(screen.queryByTestId('distribution-tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
buildDistributionTooltipRows,
|
||||
formatPercent,
|
||||
resolveGroupColumn,
|
||||
} from '../distributionTooltipContent';
|
||||
|
||||
describe('formatPercent', () => {
|
||||
it('rounds, and never shows a real share as 0%', () => {
|
||||
expect(formatPercent(0.256)).toBe('26%');
|
||||
expect(formatPercent(0.004)).toBe('<1%');
|
||||
expect(formatPercent(0)).toBe('0%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGroupColumn', () => {
|
||||
it('names the column after a shared single key and shows its values', () => {
|
||||
const column = resolveGroupColumn([
|
||||
'{service.name="cart"}',
|
||||
'{service.name="checkout"}',
|
||||
]);
|
||||
|
||||
expect(column.heading).toBe('service.name');
|
||||
expect(column.format('{service.name="cart"}')).toBe('cart');
|
||||
});
|
||||
|
||||
it('keeps full labels for several keys or mixed labels', () => {
|
||||
const several = resolveGroupColumn(['{env="prod", service.name="cart"}']);
|
||||
const mixed = resolveGroupColumn(['{service.name="cart"}', 'Other']);
|
||||
|
||||
expect(several.heading).toBe('Group');
|
||||
expect(several.format('{env="prod", service.name="cart"}')).toBe(
|
||||
'{env="prod", service.name="cart"}',
|
||||
);
|
||||
expect(mixed.heading).toBe('Group');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDistributionTooltipRows', () => {
|
||||
it('gives each drawn group, in legend order, its count, share and cumulative share', () => {
|
||||
const rows = buildDistributionTooltipRows({
|
||||
labels: ['cart', 'checkout'],
|
||||
colors: ['#f00', '#0f0'],
|
||||
counts: [
|
||||
[2, 6, 2],
|
||||
[5, 0, 5],
|
||||
],
|
||||
bucket: 1,
|
||||
hoveredSeries: 0,
|
||||
formatLabel: (label) => label.toUpperCase(),
|
||||
});
|
||||
|
||||
expect(rows).toStrictEqual([
|
||||
{
|
||||
seriesIndex: 0,
|
||||
label: 'CART',
|
||||
color: '#f00',
|
||||
count: 6,
|
||||
share: 0.6,
|
||||
cumulativeShare: 0.8,
|
||||
isHovered: true,
|
||||
},
|
||||
{
|
||||
seriesIndex: 1,
|
||||
label: 'CHECKOUT',
|
||||
color: '#0f0',
|
||||
count: 0,
|
||||
share: 0,
|
||||
cumulativeShare: 0.5,
|
||||
isHovered: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { DistributionGroupColumn, DistributionTooltipRow } from './types';
|
||||
|
||||
const SINGLE_LABEL = /^\{([^=,]+)="([^"]*)"\}$/;
|
||||
|
||||
/** `<1%` rather than `0%`, so a share that exists never reads as none. */
|
||||
export function formatPercent(share: number): string {
|
||||
if (share > 0 && share < 0.01) {
|
||||
return '<1%';
|
||||
}
|
||||
return `${Math.round(share * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the group column after the one group-by key every label shares, showing just
|
||||
* its values; anything else (several keys, Other) keeps the full labels.
|
||||
*/
|
||||
export function resolveGroupColumn(labels: string[]): DistributionGroupColumn {
|
||||
const keys = new Set<string>();
|
||||
labels.forEach((label) => {
|
||||
keys.add(SINGLE_LABEL.exec(label)?.[1] ?? '');
|
||||
});
|
||||
const [key] = [...keys];
|
||||
if (keys.size !== 1 || !key) {
|
||||
return { heading: 'Group', format: (label) => label };
|
||||
}
|
||||
return {
|
||||
heading: key,
|
||||
format: (label) => SINGLE_LABEL.exec(label)?.[2] ?? label,
|
||||
};
|
||||
}
|
||||
|
||||
/** One row per drawn group, in legend order, with its count in `bucket`. */
|
||||
export function buildDistributionTooltipRows({
|
||||
labels,
|
||||
colors,
|
||||
counts,
|
||||
bucket,
|
||||
hoveredSeries,
|
||||
formatLabel,
|
||||
}: {
|
||||
labels: string[];
|
||||
colors: string[];
|
||||
/** `counts[series][bucket]`. */
|
||||
counts: number[][];
|
||||
bucket: number;
|
||||
hoveredSeries: number | null;
|
||||
formatLabel: (label: string) => string;
|
||||
}): DistributionTooltipRow[] {
|
||||
return labels.map((label, series) => {
|
||||
const seriesCounts = counts[series] ?? [];
|
||||
let total = 0;
|
||||
let atOrBelow = 0;
|
||||
seriesCounts.forEach((count, index) => {
|
||||
total += count;
|
||||
if (index <= bucket) {
|
||||
atOrBelow += count;
|
||||
}
|
||||
});
|
||||
const count = seriesCounts[bucket] ?? 0;
|
||||
return {
|
||||
seriesIndex: series,
|
||||
label: formatLabel(label),
|
||||
color: colors[series],
|
||||
count,
|
||||
share: total > 0 ? count / total : 0,
|
||||
cumulativeShare: total > 0 ? atOrBelow / total : 0,
|
||||
isHovered: series === hoveredSeries,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type {
|
||||
IRenderTooltipFooterArgs,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
|
||||
import type { DistributionBarsLayout } from '../../layout';
|
||||
import type { DistributionXAxis } from '../../types';
|
||||
|
||||
export interface DistributionTooltipRow {
|
||||
/** Index among the drawn series. */
|
||||
seriesIndex: number;
|
||||
label: string;
|
||||
color: string;
|
||||
count: number;
|
||||
/** Of the group's own total over the range. */
|
||||
share: number;
|
||||
/** Of the group's total, at or below this bucket. */
|
||||
cumulativeShare: number;
|
||||
isHovered: boolean;
|
||||
}
|
||||
|
||||
export interface DistributionGroupColumn {
|
||||
heading: string;
|
||||
format: (label: string) => string;
|
||||
}
|
||||
|
||||
export interface DistributionTooltipProps extends Pick<
|
||||
TooltipRenderArgs,
|
||||
'uPlotInstance' | 'isPinned' | 'dismiss'
|
||||
> {
|
||||
xAxis: DistributionXAxis;
|
||||
layout: DistributionBarsLayout;
|
||||
labels: string[];
|
||||
colors: string[];
|
||||
/** `counts[series][bucket]`. */
|
||||
counts: number[][];
|
||||
formatBound: (value: number) => string;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
}
|
||||
288
frontend/src/lib/visualization/charts/Distribution/config.ts
Normal file
288
frontend/src/lib/visualization/charts/Distribution/config.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import {
|
||||
DistributionType,
|
||||
DrawStyle,
|
||||
StackMode,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { createVariableWidthBarsPathBuilder } from 'lib/uPlotV2/paths/variableWidthBars';
|
||||
import { resolveRowIndex } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { createHatchPattern } from 'lib/uPlotV2/plugins/HeatmapPlugin/paint';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { withOpacity } from './colors';
|
||||
import {
|
||||
drawPercentileMarkers,
|
||||
MARKER_HIT_PX,
|
||||
PercentileMarkerHitArea,
|
||||
resolveHoveredMarker,
|
||||
} from './drawPercentileMarkers';
|
||||
import {
|
||||
DistributionBarsLayout,
|
||||
LOG_Y_BASE,
|
||||
resolveNearestSeries,
|
||||
thinLabelledSplits,
|
||||
} from './layout';
|
||||
import { DistributionPercentile, groupPercentileMarkers } from './percentiles';
|
||||
import { DistributionXAxis } from './types';
|
||||
import { createBoundFormatter } from './utils';
|
||||
|
||||
/** Minimum space between two x tick labels, in CSS pixels. */
|
||||
const X_LABEL_GAP_PX = 12;
|
||||
const BAR_GAP_PX = 1;
|
||||
const BAR_OPACITY = 0.85;
|
||||
const OVERFLOW_BAR_OPACITY = 0.4;
|
||||
/** Every group but the hovered one fades to this. */
|
||||
const DIMMED_BAR_OPACITY = 0.25;
|
||||
const DIMMED_OVERFLOW_BAR_OPACITY = 0.12;
|
||||
const OVERFLOW_AXIS_LABEL = '∞';
|
||||
/** Headroom above the tallest bar: a fraction on linear, a factor on log. Marker
|
||||
* labels sit at the top of the plot, so they take more. */
|
||||
const LINEAR_HEADROOM = 1.1;
|
||||
const LINEAR_HEADROOM_WITH_MARKERS = 1.3;
|
||||
const LOG_HEADROOM = 2;
|
||||
const LOG_HEADROOM_WITH_MARKERS = 6;
|
||||
|
||||
function resolveYRange({
|
||||
yMax,
|
||||
isLogY,
|
||||
stack,
|
||||
hasMarkers,
|
||||
}: {
|
||||
yMax: number;
|
||||
isLogY: boolean;
|
||||
stack: StackMode;
|
||||
hasMarkers: boolean;
|
||||
}): [number, number] {
|
||||
if (stack === StackMode.Percent) {
|
||||
return [0, hasMarkers ? 100 * LINEAR_HEADROOM_WITH_MARKERS : 100];
|
||||
}
|
||||
if (isLogY) {
|
||||
const headroom = hasMarkers ? LOG_HEADROOM_WITH_MARKERS : LOG_HEADROOM;
|
||||
return [LOG_Y_BASE, Math.max(yMax, LOG_Y_BASE) * headroom];
|
||||
}
|
||||
const headroom = hasMarkers ? LINEAR_HEADROOM_WITH_MARKERS : LINEAR_HEADROOM;
|
||||
return [0, yMax > 0 ? yMax * headroom : 1];
|
||||
}
|
||||
|
||||
export interface BuildDistributionConfigArgs {
|
||||
id: string;
|
||||
xAxis: DistributionXAxis;
|
||||
layout: DistributionBarsLayout;
|
||||
labels: string[];
|
||||
colors: string[];
|
||||
stack: StackMode;
|
||||
isLogY: boolean;
|
||||
isDarkMode: boolean;
|
||||
/** Drawn across the plot, labelled at the top. */
|
||||
markers: DistributionPercentile[];
|
||||
/** Unit of the bucket bounds; counts take none. */
|
||||
xAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}
|
||||
|
||||
export function buildDistributionConfig({
|
||||
id,
|
||||
xAxis,
|
||||
layout,
|
||||
labels,
|
||||
colors,
|
||||
stack,
|
||||
isLogY,
|
||||
isDarkMode,
|
||||
markers,
|
||||
xAxisUnit,
|
||||
decimalPrecision,
|
||||
}: BuildDistributionConfigArgs): UPlotConfigBuilder {
|
||||
const builder = new UPlotConfigBuilder({ id });
|
||||
// uPlot's focus picks the series nearest in value, meaningless for stacked bars; the
|
||||
// setCursor hook focuses the one under the cursor itself. Alpha 1 leaves the
|
||||
// dimming to the bar fills.
|
||||
builder.setFocus({ alpha: 1 });
|
||||
// The x range is pinned to the buckets, so a drag-zoom would only flash a box.
|
||||
builder.setCursor({
|
||||
focus: { prox: -1 },
|
||||
points: { show: false },
|
||||
drag: { x: false, y: false },
|
||||
});
|
||||
|
||||
const formatBound = createBoundFormatter(xAxisUnit, decimalPrecision);
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: false,
|
||||
auto: false,
|
||||
range: (): [number, number] => [xAxis.min, xAxis.max],
|
||||
});
|
||||
const yRange = resolveYRange({
|
||||
yMax: layout.yMax,
|
||||
isLogY,
|
||||
stack,
|
||||
hasMarkers: markers.length > 0,
|
||||
});
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
auto: false,
|
||||
distribution: isLogY ? DistributionType.Logarithmic : DistributionType.Linear,
|
||||
range: (): [number, number] => yRange,
|
||||
});
|
||||
|
||||
const axisSplits =
|
||||
xAxis.overflowSplit === null
|
||||
? xAxis.splits
|
||||
: [...xAxis.splits, xAxis.overflowSplit];
|
||||
const splitLabels = new Map<number, string>();
|
||||
xAxis.splits.forEach((split, index) => {
|
||||
splitLabels.set(split, formatBound(xAxis.rows[index].upper));
|
||||
});
|
||||
if (xAxis.overflowSplit !== null) {
|
||||
splitLabels.set(xAxis.overflowSplit, OVERFLOW_AXIS_LABEL);
|
||||
}
|
||||
|
||||
const labelOf = (split: number): string =>
|
||||
splitLabels.get(split) ?? formatBound(xAxis.toBucketValue(split));
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
// Thinned by each label's own width: bound labels run from `5 s` to
|
||||
// `16.67 mins`, so no fixed spacing keeps them apart.
|
||||
splits: (self, axisIdx): number[] => {
|
||||
const visible = axisSplits.filter(
|
||||
(split) => split >= xAxis.min && split <= xAxis.max,
|
||||
);
|
||||
const font = self.axes[axisIdx]?.font?.[0];
|
||||
if (font) {
|
||||
self.ctx.font = font;
|
||||
}
|
||||
const kept = thinLabelledSplits({
|
||||
positions: visible.map((split) => self.valToPos(split, 'x')),
|
||||
widths: visible.map(
|
||||
(split) => self.ctx.measureText(labelOf(split)).width / uPlot.pxRatio,
|
||||
),
|
||||
gap: X_LABEL_GAP_PX,
|
||||
});
|
||||
return kept.map((index) => visible[index]);
|
||||
},
|
||||
values: (_, splits): string[] => splits.map(labelOf),
|
||||
});
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit: stack === StackMode.Percent ? 'percent' : 'short',
|
||||
// The marker headroom runs a share axis past 100%, which labels nothing real.
|
||||
...(stack === StackMode.Percent && {
|
||||
filter: (_self: uPlot, splits: number[]): Array<number | null> =>
|
||||
splits.map((split) => (split <= 100 ? split : null)),
|
||||
}),
|
||||
});
|
||||
|
||||
let hatchPattern: CanvasPattern | null | undefined;
|
||||
const getHatchPattern = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
): CanvasPattern | null => {
|
||||
hatchPattern ??= createHatchPattern(ctx, isDarkMode);
|
||||
return hatchPattern;
|
||||
};
|
||||
|
||||
// Read by every series' fill on each draw; a change redraws the paths.
|
||||
const hover: {
|
||||
series: number | null;
|
||||
focused: number | null;
|
||||
marker: number | null;
|
||||
} = { series: null, focused: null, marker: null };
|
||||
const plotMarkers = groupPercentileMarkers(
|
||||
markers.filter(
|
||||
({ axisValue }) => axisValue >= xAxis.min && axisValue <= xAxis.max,
|
||||
),
|
||||
);
|
||||
let markerHitAreas: PercentileMarkerHitArea[] = [];
|
||||
const isDimmed = (seriesIndex: number): boolean =>
|
||||
hover.series !== null && hover.series !== seriesIndex;
|
||||
|
||||
labels.forEach((label, seriesIndex) => {
|
||||
const color = colors[seriesIndex];
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
label,
|
||||
drawStyle: DrawStyle.Bar,
|
||||
pathBuilder: createVariableWidthBarsPathBuilder({
|
||||
getBars: () => layout.rects[seriesIndex] ?? [],
|
||||
fill: () =>
|
||||
withOpacity(
|
||||
color,
|
||||
isDimmed(seriesIndex) ? DIMMED_BAR_OPACITY : BAR_OPACITY,
|
||||
),
|
||||
hatchedFill: () =>
|
||||
withOpacity(
|
||||
color,
|
||||
isDimmed(seriesIndex)
|
||||
? DIMMED_OVERFLOW_BAR_OPACITY
|
||||
: OVERFLOW_BAR_OPACITY,
|
||||
),
|
||||
getHatchPattern,
|
||||
gapPx: BAR_GAP_PX,
|
||||
}),
|
||||
lineColor: color,
|
||||
lineWidth: 0,
|
||||
colorMapping: {},
|
||||
isDarkMode,
|
||||
showPoints: false,
|
||||
spanGaps: false,
|
||||
});
|
||||
});
|
||||
|
||||
builder.addHook('draw', (u: uPlot): void => {
|
||||
markerHitAreas = drawPercentileMarkers(
|
||||
u,
|
||||
plotMarkers,
|
||||
formatBound,
|
||||
isDarkMode,
|
||||
hover.marker,
|
||||
);
|
||||
});
|
||||
|
||||
// Registered as a hook, not a plugin, so it runs before TooltipPlugin's: the tooltip
|
||||
// only shows for a focused series, and must find it focused when it positions.
|
||||
builder.addHook('setCursor', (u: uPlot): void => {
|
||||
const { left = -1, top = -1 } = u.cursor;
|
||||
const isOnPlot = left >= 0 && top >= 0;
|
||||
const x = isOnPlot ? u.posToVal(left, 'x') : Number.NaN;
|
||||
const bucket = isOnPlot ? resolveRowIndex(xAxis.edges, x) : null;
|
||||
const marker = isOnPlot
|
||||
? resolveHoveredMarker(markerHitAreas, left, top, MARKER_HIT_PX)
|
||||
: null;
|
||||
// Over a marker, the bars neither dim nor show their tooltip.
|
||||
const hovered =
|
||||
bucket === null || marker !== null
|
||||
? null
|
||||
: resolveNearestSeries(layout, bucket, x, u.posToVal(top, 'y'));
|
||||
// An empty bucket still gets a tooltip, as a bar chart's zero column does; with no
|
||||
// nearest group to name, the first stands in.
|
||||
const isEmptyBucket = bucket !== null && marker === null && hovered === null;
|
||||
const focused = isEmptyBucket && labels.length > 0 ? 0 : hovered;
|
||||
|
||||
if (focused !== hover.focused) {
|
||||
hover.focused = focused;
|
||||
u.setSeries(focused === null ? null : focused + 1, { focus: true });
|
||||
}
|
||||
if (marker !== hover.marker) {
|
||||
// The crosshair would cut through the marker's badges.
|
||||
u.root
|
||||
.querySelectorAll<HTMLElement>('.u-cursor-x, .u-cursor-y')
|
||||
.forEach((line) => {
|
||||
line.style.visibility = marker === null ? '' : 'hidden';
|
||||
});
|
||||
}
|
||||
if (hovered !== hover.series || marker !== hover.marker) {
|
||||
hover.series = hovered;
|
||||
hover.marker = marker;
|
||||
u.redraw(true, false);
|
||||
}
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
DistributionPercentile,
|
||||
formatPercentileBadge,
|
||||
formatPercentileValue,
|
||||
} from './percentiles';
|
||||
|
||||
const MARKER_DASH: [number, number] = [3, 3];
|
||||
const MARKER_FONT_SIZE_PX = 9;
|
||||
/** Space between a badge's name and its diamond's edges. */
|
||||
const MARKER_BADGE_PADDING_PX = 2;
|
||||
const MARKER_BADGE_GAP_PX = 2;
|
||||
const MARKER_VALUE_FONT_SIZE_PX = 11;
|
||||
/** How far from a marker line the cursor still hovers it, in CSS pixels. */
|
||||
export const MARKER_HIT_PX = 4;
|
||||
const MARKER_PILL_PADDING_X_PX = 6;
|
||||
const MARKER_PILL_PADDING_Y_PX = 3;
|
||||
const MARKER_PILL_RADIUS_PX = 4;
|
||||
/** Height of the pill's notch pointing up at the line; also its gap from the axis. */
|
||||
const MARKER_PILL_POINTER_PX = 4;
|
||||
|
||||
interface HitRect {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
/** A drawn marker's hover targets, in CSS pixels relative to the plot. */
|
||||
export interface PercentileMarkerHitArea {
|
||||
x: number;
|
||||
badges: HitRect[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker under `(x, y)`: one whose badge it is over, else the line nearest
|
||||
* `x` within `tolerance`. `null` when none is.
|
||||
*/
|
||||
export function resolveHoveredMarker(
|
||||
areas: PercentileMarkerHitArea[],
|
||||
x: number,
|
||||
y: number,
|
||||
tolerance: number,
|
||||
): number | null {
|
||||
const overBadge = areas.findIndex(({ badges }) =>
|
||||
badges.some(
|
||||
(badge) =>
|
||||
x >= badge.left && x <= badge.right && y >= badge.top && y <= badge.bottom,
|
||||
),
|
||||
);
|
||||
if (overBadge !== -1) {
|
||||
return overBadge;
|
||||
}
|
||||
let nearest: number | null = null;
|
||||
let nearestDistance = tolerance;
|
||||
areas.forEach((area, index) => {
|
||||
const distance = Math.abs(area.x - x);
|
||||
if (distance <= nearestDistance) {
|
||||
nearest = index;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
});
|
||||
return nearest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stacks marker badges into rows so none overlap: each goes in the first row whose
|
||||
* last badge ends before it starts. Input and output are in the same pixel space.
|
||||
*/
|
||||
export function layoutMarkerLabels(
|
||||
labels: Array<{ x: number; width: number }>,
|
||||
gap: number,
|
||||
): number[] {
|
||||
const rowEnds: number[] = [];
|
||||
const order = labels
|
||||
.map((label, index) => ({ ...label, index }))
|
||||
.sort((a, b) => a.x - b.x);
|
||||
const rows = new Array<number>(labels.length).fill(0);
|
||||
|
||||
order.forEach(({ x, width, index }) => {
|
||||
let row = rowEnds.findIndex((end) => x >= end + gap);
|
||||
if (row === -1) {
|
||||
row = rowEnds.length;
|
||||
rowEnds.push(0);
|
||||
}
|
||||
rowEnds[row] = x + width;
|
||||
rows[index] = row;
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** A square diamond centred on `(x, y)` with half-diagonal `r`, filled then outlined. */
|
||||
function drawDiamond(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
r: number,
|
||||
fill: string,
|
||||
stroke: string,
|
||||
): void {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y - r);
|
||||
ctx.lineTo(x + r, y);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.lineTo(x - r, y);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = fill;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashed lines across the plot with a diamond badge at each end naming the
|
||||
* percentile, one per group on the same spot; badges stack into rows so none overlap. The hovered marker is
|
||||
* emphasised and shows its value on the x axis. Returns each marker's hover targets.
|
||||
*/
|
||||
export function drawPercentileMarkers(
|
||||
u: uPlot,
|
||||
markers: DistributionPercentile[][],
|
||||
formatBound: (value: number) => string,
|
||||
isDarkMode: boolean,
|
||||
hovered: number | null,
|
||||
): PercentileMarkerHitArea[] {
|
||||
if (markers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const { ctx, bbox } = u;
|
||||
const { pxRatio } = uPlot;
|
||||
const color = isDarkMode ? themeColors.white : themeColors.black;
|
||||
const background = isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100;
|
||||
const bottom = bbox.top + bbox.height;
|
||||
|
||||
ctx.save();
|
||||
ctx.font = `600 ${MARKER_FONT_SIZE_PX * pxRatio}px Inter, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const named = markers.map((group) => {
|
||||
const text = formatPercentileBadge(group);
|
||||
return { text, width: ctx.measureText(text).width };
|
||||
});
|
||||
// One square diamond for all, fitting the widest name's box: |dx| + |dy| ≤ r at
|
||||
// its corners. Cap height stands in for the text's height.
|
||||
const r =
|
||||
(Math.max(...named.map(({ width }) => width)) +
|
||||
MARKER_FONT_SIZE_PX * pxRatio * 0.7) /
|
||||
2 +
|
||||
MARKER_BADGE_PADDING_PX * pxRatio;
|
||||
const badges = markers.map((group, index) => ({
|
||||
text: named[index].text,
|
||||
x: u.valToPos(group[0].axisValue, 'x', true),
|
||||
}));
|
||||
const rows = layoutMarkerLabels(
|
||||
badges.map(({ x }) => ({ x: x - r, width: 2 * r })),
|
||||
MARKER_BADGE_GAP_PX * pxRatio,
|
||||
);
|
||||
const rowStep = 2 * r + MARKER_BADGE_GAP_PX * pxRatio;
|
||||
const badgeCentres = (index: number): [number, number] => {
|
||||
const offset = r + rows[index] * rowStep;
|
||||
return [bbox.top + offset, bottom - offset];
|
||||
};
|
||||
|
||||
const drawMarker = (index: number): void => {
|
||||
const { text, x } = badges[index];
|
||||
const isHovered = index === hovered;
|
||||
|
||||
ctx.globalAlpha = isHovered ? 1 : 0.55;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = pxRatio;
|
||||
ctx.setLineDash(MARKER_DASH.map((dash) => dash * pxRatio));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, bbox.top);
|
||||
ctx.lineTo(x, bottom);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.setLineDash([]);
|
||||
badgeCentres(index).forEach((y) => {
|
||||
drawDiamond(ctx, x, y, r, isHovered ? color : background, color);
|
||||
ctx.fillStyle = isHovered ? background : color;
|
||||
ctx.fillText(text, x, y);
|
||||
});
|
||||
|
||||
if (!isHovered) {
|
||||
return;
|
||||
}
|
||||
// The value sits on the x axis under the line, over the tick labels, in a pill
|
||||
// whose notch points up at the line.
|
||||
ctx.font = `500 ${MARKER_VALUE_FONT_SIZE_PX * pxRatio}px Inter, sans-serif`;
|
||||
const value = formatPercentileValue(markers[index], formatBound);
|
||||
const padX = MARKER_PILL_PADDING_X_PX * pxRatio;
|
||||
const padY = MARKER_PILL_PADDING_Y_PX * pxRatio;
|
||||
const pointer = MARKER_PILL_POINTER_PX * pxRatio;
|
||||
const width = ctx.measureText(value).width + 2 * padX;
|
||||
const height = MARKER_VALUE_FONT_SIZE_PX * pxRatio + 2 * padY;
|
||||
const left = Math.min(Math.max(x - width / 2, 0), ctx.canvas.width - width);
|
||||
const top = bottom + 2 * pointer;
|
||||
const notchX = Math.min(
|
||||
Math.max(x, left + pointer + MARKER_PILL_RADIUS_PX * pxRatio),
|
||||
left + width - pointer - MARKER_PILL_RADIUS_PX * pxRatio,
|
||||
);
|
||||
// A halo in the chart's background hides the tick labels it overlaps.
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(left - padX, top, width + 2 * padX, height + 2 * padY);
|
||||
ctx.fillStyle = isDarkMode ? Color.BG_SLATE_400 : Color.BG_INK_400;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(left, top, width, height, MARKER_PILL_RADIUS_PX * pxRatio);
|
||||
ctx.moveTo(notchX - pointer, top);
|
||||
ctx.lineTo(notchX, top - pointer);
|
||||
ctx.lineTo(notchX + pointer, top);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.fillStyle = Color.BG_VANILLA_100;
|
||||
ctx.fillText(value, left + width / 2, top + height / 2);
|
||||
};
|
||||
|
||||
// The hovered one last, so it draws over its neighbours.
|
||||
badges.forEach((_, index) => index !== hovered && drawMarker(index));
|
||||
if (hovered !== null && hovered < badges.length) {
|
||||
drawMarker(hovered);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
return badges.map(({ x }, index) => {
|
||||
const toPlot = (value: number, origin: number): number =>
|
||||
(value - origin) / pxRatio;
|
||||
return {
|
||||
x: toPlot(x, bbox.left),
|
||||
badges: badgeCentres(index).map((y) => ({
|
||||
left: toPlot(x - r, bbox.left),
|
||||
right: toPlot(x + r, bbox.left),
|
||||
top: toPlot(y - r, bbox.top),
|
||||
bottom: toPlot(y + r, bbox.top),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
232
frontend/src/lib/visualization/charts/Distribution/layout.ts
Normal file
232
frontend/src/lib/visualization/charts/Distribution/layout.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import {
|
||||
cropHeatmapYAxis,
|
||||
resolveHeatmapYAxis,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { HeatmapAxisScale } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
import { DistributionBarRect, DistributionXAxis } from './types';
|
||||
|
||||
/** Bottom of a log count axis: a bar has to start somewhere above zero. */
|
||||
export const LOG_Y_BASE = 1;
|
||||
|
||||
/**
|
||||
* N bounds give N + 1 buckets: `≤ first`, the finite ones, and `> last`. Every bucket
|
||||
* is equally wide: bucket `i` spans `[i, i + 1]`, and ticks still name the bounds.
|
||||
*/
|
||||
export function resolveDistributionXAxis(buckets: number[]): DistributionXAxis {
|
||||
const { rows, overflowSplit } = resolveHeatmapYAxis(
|
||||
buckets,
|
||||
HeatmapAxisScale.Linear,
|
||||
);
|
||||
const edges = rows.map((_, index) => index).concat(rows.length);
|
||||
return {
|
||||
rows,
|
||||
edges,
|
||||
splits: rows.slice(0, -1).map((_, index) => index + 1),
|
||||
overflowSplit: overflowSplit === null ? null : rows.length,
|
||||
toBucketValue: (axisValue: number): number => {
|
||||
const index = Math.min(rows.length - 1, Math.max(0, Math.floor(axisValue)));
|
||||
const row = rows[index];
|
||||
return row.lower + (axisValue - index) * (row.upper - row.lower);
|
||||
},
|
||||
min: 0,
|
||||
max: rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sum of each bucket across the visible series. */
|
||||
export function sumVisibleCounts(
|
||||
counts: number[][],
|
||||
visible: boolean[],
|
||||
bucketCount: number,
|
||||
): number[] {
|
||||
const totals = new Array<number>(bucketCount).fill(0);
|
||||
counts.forEach((seriesCounts, seriesIndex) => {
|
||||
if (!visible[seriesIndex]) {
|
||||
return;
|
||||
}
|
||||
for (let bucket = 0; bucket < bucketCount; bucket += 1) {
|
||||
totals[bucket] += seriesCounts[bucket] ?? 0;
|
||||
}
|
||||
});
|
||||
return totals;
|
||||
}
|
||||
|
||||
/** Drops empty buckets past the last occupied one, as the heatmap does. */
|
||||
export function cropDistributionXAxis(
|
||||
axis: DistributionXAxis,
|
||||
totals: number[],
|
||||
): DistributionXAxis {
|
||||
return cropHeatmapYAxis(
|
||||
axis,
|
||||
totals.map((total) => [total]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows the axis window to the buckets overlapping `[min, max]`, never below one.
|
||||
* A bucket straddling a bound stays whole: cutting it would misstate its count.
|
||||
*/
|
||||
export function clampDistributionXAxis(
|
||||
axis: DistributionXAxis,
|
||||
min?: number,
|
||||
max?: number,
|
||||
): DistributionXAxis {
|
||||
const { rows, edges } = axis;
|
||||
let first = edges.indexOf(axis.min);
|
||||
let last = edges.indexOf(axis.max) - 1;
|
||||
while (min !== undefined && first < last && rows[first].upper <= min) {
|
||||
first += 1;
|
||||
}
|
||||
while (max !== undefined && last > first && rows[last].lower >= max) {
|
||||
last -= 1;
|
||||
}
|
||||
return {
|
||||
...axis,
|
||||
overflowSplit: rows[last].isOverflow ? axis.overflowSplit : null,
|
||||
min: edges[first],
|
||||
max: edges[last + 1],
|
||||
};
|
||||
}
|
||||
|
||||
export interface DistributionBarsLayout {
|
||||
/** One list per series, in series order. */
|
||||
rects: DistributionBarRect[][];
|
||||
/** Top of the tallest bar inside the axis window, for the count axis range. */
|
||||
yMax: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every bar, in scale space. `normal` stacks the series in each bucket, `percent`
|
||||
* stacks their shares of it, `none` splits the bucket width between them.
|
||||
*/
|
||||
export function resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
stack,
|
||||
isLogY,
|
||||
}: {
|
||||
xAxis: DistributionXAxis;
|
||||
/** `counts[series][bucket]`. */
|
||||
counts: number[][];
|
||||
stack: StackMode;
|
||||
isLogY: boolean;
|
||||
}): DistributionBarsLayout {
|
||||
const { rows, edges } = xAxis;
|
||||
const rects: DistributionBarRect[][] = counts.map(() => []);
|
||||
const base = isLogY ? LOG_Y_BASE : 0;
|
||||
let yMax = 0;
|
||||
|
||||
rows.forEach((row, bucketIndex) => {
|
||||
const x0 = edges[bucketIndex];
|
||||
const x1 = edges[bucketIndex + 1];
|
||||
// Buckets outside the window are off the plot and mustn't set its height.
|
||||
const isInWindow = x0 >= xAxis.min && x1 <= xAxis.max;
|
||||
const total = counts.reduce(
|
||||
(sum, seriesCounts) => sum + (seriesCounts[bucketIndex] ?? 0),
|
||||
0,
|
||||
);
|
||||
let cumulative = 0;
|
||||
|
||||
counts.forEach((seriesCounts, seriesIndex) => {
|
||||
const count = seriesCounts[bucketIndex] ?? 0;
|
||||
if (count <= 0) {
|
||||
return;
|
||||
}
|
||||
const bar = { bucketIndex, hatched: row.isOverflow };
|
||||
|
||||
if (stack === StackMode.None) {
|
||||
// Below the log base a bar would draw downwards from the axis.
|
||||
if (count <= base) {
|
||||
return;
|
||||
}
|
||||
const width = (x1 - x0) / counts.length;
|
||||
rects[seriesIndex].push({
|
||||
...bar,
|
||||
x0: x0 + seriesIndex * width,
|
||||
x1: x0 + (seriesIndex + 1) * width,
|
||||
y0: base,
|
||||
y1: count,
|
||||
});
|
||||
if (isInWindow) {
|
||||
yMax = Math.max(yMax, count);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const scale = stack === StackMode.Percent ? 100 / total : 1;
|
||||
const y0 = cumulative * scale;
|
||||
cumulative += count;
|
||||
const y1 = cumulative * scale;
|
||||
if (y1 <= base) {
|
||||
return;
|
||||
}
|
||||
rects[seriesIndex].push({
|
||||
...bar,
|
||||
x0,
|
||||
x1,
|
||||
y0: Math.max(y0, base),
|
||||
y1,
|
||||
});
|
||||
if (isInWindow) {
|
||||
yMax = Math.max(yMax, y1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { rects, yMax };
|
||||
}
|
||||
|
||||
/**
|
||||
* The group nearest `(x, y)` vertically in `bucket`, as uPlot focuses bar series;
|
||||
* side by side, only the sub-bar under `x` competes. `null` over an empty bucket.
|
||||
*/
|
||||
export function resolveNearestSeries(
|
||||
layout: DistributionBarsLayout,
|
||||
bucket: number,
|
||||
x: number,
|
||||
y: number,
|
||||
): number | null {
|
||||
const candidates = layout.rects.flatMap((rects, series) =>
|
||||
rects
|
||||
.filter((rect) => rect.bucketIndex === bucket)
|
||||
.map((rect) => ({ rect, series })),
|
||||
);
|
||||
const underX = candidates.filter(({ rect }) => x >= rect.x0 && x < rect.x1);
|
||||
let nearest: number | null = null;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
(underX.length > 0 ? underX : candidates).forEach(({ rect, series }) => {
|
||||
const distance = Math.max(rect.y0 - y, y - rect.y1, 0);
|
||||
if (distance < nearestDistance) {
|
||||
nearest = series;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
});
|
||||
return nearest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexes of the ticks whose centred labels fit without touching, walking from the
|
||||
* right so the `∞` edge survives. Positions and widths share one pixel space.
|
||||
*/
|
||||
export function thinLabelledSplits({
|
||||
positions,
|
||||
widths,
|
||||
gap,
|
||||
}: {
|
||||
positions: number[];
|
||||
widths: number[];
|
||||
gap: number;
|
||||
}): number[] {
|
||||
const kept: number[] = [];
|
||||
let leftEdge = Number.POSITIVE_INFINITY;
|
||||
for (let index = positions.length - 1; index >= 0; index -= 1) {
|
||||
const half = widths[index] / 2;
|
||||
if (positions[index] + half + gap <= leftEdge) {
|
||||
kept.push(index);
|
||||
leftEdge = positions[index] - half;
|
||||
}
|
||||
}
|
||||
return kept.reverse();
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { DistributionXAxis } from './types';
|
||||
|
||||
export interface DistributionPercentile {
|
||||
/** 0–100, as configured. */
|
||||
percentile: number;
|
||||
/** The estimate; in overflow, the last finite bound it is known to exceed. */
|
||||
value: number;
|
||||
/** Where the marker is drawn, in x-scale space. */
|
||||
axisValue: number;
|
||||
isOverflow: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Percentiles of the summed counts, by `histogram_quantile`'s method: find the bucket
|
||||
* holding the target rank and interpolate linearly inside it, with the lowest bucket
|
||||
* starting at zero. So the values match a time-series panel's percentiles.
|
||||
*/
|
||||
export function computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals,
|
||||
percentiles,
|
||||
}: {
|
||||
xAxis: DistributionXAxis;
|
||||
/** Summed visible counts per bucket. */
|
||||
totals: number[];
|
||||
percentiles: readonly number[];
|
||||
}): DistributionPercentile[] {
|
||||
const { rows, edges } = xAxis;
|
||||
const total = totals.reduce((sum, count) => sum + count, 0);
|
||||
if (total <= 0 || rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return percentiles.flatMap<DistributionPercentile>((percentile) => {
|
||||
const rank = (percentile / 100) * total;
|
||||
let below = 0;
|
||||
for (let index = 0; index < rows.length; index += 1) {
|
||||
const count = totals[index] ?? 0;
|
||||
if (count > 0 && below + count >= rank) {
|
||||
const row = rows[index];
|
||||
if (row.isOverflow) {
|
||||
return [
|
||||
{
|
||||
percentile,
|
||||
value: row.lower,
|
||||
axisValue: edges[index],
|
||||
isOverflow: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
const fraction = (rank - below) / count;
|
||||
const lower = row.isUnderflow ? Math.min(0, row.upper) : row.lower;
|
||||
return [
|
||||
{
|
||||
percentile,
|
||||
value: lower + fraction * (row.upper - lower),
|
||||
axisValue: edges[index] + fraction * (edges[index + 1] - edges[index]),
|
||||
isOverflow: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
below += count;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export function formatPercentileName(percentile: number): string {
|
||||
return `p${Number(percentile.toFixed(3))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markers that fall on the same spot, sharing one badge: the percentiles landing
|
||||
* in overflow all sit on its edge. Each group is in ascending percentile order.
|
||||
*/
|
||||
export function groupPercentileMarkers(
|
||||
markers: DistributionPercentile[],
|
||||
): DistributionPercentile[][] {
|
||||
const groups = new Map<number, DistributionPercentile[]>();
|
||||
markers.forEach((marker) => {
|
||||
groups.set(marker.axisValue, [
|
||||
...(groups.get(marker.axisValue) ?? []),
|
||||
marker,
|
||||
]);
|
||||
});
|
||||
return [...groups.values()].map((group) =>
|
||||
[...group].sort((a, b) => a.percentile - b.percentile),
|
||||
);
|
||||
}
|
||||
|
||||
/** `p90` for one marker; `p90+` for a group, starting at its lowest percentile. */
|
||||
export function formatPercentileBadge(group: DistributionPercentile[]): string {
|
||||
const name = formatPercentileName(group[0].percentile);
|
||||
return group.length > 1 ? `${name}+` : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* `p99 ≈ 412 ms` for an estimate; `p90, p99 > 5 s` where only the overflow bound is
|
||||
* known. A group shares one spot, so one value and overflow flag.
|
||||
*/
|
||||
export function formatPercentileValue(
|
||||
group: DistributionPercentile[],
|
||||
formatBound: (value: number) => string,
|
||||
): string {
|
||||
const names = group
|
||||
.map(({ percentile }) => formatPercentileName(percentile))
|
||||
.join(', ');
|
||||
const [{ value, isOverflow }] = group;
|
||||
return `${names} ${isOverflow ? '>' : '≈'} ${formatBound(value)}`;
|
||||
}
|
||||
133
frontend/src/lib/visualization/charts/Distribution/topGroups.ts
Normal file
133
frontend/src/lib/visualization/charts/Distribution/topGroups.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { DistributionSeries, DistributionXAxis } from './types';
|
||||
|
||||
/** Colours stop being told apart past this many stacked groups. */
|
||||
export const MAX_STACKED_GROUPS = 10;
|
||||
/** Narrowest a side-by-side bar may get, in CSS pixels. */
|
||||
const MIN_SIDE_BY_SIDE_BAR_PX = 3;
|
||||
export const OTHER_GROUP_LABEL = 'Other';
|
||||
|
||||
/**
|
||||
* How many series fit: a fixed cap when stacked, and side by side as many as keep
|
||||
* every bar in the narrowest bucket at least `MIN_SIDE_BY_SIDE_BAR_PX` wide.
|
||||
*/
|
||||
export function resolveMaxDrawnGroups({
|
||||
stack,
|
||||
xAxis,
|
||||
plotWidth,
|
||||
}: {
|
||||
stack: StackMode;
|
||||
xAxis: DistributionXAxis;
|
||||
/** Approximate width of the plotting area, in CSS pixels. */
|
||||
plotWidth: number;
|
||||
}): number {
|
||||
if (stack !== StackMode.None) {
|
||||
return MAX_STACKED_GROUPS;
|
||||
}
|
||||
const span = xAxis.max - xAxis.min;
|
||||
let narrowest = Number.POSITIVE_INFINITY;
|
||||
for (let index = 0; index + 1 < xAxis.edges.length; index += 1) {
|
||||
if (xAxis.edges[index] < xAxis.max) {
|
||||
narrowest = Math.min(narrowest, xAxis.edges[index + 1] - xAxis.edges[index]);
|
||||
}
|
||||
}
|
||||
if (!(span > 0) || !Number.isFinite(narrowest)) {
|
||||
return MAX_STACKED_GROUPS;
|
||||
}
|
||||
const narrowestPx = (plotWidth * narrowest) / span;
|
||||
return Math.min(
|
||||
MAX_STACKED_GROUPS,
|
||||
Math.max(1, Math.floor(narrowestPx / MIN_SIDE_BY_SIDE_BAR_PX)),
|
||||
);
|
||||
}
|
||||
|
||||
export interface DrawnGroups {
|
||||
labels: string[];
|
||||
counts: number[][];
|
||||
colors: string[];
|
||||
/** Each drawn group's index among the source series; `null` for Other. */
|
||||
sourceIndexes: (number | null)[];
|
||||
/** Visible groups summed into the Other series; 0 when none were. */
|
||||
otherGroupCount: number;
|
||||
/** Visible groups in all, drawn or folded. */
|
||||
visibleGroupCount: number;
|
||||
}
|
||||
|
||||
function sum(counts: number[]): number {
|
||||
return counts.reduce((total, count) => total + count, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The visible groups to draw: all of them when they fit, else the largest by total
|
||||
* and one Other series summing the rest. Exact, because bucket counts add. Drawn
|
||||
* groups keep legend order.
|
||||
*/
|
||||
export function groupIntoOther({
|
||||
labels,
|
||||
counts,
|
||||
colors,
|
||||
visible,
|
||||
maxGroups,
|
||||
otherColor,
|
||||
}: {
|
||||
labels: string[];
|
||||
counts: number[][];
|
||||
colors: string[];
|
||||
visible: boolean[];
|
||||
maxGroups: number;
|
||||
otherColor: string;
|
||||
}): DrawnGroups {
|
||||
const visibleIndexes = labels
|
||||
.map((_, index) => index)
|
||||
.filter((index) => visible[index]);
|
||||
|
||||
if (visibleIndexes.length <= maxGroups) {
|
||||
return {
|
||||
labels: visibleIndexes.map((index) => labels[index]),
|
||||
counts: visibleIndexes.map((index) => counts[index]),
|
||||
colors: visibleIndexes.map((index) => colors[index]),
|
||||
sourceIndexes: visibleIndexes,
|
||||
otherGroupCount: 0,
|
||||
visibleGroupCount: visibleIndexes.length,
|
||||
};
|
||||
}
|
||||
|
||||
const keep = new Set(
|
||||
[...visibleIndexes]
|
||||
.sort((a, b) => sum(counts[b]) - sum(counts[a]) || a - b)
|
||||
.slice(0, Math.max(0, maxGroups - 1)),
|
||||
);
|
||||
const kept = visibleIndexes.filter((index) => keep.has(index));
|
||||
const folded = visibleIndexes.filter((index) => !keep.has(index));
|
||||
const bucketCount = Math.max(...folded.map((index) => counts[index].length));
|
||||
const other = Array.from({ length: bucketCount }, (_, bucket) =>
|
||||
folded.reduce((total, index) => total + (counts[index][bucket] ?? 0), 0),
|
||||
);
|
||||
|
||||
return {
|
||||
labels: [...kept.map((index) => labels[index]), OTHER_GROUP_LABEL],
|
||||
counts: [...kept.map((index) => counts[index]), other],
|
||||
colors: [...kept.map((index) => colors[index]), otherColor],
|
||||
sourceIndexes: [...kept, null],
|
||||
otherGroupCount: folded.length,
|
||||
visibleGroupCount: visibleIndexes.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawn group at `drawnIndex` as a source series and its colour; `null` for Other,
|
||||
* which sums several groups no single filter can name.
|
||||
*/
|
||||
export function resolveClickedGroup(
|
||||
drawn: DrawnGroups,
|
||||
drawnIndex: number | null,
|
||||
series: DistributionSeries[],
|
||||
): { series: DistributionSeries; color: string } | null {
|
||||
const source =
|
||||
drawnIndex === null ? null : (drawn.sourceIndexes[drawnIndex] ?? null);
|
||||
if (drawnIndex === null || source === null) {
|
||||
return null;
|
||||
}
|
||||
return { series: series[source], color: drawn.colors[drawnIndex] };
|
||||
}
|
||||
31
frontend/src/lib/visualization/charts/Distribution/types.ts
Normal file
31
frontend/src/lib/visualization/charts/Distribution/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { VariableWidthBar } from 'lib/uPlotV2/paths/variableWidthBars';
|
||||
import type { HeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
export interface DistributionSeries {
|
||||
label: string;
|
||||
/** Group-by key → value, for drilling into this group. Empty when ungrouped. */
|
||||
groupLabels?: Record<string, string>;
|
||||
/** One count per bucket bound plus the trailing overflow, summed over the range. */
|
||||
counts: number[];
|
||||
}
|
||||
|
||||
/** A click on a group's bar; never on Other, which no filter can name. */
|
||||
export interface DistributionBarClick {
|
||||
series: DistributionSeries;
|
||||
color: string;
|
||||
/** Viewport coordinates of the click. */
|
||||
coordinates: { x: number; y: number };
|
||||
}
|
||||
|
||||
export enum DistributionYScale {
|
||||
Linear = 'linear',
|
||||
Log = 'log',
|
||||
}
|
||||
|
||||
/** The bucket axis in x-scale space. The heatmap's row axis, laid sideways. */
|
||||
export type DistributionXAxis = HeatmapYAxis;
|
||||
|
||||
/** `x` in bucket-axis units, `y` in counts (or percent); the overflow bucket is hatched. */
|
||||
export interface DistributionBarRect extends VariableWidthBar {
|
||||
bucketIndex: number;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { resolveRowIndex } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { type DistributionBarsLayout, resolveNearestSeries } from './layout';
|
||||
import { type DrawnGroups, resolveClickedGroup } from './topGroups';
|
||||
import type {
|
||||
DistributionBarClick,
|
||||
DistributionSeries,
|
||||
DistributionXAxis,
|
||||
} from './types';
|
||||
|
||||
/** Resolves a plot click to the group under it; Other and empty buckets report none. */
|
||||
export function useDistributionBarClick({
|
||||
xAxis,
|
||||
layout,
|
||||
drawn,
|
||||
series,
|
||||
onBarClick,
|
||||
}: {
|
||||
xAxis: DistributionXAxis;
|
||||
layout: DistributionBarsLayout;
|
||||
drawn: DrawnGroups;
|
||||
series: DistributionSeries[];
|
||||
onBarClick?: (click: DistributionBarClick) => void;
|
||||
}): ((data: ChartClickData) => void) | undefined {
|
||||
const onBarClickRef = useRef(onBarClick);
|
||||
onBarClickRef.current = onBarClick;
|
||||
|
||||
const handleClick = useCallback(
|
||||
({
|
||||
xValue,
|
||||
yValue,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
}: ChartClickData): void => {
|
||||
const bucket = resolveRowIndex(xAxis.edges, xValue);
|
||||
const clicked = resolveClickedGroup(
|
||||
drawn,
|
||||
bucket === null
|
||||
? null
|
||||
: resolveNearestSeries(layout, bucket, xValue, yValue),
|
||||
series,
|
||||
);
|
||||
if (clicked) {
|
||||
onBarClickRef.current?.({
|
||||
...clicked,
|
||||
coordinates: { x: absoluteMouseX, y: absoluteMouseY },
|
||||
});
|
||||
}
|
||||
},
|
||||
[xAxis, layout, drawn, series],
|
||||
);
|
||||
|
||||
return onBarClick ? handleClick : undefined;
|
||||
}
|
||||
32
frontend/src/lib/visualization/charts/Distribution/utils.ts
Normal file
32
frontend/src/lib/visualization/charts/Distribution/utils.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { DistributionXAxis } from './types';
|
||||
|
||||
export function createBoundFormatter(
|
||||
unit?: string,
|
||||
decimalPrecision?: PrecisionOption,
|
||||
): (value: number) => string {
|
||||
return (value: number): string =>
|
||||
getToolTipValue(String(value), unit, decimalPrecision);
|
||||
}
|
||||
|
||||
/**
|
||||
* `[bucket centres, ...counts per series]`. The series draw from the layout, so the
|
||||
* data only feeds uPlot's cursor and tooltip.
|
||||
*/
|
||||
export function prepareDistributionChartData(
|
||||
xAxis: DistributionXAxis,
|
||||
counts: number[][],
|
||||
): uPlot.AlignedData {
|
||||
const centres = xAxis.rows.map(
|
||||
(_, index) => (xAxis.edges[index] + xAxis.edges[index + 1]) / 2,
|
||||
);
|
||||
return [
|
||||
centres,
|
||||
...counts.map((seriesCounts) =>
|
||||
centres.map((_, index) => seriesCounts[index] ?? 0),
|
||||
),
|
||||
] as unknown as uPlot.AlignedData;
|
||||
}
|
||||
@@ -26,6 +26,11 @@ import {
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import type {
|
||||
DistributionBarClick,
|
||||
DistributionSeries,
|
||||
DistributionYScale,
|
||||
} from 'lib/visualization/charts/Distribution/types';
|
||||
|
||||
interface BaseChartProps {
|
||||
width: number;
|
||||
@@ -167,6 +172,48 @@ export interface HeatmapChartProps {
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-range bucket counts, one series per group, drawn as bars whose widths follow
|
||||
* the bucket bounds. Builds its own config, since `buckets` fully determines the x
|
||||
* axis. `buckets` and `series` must be referentially stable: a new identity rebuilds
|
||||
* the plot.
|
||||
*/
|
||||
export interface DistributionChartProps {
|
||||
/** Widget id; the group selection persists under it. */
|
||||
id: string;
|
||||
/** Ascending. N boundaries describe N+1 buckets, the last one overflow. */
|
||||
buckets: number[];
|
||||
series: DistributionSeries[];
|
||||
width: number;
|
||||
height: number;
|
||||
isDarkMode: boolean;
|
||||
/** Buckets wholly below it are hidden. */
|
||||
xMin?: number;
|
||||
/** Buckets wholly above it are hidden; unset crops trailing empty buckets. */
|
||||
xMax?: number;
|
||||
/** Default linear. Ignored under `percent`, whose axis is a share. */
|
||||
yScale?: DistributionYScale;
|
||||
/** Default normal. `none` draws the groups side by side. */
|
||||
stack?: StackMode;
|
||||
/** Unit of the bucket bounds; counts are always plain numbers. */
|
||||
xAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
/** Hex colours keyed by group label, overriding the automatic palette. */
|
||||
customColors?: Record<string, string>;
|
||||
/** 0–100. Marked across the plot, over the visible groups combined. */
|
||||
percentiles?: readonly number[];
|
||||
/** Fires for a click on a group's bar; not on Other or empty space. */
|
||||
onBarClick?: (click: DistributionBarClick) => void;
|
||||
showLegend?: boolean;
|
||||
legendPosition?: LegendPosition;
|
||||
showTooltip?: boolean;
|
||||
canPinTooltip?: boolean;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
tooltipPortalRoot?: HTMLElement | null;
|
||||
layoutChildren?: React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One resolved pie/donut slice: a display label, its (already parsed) positive
|
||||
* numeric value, and the colour used for the arc + legend swatch.
|
||||
|
||||
@@ -21,6 +21,20 @@ describe('buildExplorerNavigationUrl', () => {
|
||||
expect(url).toContain(`${QueryParams.viewKey}=`);
|
||||
});
|
||||
|
||||
it('encodes the composite query for a reader that decodes it, even with a percent sign', () => {
|
||||
const query = {
|
||||
queryType: 'builder',
|
||||
filter: { expression: "body LIKE '%timeout%'" },
|
||||
} as never;
|
||||
|
||||
const url = buildExplorerNavigationUrl(ROUTES.LOGS_EXPLORER, query, {});
|
||||
|
||||
const param = new URL(url, window.location.origin).searchParams.get(
|
||||
QueryParams.compositeQuery,
|
||||
) as string;
|
||||
expect(JSON.parse(decodeURIComponent(param))).toStrictEqual(query);
|
||||
});
|
||||
|
||||
it('JSON-encodes panelTypes so the explorer opens the right view', () => {
|
||||
const url = buildExplorerNavigationUrl(
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
|
||||
@@ -60,7 +60,7 @@ func (bc *bucketCache) GetMissRanges(
|
||||
) (cached *qbtypes.Result, missing []*qbtypes.TimeRange) {
|
||||
|
||||
// Get query window
|
||||
startMs, endMs := cacheWindow(q, uint64(step.Milliseconds()))
|
||||
startMs, endMs := q.Window()
|
||||
|
||||
bc.logger.DebugContext(ctx, "getting miss ranges", slog.String("fingerprint", q.Fingerprint()), slog.Uint64("start", startMs), slog.Uint64("end", endMs))
|
||||
|
||||
@@ -127,7 +127,7 @@ func (bc *bucketCache) GetMissRanges(
|
||||
// Put stores fresh query results in the cache.
|
||||
func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Query, step qbtypes.Step, fresh *qbtypes.Result) {
|
||||
// Get query window
|
||||
startMs, endMs := cacheWindow(q, uint64(step.Milliseconds()))
|
||||
startMs, endMs := q.Window()
|
||||
|
||||
// Calculate the flux boundary - data after this point should not be cached
|
||||
currentMs := uint64(time.Now().UnixMilli())
|
||||
@@ -152,8 +152,7 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
|
||||
}
|
||||
|
||||
// Trim the result to exclude data within flux interval
|
||||
requestStartMs, _ := q.Window()
|
||||
trimmedResult := bc.trimResultToFluxBoundary(fresh, requestStartMs, cachableEndMs)
|
||||
trimmedResult := bc.trimResultToFluxBoundary(fresh, cachableEndMs)
|
||||
if trimmedResult == nil {
|
||||
// Result type is not cacheable (raw or scalar)
|
||||
return
|
||||
@@ -223,30 +222,6 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
|
||||
}
|
||||
}
|
||||
|
||||
// lookbackSteps is how many steps before its window a query's statement
|
||||
// reads. runningDiff drops its first point, so the metrics statement reads
|
||||
// one step before the window to give the first interval a difference.
|
||||
func lookbackSteps(q qbtypes.Query) int {
|
||||
bq, ok := q.(*builderQuery[qbtypes.MetricAggregation])
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
for _, fn := range bq.spec.Functions {
|
||||
if fn.Name == qbtypes.FunctionNameRunningDiff {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// cacheWindow is the query window widened by its lookback, so a hit carries
|
||||
// the steps the statement reads before the window.
|
||||
func cacheWindow(q qbtypes.Query, stepMs uint64) (uint64, uint64) {
|
||||
startMs, endMs := q.Window()
|
||||
lookbackMs := uint64(lookbackSteps(q)) * stepMs
|
||||
return startMs - min(lookbackMs, startMs), endMs
|
||||
}
|
||||
|
||||
// generateCacheKey names the cache entry holding one grid cell of a query.
|
||||
func (bc *bucketCache) generateCacheKey(q qbtypes.Query, cellIdx uint64) string {
|
||||
return fmt.Sprintf("v6:query:%s:cellidx:%d", q.Fingerprint(), cellIdx)
|
||||
@@ -785,7 +760,7 @@ func (bc *bucketCache) deduplicateWarnings(warnings []string) []string {
|
||||
}
|
||||
|
||||
// trimResultToFluxBoundary trims the result to exclude data points beyond the flux boundary.
|
||||
func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, requestStartMs, fluxBoundary uint64) *qbtypes.Result {
|
||||
func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, fluxBoundary uint64) *qbtypes.Result {
|
||||
trimmedResult := &qbtypes.Result{
|
||||
Type: result.Type,
|
||||
Stats: result.Stats,
|
||||
@@ -815,10 +790,8 @@ func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, requestS
|
||||
|
||||
// Filter values to exclude those beyond flux boundary and partial values
|
||||
for _, value := range series.Values {
|
||||
// Skip partial values - they cannot be cached. A lookback
|
||||
// step before the request window is flagged partial
|
||||
// against that window but is a whole step, so it stays.
|
||||
if value.Partial && uint64(value.Timestamp) >= requestStartMs {
|
||||
// Skip partial values - they cannot be cached
|
||||
if value.Partial {
|
||||
continue
|
||||
}
|
||||
if uint64(value.Timestamp) <= fluxBoundary {
|
||||
|
||||
@@ -71,7 +71,7 @@ func TestTrimResultToFluxBoundaryKeepsTheHeatmapAxis(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
trimmed := cache.trimResultToFluxBoundary(result, 0, 1710000060000)
|
||||
trimmed := cache.trimResultToFluxBoundary(result, 1710000060000)
|
||||
|
||||
tsData, ok := trimmed.Value.(*qbtypes.TimeSeriesData)
|
||||
require.True(t, ok)
|
||||
|
||||
@@ -814,7 +814,7 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
|
||||
|
||||
// If entire range is missing, execute normally
|
||||
if cachedResult == nil && len(missingRanges) == 1 {
|
||||
startMs, endMs := cacheWindow(query, uint64(step.Milliseconds()))
|
||||
startMs, endMs := query.Window()
|
||||
if missingRanges[0].From == startMs && missingRanges[0].To == endMs {
|
||||
sem <- struct{}{}
|
||||
result, err := query.Execute(ctx)
|
||||
@@ -898,11 +898,6 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
|
||||
|
||||
// Merge cached and fresh results
|
||||
mergedResult := q.mergeResults(cachedResult, freshResults)
|
||||
// A gap statement at the start of the cache window reads its own lookback
|
||||
// step before it, which the request's statement would not read.
|
||||
if cacheStartMs, _ := cacheWindow(query, uint64(step.Milliseconds())); lookbackSteps(query) > 0 {
|
||||
dropValuesBefore(mergedResult, cacheStartMs)
|
||||
}
|
||||
mergedResult.Stats.RowsScanned += totalStats.RowsScanned
|
||||
mergedResult.Stats.BytesScanned += totalStats.BytesScanned
|
||||
mergedResult.Stats.DurationMS += totalStats.DurationMS
|
||||
@@ -913,18 +908,6 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
|
||||
return mergedResult, nil
|
||||
}
|
||||
|
||||
func dropValuesBefore(result *qbtypes.Result, fromMs uint64) {
|
||||
data, ok := result.Value.(*qbtypes.TimeSeriesData)
|
||||
if !ok || data == nil {
|
||||
return
|
||||
}
|
||||
for _, aggBucket := range data.Aggregations {
|
||||
for _, series := range aggBucket.Series {
|
||||
series.Values = slices.DeleteFunc(series.Values, func(value *qbtypes.TimeSeriesValue) bool { return uint64(value.Timestamp) < fromMs })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createRangedQuery creates a copy of the query with a different time range.
|
||||
func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query, timeRange qbtypes.TimeRange) qbtypes.Query {
|
||||
// this is called in a goroutine, so we create a copy of the query to avoid race conditions
|
||||
|
||||
@@ -669,6 +669,216 @@ func TestValidateHeatmapDashboard(t *testing.T) {
|
||||
assert.Equal(t, request, rereadRequest)
|
||||
}
|
||||
|
||||
func TestValidateDistributionDashboard(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/perses_distribution_panel.json")
|
||||
require.NoError(t, err, "reading example file")
|
||||
|
||||
spec, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &DistributionPanelSpec{}, spec.Panels["p1"].Spec.Plugin.Spec)
|
||||
panelSpec := spec.Panels["p1"].Spec.Plugin.Spec.(*DistributionPanelSpec)
|
||||
require.NotNil(t, panelSpec.Axes.X.Min)
|
||||
assert.Equal(t, 10.0, *panelSpec.Axes.X.Min)
|
||||
assert.Nil(t, panelSpec.Axes.X.Max)
|
||||
assert.Equal(t, "log", panelSpec.Axes.Y.Scale.ValueOrDefault())
|
||||
assert.Equal(t, "none", panelSpec.ChartAppearance.Stack.ValueOrDefault())
|
||||
assert.True(t, panelSpec.ChartAppearance.Percentiles.Enabled)
|
||||
assert.Equal(t, []float64{75, 99.9}, panelSpec.ChartAppearance.Percentiles.Values)
|
||||
|
||||
dashboard := &DashboardV2{Spec: *spec}
|
||||
request, err := dashboard.GetPanelQuery(1, 2, "p1")
|
||||
require.NoError(t, err, "building the panel's query failed")
|
||||
assert.Equal(t, qb.RequestTypeDistribution, request.RequestType)
|
||||
require.NoError(t, request.Validate(), "the request built from the panel is not a valid distribution request")
|
||||
|
||||
stored, err := json.Marshal(spec)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
reread, err := unmarshalDashboard(stored)
|
||||
require.NoError(t, err, "the stored dashboard does not validate")
|
||||
assert.Equal(t, panelSpec, reread.Panels["p1"].Spec.Plugin.Spec)
|
||||
}
|
||||
|
||||
func TestDistributionPanelSpecDefaults(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
spec string
|
||||
}{
|
||||
{name: "EmptySpec", spec: `{}`},
|
||||
{name: "EmptyChartAppearance", spec: `{"chartAppearance": {}}`},
|
||||
{name: "EmptyPercentiles", spec: `{"chartAppearance": {"percentiles": {}}}`},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
var plugin PanelPlugin
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"kind": "signoz/DistributionPanel", "spec": `+testCase.spec+`}`), &plugin))
|
||||
|
||||
spec := plugin.Spec.(*DistributionPanelSpec)
|
||||
assert.Nil(t, spec.Axes.X.Min)
|
||||
assert.Nil(t, spec.Axes.X.Max)
|
||||
assert.Equal(t, "linear", spec.Axes.Y.Scale.ValueOrDefault())
|
||||
assert.Equal(t, "normal", spec.ChartAppearance.Stack.ValueOrDefault())
|
||||
assert.True(t, spec.ChartAppearance.Percentiles.Enabled)
|
||||
assert.Equal(t, []float64{50, 90, 99}, spec.ChartAppearance.Percentiles.Values)
|
||||
|
||||
stored, err := json.Marshal(spec)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(stored), `"stack":"normal"`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionPanelSpecPercentileValues(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
spec string
|
||||
expectedEnabled bool
|
||||
expectedValues []float64
|
||||
}{
|
||||
{name: "Disabled_KeepsDefaultValues", spec: `{"chartAppearance": {"percentiles": {"enabled": false}}}`, expectedEnabled: false, expectedValues: []float64{50, 90, 99}},
|
||||
{name: "EmptyValues_MarksNone", spec: `{"chartAppearance": {"percentiles": {"values": []}}}`, expectedEnabled: true, expectedValues: []float64{}},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
var plugin PanelPlugin
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"kind": "signoz/DistributionPanel", "spec": `+testCase.spec+`}`), &plugin))
|
||||
|
||||
spec := plugin.Spec.(*DistributionPanelSpec)
|
||||
assert.Equal(t, testCase.expectedEnabled, spec.ChartAppearance.Percentiles.Enabled)
|
||||
assert.Equal(t, testCase.expectedValues, spec.ChartAppearance.Percentiles.Values)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionPanelSpecXMinEqualsMax(t *testing.T) {
|
||||
var plugin PanelPlugin
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"kind": "signoz/DistributionPanel", "spec": {"axes": {"x": {"min": 10, "max": 10}}}}`), &plugin))
|
||||
|
||||
spec := plugin.Spec.(*DistributionPanelSpec)
|
||||
require.NotNil(t, spec.Axes.X.Min)
|
||||
require.NotNil(t, spec.Axes.X.Max)
|
||||
assert.Equal(t, 10.0, *spec.Axes.X.Min)
|
||||
assert.Equal(t, 10.0, *spec.Axes.X.Max)
|
||||
}
|
||||
|
||||
func TestDistributionPanelSpecRejections(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
spec string
|
||||
expectedMessage string
|
||||
}{
|
||||
{
|
||||
name: "XMin_AboveMax",
|
||||
spec: `{"axes": {"x": {"min": 20, "max": 10}}}`,
|
||||
expectedMessage: "axes.x.min 20 is greater than axes.x.max 10",
|
||||
},
|
||||
{
|
||||
name: "YScale_Unknown",
|
||||
spec: `{"axes": {"y": {"scale": "symlog"}}}`,
|
||||
expectedMessage: "invalid distribution y scale",
|
||||
},
|
||||
{
|
||||
name: "UnknownField",
|
||||
spec: `{"axes": {"y": {"softMax": 10}}}`,
|
||||
expectedMessage: "unknown field \"softMax\"",
|
||||
},
|
||||
{
|
||||
name: "Percentile_Zero",
|
||||
spec: `{"chartAppearance": {"percentiles": {"values": [0, 50]}}}`,
|
||||
expectedMessage: "0 is not in (0, 100)",
|
||||
},
|
||||
{
|
||||
name: "Percentile_Hundred",
|
||||
spec: `{"chartAppearance": {"percentiles": {"values": [100]}}}`,
|
||||
expectedMessage: "100 is not in (0, 100)",
|
||||
},
|
||||
{
|
||||
name: "Percentile_Duplicate",
|
||||
spec: `{"chartAppearance": {"percentiles": {"values": [90, 90]}}}`,
|
||||
expectedMessage: "90 is listed more than once",
|
||||
},
|
||||
{
|
||||
name: "Percentiles_MoreThanFive",
|
||||
spec: `{"chartAppearance": {"percentiles": {"values": [10, 25, 50, 75, 90, 99]}}}`,
|
||||
expectedMessage: "at most 5 percentiles",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
var plugin PanelPlugin
|
||||
err := json.Unmarshal([]byte(`{"kind": "signoz/DistributionPanel", "spec": `+testCase.spec+`}`), &plugin)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), testCase.expectedMessage)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionPanelQueryKinds(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
queryPluginKind string
|
||||
queryPluginSpec string
|
||||
expectedAllowed bool
|
||||
}{
|
||||
{
|
||||
name: "PromQL_Allowed",
|
||||
queryPluginKind: "signoz/PromQLQuery",
|
||||
queryPluginSpec: `{"name": "A", "query": "sum by (le) (increase(signoz_latency_bucket[5m]))"}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
name: "ClickHouseSQL_Allowed",
|
||||
queryPluginKind: "signoz/ClickHouseSQL",
|
||||
queryPluginSpec: `{"name": "A", "query": "SELECT ts, bucket, value FROM cells"}`,
|
||||
expectedAllowed: true,
|
||||
},
|
||||
{
|
||||
name: "TraceOperator_Refused",
|
||||
queryPluginKind: "signoz/TraceOperator",
|
||||
queryPluginSpec: `{"name": "T1", "expression": "A => B"}`,
|
||||
expectedAllowed: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
data := fmt.Sprintf(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/DistributionPanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "distribution",
|
||||
"spec": {
|
||||
"plugin": {"kind": %q, "spec": %s}
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`, testCase.queryPluginKind, testCase.queryPluginSpec)
|
||||
|
||||
_, err := unmarshalDashboard([]byte(data))
|
||||
|
||||
if testCase.expectedAllowed {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "is not supported by panel kind")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateOneInvalidPanel(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
|
||||
@@ -28,17 +28,18 @@ type PanelPlugin struct {
|
||||
// (and strips the duplicate parent properties) after reflection.
|
||||
func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return markDiscriminator(s, "kind", map[string]string{
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
|
||||
string(PanelKindDistribution): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,6 +74,7 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
|
||||
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
|
||||
PanelPluginVariant[HeatmapPanelSpec]{Kind: string(PanelKindHeatmap)},
|
||||
PanelPluginVariant[ScatterPlotPanelSpec]{Kind: string(PanelKindScatterPlot)},
|
||||
PanelPluginVariant[DistributionPanelSpec]{Kind: string(PanelKindDistribution)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,17 +238,18 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
|
||||
|
||||
var (
|
||||
panelPluginSpecs = map[PanelPluginKind]func() any{
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
|
||||
PanelKindDistribution: func() any { return new(DistributionPanelSpec) },
|
||||
}
|
||||
queryPluginSpecs = map[QueryPluginKind]func() any{
|
||||
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
|
||||
@@ -263,17 +266,18 @@ var (
|
||||
VariableKindCustom: func() any { return new(CustomVariableSpec) },
|
||||
}
|
||||
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindClickHouseSQL},
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindClickHouseSQL},
|
||||
PanelKindDistribution: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ func (k *PanelKind) UnmarshalJSON(data []byte) error {
|
||||
|
||||
type PanelSpec struct {
|
||||
Display Display `json:"display" required:"true" description:"The panel title and description."`
|
||||
Plugin PanelPlugin `json:"plugin" required:"true" description:"The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel or signoz/HeatmapPanel, and spec holds that kind's options ({} accepts every default)."`
|
||||
Plugin PanelPlugin `json:"plugin" required:"true" description:"The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel, signoz/HeatmapPanel or signoz/DistributionPanel, and spec holds that kind's options ({} accepts every default)."`
|
||||
Queries []Query `json:"queries" required:"true" nullable:"false" description:"The queries the panel runs. Use exactly one entry for a data panel and [] for signoz/TextPanel. To run several named queries or formulas, use one entry whose spec.plugin.kind is signoz/CompositeQuery and put the constituent queries in its plugin spec."`
|
||||
Links []Link `json:"links,omitzero" description:"Context links offered in the data point drilldown menu of the panel, with dashboard variables resolved in the URL."`
|
||||
}
|
||||
@@ -92,13 +92,13 @@ type Link struct {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type Query struct {
|
||||
Kind qb.RequestType `json:"kind" required:"true" description:"The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, trace for trace panels."`
|
||||
Kind qb.RequestType `json:"kind" required:"true" description:"The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, distribution for distribution panels, trace for trace panels."`
|
||||
Spec QuerySpec `json:"spec" required:"true" description:"The query content: an optional name and the query plugin."`
|
||||
}
|
||||
|
||||
type QuerySpec struct {
|
||||
Name string `json:"name,omitempty" description:"An optional label for the query. The name queries refer to each other by lives in the plugin spec."`
|
||||
Plugin QueryPlugin `json:"plugin" required:"true" description:"The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery."`
|
||||
Plugin QueryPlugin `json:"plugin" required:"true" description:"The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap and distribution panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery."`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
@@ -189,21 +189,22 @@ func (AIBuilderQuerySpec) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
type PanelPluginKind string
|
||||
|
||||
const (
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
|
||||
PanelKindDistribution PanelPluginKind = "signoz/DistributionPanel"
|
||||
)
|
||||
|
||||
func (PanelPluginKind) Enum() []any {
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot}
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot, PanelKindDistribution}
|
||||
}
|
||||
|
||||
func (k PanelPluginKind) rendersWithoutQuery() bool {
|
||||
@@ -346,6 +347,89 @@ func (c HeatmapColors) validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type DistributionPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization" description:"The time range the bucket counts are summed over."`
|
||||
Formatting PanelFormatting `json:"formatting" description:"Unit and decimal precision of the bucket bounds; counts take no unit."`
|
||||
Axes DistributionAxes `json:"axes" description:"The X (bucket) axis range and Y (count) axis scale."`
|
||||
Legend Legend `json:"legend" description:"Legend placement and series colors."`
|
||||
ChartAppearance DistributionChartAppearance `json:"chartAppearance" description:"Stacking and percentile markers."`
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes over the defaults, since the stack default (normal)
|
||||
// differs from StackMode's own and percentile markers default to on.
|
||||
func (spec *DistributionPanelSpec) UnmarshalJSON(data []byte) error {
|
||||
type alias DistributionPanelSpec
|
||||
tmp := alias{ChartAppearance: DistributionChartAppearance{
|
||||
Stack: StackModeNormal,
|
||||
Percentiles: DistributionPercentiles{Enabled: true, Values: []float64{50, 90, 99}},
|
||||
}}
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
*spec = DistributionPanelSpec(tmp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (spec *DistributionPanelSpec) validate() error {
|
||||
if err := spec.Axes.X.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return spec.ChartAppearance.Percentiles.validate()
|
||||
}
|
||||
|
||||
type DistributionAxes struct {
|
||||
X DistributionXAxis `json:"x" description:"The bucket axis."`
|
||||
Y DistributionYAxis `json:"y" description:"The count axis."`
|
||||
}
|
||||
|
||||
// DistributionXAxis draws every bucket equally wide, whatever its bounds.
|
||||
type DistributionXAxis struct {
|
||||
Min *float64 `json:"min" description:"Buckets wholly below this value are hidden. Null shows them all."`
|
||||
Max *float64 `json:"max" description:"Buckets wholly above this value are hidden. Null shows them all."`
|
||||
}
|
||||
|
||||
func (axis DistributionXAxis) validate() error {
|
||||
if axis.Min != nil && axis.Max != nil && *axis.Min > *axis.Max {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "axes.x.min %v is greater than axes.x.max %v", *axis.Min, *axis.Max)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type DistributionYAxis struct {
|
||||
Scale DistributionYScale `json:"scale" required:"true" description:"How counts are spaced: linear (default) or log."`
|
||||
}
|
||||
|
||||
type DistributionChartAppearance struct {
|
||||
Stack StackMode `json:"stack" required:"true" description:"How groups combine in a bucket: normal (default) stacks them, none draws them side by side, percent stacks them as shares of the bucket."`
|
||||
Percentiles DistributionPercentiles `json:"percentiles" description:"Percentile markers drawn across the bucket axis."`
|
||||
}
|
||||
|
||||
const maxDistributionPercentiles = 5
|
||||
|
||||
type DistributionPercentiles struct {
|
||||
Enabled bool `json:"enabled" required:"true" description:"Whether percentile markers are drawn. Defaults to true."`
|
||||
Values []float64 `json:"values" required:"true" description:"The percentiles to mark, each in (0, 100), at most 5, no duplicates. Defaults to [50, 90, 99]."`
|
||||
}
|
||||
|
||||
func (percentiles DistributionPercentiles) validate() error {
|
||||
if len(percentiles.Values) > maxDistributionPercentiles {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "chartAppearance.percentiles.values: at most %d percentiles, got %d", maxDistributionPercentiles, len(percentiles.Values))
|
||||
}
|
||||
seen := make(map[float64]struct{}, len(percentiles.Values))
|
||||
for _, value := range percentiles.Values {
|
||||
if value <= 0 || value >= 100 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "chartAppearance.percentiles.values: %v is not in (0, 100)", value)
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "chartAppearance.percentiles.values: %v is listed more than once", value)
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TextPanelSpec struct {
|
||||
Mode TextMode `json:"mode" description:"How text is interpreted. Only markdown is supported."`
|
||||
Text string `json:"text" description:"The content to render."`
|
||||
@@ -1462,6 +1546,43 @@ func (s *HeatmapYScale) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
type DistributionYScale struct{ valuer.String }
|
||||
|
||||
var (
|
||||
DistributionYScaleLinear = DistributionYScale{valuer.NewString("linear")} // default
|
||||
DistributionYScaleLog = DistributionYScale{valuer.NewString("log")}
|
||||
)
|
||||
|
||||
func (DistributionYScale) Enum() []any {
|
||||
return []any{DistributionYScaleLinear, DistributionYScaleLog}
|
||||
}
|
||||
|
||||
func (scale DistributionYScale) ValueOrDefault() string {
|
||||
if scale.IsZero() {
|
||||
return DistributionYScaleLinear.StringValue()
|
||||
}
|
||||
return scale.StringValue()
|
||||
}
|
||||
|
||||
func (scale DistributionYScale) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(scale.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (scale *DistributionYScale) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid distribution y scale: must be a string, one of `linear` or `log`")
|
||||
}
|
||||
parsed := DistributionYScale{valuer.NewString(v)}
|
||||
switch parsed {
|
||||
case DistributionYScaleLinear, DistributionYScaleLog:
|
||||
*scale = parsed
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid distribution y scale %q: must be `linear` or `log`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type HeatmapColorScale struct{ valuer.String }
|
||||
|
||||
var (
|
||||
|
||||
95
pkg/types/dashboardtypes/testdata/perses_distribution_panel.json
vendored
Normal file
95
pkg/types/dashboardtypes/testdata/perses_distribution_panel.json
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"display": {
|
||||
"name": "latency",
|
||||
"description": "how request duration is distributed over the range"
|
||||
},
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "request duration",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/DistributionPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "global_time"
|
||||
},
|
||||
"formatting": {
|
||||
"unit": "s",
|
||||
"decimalPrecision": "3"
|
||||
},
|
||||
"axes": {
|
||||
"x": {
|
||||
"min": 10
|
||||
},
|
||||
"y": {
|
||||
"scale": "log"
|
||||
}
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"mode": "list"
|
||||
},
|
||||
"chartAppearance": {
|
||||
"stack": "none",
|
||||
"percentiles": {
|
||||
"enabled": true,
|
||||
"values": [75, 99.9]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"queries": [
|
||||
{
|
||||
"kind": "distribution",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "http.server.request.duration",
|
||||
"timeAggregation": "increase",
|
||||
"spaceAggregation": "sum"
|
||||
}
|
||||
],
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "service.name"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 12,
|
||||
"height": 8,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/p1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
481
tests/e2e/drivers/saved-views.ts
generated
Normal file
481
tests/e2e/drivers/saved-views.ts
generated
Normal file
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
type Locator,
|
||||
type Page,
|
||||
type Request,
|
||||
type Response,
|
||||
} from '@playwright/test';
|
||||
|
||||
import { BASE_PATH } from './base-path';
|
||||
import { collectRequests } from './console';
|
||||
import { dismissQuickFiltersAnnouncement } from './logs/explorer';
|
||||
import {
|
||||
filterExpressions,
|
||||
isQueryRangeRequest,
|
||||
queryRangeBody,
|
||||
} from './saved-views/query';
|
||||
import { SAVED_VIEWS_API, seededView } from './saved-views/spec';
|
||||
import type {
|
||||
GettableSavedView,
|
||||
PostableSavedView,
|
||||
SavedViewSource,
|
||||
SeededView,
|
||||
} from './saved-views/spec.types';
|
||||
|
||||
import { driver, kit } from './kit';
|
||||
import {
|
||||
createViaApi,
|
||||
getViaApi,
|
||||
removeTrackedSavedViews,
|
||||
statusViaApi,
|
||||
trackSavedView,
|
||||
} from './saved-views/api';
|
||||
|
||||
// The saved views header and list on the explorers: the sliding list on logs,
|
||||
// traces and meter, the pinned one on metrics.
|
||||
|
||||
export const MY_VIEW = 'My view';
|
||||
|
||||
const EXPLORER_PATH: Record<SavedViewSource, string> = {
|
||||
logs: '/logs/logs-explorer',
|
||||
traces: '/traces-explorer',
|
||||
metrics: '/metrics-explorer/explorer',
|
||||
meter: '/meter/explorer',
|
||||
};
|
||||
|
||||
export function explorerPathname(source: SavedViewSource): string {
|
||||
return `${BASE_PATH}${EXPLORER_PATH[source]}`;
|
||||
}
|
||||
|
||||
/** Row-menu items by the suffix of their `saved-views-row-menu-item-<key>` test id. */
|
||||
export type RowAction = 'copyLink' | 'copyId' | 'editDetails' | 'delete';
|
||||
|
||||
/** Save-menu items by the suffix of their `saved-views-save-changes-item-<key>` test id. */
|
||||
export type SaveAction = 'update' | 'save-as-new';
|
||||
|
||||
export type LinkGesture = 'modifier' | 'middle';
|
||||
|
||||
export interface OpenedTab {
|
||||
page: Page;
|
||||
savedViews: SavedViews;
|
||||
}
|
||||
|
||||
export type ExplorerTab = 'list' | 'timeseries' | 'table';
|
||||
|
||||
export interface SavedViews {
|
||||
/** Created over the page's own session; deleted when the test ends. */
|
||||
seed(view: PostableSavedView): Promise<SeededView>;
|
||||
/** The stored view, read straight from the API. */
|
||||
get(id: string): Promise<GettableSavedView>;
|
||||
/** The status of `GET /api/v2/saved_views/<id>`, for asserting a view is gone. */
|
||||
status(id: string): Promise<number>;
|
||||
|
||||
/** On a bare url by default, which is `relativeTime=1h` and nothing else. */
|
||||
openExplorer(
|
||||
source?: SavedViewSource,
|
||||
params?: Record<string, string>,
|
||||
): Promise<void>;
|
||||
/** Reloads and waits for the explorer to send the filter's query. */
|
||||
reload(filter?: string, requestType?: string): Promise<void>;
|
||||
/** The explorer's next query that carries `filter`, of `requestType` when given. */
|
||||
queryRan(filter: string, requestType?: string): Promise<Response>;
|
||||
/** The current url. */
|
||||
url(): URL;
|
||||
/** A query param of the current url, where the open view and time range live. */
|
||||
param(name: string): string | null;
|
||||
/** The id in the `viewKey` param, or null while no view is open. */
|
||||
activeViewId(): string | null;
|
||||
|
||||
header(): Locator;
|
||||
name(): Locator;
|
||||
createButton(): Locator;
|
||||
clearButton(): Locator;
|
||||
discardButton(): Locator;
|
||||
saveChangesButton(): Locator;
|
||||
saveAction(action: SaveAction): Locator;
|
||||
/** The view is open and nothing differs from what is stored. */
|
||||
expectClean(displayName: string): Promise<void>;
|
||||
/** The view is open and the explorer holds changes to it. */
|
||||
expectDirty(displayName: string): Promise<void>;
|
||||
/** No view is open. */
|
||||
expectNoView(): Promise<void>;
|
||||
toast(text: string): Locator;
|
||||
|
||||
openListButton(): Locator;
|
||||
panel(): Locator;
|
||||
searchInput(): Locator;
|
||||
openList(): Promise<void>;
|
||||
/** Narrows the list, and waits for exactly `rows` rows when given. */
|
||||
search(text: string, rows?: number): Promise<void>;
|
||||
rows(): Locator;
|
||||
row(id: string): Locator;
|
||||
rowLink(id: string): Locator;
|
||||
/** The row link's href, resolved against the current page. */
|
||||
rowHref(id: string): Promise<URL>;
|
||||
/**
|
||||
* Searches for the view, opens it from its row and waits for the explorer's
|
||||
* query for it. Resolves with that query's response.
|
||||
*/
|
||||
open(view: SeededView): Promise<Response>;
|
||||
|
||||
rowMenuButton(id: string): Locator;
|
||||
rowAction(action: RowAction): Locator;
|
||||
openRowMenu(id: string): Promise<void>;
|
||||
/** Opens the row's menu and picks an action once its permission check settles. */
|
||||
chooseRowAction(id: string, action: RowAction): Promise<void>;
|
||||
/** Opens the row's menu and picks Copy link, returning the copied text. */
|
||||
copyLink(id: string): Promise<string>;
|
||||
/**
|
||||
* Ctrl or Cmd click, or a middle click, on the row link. Resolves once the
|
||||
* tab exists, not once it has loaded; the driver is bound to the new tab.
|
||||
*/
|
||||
openInNewTab(id: string, gesture?: LinkGesture): Promise<OpenedTab>;
|
||||
/**
|
||||
* Opens `url` in a new tab of the same browser context and waits for the
|
||||
* explorer's query for `view`.
|
||||
*/
|
||||
openLink(url: URL | string, view: SeededView): Promise<OpenedTab>;
|
||||
/** Tabs the browser context has opened since this driver was built. */
|
||||
newTabCount(): number;
|
||||
rename(id: string, displayName: string): Promise<void>;
|
||||
/** Confirms the delete dialog and waits for the DELETE. */
|
||||
deleteView(id: string): Promise<void>;
|
||||
|
||||
modal(): Locator;
|
||||
modalNameInput(): Locator;
|
||||
modalSubmit(): Locator;
|
||||
deleteConfirm(): Locator;
|
||||
/** Create from the header: names the view, saves it and returns its id. */
|
||||
createFromHeader(displayName: string): Promise<string>;
|
||||
/** Types into the query bar, runs it and waits for the query to carry it. */
|
||||
applyFilter(filter: string): Promise<void>;
|
||||
queryBar(): Locator;
|
||||
/** Header cells of the logs table with a title: the state indicator has none. */
|
||||
logsColumnHeaders(): Locator;
|
||||
openLogsFormatMenu(): Promise<void>;
|
||||
closeLogsFormatMenu(): Promise<void>;
|
||||
/** The checked format in the open menu: Raw, Default or Column. */
|
||||
selectedLogsFormat(): Locator;
|
||||
logsMaxLines(): Locator;
|
||||
logsFontSize(): Locator;
|
||||
explorerTab(tab: ExplorerTab): Locator;
|
||||
/** Changes when the document reloads and not when the app navigates in place. */
|
||||
documentId(): Promise<number>;
|
||||
/** Update selected view, waiting for the PUT and the toast. */
|
||||
update(): Promise<void>;
|
||||
discard(): Promise<void>;
|
||||
|
||||
/** `GET /api/v2/saved_views/<id>` requests the page has made so far. */
|
||||
viewGets(id: string): Request[];
|
||||
}
|
||||
|
||||
/** The driver on any page of the same context, such as a tab a link opened. */
|
||||
export function savedViewsFor(page: Page): SavedViews {
|
||||
const requests = collectRequests(page);
|
||||
let newTabs = 0;
|
||||
page.context().on('page', () => {
|
||||
newTabs += 1;
|
||||
});
|
||||
const tid = (id: string): Locator => page.getByTestId(id);
|
||||
const formatMenu = (): Locator => page.locator('.format-options-popover');
|
||||
const viewPath = (id: string): string =>
|
||||
`${BASE_PATH}${SAVED_VIEWS_API}/${id}`;
|
||||
|
||||
// A menu row, like a header button, ignores clicks until its permission
|
||||
// check settles, and stays enabled-looking meanwhile.
|
||||
const settle = async (control: Locator): Promise<void> => {
|
||||
await expect(control).toBeVisible();
|
||||
await expect(control).not.toHaveAttribute('aria-busy');
|
||||
await expect(control).not.toHaveAttribute('data-loading');
|
||||
await expect(control).not.toHaveAttribute('data-disabled');
|
||||
};
|
||||
const press = async (control: Locator): Promise<void> => {
|
||||
await settle(control);
|
||||
await control.click();
|
||||
};
|
||||
|
||||
const savedViews: SavedViews = {
|
||||
async seed(view) {
|
||||
const id = await createViaApi(page, view);
|
||||
trackSavedView(page, id);
|
||||
return seededView(id, view);
|
||||
},
|
||||
get: (id) => getViaApi(page, id),
|
||||
status: (id) => statusViaApi(page, id),
|
||||
|
||||
async openExplorer(source = 'logs', params = { relativeTime: '1h' }) {
|
||||
await dismissQuickFiltersAnnouncement(page);
|
||||
const search = new URLSearchParams(params).toString();
|
||||
await page.goto(`${EXPLORER_PATH[source]}${search ? `?${search}` : ''}`);
|
||||
await expect(savedViews.header()).toBeVisible();
|
||||
},
|
||||
async reload(filter, requestType) {
|
||||
await Promise.all([
|
||||
filter === undefined ? undefined : savedViews.queryRan(filter, requestType),
|
||||
page.reload(),
|
||||
]);
|
||||
await expect(savedViews.header()).toBeVisible();
|
||||
},
|
||||
queryRan: (filter, requestType) =>
|
||||
page.waitForResponse((res) => {
|
||||
const request = res.request();
|
||||
return (
|
||||
isQueryRangeRequest(request) &&
|
||||
filterExpressions(request).includes(filter) &&
|
||||
(requestType === undefined ||
|
||||
queryRangeBody(request).requestType === requestType)
|
||||
);
|
||||
}),
|
||||
url: () => new URL(page.url()),
|
||||
param: (name) => new URL(page.url()).searchParams.get(name),
|
||||
activeViewId() {
|
||||
const raw = savedViews.param('viewKey');
|
||||
return raw === null ? null : (JSON.parse(raw) as string);
|
||||
},
|
||||
|
||||
header: () => tid('saved-views-header'),
|
||||
name: () => tid('saved-views-name'),
|
||||
createButton: () => tid('saved-views-create'),
|
||||
clearButton: () => tid('saved-views-clear'),
|
||||
discardButton: () => tid('saved-views-discard'),
|
||||
saveChangesButton: () => tid('saved-views-save-changes'),
|
||||
saveAction: (action) => tid(`saved-views-save-changes-item-${action}`),
|
||||
async expectClean(displayName) {
|
||||
await expect(savedViews.name()).toHaveText(displayName);
|
||||
await expect(savedViews.clearButton()).toBeVisible();
|
||||
await expect(savedViews.discardButton()).toBeHidden();
|
||||
await expect(savedViews.saveChangesButton()).toBeHidden();
|
||||
},
|
||||
async expectDirty(displayName) {
|
||||
await expect(savedViews.name()).toHaveText(displayName);
|
||||
await expect(savedViews.discardButton()).toBeVisible();
|
||||
await expect(savedViews.saveChangesButton()).toBeVisible();
|
||||
await expect(savedViews.clearButton()).toBeHidden();
|
||||
},
|
||||
async expectNoView() {
|
||||
await expect(savedViews.name()).toHaveText(MY_VIEW);
|
||||
await expect(savedViews.createButton()).toBeVisible();
|
||||
await expect(savedViews.clearButton()).toBeHidden();
|
||||
await expect.poll(() => savedViews.param('viewKey')).toBeNull();
|
||||
},
|
||||
toast: (text) =>
|
||||
page.locator('[data-sonner-toast]').filter({ hasText: text }),
|
||||
|
||||
openListButton: () => tid('saved-views-open'),
|
||||
panel: () => tid('saved-views-panel'),
|
||||
searchInput: () => tid('saved-views-search'),
|
||||
async openList() {
|
||||
await expect(
|
||||
savedViews.openListButton().or(savedViews.panel()),
|
||||
).toBeVisible();
|
||||
if (!(await savedViews.panel().isVisible())) {
|
||||
await press(savedViews.openListButton());
|
||||
}
|
||||
await expect(savedViews.panel()).toBeVisible();
|
||||
},
|
||||
async search(text, rows) {
|
||||
await savedViews.searchInput().fill(text);
|
||||
if (rows !== undefined) {
|
||||
await expect(savedViews.rows()).toHaveCount(rows);
|
||||
}
|
||||
},
|
||||
rows: () => tid('saved-views-row'),
|
||||
row: (id) =>
|
||||
page.locator(`[data-testid="saved-views-row"][data-view-id="${id}"]`),
|
||||
rowLink: (id) => savedViews.row(id).getByTestId('saved-views-row-name'),
|
||||
async rowHref(id) {
|
||||
const href = await savedViews.rowLink(id).getAttribute('href');
|
||||
expect(href, `row ${id} has no href`).not.toBeNull();
|
||||
return new URL(href as string, page.url());
|
||||
},
|
||||
async open(view) {
|
||||
await savedViews.openList();
|
||||
await savedViews.search(view.displayName);
|
||||
const [response] = await Promise.all([
|
||||
savedViews.queryRan(view.filter, view.requestType),
|
||||
savedViews.rowLink(view.id).click(),
|
||||
]);
|
||||
await expect.poll(() => savedViews.activeViewId()).toBe(view.id);
|
||||
await expect(savedViews.name()).toHaveText(view.displayName);
|
||||
return response;
|
||||
},
|
||||
|
||||
rowMenuButton: (id) => savedViews.row(id).getByTestId('saved-views-row-menu'),
|
||||
rowAction: (action) => tid(`saved-views-row-menu-item-${action}`),
|
||||
async openRowMenu(id) {
|
||||
// The menu button has no width until the row is hovered.
|
||||
await savedViews.row(id).hover();
|
||||
await savedViews.rowMenuButton(id).click();
|
||||
},
|
||||
async chooseRowAction(id, action) {
|
||||
await savedViews.openRowMenu(id);
|
||||
await press(savedViews.rowAction(action));
|
||||
},
|
||||
async copyLink(id) {
|
||||
// Clipboard permissions are Chromium-only, so the copy is caught in the
|
||||
// page: react-use copies through a selection and execCommand, whose
|
||||
// `copy` event a capture listener sees first.
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as { __copied?: string };
|
||||
delete w.__copied;
|
||||
document.addEventListener(
|
||||
'copy',
|
||||
() => {
|
||||
w.__copied = document.getSelection()?.toString() ?? '';
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
);
|
||||
});
|
||||
await savedViews.chooseRowAction(id, 'copyLink');
|
||||
const copied = (): Promise<string | undefined> =>
|
||||
page.evaluate(() => (window as unknown as { __copied?: string }).__copied);
|
||||
await expect.poll(copied).toBeTruthy();
|
||||
return (await copied()) as string;
|
||||
},
|
||||
async openInNewTab(id, gesture = 'modifier') {
|
||||
const [tab] = await Promise.all([
|
||||
page.context().waitForEvent('page'),
|
||||
savedViews
|
||||
.rowLink(id)
|
||||
.click(
|
||||
gesture === 'modifier'
|
||||
? { modifiers: ['ControlOrMeta'] }
|
||||
: { button: 'middle' },
|
||||
),
|
||||
]);
|
||||
return { page: tab, savedViews: savedViewsFor(tab) };
|
||||
},
|
||||
async openLink(url, view) {
|
||||
const tab = await page.context().newPage();
|
||||
const opened = savedViewsFor(tab);
|
||||
await Promise.all([
|
||||
opened.queryRan(view.filter, view.requestType),
|
||||
tab.goto(url.toString()),
|
||||
]);
|
||||
return { page: tab, savedViews: opened };
|
||||
},
|
||||
newTabCount: () => newTabs,
|
||||
async rename(id, displayName) {
|
||||
await savedViews.chooseRowAction(id, 'editDetails');
|
||||
await expect(savedViews.modalNameInput()).toBeVisible();
|
||||
await savedViews.modalNameInput().fill(displayName);
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.request().method() === 'PUT' &&
|
||||
new URL(res.url()).pathname === viewPath(id),
|
||||
),
|
||||
savedViews.modalSubmit().click(),
|
||||
]);
|
||||
expect(response.ok(), `PUT ${SAVED_VIEWS_API}/${id}`).toBe(true);
|
||||
await expect(savedViews.modal()).toBeHidden();
|
||||
},
|
||||
async deleteView(id) {
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.request().method() === 'DELETE' &&
|
||||
new URL(res.url()).pathname === viewPath(id),
|
||||
),
|
||||
press(savedViews.deleteConfirm()),
|
||||
]);
|
||||
await expect(savedViews.deleteConfirm()).toBeHidden();
|
||||
},
|
||||
|
||||
modal: () => tid('save-view-modal'),
|
||||
modalNameInput: () => tid('save-view-name'),
|
||||
modalSubmit: () => tid('save-view-submit'),
|
||||
deleteConfirm: () => tid('delete-saved-view-confirm'),
|
||||
async createFromHeader(displayName) {
|
||||
await press(savedViews.createButton());
|
||||
await savedViews.modalNameInput().fill(displayName);
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.request().method() === 'POST' &&
|
||||
new URL(res.url()).pathname === `${BASE_PATH}${SAVED_VIEWS_API}`,
|
||||
),
|
||||
savedViews.modalSubmit().click(),
|
||||
]);
|
||||
expect(response.status(), `POST ${SAVED_VIEWS_API}`).toBe(201);
|
||||
const { id } = ((await response.json()) as { data: { id: string } }).data;
|
||||
trackSavedView(page, id);
|
||||
await expect(savedViews.modal()).toBeHidden();
|
||||
return id;
|
||||
},
|
||||
logsColumnHeaders: () => page.locator('thead th').filter({ hasText: /\S/ }),
|
||||
async openLogsFormatMenu() {
|
||||
await tid('periscope-btn-format-options').click();
|
||||
await expect(formatMenu()).toBeVisible();
|
||||
},
|
||||
async closeLogsFormatMenu() {
|
||||
await tid('periscope-btn-format-options').click();
|
||||
await expect(formatMenu()).toBeHidden();
|
||||
},
|
||||
selectedLogsFormat: () =>
|
||||
formatMenu()
|
||||
.locator('.menu-items .item')
|
||||
.filter({ has: page.locator('svg') }),
|
||||
logsMaxLines: () => formatMenu().getByTestId('max-lines-per-row'),
|
||||
logsFontSize: () => formatMenu().getByTestId('font-size-select'),
|
||||
explorerTab: (tab) => page.locator(`.${tab}-view-tab`),
|
||||
documentId: () => page.evaluate(() => performance.timeOrigin),
|
||||
queryBar: () =>
|
||||
page.locator('.query-where-clause-editor .cm-content').first(),
|
||||
async applyFilter(filter) {
|
||||
await Promise.all([
|
||||
savedViews.queryRan(filter),
|
||||
(async (): Promise<void> => {
|
||||
await savedViews.queryBar().click();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await page.keyboard.insertText(filter);
|
||||
await expect(savedViews.queryBar()).toHaveText(filter);
|
||||
await page.locator('[data-run-query-btn]').click();
|
||||
})(),
|
||||
]);
|
||||
},
|
||||
async update() {
|
||||
const id = savedViews.activeViewId();
|
||||
await savedViews.saveChangesButton().click();
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.request().method() === 'PUT' &&
|
||||
new URL(res.url()).pathname === viewPath(id as string),
|
||||
),
|
||||
press(savedViews.saveAction('update')),
|
||||
]);
|
||||
expect(response.ok(), `PUT ${SAVED_VIEWS_API}/${id}`).toBe(true);
|
||||
await expect(savedViews.toast('View updated')).toBeVisible();
|
||||
},
|
||||
async discard() {
|
||||
await press(savedViews.discardButton());
|
||||
},
|
||||
|
||||
viewGets: (id) =>
|
||||
requests.filter(
|
||||
(request) =>
|
||||
request.method() === 'GET' &&
|
||||
new URL(request.url()).pathname === viewPath(id),
|
||||
),
|
||||
};
|
||||
|
||||
return savedViews;
|
||||
}
|
||||
|
||||
const withSavedViews = driver(
|
||||
'savedViews',
|
||||
({ page }) => savedViewsFor(page),
|
||||
(_, { page }) => removeTrackedSavedViews(page),
|
||||
);
|
||||
|
||||
export const test = withSavedViews(kit);
|
||||
|
||||
export { expect } from './kit';
|
||||
export * from './saved-views/spec';
|
||||
export type * from './saved-views/spec.types';
|
||||
export { queryRangeBody } from './saved-views/query';
|
||||
78
tests/e2e/drivers/saved-views/api.ts
generated
Normal file
78
tests/e2e/drivers/saved-views/api.ts
generated
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { authToken } from '../api';
|
||||
import { SAVED_VIEWS_API } from './spec';
|
||||
import type { GettableSavedView, PostableSavedView } from './spec.types';
|
||||
|
||||
async function bearer(page: Page): Promise<{ Authorization: string }> {
|
||||
return { Authorization: `Bearer ${await authToken(page)}` };
|
||||
}
|
||||
|
||||
export async function createViaApi(
|
||||
page: Page,
|
||||
view: PostableSavedView,
|
||||
): Promise<string> {
|
||||
const res = await page.request.post(SAVED_VIEWS_API, {
|
||||
data: view,
|
||||
headers: await bearer(page),
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`POST ${SAVED_VIEWS_API} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return ((await res.json()) as { data: { id: string } }).data.id;
|
||||
}
|
||||
|
||||
export async function getViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
): Promise<GettableSavedView> {
|
||||
const res = await page.request.get(`${SAVED_VIEWS_API}/${id}`, {
|
||||
headers: await bearer(page),
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`GET ${SAVED_VIEWS_API}/${id} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return ((await res.json()) as { data: GettableSavedView }).data;
|
||||
}
|
||||
|
||||
/** The status alone, for asserting a view is gone. */
|
||||
export async function statusViaApi(page: Page, id: string): Promise<number> {
|
||||
const res = await page.request.get(`${SAVED_VIEWS_API}/${id}`, {
|
||||
headers: await bearer(page),
|
||||
});
|
||||
return res.status();
|
||||
}
|
||||
|
||||
/** Not asserted: a test that deleted its own view must not fail on cleanup. */
|
||||
export async function deleteViaApi(page: Page, id: string): Promise<void> {
|
||||
try {
|
||||
await page.request.delete(`${SAVED_VIEWS_API}/${id}`, {
|
||||
headers: await bearer(page),
|
||||
});
|
||||
} catch {
|
||||
/* the view outlives the run rather than hiding why it failed */
|
||||
}
|
||||
}
|
||||
|
||||
const trackedByPage = new WeakMap<Page, string[]>();
|
||||
|
||||
/** Registers a view for deletion when the test owning `page` ends. */
|
||||
export function trackSavedView(page: Page, id: string): void {
|
||||
trackedByPage.set(page, [...(trackedByPage.get(page) ?? []), id]);
|
||||
}
|
||||
|
||||
export async function removeTrackedSavedViews(page: Page): Promise<void> {
|
||||
const ids = trackedByPage.get(page) ?? [];
|
||||
trackedByPage.delete(page);
|
||||
for (const id of ids) {
|
||||
await deleteViaApi(page, id);
|
||||
}
|
||||
}
|
||||
40
tests/e2e/drivers/saved-views/query.ts
generated
Normal file
40
tests/e2e/drivers/saved-views/query.ts
generated
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import type { Request } from '@playwright/test';
|
||||
|
||||
import { isQueryRange } from '../query-range-mock';
|
||||
|
||||
interface QueryEnvelope {
|
||||
type: string;
|
||||
spec: {
|
||||
name?: string;
|
||||
signal?: string;
|
||||
filter?: { expression?: string };
|
||||
aggregations?: { expression?: string }[];
|
||||
groupBy?: { name: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface QueryRangeRequest {
|
||||
start: number;
|
||||
end: number;
|
||||
requestType: string;
|
||||
compositeQuery: { queries: QueryEnvelope[] };
|
||||
}
|
||||
|
||||
export function isQueryRangeRequest(request: Request): boolean {
|
||||
return request.method() === 'POST' && isQueryRange(request.url());
|
||||
}
|
||||
|
||||
export function queryRangeBody(request: Request): QueryRangeRequest {
|
||||
return request.postDataJSON() as QueryRangeRequest;
|
||||
}
|
||||
|
||||
/** The filter of every query in the request, in order. */
|
||||
export function filterExpressions(request: Request): string[] {
|
||||
return queryRangeBody(request).compositeQuery.queries.map(
|
||||
(query) => query.spec.filter?.expression ?? '',
|
||||
);
|
||||
}
|
||||
121
tests/e2e/drivers/saved-views/spec.ts
generated
Normal file
121
tests/e2e/drivers/saved-views/spec.ts
generated
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
// Typed builders for the saved view spec: pure data, no Playwright. The types
|
||||
// live in `spec.types.ts`.
|
||||
//
|
||||
// `/api/v2/saved_views`: create and update take the whole spec, and an update
|
||||
// replaces it, so a field left out is reset.
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import type {
|
||||
LogsViewOptions,
|
||||
PostableSavedView,
|
||||
SavedViewColumn,
|
||||
SavedViewPanelType,
|
||||
SavedViewRequestType,
|
||||
SeededView,
|
||||
} from './spec.types';
|
||||
|
||||
export const SAVED_VIEWS_API = '/api/v2/saved_views';
|
||||
|
||||
const REQUEST_TYPE: Record<SavedViewPanelType, SavedViewRequestType> = {
|
||||
list: 'raw',
|
||||
graph: 'time_series',
|
||||
table: 'scalar',
|
||||
};
|
||||
|
||||
/** Workers and browser projects share one org, so every view name is unique. */
|
||||
export function uniqueViewName(label: string): string {
|
||||
return `e2e sv ${label} ${randomBytes(4).toString('hex')}`;
|
||||
}
|
||||
|
||||
export const LOG_COLUMNS: Record<string, SavedViewColumn> = {
|
||||
timestamp: {
|
||||
name: 'timestamp',
|
||||
signal: 'logs',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: '',
|
||||
},
|
||||
body: {
|
||||
name: 'body',
|
||||
signal: 'logs',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: '',
|
||||
},
|
||||
serviceName: {
|
||||
name: 'service.name',
|
||||
signal: 'logs',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
severityText: {
|
||||
name: 'severity_text',
|
||||
signal: 'logs',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
};
|
||||
|
||||
export function logsView({
|
||||
displayName,
|
||||
filter = '',
|
||||
panelType = 'list',
|
||||
groupBy = [],
|
||||
columns = [LOG_COLUMNS.timestamp, LOG_COLUMNS.body],
|
||||
display,
|
||||
}: LogsViewOptions): PostableSavedView {
|
||||
const isList = panelType === 'list';
|
||||
return {
|
||||
generateName: true,
|
||||
source: 'logs',
|
||||
schemaVersion: 'v2',
|
||||
spec: {
|
||||
displayName,
|
||||
panelType,
|
||||
requestType: REQUEST_TYPE[panelType],
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'logs',
|
||||
disabled: false,
|
||||
filter: { expression: filter },
|
||||
...(isList
|
||||
? {
|
||||
order: [
|
||||
{ key: { name: 'timestamp' }, direction: 'desc' },
|
||||
{ key: { name: 'id' }, direction: 'desc' },
|
||||
],
|
||||
}
|
||||
: {
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
groupBy: groupBy.map((name) => ({
|
||||
name,
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
})),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
selectedFields: isList ? columns : [],
|
||||
...(display && { display }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function seededView(id: string, view: PostableSavedView): SeededView {
|
||||
const [query] = view.spec.queries as {
|
||||
spec?: { filter?: { expression?: string } };
|
||||
}[];
|
||||
return {
|
||||
id,
|
||||
displayName: view.spec.displayName,
|
||||
filter: query?.spec?.filter?.expression ?? '',
|
||||
requestType: view.spec.requestType,
|
||||
};
|
||||
}
|
||||
69
tests/e2e/drivers/saved-views/spec.types.ts
generated
Normal file
69
tests/e2e/drivers/saved-views/spec.types.ts
generated
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
// The saved view spec of `/api/v2/saved_views`.
|
||||
|
||||
export type SavedViewSource = 'logs' | 'traces' | 'metrics' | 'meter';
|
||||
export type SavedViewPanelType = 'list' | 'graph' | 'table';
|
||||
|
||||
export type SavedViewRequestType = 'raw' | 'time_series' | 'scalar';
|
||||
|
||||
export interface SavedViewColumn {
|
||||
name: string;
|
||||
signal?: string;
|
||||
fieldContext?: string;
|
||||
fieldDataType?: string;
|
||||
}
|
||||
|
||||
export interface SavedViewDisplay {
|
||||
format?: string;
|
||||
maxLines?: number;
|
||||
fontSize?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface SavedViewSpec {
|
||||
displayName: string;
|
||||
panelType: SavedViewPanelType;
|
||||
requestType: SavedViewRequestType;
|
||||
queries: Record<string, unknown>[];
|
||||
selectedFields?: SavedViewColumn[];
|
||||
display?: SavedViewDisplay;
|
||||
}
|
||||
|
||||
export interface PostableSavedView {
|
||||
generateName: true;
|
||||
source: SavedViewSource;
|
||||
schemaVersion: 'v2';
|
||||
spec: SavedViewSpec;
|
||||
}
|
||||
|
||||
export interface GettableSavedView {
|
||||
id: string;
|
||||
name: string;
|
||||
source: SavedViewSource;
|
||||
createdBy: string;
|
||||
updatedBy?: string;
|
||||
spec: SavedViewSpec;
|
||||
}
|
||||
|
||||
export interface LogsViewOptions {
|
||||
displayName: string;
|
||||
filter?: string;
|
||||
panelType?: SavedViewPanelType;
|
||||
/** Group by attributes of a graph or table view, as resource attributes. */
|
||||
groupBy?: string[];
|
||||
/** Defaults to timestamp and body, which a list view always shows. */
|
||||
columns?: SavedViewColumn[];
|
||||
display?: SavedViewDisplay;
|
||||
}
|
||||
|
||||
export interface SeededView {
|
||||
id: string;
|
||||
displayName: string;
|
||||
/** The first query's filter, which the explorer sends once the view is open. */
|
||||
filter: string;
|
||||
/** What the explorer's own query for the view asks for: raw, time_series or scalar. */
|
||||
requestType: string;
|
||||
}
|
||||
176
tests/e2e/tests/saved-views/lifecycle.spec.ts
Normal file
176
tests/e2e/tests/saved-views/lifecycle.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
expect,
|
||||
LOG_COLUMNS,
|
||||
logsView,
|
||||
test,
|
||||
uniqueViewName,
|
||||
} from '../../drivers/saved-views';
|
||||
|
||||
const SAVED_FILTER = "service.name = 'frontend'";
|
||||
const EDITED_FILTER = "service.name = 'cartservice'";
|
||||
|
||||
test.describe('Saved views: lifecycle on the logs explorer', () => {
|
||||
test('TC-01 a view created from the header survives a reload clean', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const displayName = uniqueViewName('create');
|
||||
await savedViews.openExplorer();
|
||||
await savedViews.expectNoView();
|
||||
await savedViews.applyFilter(SAVED_FILTER);
|
||||
|
||||
const id = await savedViews.createFromHeader(displayName);
|
||||
await expect(savedViews.toast('View created')).toBeVisible();
|
||||
await expect.poll(() => savedViews.activeViewId()).toBe(id);
|
||||
await savedViews.expectClean(displayName);
|
||||
|
||||
const stored = await savedViews.get(id);
|
||||
expect(stored.source).toBe('logs');
|
||||
expect(stored.spec.displayName).toBe(displayName);
|
||||
expect(stored.spec.panelType).toBe('list');
|
||||
expect(JSON.stringify(stored.spec.queries)).toContain(SAVED_FILTER);
|
||||
|
||||
await savedViews.reload(SAVED_FILTER);
|
||||
await savedViews.expectClean(displayName);
|
||||
await expect(savedViews.queryBar()).toHaveText(SAVED_FILTER);
|
||||
|
||||
await savedViews.openList();
|
||||
await savedViews.search(displayName);
|
||||
await expect(savedViews.row(id)).toBeVisible();
|
||||
await expect(savedViews.rowLink(id)).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
test('TC-02 an edited view turns dirty, updates, and opens with the new filter after a reload', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const view = await savedViews.seed(
|
||||
logsView({
|
||||
displayName: uniqueViewName('update'),
|
||||
filter: SAVED_FILTER,
|
||||
}),
|
||||
);
|
||||
await savedViews.openExplorer();
|
||||
await savedViews.open(view);
|
||||
await savedViews.expectClean(view.displayName);
|
||||
|
||||
await savedViews.applyFilter(EDITED_FILTER);
|
||||
await savedViews.expectDirty(view.displayName);
|
||||
|
||||
await savedViews.update();
|
||||
await savedViews.expectClean(view.displayName);
|
||||
|
||||
const stored = await savedViews.get(view.id);
|
||||
expect(JSON.stringify(stored.spec.queries)).toContain(EDITED_FILTER);
|
||||
expect(JSON.stringify(stored.spec.queries)).not.toContain(SAVED_FILTER);
|
||||
|
||||
await savedViews.reload(EDITED_FILTER);
|
||||
await savedViews.expectClean(view.displayName);
|
||||
await expect(savedViews.queryBar()).toHaveText(EDITED_FILTER);
|
||||
});
|
||||
|
||||
test('TC-03 discarding changes brings back the stored filter and tab', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const view = await savedViews.seed(
|
||||
logsView({
|
||||
displayName: uniqueViewName('discard'),
|
||||
filter: SAVED_FILTER,
|
||||
}),
|
||||
);
|
||||
await savedViews.openExplorer();
|
||||
await savedViews.open(view);
|
||||
await expect(savedViews.explorerTab('list')).toHaveClass(/active-tab/);
|
||||
|
||||
await savedViews.applyFilter(EDITED_FILTER);
|
||||
await savedViews.explorerTab('timeseries').click();
|
||||
await expect(savedViews.explorerTab('timeseries')).toHaveClass(/active-tab/);
|
||||
await savedViews.expectDirty(view.displayName);
|
||||
|
||||
await savedViews.discard();
|
||||
await savedViews.expectClean(view.displayName);
|
||||
await expect(savedViews.queryBar()).toHaveText(SAVED_FILTER);
|
||||
await expect(savedViews.explorerTab('list')).toHaveClass(/active-tab/);
|
||||
expect(savedViews.param('panelTypes')).toBe('"list"');
|
||||
|
||||
const stored = await savedViews.get(view.id);
|
||||
expect(JSON.stringify(stored.spec.queries)).toContain(SAVED_FILTER);
|
||||
});
|
||||
|
||||
test('TC-04 deleting the open view clears it without trying to load it again', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const view = await savedViews.seed(
|
||||
logsView({
|
||||
displayName: uniqueViewName('delete'),
|
||||
filter: SAVED_FILTER,
|
||||
}),
|
||||
);
|
||||
await savedViews.openExplorer();
|
||||
await savedViews.open(view);
|
||||
|
||||
await savedViews.chooseRowAction(view.id, 'delete');
|
||||
const getsBeforeDelete = savedViews.viewGets(view.id).length;
|
||||
expect(getsBeforeDelete).toBeGreaterThan(0);
|
||||
await savedViews.deleteView(view.id);
|
||||
|
||||
await expect(savedViews.toast('View deleted')).toBeVisible();
|
||||
await expect(savedViews.row(view.id)).toHaveCount(0);
|
||||
await savedViews.expectNoView();
|
||||
await expect.poll(() => savedViews.param('panelTypes')).toBeNull();
|
||||
expect(savedViews.param('viewName')).toBeNull();
|
||||
expect(savedViews.param('compositeQuery') ?? '').not.toContain('frontend');
|
||||
expect(savedViews.viewGets(view.id)).toHaveLength(getsBeforeDelete);
|
||||
expect(await savedViews.status(view.id)).toBe(404);
|
||||
});
|
||||
|
||||
test('TC-05 renaming a view keeps its columns and display settings', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const columns = [
|
||||
LOG_COLUMNS.timestamp,
|
||||
LOG_COLUMNS.body,
|
||||
LOG_COLUMNS.serviceName,
|
||||
LOG_COLUMNS.severityText,
|
||||
];
|
||||
const headers = ['Timestamp', 'Body', 'service.name', 'severity_text'];
|
||||
const display = { format: 'table', maxLines: 3, fontSize: 'medium' };
|
||||
const view = await savedViews.seed(
|
||||
logsView({
|
||||
displayName: uniqueViewName('rename'),
|
||||
filter: SAVED_FILTER,
|
||||
columns,
|
||||
display,
|
||||
}),
|
||||
);
|
||||
const renamed = uniqueViewName('renamed');
|
||||
|
||||
const expectDisplay = async (): Promise<void> => {
|
||||
await expect(savedViews.logsColumnHeaders()).toHaveText(headers);
|
||||
await savedViews.openLogsFormatMenu();
|
||||
await expect(savedViews.selectedLogsFormat()).toHaveText('Column');
|
||||
await expect(savedViews.logsMaxLines()).toHaveValue('3');
|
||||
await expect(savedViews.logsFontSize()).toHaveText('medium');
|
||||
await savedViews.closeLogsFormatMenu();
|
||||
};
|
||||
|
||||
await savedViews.openExplorer('logs', { relativeTime: '1d' });
|
||||
await savedViews.open(view);
|
||||
await expectDisplay();
|
||||
|
||||
await savedViews.openList();
|
||||
await savedViews.search(view.displayName);
|
||||
await savedViews.rename(view.id, renamed);
|
||||
await expect(savedViews.toast('View updated')).toBeVisible();
|
||||
await expect(savedViews.name()).toHaveText(renamed);
|
||||
|
||||
const stored = await savedViews.get(view.id);
|
||||
expect(stored.spec.displayName).toBe(renamed);
|
||||
expect(stored.spec.selectedFields?.map((column) => column.name)).toEqual(
|
||||
columns.map((column) => column.name),
|
||||
);
|
||||
expect(stored.spec.display).toMatchObject(display);
|
||||
|
||||
await savedViews.reload(SAVED_FILTER);
|
||||
await savedViews.expectClean(renamed);
|
||||
await expectDisplay();
|
||||
});
|
||||
});
|
||||
79
tests/e2e/tests/saved-views/open.spec.ts
Normal file
79
tests/e2e/tests/saved-views/open.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
type ExplorerTab,
|
||||
expect,
|
||||
logsView,
|
||||
queryRangeBody,
|
||||
type SavedViewPanelType,
|
||||
test,
|
||||
uniqueViewName,
|
||||
} from '../../drivers/saved-views';
|
||||
|
||||
const FILTER = "service.name = 'frontend'";
|
||||
|
||||
const PANELS: {
|
||||
panelType: SavedViewPanelType;
|
||||
tab: ExplorerTab;
|
||||
requestType: string;
|
||||
aggregation: string | undefined;
|
||||
groupBy: string[] | undefined;
|
||||
}[] = [
|
||||
{
|
||||
panelType: 'list',
|
||||
tab: 'list',
|
||||
requestType: 'raw',
|
||||
aggregation: undefined,
|
||||
groupBy: undefined,
|
||||
},
|
||||
{
|
||||
panelType: 'graph',
|
||||
tab: 'timeseries',
|
||||
requestType: 'time_series',
|
||||
aggregation: 'count()',
|
||||
groupBy: ['service.name'],
|
||||
},
|
||||
{
|
||||
panelType: 'table',
|
||||
tab: 'table',
|
||||
requestType: 'scalar',
|
||||
aggregation: 'count()',
|
||||
groupBy: ['service.name'],
|
||||
},
|
||||
];
|
||||
|
||||
test.describe('Saved views: opening from the list', () => {
|
||||
for (const [
|
||||
index,
|
||||
{ panelType, tab, requestType, aggregation, groupBy },
|
||||
] of PANELS.entries()) {
|
||||
test(`TC-0${index + 1} a ${panelType} view opens with its query, tab and a clean header`, async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const view = await savedViews.seed(
|
||||
logsView({
|
||||
displayName: uniqueViewName(`open ${panelType}`),
|
||||
filter: FILTER,
|
||||
panelType,
|
||||
groupBy,
|
||||
}),
|
||||
);
|
||||
await savedViews.openExplorer();
|
||||
const response = await savedViews.open(view);
|
||||
|
||||
expect(savedViews.param('panelTypes')).toBe(JSON.stringify(panelType));
|
||||
await expect(savedViews.explorerTab(tab)).toHaveClass(/active-tab/);
|
||||
await expect(savedViews.queryBar()).toHaveText(FILTER);
|
||||
await expect(savedViews.rowLink(view.id)).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page',
|
||||
);
|
||||
await savedViews.expectClean(view.displayName);
|
||||
|
||||
const body = queryRangeBody(response.request());
|
||||
expect(body.requestType).toBe(requestType);
|
||||
const [query] = body.compositeQuery.queries;
|
||||
expect(query.spec.filter?.expression).toBe(FILTER);
|
||||
expect(query.spec.aggregations?.[0]?.expression).toBe(aggregation);
|
||||
expect(query.spec.groupBy?.map((group) => group.name)).toEqual(groupBy);
|
||||
});
|
||||
}
|
||||
});
|
||||
169
tests/e2e/tests/saved-views/row-link.spec.ts
Normal file
169
tests/e2e/tests/saved-views/row-link.spec.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
expect,
|
||||
explorerPathname,
|
||||
logsView,
|
||||
type SavedViews,
|
||||
test,
|
||||
uniqueViewName,
|
||||
} from '../../drivers/saved-views';
|
||||
|
||||
const LOGS_PATH = explorerPathname('logs');
|
||||
const FRONTEND_FILTER = "service.name = 'frontend'";
|
||||
const CART_FILTER = "service.name = 'cartservice'";
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
/** Whole milliseconds, as the links carry them. */
|
||||
function customRange(): { startTime: string; endTime: string } {
|
||||
const end = Math.floor(Date.now() / 60_000) * 60_000;
|
||||
return {
|
||||
startTime: String(end - 2 * HOUR_MS),
|
||||
endTime: String(end - HOUR_MS),
|
||||
};
|
||||
}
|
||||
|
||||
async function seedTwoViews(savedViews: SavedViews) {
|
||||
const target = await savedViews.seed(
|
||||
logsView({ displayName: uniqueViewName('link'), filter: FRONTEND_FILTER }),
|
||||
);
|
||||
const other = await savedViews.seed(
|
||||
logsView({ displayName: uniqueViewName('other'), filter: CART_FILTER }),
|
||||
);
|
||||
return { target, other };
|
||||
}
|
||||
|
||||
test.describe('Saved views: row links', () => {
|
||||
test('TC-01 a row links to its view in the selected relative time range', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const { target } = await seedTwoViews(savedViews);
|
||||
await savedViews.openExplorer('logs', { relativeTime: '1h' });
|
||||
await savedViews.openList();
|
||||
await savedViews.search(target.displayName);
|
||||
|
||||
const href = await savedViews.rowHref(target.id);
|
||||
expect(href.pathname).toBe(LOGS_PATH);
|
||||
expect(href.searchParams.get('viewKey')).toBe(JSON.stringify(target.id));
|
||||
expect(href.searchParams.get('panelTypes')).toBe('"list"');
|
||||
expect(href.searchParams.get('relativeTime')).toBe('1h');
|
||||
expect(href.searchParams.has('startTime')).toBe(false);
|
||||
expect(href.searchParams.has('endTime')).toBe(false);
|
||||
expect(href.searchParams.get('compositeQuery')).toContain(
|
||||
encodeURIComponent(FRONTEND_FILTER),
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-02 a row links to its view in a custom time range, to the millisecond', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const { target } = await seedTwoViews(savedViews);
|
||||
const range = customRange();
|
||||
await savedViews.openExplorer('logs', range);
|
||||
await savedViews.openList();
|
||||
await savedViews.search(target.displayName);
|
||||
|
||||
const href = await savedViews.rowHref(target.id);
|
||||
expect(href.searchParams.get('startTime')).toBe(range.startTime);
|
||||
expect(href.searchParams.get('endTime')).toBe(range.endTime);
|
||||
expect(href.searchParams.has('relativeTime')).toBe(false);
|
||||
expect(href.searchParams.get('viewKey')).toBe(JSON.stringify(target.id));
|
||||
});
|
||||
|
||||
test('TC-03 a plain click opens the view in place and marks its row', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const { target, other } = await seedTwoViews(savedViews);
|
||||
await savedViews.openExplorer('logs', { relativeTime: '1h' });
|
||||
const documentId = await savedViews.documentId();
|
||||
|
||||
await savedViews.openList();
|
||||
await savedViews.search(target.displayName);
|
||||
await savedViews.open(target);
|
||||
|
||||
await savedViews.expectClean(target.displayName);
|
||||
await expect(savedViews.rowLink(target.id)).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page',
|
||||
);
|
||||
expect(savedViews.param('relativeTime')).toBe('1h');
|
||||
expect(await savedViews.documentId()).toBe(documentId);
|
||||
expect(savedViews.newTabCount()).toBe(0);
|
||||
|
||||
await savedViews.search(other.displayName);
|
||||
await expect(savedViews.rowLink(other.id)).not.toHaveAttribute(
|
||||
'aria-current',
|
||||
'page',
|
||||
);
|
||||
});
|
||||
|
||||
for (const gesture of ['modifier', 'middle'] as const) {
|
||||
test(`TC-04 a ${gesture} click opens the view in a new tab and leaves this one alone`, async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const { target } = await seedTwoViews(savedViews);
|
||||
await savedViews.openExplorer('logs', { relativeTime: '1h' });
|
||||
await savedViews.openList();
|
||||
await savedViews.search(target.displayName);
|
||||
const urlBefore = savedViews.url().href;
|
||||
|
||||
const { savedViews: opened } = await savedViews.openInNewTab(
|
||||
target.id,
|
||||
gesture,
|
||||
);
|
||||
await opened.queryRan(target.filter);
|
||||
|
||||
await opened.expectClean(target.displayName);
|
||||
expect(opened.activeViewId()).toBe(target.id);
|
||||
expect(opened.param('relativeTime')).toBe('1h');
|
||||
await expect(opened.queryBar()).toHaveText(target.filter);
|
||||
expect(savedViews.url().href).toBe(urlBefore);
|
||||
await expect(savedViews.name()).toHaveText('My view');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Saved views: copied links', () => {
|
||||
test('TC-05 a copied link reopens the view in the same relative time range', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const { target } = await seedTwoViews(savedViews);
|
||||
await savedViews.openExplorer('logs', { relativeTime: '1h' });
|
||||
await savedViews.openList();
|
||||
await savedViews.search(target.displayName);
|
||||
|
||||
const copied = new URL(await savedViews.copyLink(target.id));
|
||||
await expect(savedViews.toast('Link copied')).toBeVisible();
|
||||
expect(copied.origin).toBe(savedViews.url().origin);
|
||||
expect(copied.pathname).toBe(LOGS_PATH);
|
||||
expect(copied.searchParams.get('relativeTime')).toBe('1h');
|
||||
expect(copied.searchParams.get('viewKey')).toBe(JSON.stringify(target.id));
|
||||
|
||||
const { savedViews: reopened } = await savedViews.openLink(copied, target);
|
||||
|
||||
await reopened.expectClean(target.displayName);
|
||||
expect(reopened.activeViewId()).toBe(target.id);
|
||||
expect(reopened.param('relativeTime')).toBe('1h');
|
||||
await expect(reopened.queryBar()).toHaveText(target.filter);
|
||||
});
|
||||
|
||||
test('TC-06 a copied link reopens the view in the same custom time range', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const { target } = await seedTwoViews(savedViews);
|
||||
const range = customRange();
|
||||
await savedViews.openExplorer('logs', range);
|
||||
await savedViews.openList();
|
||||
await savedViews.search(target.displayName);
|
||||
|
||||
const copied = new URL(await savedViews.copyLink(target.id));
|
||||
expect(copied.searchParams.get('startTime')).toBe(range.startTime);
|
||||
expect(copied.searchParams.get('endTime')).toBe(range.endTime);
|
||||
expect(copied.searchParams.has('relativeTime')).toBe(false);
|
||||
|
||||
const { savedViews: reopened } = await savedViews.openLink(copied, target);
|
||||
|
||||
await reopened.expectClean(target.displayName);
|
||||
expect(reopened.param('startTime')).toBe(range.startTime);
|
||||
expect(reopened.param('endTime')).toBe(range.endTime);
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
from uuid import uuid4
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.metrics import Metrics
|
||||
from fixtures.querier import (
|
||||
build_builder_query,
|
||||
build_function,
|
||||
get_all_series,
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
)
|
||||
|
||||
MINUTE = timedelta(minutes=1)
|
||||
|
||||
|
||||
def test_running_diff_full_hit_matches_no_cache(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
A gauge that grows by 10 every minute over four minutes.
|
||||
|
||||
Tests:
|
||||
runningDiff over the last three of those minutes, requested twice. The
|
||||
metrics statement reads one step before the window so the first
|
||||
interval has a difference. The second request, a full cache hit, must
|
||||
return the same three differences as the request with noCache.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
# the cache outlives the run, so a fixed name would serve the previous
|
||||
# run's points back to this one
|
||||
metric_name = f"cache_running_diff_{uuid4().hex[:8]}"
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"service": "svc-a"},
|
||||
timestamp=now - (14 - i) * MINUTE,
|
||||
value=100.0 + 10 * i,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for i in range(4)
|
||||
]
|
||||
)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query = build_builder_query("A", metric_name, "avg", "avg", group_by=["service"], functions=[build_function("runningDiff")])
|
||||
start_ms = int((now - 13 * MINUTE).timestamp() * 1000)
|
||||
end_ms = int((now - 10 * MINUTE).timestamp() * 1000)
|
||||
|
||||
warm = make_query_request(signoz, token, start_ms, end_ms, [query], no_cache=False)
|
||||
assert warm.status_code == HTTPStatus.OK, warm.text
|
||||
cached = make_query_request(signoz, token, start_ms, end_ms, [query], no_cache=False)
|
||||
assert cached.status_code == HTTPStatus.OK, cached.text
|
||||
fresh = make_query_request(signoz, token, start_ms, end_ms, [query], no_cache=True)
|
||||
assert fresh.status_code == HTTPStatus.OK, fresh.text
|
||||
|
||||
fresh_points = {point["timestamp"]: point["value"] for point in index_series_by_label(get_all_series(fresh.json(), "A"), "service")["svc-a"]["values"]}
|
||||
assert sorted(fresh_points.values()) == [10, 10, 10], fresh_points
|
||||
|
||||
cached_points = {point["timestamp"]: point["value"] for point in index_series_by_label(get_all_series(cached.json(), "A"), "service")["svc-a"]["values"]}
|
||||
assert cached_points == fresh_points
|
||||
Reference in New Issue
Block a user