mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-15 12:50:40 +00:00
Compare commits
19 Commits
tvats-remo
...
test/compo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aab0d86f24 | ||
|
|
27f7e4ef2d | ||
|
|
7acebd8e4e | ||
|
|
101dc51eeb | ||
|
|
2de8bc7e66 | ||
|
|
542e0bfdd9 | ||
|
|
90fdef2991 | ||
|
|
0f75302284 | ||
|
|
f7e17e55fb | ||
|
|
d88f8e9b2f | ||
|
|
0c069c71e7 | ||
|
|
80232c03d6 | ||
|
|
b56c2c6350 | ||
|
|
ca7daf8ea9 | ||
|
|
d038407475 | ||
|
|
da3719ec7e | ||
|
|
99745b7dba | ||
|
|
fb60ae1594 | ||
|
|
d099467285 |
2
.github/workflows/e2eci.yaml
vendored
2
.github/workflows/e2eci.yaml
vendored
@@ -88,7 +88,7 @@ jobs:
|
||||
postgres:15 \
|
||||
clickhouse/clickhouse-server:25.12.5 \
|
||||
clickhouse/clickhouse-keeper:25.12.5 \
|
||||
signoz/signoz-schema-migrator:v0.144.15 \
|
||||
signoz/signoz-otel-collector:v0.144.16-rc.1 \
|
||||
wiremock/wiremock:2.35.1-1 \
|
||||
| xargs -P 0 -n 1 docker pull -q
|
||||
- name: python
|
||||
|
||||
7
.github/workflows/integrationci.yaml
vendored
7
.github/workflows/integrationci.yaml
vendored
@@ -40,6 +40,7 @@ jobs:
|
||||
suite:
|
||||
- alertmanager
|
||||
- alertmanagerrotation
|
||||
- auditquerier
|
||||
- basepath
|
||||
- callbackauthn
|
||||
- cloudintegrations
|
||||
@@ -83,8 +84,8 @@ jobs:
|
||||
- wal
|
||||
clickhouse-version:
|
||||
- 25.12.5
|
||||
schema-migrator-version:
|
||||
- v0.144.15
|
||||
signoz-otel-collector-version:
|
||||
- v0.144.16-rc.1
|
||||
postgres-version:
|
||||
- 15
|
||||
if: |
|
||||
@@ -155,4 +156,4 @@ jobs:
|
||||
--sqlite-mode ${{matrix.sqlite-mode}} \
|
||||
--postgres-version ${{matrix.postgres-version}} \
|
||||
--clickhouse-version ${{matrix.clickhouse-version}} \
|
||||
--schema-migrator-version ${{matrix.schema-migrator-version}}
|
||||
--signoz-otel-collector-version ${{matrix.signoz-otel-collector-version}}
|
||||
|
||||
@@ -256,7 +256,7 @@ Tests can be configured using pytest options:
|
||||
- `--sqlite-mode` — SQLite journal mode: `delete` or `wal` (default: `delete`). Only relevant when `--sqlstore-provider=sqlite`.
|
||||
- `--postgres-version` — PostgreSQL version (default: `15`)
|
||||
- `--clickhouse-version` — ClickHouse version, also used for ClickHouse Keeper (default: `25.12.5`)
|
||||
- `--schema-migrator-version` — SigNoz schema migrator version (default: `v0.144.15`)
|
||||
- `--signoz-otel-collector-version`: SigNoz OTel collector version, used to run the ClickHouse migrations (default: `v0.144.16-rc.1`)
|
||||
- `--with-web` — Build the frontend into the SigNoz image (required for e2e)
|
||||
|
||||
Example:
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { createAppContextMock } from 'tests/fixtures/appContextMock';
|
||||
import { LicensePlatform } from 'types/api/licensesV3/getActive';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { fn } from 'storybook/test';
|
||||
|
||||
import { storyChangelog } from './__story_mockdata__/changelog';
|
||||
import {
|
||||
showsCloudChangelog,
|
||||
showsSelfHostedChangelog,
|
||||
} from './__story_mockdata__/changelogModal.play';
|
||||
|
||||
import ChangelogModal from '../ChangelogModal';
|
||||
|
||||
const { activeLicense } = createAppContextMock(USER_ROLES.ADMIN);
|
||||
|
||||
/** `AppLayout` opens it one second after load, for a version the user has not seen. */
|
||||
const meta = {
|
||||
title: 'Components/Changelog Modal',
|
||||
component: ChangelogModal,
|
||||
tags: ['play'],
|
||||
parameters: { signoz: {} },
|
||||
args: {
|
||||
changelog: storyChangelog,
|
||||
onClose: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof ChangelogModal>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Cloud workspaces update on their own, so the footer has no update actions. */
|
||||
export const Cloud: Story = {
|
||||
play: showsCloudChangelog,
|
||||
};
|
||||
|
||||
/** Self-hosted workspaces get "Skip for now" and "Update my workspace". */
|
||||
export const SelfHosted: Story = {
|
||||
parameters: {
|
||||
signoz: {
|
||||
appContext: {
|
||||
activeLicense: activeLicense && {
|
||||
...activeLicense,
|
||||
platform: LicensePlatform.SELF_HOSTED,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
play: showsSelfHostedChangelog,
|
||||
};
|
||||
62
frontend/src/components/ChangelogModal/stories/__story_mockdata__/changelog.ts
generated
Normal file
62
frontend/src/components/ChangelogModal/stories/__story_mockdata__/changelog.ts
generated
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
ChangelogSchema,
|
||||
DeploymentType,
|
||||
} from 'types/api/changelog/getChangelogByVersion';
|
||||
|
||||
const TIMESTAMP = '2026-11-03T12:00:00Z';
|
||||
|
||||
function feature(
|
||||
id: number,
|
||||
title: string,
|
||||
description: string,
|
||||
): ChangelogSchema['features'][number] {
|
||||
return {
|
||||
id,
|
||||
documentId: `feature-${id}`,
|
||||
title,
|
||||
description,
|
||||
media: null,
|
||||
sort_order: id,
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
publishedAt: TIMESTAMP,
|
||||
deployment_type: DeploymentType.ALL,
|
||||
};
|
||||
}
|
||||
|
||||
/** Long enough to scroll, so the "Scroll for more" button shows. */
|
||||
export const storyChangelog: ChangelogSchema = {
|
||||
id: 1,
|
||||
documentId: 'changelog-v0.99.0',
|
||||
version: 'v0.99.0',
|
||||
release_date: '2026-11-03',
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
publishedAt: TIMESTAMP,
|
||||
features: [
|
||||
feature(
|
||||
1,
|
||||
'Dashboard sections',
|
||||
'Group panels into **sections** you can collapse, rename and drag. Existing dashboards move into a single section the first time you edit them.',
|
||||
),
|
||||
feature(
|
||||
2,
|
||||
'Service accounts',
|
||||
'Create service accounts with their own API keys and roles. Keys can expire, and you can revoke them without touching the account. See the [docs](https://signoz.io/docs/) for the API.',
|
||||
),
|
||||
feature(
|
||||
3,
|
||||
'Faster log search',
|
||||
'Full-text search over log bodies now uses a token index:\n\n- queries over a day of logs return up to 4x faster\n- `has()` and `hasToken()` use the same index\n- the old behaviour stays behind a setting for a release',
|
||||
),
|
||||
feature(
|
||||
4,
|
||||
'Routing policies',
|
||||
'Send an alert to a channel based on its labels instead of picking the channel in every rule. Policies are evaluated in order and the first match wins.',
|
||||
),
|
||||
],
|
||||
bug_fixes:
|
||||
'- Fixed the time picker losing the selected range after a refresh.\n- Fixed trace waterfall rows overlapping when a span name wraps.\n- Fixed CSV export dropping columns with dots in their names.',
|
||||
maintenance:
|
||||
'- Upgraded the OpenTelemetry collector to the latest release.\n- Reduced the memory used by the query service on large dashboards.',
|
||||
};
|
||||
19
frontend/src/components/ChangelogModal/stories/__story_mockdata__/changelogModal.play.ts
generated
Normal file
19
frontend/src/components/ChangelogModal/stories/__story_mockdata__/changelogModal.play.ts
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
import { expect, screen, waitFor } from 'storybook/test';
|
||||
|
||||
const TITLE = /What’s New ⎯ Changelog : November 3, 2026/;
|
||||
|
||||
export const showsCloudChangelog = async (): Promise<void> => {
|
||||
await screen.findByRole('dialog', { name: TITLE });
|
||||
await expect(
|
||||
screen.queryByRole('button', { name: /Skip for now/ }),
|
||||
).not.toBeInTheDocument();
|
||||
};
|
||||
|
||||
export const showsSelfHostedChangelog = async (): Promise<void> => {
|
||||
await screen.findByRole('dialog', { name: TITLE });
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Update my workspace/ }),
|
||||
).toBeVisible(),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
|
||||
import { QueryBuilderV2 } from '../QueryBuilderV2';
|
||||
import {
|
||||
metricsFewGroupBy,
|
||||
metricsManyGroupBy,
|
||||
tracesFewAddOns,
|
||||
tracesManyAddOns,
|
||||
} from './__story_mockdata__/queryBuilderAddOns';
|
||||
import {
|
||||
expectMetricsGroupBy,
|
||||
expectTracesAddOnsOpen,
|
||||
} from './__story_mockdata__/queryBuilderAddOns.play';
|
||||
import {
|
||||
DEFAULT_ARGS,
|
||||
PANEL_EDITOR_ARGS,
|
||||
PANEL_EDITOR_PATH,
|
||||
routeWith,
|
||||
} from './QueryBuilderV2.stories.mocks';
|
||||
import QueryProbe from './QueryProbe';
|
||||
|
||||
/**
|
||||
* The add-ons a query opens with values already set: group by, having, order
|
||||
* by, limit and legend format side by side, with one value each and with
|
||||
* enough to wrap.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Components/Query Builder/Add-ons',
|
||||
component: QueryBuilderV2,
|
||||
tags: ['play'],
|
||||
decorators: [withCanvas({ maxWidth: 1100 })],
|
||||
args: { ...DEFAULT_ARGS, ...PANEL_EDITOR_ARGS },
|
||||
render: (args): JSX.Element => (
|
||||
<>
|
||||
<QueryBuilderV2 {...args} />
|
||||
<QueryProbe />
|
||||
</>
|
||||
),
|
||||
} satisfies Meta<typeof QueryBuilderV2>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Traces with every add-on open, one value in each. */
|
||||
export const FewValues: Story = {
|
||||
parameters: {
|
||||
signoz: { route: routeWith(PANEL_EDITOR_PATH, tracesFewAddOns) },
|
||||
},
|
||||
play: expectTracesAddOnsOpen(1),
|
||||
};
|
||||
|
||||
/** Traces with every add-on open and filled past one line. */
|
||||
export const ManyValues: Story = {
|
||||
parameters: {
|
||||
signoz: { route: routeWith(PANEL_EDITOR_PATH, tracesManyAddOns) },
|
||||
},
|
||||
play: expectTracesAddOnsOpen(9),
|
||||
};
|
||||
|
||||
/** A histogram grouped by one key, beside the aggregation selects. */
|
||||
export const MetricsFewGroupBy: Story = {
|
||||
parameters: {
|
||||
signoz: { route: routeWith(PANEL_EDITOR_PATH, metricsFewGroupBy) },
|
||||
},
|
||||
play: expectMetricsGroupBy(1),
|
||||
};
|
||||
|
||||
/** A histogram grouped by enough keys to wrap the group-by select. */
|
||||
export const MetricsManyGroupBy: Story = {
|
||||
parameters: {
|
||||
signoz: { route: routeWith(PANEL_EDITOR_PATH, metricsManyGroupBy) },
|
||||
},
|
||||
play: expectMetricsGroupBy(7),
|
||||
};
|
||||
@@ -8,7 +8,11 @@ import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import type { QueryBuilderV2 } from '../QueryBuilderV2';
|
||||
|
||||
@@ -18,6 +22,30 @@ type QueryBuilderProps = Parameters<typeof QueryBuilderV2>[0];
|
||||
export const routeWith = (path: string, query: Query): string =>
|
||||
`${path}?compositeQuery=${encodeURIComponent(JSON.stringify(query))}`;
|
||||
|
||||
/** `query` with its first builder query's fields replaced by `fields`. */
|
||||
export const queryWith = (
|
||||
query: Query,
|
||||
fields: Partial<IBuilderQuery>,
|
||||
): Query => ({
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: [{ ...query.builder.queryData[0], ...fields }],
|
||||
},
|
||||
});
|
||||
|
||||
/** A group-by key as the builder stores it: `id` is the select's option value. */
|
||||
export const groupByKey = (
|
||||
key: string,
|
||||
type = 'tag',
|
||||
dataType: DataTypes = DataTypes.String,
|
||||
): BaseAutocompleteData => ({
|
||||
key,
|
||||
dataType,
|
||||
type,
|
||||
id: `${key}--${dataType}--${type}`,
|
||||
});
|
||||
|
||||
export const meterQuery: Query = {
|
||||
...initialQueriesMap.metrics,
|
||||
builder: {
|
||||
|
||||
43
frontend/src/components/QueryBuilderV2/stories/__story_mockdata__/queryBuilderAddOns.play.ts
generated
Normal file
43
frontend/src/components/QueryBuilderV2/stories/__story_mockdata__/queryBuilderAddOns.play.ts
generated
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { expect, waitFor, within } from 'storybook/test';
|
||||
|
||||
const TRACES_ADD_ONS = [
|
||||
'group-by-content',
|
||||
'having-content',
|
||||
'order-by-content',
|
||||
'limit-content',
|
||||
'legend-format-content',
|
||||
];
|
||||
|
||||
const groupByTags = (scope: HTMLElement): string[] =>
|
||||
Array.from(
|
||||
within(scope)
|
||||
.getByTestId('group-by')
|
||||
.querySelectorAll('.ant-select-selection-item'),
|
||||
).map((tag) => tag.textContent ?? '');
|
||||
|
||||
/** Every add-on the seeded query fills opens on mount, group by with all its keys. */
|
||||
export const expectTracesAddOnsOpen =
|
||||
(groupByCount: number) =>
|
||||
async ({ canvasElement }: { canvasElement: HTMLElement }): Promise<void> => {
|
||||
const query = within(canvasElement).getByTestId('query-A');
|
||||
await waitFor(async () => {
|
||||
for (const testId of TRACES_ADD_ONS) {
|
||||
await expect(within(query).getByTestId(testId)).toBeVisible();
|
||||
}
|
||||
});
|
||||
const groupBy = within(query).getByTestId('group-by-content');
|
||||
await waitFor(() => expect(groupByTags(groupBy)).toHaveLength(groupByCount));
|
||||
};
|
||||
|
||||
/** The aggregation row's group by shows every seeded key. */
|
||||
export const expectMetricsGroupBy =
|
||||
(groupByCount: number) =>
|
||||
async ({ canvasElement }: { canvasElement: HTMLElement }): Promise<void> => {
|
||||
const query = within(canvasElement).getByTestId('query-A');
|
||||
await waitFor(() => expect(groupByTags(query)).toHaveLength(groupByCount));
|
||||
};
|
||||
73
frontend/src/components/QueryBuilderV2/stories/__story_mockdata__/queryBuilderAddOns.ts
generated
Normal file
73
frontend/src/components/QueryBuilderV2/stories/__story_mockdata__/queryBuilderAddOns.ts
generated
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { groupByKey, queryWith } from '../QueryBuilderV2.stories.mocks';
|
||||
|
||||
/** Every traces add-on holding one short value. */
|
||||
export const tracesFewAddOns: Query = queryWith(initialQueriesMap.traces, {
|
||||
groupBy: [groupByKey('service.name', 'resource')],
|
||||
having: { expression: 'count() > 10' },
|
||||
orderBy: [{ columnName: 'count()', order: 'desc' }],
|
||||
limit: 10,
|
||||
legend: '{{service.name}}',
|
||||
});
|
||||
|
||||
/** Every traces add-on filled past one line, so group by and order by wrap. */
|
||||
export const tracesManyAddOns: Query = queryWith(initialQueriesMap.traces, {
|
||||
groupBy: [
|
||||
groupByKey('service.name', 'resource'),
|
||||
groupByKey('deployment.environment', 'resource'),
|
||||
groupByKey('host.name', 'resource'),
|
||||
groupByKey('name', ''),
|
||||
groupByKey('http.method'),
|
||||
groupByKey('http.route'),
|
||||
groupByKey('response_status_code'),
|
||||
groupByKey('rpc.method'),
|
||||
groupByKey('hasError', '', DataTypes.bool),
|
||||
],
|
||||
having: {
|
||||
expression: 'count() > 10 AND count() < 5000 AND count() != 250',
|
||||
},
|
||||
orderBy: [
|
||||
{ columnName: 'count()', order: 'desc' },
|
||||
{ columnName: 'service.name', order: 'asc' },
|
||||
{ columnName: 'http.route', order: 'asc' },
|
||||
],
|
||||
limit: 100,
|
||||
legend:
|
||||
'{{service.name}} {{deployment.environment}} {{http.method}} {{http.route}}',
|
||||
});
|
||||
|
||||
const histogramWith = (keys: string[]): Query =>
|
||||
queryWith(initialQueriesMap.metrics, {
|
||||
aggregations: [
|
||||
{
|
||||
metricName: 'signoz_latency_bucket',
|
||||
temporality: '',
|
||||
timeAggregation: '',
|
||||
spaceAggregation: 'p90',
|
||||
reduceTo: undefined,
|
||||
},
|
||||
],
|
||||
groupBy: keys.map((key) => groupByKey(key)),
|
||||
});
|
||||
|
||||
/** A histogram grouped by one key in the aggregation row. */
|
||||
export const metricsFewGroupBy: Query = histogramWith(['service_name']);
|
||||
|
||||
/** A histogram grouped by enough keys to wrap the aggregation row's select. */
|
||||
export const metricsManyGroupBy: Query = histogramWith([
|
||||
'service_name',
|
||||
'operation',
|
||||
'status_code',
|
||||
'le',
|
||||
'resource_deployment.environment',
|
||||
'resource_host.name',
|
||||
'resource_k8s.cluster.name',
|
||||
]);
|
||||
@@ -98,6 +98,15 @@ export const noSavedFiltersHandler = rest.get(
|
||||
),
|
||||
);
|
||||
|
||||
export const noSavedTracesFiltersHandler = rest.get(
|
||||
'http://localhost/api/v2/quick_filters/traces',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(quickFiltersResponse(QuickfiltertypesSourceDTO.traces, [])),
|
||||
),
|
||||
);
|
||||
|
||||
export const loadingFiltersHandlers = [
|
||||
rest.get('http://localhost/api/v2/quick_filters/logs', (_req, res, ctx) =>
|
||||
res(ctx.delay('infinite')),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import type { ComponentProps, ComponentType } from 'react';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
import type { GlobalMockArgs } from '@/storybook/globals';
|
||||
@@ -16,8 +17,9 @@ import {
|
||||
queryBuilder,
|
||||
selectedServiceQueryBuilder,
|
||||
noSavedFiltersHandler,
|
||||
noSavedTracesFiltersHandler,
|
||||
} from './QuickFilters.stories.mocks';
|
||||
import { QuickFiltersSource } from '../types';
|
||||
import { FiltersType, QuickFiltersSource } from '../types';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Quick Filters',
|
||||
@@ -110,6 +112,29 @@ export const SelectedExpandedCheckbox: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The duration min/max row, which a checkbox-only config never reaches. */
|
||||
export const DurationFilter: Story = {
|
||||
args: {
|
||||
quickFilterSource: QuickFiltersSource.TRACES_EXPLORER,
|
||||
config: [
|
||||
...checkboxConfig,
|
||||
{
|
||||
attributeKey: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'duration_nano',
|
||||
type: '',
|
||||
},
|
||||
defaultOpen: true,
|
||||
title: 'Duration',
|
||||
type: FiltersType.DURATION,
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
msw: { handlers: [noSavedTracesFiltersHandler, ...handlers] },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the panel renders, held open: the reveal on each truncated
|
||||
* filter value. Nothing bounds those values, so the panel is answered with
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { matchRoute } from 'lib/router/matchRoute';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
} from 'api/ai-assistant/chat';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import {
|
||||
@@ -53,6 +56,7 @@ import {
|
||||
getPanelTypeForRequestType,
|
||||
requestTypeFromActionQuery,
|
||||
} from './utils/getPanelTypeForRequestType';
|
||||
import { getTimeRangeFromActionQuery } from './utils/getTimeRangeFromActionQuery';
|
||||
import { isOnResourcePage } from './utils/isOnResourcePage';
|
||||
import { openSavedViewByKey } from './utils/openSavedView';
|
||||
import {
|
||||
@@ -279,6 +283,7 @@ interface ApplyFilterDeps {
|
||||
typeof useQueryBuilder
|
||||
>['redirectWithQueryBuilderData'];
|
||||
handleSetQueryData: ReturnType<typeof useQueryBuilder>['handleSetQueryData'];
|
||||
setCustomTimeRange: (start: number, end: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -344,18 +349,35 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
|
||||
requestTypeFromActionQuery(action.query),
|
||||
action.signal,
|
||||
);
|
||||
const timeRange = getTimeRangeFromActionQuery(action.query);
|
||||
const normalized = withDerivedFilterExpressions(urlQuery as unknown as Query);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[apply_filter] normalized', normalized);
|
||||
if (signalMatchesPathname(action.signal, deps.pathname)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[apply_filter] on-page → handleSetQueryData + redirect');
|
||||
if (timeRange) {
|
||||
deps.setCustomTimeRange(timeRange.start, timeRange.end);
|
||||
}
|
||||
normalized.builder.queryData.forEach((q, i) => {
|
||||
deps.handleSetQueryData(i, q);
|
||||
});
|
||||
deps.redirectWithQueryBuilderData(normalized, {
|
||||
[QueryParams.panelTypes]: panelType,
|
||||
});
|
||||
deps.redirectWithQueryBuilderData(
|
||||
normalized,
|
||||
{
|
||||
[QueryParams.panelTypes]: panelType,
|
||||
...(timeRange && {
|
||||
[QueryParams.startTime]: timeRange.start,
|
||||
[QueryParams.endTime]: timeRange.end,
|
||||
// relativeTime outranks startTime/endTime in the time picker, so drop it.
|
||||
[QueryParams.relativeTime]: null,
|
||||
}),
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
// New params like startTime would otherwise make safeNavigate replace the entry.
|
||||
{ replace: false },
|
||||
);
|
||||
return;
|
||||
}
|
||||
const base = explorerRouteForSignal(action.signal);
|
||||
@@ -368,6 +390,10 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
|
||||
console.log('[apply_filter] off-page → navigate', base);
|
||||
const url = buildExplorerNavigationUrl(base, normalized, {
|
||||
[QueryParams.panelTypes]: panelType,
|
||||
...(timeRange && {
|
||||
[QueryParams.startTime]: timeRange.start,
|
||||
[QueryParams.endTime]: timeRange.end,
|
||||
}),
|
||||
});
|
||||
navigate(url);
|
||||
}
|
||||
@@ -402,6 +428,13 @@ export default function ActionsSection({
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
|
||||
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
|
||||
const dispatch = useDispatch();
|
||||
const setCustomTimeRange = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
dispatch(UpdateTimeInterval('custom', [start, end]));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
// Per-chip click state, keyed by chip key (see `key` below). Persists
|
||||
// loading/success/error so the chip reflects the rollback outcome until
|
||||
@@ -433,9 +466,16 @@ export default function ActionsSection({
|
||||
pathname,
|
||||
redirectWithQueryBuilderData,
|
||||
handleSetQueryData,
|
||||
setCustomTimeRange,
|
||||
});
|
||||
});
|
||||
}, [actions, pathname, redirectWithQueryBuilderData, handleSetQueryData]);
|
||||
}, [
|
||||
actions,
|
||||
pathname,
|
||||
redirectWithQueryBuilderData,
|
||||
handleSetQueryData,
|
||||
setCustomTimeRange,
|
||||
]);
|
||||
|
||||
if (actions.length === 0) {
|
||||
return null;
|
||||
@@ -568,6 +608,7 @@ export default function ActionsSection({
|
||||
pathname,
|
||||
redirectWithQueryBuilderData,
|
||||
handleSetQueryData,
|
||||
setCustomTimeRange,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getCurrentLocation } from 'lib/router/navigation';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -24,7 +25,17 @@ jest.mock('api/common/logEvent', () => ({
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('store/actions', () => {
|
||||
const actual =
|
||||
jest.requireActual<typeof import('store/actions')>('store/actions');
|
||||
return { ...actual, UpdateTimeInterval: jest.fn(actual.UpdateTimeInterval) };
|
||||
});
|
||||
|
||||
const mockUndo = undoExecution as jest.MockedFunction<typeof undoExecution>;
|
||||
const mockUpdateTimeInterval = jest.mocked(UpdateTimeInterval);
|
||||
|
||||
const START_MS = Date.UTC(2026, 6, 26, 10, 34);
|
||||
const END_MS = Date.UTC(2026, 6, 26, 10, 49);
|
||||
|
||||
const UNDO_ACTION = {
|
||||
kind: MessageActionKindDTO.undo,
|
||||
@@ -38,13 +49,18 @@ function renderUndoChip(): HTMLElement {
|
||||
}
|
||||
|
||||
// Labels differ per test: auto-applied filters are deduped module-wide by label.
|
||||
function countByServiceAction(label: string): MessageActionDTO {
|
||||
function countByServiceAction(
|
||||
label: string,
|
||||
{ start, end }: { start?: number; end?: number } = {},
|
||||
): MessageActionDTO {
|
||||
return {
|
||||
kind: MessageActionKindDTO.apply_filter,
|
||||
label,
|
||||
signal: ApplyFilterSignalDTO.logs,
|
||||
query: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
start,
|
||||
end,
|
||||
compositeQuery: {
|
||||
queries: [
|
||||
{
|
||||
@@ -62,10 +78,8 @@ function countByServiceAction(label: string): MessageActionDTO {
|
||||
};
|
||||
}
|
||||
|
||||
function currentPanelTypesParam(): string | null {
|
||||
return new URLSearchParams(getCurrentLocation().search).get(
|
||||
QueryParams.panelTypes,
|
||||
);
|
||||
function currentParam(param: string): string | null {
|
||||
return new URLSearchParams(getCurrentLocation().search).get(param);
|
||||
}
|
||||
|
||||
describe('ActionsSection rollback chip', () => {
|
||||
@@ -139,7 +153,34 @@ describe('ActionsSection apply_filter', () => {
|
||||
await waitFor(() =>
|
||||
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
|
||||
);
|
||||
expect(currentPanelTypesParam()).toBe(JSON.stringify(PANEL_TYPES.TABLE));
|
||||
expect(currentParam(QueryParams.panelTypes)).toBe(
|
||||
JSON.stringify(PANEL_TYPES.TABLE),
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the explorer on the time range that produced the answer', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ActionsSection
|
||||
actions={[
|
||||
countByServiceAction('Open with time', {
|
||||
start: START_MS,
|
||||
end: END_MS,
|
||||
}),
|
||||
]}
|
||||
messageId="message-with-time"
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: ROUTES.HOME },
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('ai-action-chip-apply_filter'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
|
||||
);
|
||||
expect(currentParam(QueryParams.startTime)).toBe(String(START_MS));
|
||||
expect(currentParam(QueryParams.endTime)).toBe(String(END_MS));
|
||||
});
|
||||
|
||||
it('switches the open explorer to the Table view when applied on it', async () => {
|
||||
@@ -167,6 +208,54 @@ describe('ActionsSection apply_filter', () => {
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ builder: expect.anything() }),
|
||||
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
|
||||
undefined,
|
||||
undefined,
|
||||
{ replace: false },
|
||||
);
|
||||
});
|
||||
|
||||
it('switches the open explorer to the answer time range', async () => {
|
||||
const redirectWithQueryBuilderData = jest.fn() as jest.MockedFunction<
|
||||
QueryBuilderContextType['redirectWithQueryBuilderData']
|
||||
>;
|
||||
const handleSetQueryData = jest.fn() as jest.MockedFunction<
|
||||
QueryBuilderContextType['handleSetQueryData']
|
||||
>;
|
||||
render(
|
||||
<ActionsSection
|
||||
actions={[
|
||||
countByServiceAction('Apply with time', {
|
||||
start: START_MS,
|
||||
end: END_MS,
|
||||
}),
|
||||
]}
|
||||
messageId="message-on-page-with-time"
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: ROUTES.LOGS_EXPLORER,
|
||||
queryBuilderOverrides: { redirectWithQueryBuilderData, handleSetQueryData },
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ builder: expect.anything() }),
|
||||
{
|
||||
[QueryParams.panelTypes]: PANEL_TYPES.TABLE,
|
||||
[QueryParams.startTime]: START_MS,
|
||||
[QueryParams.endTime]: END_MS,
|
||||
[QueryParams.relativeTime]: null,
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{ replace: false },
|
||||
);
|
||||
expect(mockUpdateTimeInterval).toHaveBeenCalledWith('custom', [
|
||||
START_MS,
|
||||
END_MS,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MessageActionDTOQuery } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { normalizeTimeToMs } from 'utils/timeUtils';
|
||||
|
||||
export interface ActionTimeRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
function toTimestampMs(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
return normalizeTimeToMs(Math.trunc(value));
|
||||
}
|
||||
|
||||
export function getTimeRangeFromActionQuery(
|
||||
query: MessageActionDTOQuery | undefined,
|
||||
): ActionTimeRange | null {
|
||||
const start = toTimestampMs(query?.start);
|
||||
const end = toTimestampMs(query?.end);
|
||||
if (start === null || end === null || end <= start) {
|
||||
return null;
|
||||
}
|
||||
return { start, end };
|
||||
}
|
||||
@@ -16,13 +16,42 @@ import {
|
||||
} from './K8sBaseDetails.stories.mocks';
|
||||
import type { K8sBaseDetailsProps } from './types';
|
||||
import {
|
||||
DrawerHistoryEntry,
|
||||
getDrawerHistoryDepth,
|
||||
pushDrawerHistory,
|
||||
resetDrawerHistory,
|
||||
useDrawerHistoryStore,
|
||||
} from './useDrawerHistoryStore';
|
||||
|
||||
type DrawerArgs = PageStoryArgs<typeof drawerMocks> &
|
||||
K8sBaseDetailsProps<unknown>;
|
||||
K8sBaseDetailsProps<unknown> & { earlierSteps?: number };
|
||||
|
||||
const TRAIL: DrawerHistoryEntry[] = [
|
||||
{
|
||||
category: InfraMonitoringEntity.CLUSTERS,
|
||||
label: 'mgmt',
|
||||
queryKeyPrefix: 'cluster',
|
||||
},
|
||||
{
|
||||
category: InfraMonitoringEntity.NAMESPACES,
|
||||
label: 'generator',
|
||||
queryKeyPrefix: 'namespace',
|
||||
},
|
||||
{
|
||||
category: InfraMonitoringEntity.DEPLOYMENTS,
|
||||
label: 'app-demo-loadgenerator',
|
||||
queryKeyPrefix: 'deployment',
|
||||
},
|
||||
{
|
||||
category: InfraMonitoringEntity.NODES,
|
||||
label: NODE_NAME,
|
||||
queryKeyPrefix: 'node',
|
||||
},
|
||||
].map(({ category, label, queryKeyPrefix }) => ({
|
||||
params: { selectedItem: label, category },
|
||||
category,
|
||||
label,
|
||||
entity: { name: label },
|
||||
queryKeyPrefix,
|
||||
}));
|
||||
|
||||
const route = `/infrastructure-monitoring/kubernetes?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=pods&${INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM}=${POD_UID}`;
|
||||
|
||||
@@ -33,19 +62,13 @@ const meta = {
|
||||
decorators: [
|
||||
(Story, context): JSX.Element => {
|
||||
// The back control appears only for a resource opened from another
|
||||
const depth = getDrawerHistoryDepth();
|
||||
if (context.args.openedFromANode && depth === 0) {
|
||||
pushDrawerHistory({
|
||||
params: { selectedItem: NODE_NAME, category: InfraMonitoringEntity.NODES },
|
||||
category: InfraMonitoringEntity.NODES,
|
||||
label: NODE_NAME,
|
||||
entity: { nodeName: NODE_NAME },
|
||||
queryKeyPrefix: 'node',
|
||||
const steps =
|
||||
context.args.earlierSteps ?? (context.args.openedFromANode ? 1 : 0);
|
||||
if (getDrawerHistoryDepth() !== steps) {
|
||||
useDrawerHistoryStore.setState({
|
||||
entries: TRAIL.slice(TRAIL.length - steps),
|
||||
});
|
||||
}
|
||||
if (!context.args.openedFromANode && depth > 0) {
|
||||
resetDrawerHistory();
|
||||
}
|
||||
|
||||
return <Story />;
|
||||
},
|
||||
@@ -60,3 +83,19 @@ type Story = StoryObj<DrawerArgs>;
|
||||
export const Default: Story = {
|
||||
args: { ...podEntityConfig.details } as Partial<DrawerArgs> as DrawerArgs,
|
||||
};
|
||||
|
||||
/** A pod drilled through a cluster, namespace, deployment and node, so the back control names the node. */
|
||||
export const DeepTrail: Story = {
|
||||
args: {
|
||||
...podEntityConfig.details,
|
||||
earlierSteps: 4,
|
||||
} as Partial<DrawerArgs> as DrawerArgs,
|
||||
};
|
||||
|
||||
/** A pod opened from the list, so the header shows the close control in place of back. */
|
||||
export const OpenedFromTheList: Story = {
|
||||
args: {
|
||||
...podEntityConfig.details,
|
||||
openedFromANode: false,
|
||||
} as Partial<DrawerArgs> as DrawerArgs,
|
||||
};
|
||||
|
||||
@@ -90,7 +90,7 @@ export const useHandleExplorerTabChange = (): {
|
||||
},
|
||||
redirectToUrl,
|
||||
undefined,
|
||||
newTab,
|
||||
{ newTab },
|
||||
);
|
||||
},
|
||||
[panelType, getUpdateQuery, redirectWithQueryBuilderData, viewKey],
|
||||
|
||||
@@ -139,6 +139,35 @@ export const AwaitingClarification: Story = {
|
||||
args: { agent: 'awaiting-clarification', contents: BRIEF },
|
||||
};
|
||||
|
||||
/** The severity select open, including the free-text option the form's Select renders. */
|
||||
export const ClarificationSelectOpen: Story = {
|
||||
args: { agent: 'awaiting-clarification', contents: BRIEF },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// Open from the keyboard, and retry: the message list remounts its items
|
||||
// while it measures the thread, which can close the popup again. The
|
||||
// "Other" checkbox in the Notify field carries the same text, so assert
|
||||
// on the popup's option role.
|
||||
await waitFor(async () => {
|
||||
const severityLabel = canvas.getByText('Severity');
|
||||
const field = severityLabel.closest('div');
|
||||
|
||||
if (!field) {
|
||||
throw new Error('Severity field is not in the clarification form');
|
||||
}
|
||||
|
||||
within(field).getByRole('combobox').focus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await screen.findByRole(
|
||||
'option',
|
||||
{ name: 'Other (type your own)' },
|
||||
{ timeout: 1_000 },
|
||||
);
|
||||
}, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** Reopening the page on a thread that has not come back yet. */
|
||||
export const LoadingThread: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
@@ -231,6 +260,23 @@ export const NegativeFeedback: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Renaming a thread: the title input the row swaps in. */
|
||||
export const ConversationRename: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickUntil(async () => {
|
||||
const [actions] = await within(canvasElement).findAllByLabelText(
|
||||
/conversation actions/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
return actions;
|
||||
}, /^Rename$/);
|
||||
await userEvent.click(screen.getByText('Rename'));
|
||||
await within(canvasElement).findByDisplayValue(/checkout p99 regression/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** What a conversation row offers: rename, a link to it, and archiving. */
|
||||
export const ConversationActions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
WEBHOOK_AUTHS,
|
||||
type WebhookAuthChoice,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
import {
|
||||
recordChannelTestRequest,
|
||||
resetChannelTestRequests,
|
||||
} from './__story_mockdata__/channelsEdit';
|
||||
|
||||
const STORY_CHANNEL_ID = '1';
|
||||
|
||||
@@ -87,6 +91,13 @@ export const channelsEditMocks = defineStoryMocks({
|
||||
: resolveChannelActionSuccess,
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/notification_channels/test',
|
||||
async (req) => {
|
||||
recordChannelTestRequest(await req.json());
|
||||
},
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/notification_channels/test',
|
||||
values.testOutcome === 'fails'
|
||||
@@ -95,4 +106,5 @@ export const channelsEditMocks = defineStoryMocks({
|
||||
),
|
||||
],
|
||||
config: () => ({ route: `/alerts/channels/edit/${STORY_CHANNEL_ID}` }),
|
||||
effect: () => resetChannelTestRequests(),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { expectEditableForm } from './__story_mockdata__/channelsEdit.play';
|
||||
import { channelsEditMocks } from './ChannelsEdit.stories.mocks';
|
||||
|
||||
import AlertList from '../../index';
|
||||
@@ -36,8 +37,10 @@ type Story = StoryObj<ChannelsEditArgs>;
|
||||
/**
|
||||
* A saved notification channel opened for editing: the name and the type are
|
||||
* fixed, and the integration's own settings are filled from what was stored.
|
||||
* The Channel type control swaps the integration, and typing into any field
|
||||
* unlocks Save.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
export const Default: Story = { play: expectEditableForm };
|
||||
|
||||
/** Mutation: clearing the required webhook URL surfaces the form's validation feedback. */
|
||||
export const InvalidRequiredFields: Story = {
|
||||
@@ -110,21 +113,25 @@ export const SaveFailure: Story = {
|
||||
*/
|
||||
export const PagerDuty: Story = {
|
||||
args: { channelType: 'pagerduty' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/** The webhook channel, saved with basic auth on the outgoing request. */
|
||||
export const Webhook: Story = {
|
||||
args: { channelType: 'webhook' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/** The webhook channel saved with a bearer token: only the token field shows. */
|
||||
export const WebhookBearerToken: Story = {
|
||||
args: { channelType: 'webhook', webhookAuth: 'bearer' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/** The webhook channel saved without credentials: the auth option is None. */
|
||||
export const WebhookWithoutAuth: Story = {
|
||||
args: { channelType: 'webhook', webhookAuth: 'none' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -133,6 +140,7 @@ export const WebhookWithoutAuth: Story = {
|
||||
*/
|
||||
export const Opsgenie: Story = {
|
||||
args: { channelType: 'opsgenie' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -141,6 +149,7 @@ export const Opsgenie: Story = {
|
||||
*/
|
||||
export const Email: Story = {
|
||||
args: { channelType: 'email' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -149,11 +158,13 @@ export const Email: Story = {
|
||||
*/
|
||||
export const MicrosoftTeams: Story = {
|
||||
args: { channelType: 'msteams' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/** The Google Chat channel, filled from the space's incoming webhook. */
|
||||
export const GoogleChat: Story = {
|
||||
args: { channelType: 'googlechat' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -163,7 +174,8 @@ export const GoogleChat: Story = {
|
||||
*/
|
||||
export const Jira: Story = {
|
||||
args: { channelType: 'jira' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await expectEditableForm({ canvasElement, args });
|
||||
// the advanced fields have their own card now, so nothing has to be
|
||||
// expanded before they can be read
|
||||
await expect(
|
||||
@@ -183,7 +195,8 @@ export const Jira: Story = {
|
||||
*/
|
||||
export const JiraSelfHosted: Story = {
|
||||
args: { channelType: 'jira', jiraDeployment: 'selfhosted' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await expectEditableForm({ canvasElement, args });
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
await canvas.findByTestId('jira-pat-textbox', undefined, {
|
||||
@@ -202,6 +215,7 @@ export const JiraSelfHosted: Story = {
|
||||
*/
|
||||
export const JiraServiceManagementOps: Story = {
|
||||
args: { channelType: 'jsmops' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -210,9 +224,11 @@ export const JiraServiceManagementOps: Story = {
|
||||
*/
|
||||
export const IncidentIO: Story = {
|
||||
args: { channelType: 'incidentio' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/** A Telegram channel, with its bot token masked behind the password field. */
|
||||
export const Telegram: Story = {
|
||||
args: { channelType: 'telegram' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
146
frontend/src/pages/AlertList/ChannelsEdit/stories/__story_mockdata__/channelsEdit.play.ts
generated
Normal file
146
frontend/src/pages/AlertList/ChannelsEdit/stories/__story_mockdata__/channelsEdit.play.ts
generated
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { channelSpec } from '../../../stories/__story_mockdata__/alerts';
|
||||
import { PAGE_LOAD } from '../../../stories/channelAuthz';
|
||||
import type { channelsEditMocks } from '../ChannelsEdit.stories.mocks';
|
||||
import { channelTestRequests } from './channelsEdit';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: PageStoryArgs<typeof channelsEditMocks>;
|
||||
}
|
||||
|
||||
/** Native text controls only: the switches and radios keep a hidden input of their own. */
|
||||
const FIELD_SELECTOR =
|
||||
'form input:not([aria-hidden="true"]):not([type="hidden"]), form textarea';
|
||||
|
||||
const allFields = (canvasElement: HTMLElement): HTMLElement[] =>
|
||||
Array.from(canvasElement.querySelectorAll<HTMLElement>(FIELD_SELECTOR));
|
||||
|
||||
/** A saved channel's name is fixed, so it is off whether or not the form is. */
|
||||
const editableFields = (canvasElement: HTMLElement): HTMLElement[] =>
|
||||
allFields(canvasElement).filter((field) => field.id !== 'channel-name');
|
||||
|
||||
const SLACK_ROW_BUTTONS = /^slack-(field|action)-(remove-\d+|add)$/;
|
||||
|
||||
const slackRowButtons = (canvasElement: HTMLElement): HTMLElement[] =>
|
||||
within(canvasElement).queryAllByTestId(SLACK_ROW_BUTTONS);
|
||||
|
||||
export const expectEditableForm = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const save = await within(canvasElement).findByTestId(
|
||||
'save-channel-button',
|
||||
undefined,
|
||||
PAGE_LOAD,
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
() => expect(editableFields(canvasElement).length).toBeGreaterThan(0),
|
||||
PAGE_LOAD,
|
||||
);
|
||||
for (const field of editableFields(canvasElement)) {
|
||||
await expect(field).toBeEnabled();
|
||||
}
|
||||
|
||||
await expect(save).toHaveAttribute('aria-disabled', 'true');
|
||||
await userEvent.type(editableFields(canvasElement)[0], 'x');
|
||||
await waitFor(
|
||||
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
|
||||
PAGE_LOAD,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The permission check answers after the form has mounted editable, so the
|
||||
* fields are polled until they lock rather than read once.
|
||||
*/
|
||||
export const expectReadOnlyForm = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
const save = await canvas.findByTestId(
|
||||
'save-channel-button',
|
||||
undefined,
|
||||
PAGE_LOAD,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const fields = allFields(canvasElement);
|
||||
|
||||
void expect(fields.length).toBeGreaterThan(1);
|
||||
fields.forEach((field) => {
|
||||
void expect(field).toBeDisabled();
|
||||
});
|
||||
}, PAGE_LOAD);
|
||||
await expect(save).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
if (args.channelType === 'slack') {
|
||||
const buttons = slackRowButtons(canvasElement);
|
||||
const fieldCount = allFields(canvasElement).length;
|
||||
|
||||
// two field rows, one action row, and an add button for each list
|
||||
await expect(buttons).toHaveLength(5);
|
||||
for (const button of buttons) {
|
||||
await userEvent.click(button);
|
||||
}
|
||||
|
||||
await expect(slackRowButtons(canvasElement)).toHaveLength(5);
|
||||
await expect(allFields(canvasElement)).toHaveLength(fieldCount);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Switching the mode swaps which credential fields render, so a click that
|
||||
* landed would change how many fields the form holds.
|
||||
*/
|
||||
export const expectModeRadioLocked = async (
|
||||
context: PlayContext,
|
||||
radio: RegExp,
|
||||
): Promise<void> => {
|
||||
await expectReadOnlyForm(context);
|
||||
|
||||
const { canvasElement } = context;
|
||||
const fieldCount = allFields(canvasElement).length;
|
||||
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole('radio', { name: radio }),
|
||||
);
|
||||
|
||||
await expect(allFields(canvasElement)).toHaveLength(fieldCount);
|
||||
};
|
||||
|
||||
export const expectWebhookAuthLocked = (context: PlayContext): Promise<void> =>
|
||||
expectModeRadioLocked(context, /bearer/i);
|
||||
|
||||
export const expectJiraDeploymentLocked = (
|
||||
context: PlayContext,
|
||||
): Promise<void> => expectModeRadioLocked(context, /self-hosted/i);
|
||||
|
||||
export const sendSavedConfig = async (context: PlayContext): Promise<void> => {
|
||||
const { canvasElement, args } = context;
|
||||
|
||||
await expectReadOnlyForm(context);
|
||||
|
||||
const test = await within(canvasElement).findByTestId('test-channel-button');
|
||||
|
||||
await waitFor(
|
||||
() => expect(test).toHaveAttribute('aria-disabled', 'false'),
|
||||
PAGE_LOAD,
|
||||
);
|
||||
await userEvent.click(test);
|
||||
await waitFor(() => expect(channelTestRequests()).toHaveLength(1), PAGE_LOAD);
|
||||
|
||||
await expect(channelTestRequests()[0].config).toMatchObject({
|
||||
kind: args.channelType,
|
||||
spec: channelSpec(args.channelType, args.webhookAuth, args.jiraDeployment),
|
||||
});
|
||||
};
|
||||
22
frontend/src/pages/AlertList/ChannelsEdit/stories/__story_mockdata__/channelsEdit.ts
generated
Normal file
22
frontend/src/pages/AlertList/ChannelsEdit/stories/__story_mockdata__/channelsEdit.ts
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import type { AlertmanagertypesTestableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/** Every test send the form made since the story mounted, oldest first. */
|
||||
const testRequests: AlertmanagertypesTestableNotificationChannelDTO[] = [];
|
||||
|
||||
export const recordChannelTestRequest = (
|
||||
request: AlertmanagertypesTestableNotificationChannelDTO,
|
||||
): void => {
|
||||
testRequests.push(request);
|
||||
};
|
||||
|
||||
export const channelTestRequests =
|
||||
(): readonly AlertmanagertypesTestableNotificationChannelDTO[] => testRequests;
|
||||
|
||||
export const resetChannelTestRequests = (): void => {
|
||||
testRequests.length = 0;
|
||||
};
|
||||
@@ -13,6 +13,12 @@ import {
|
||||
READ,
|
||||
UPDATE,
|
||||
} from '../../../stories/channelAuthz';
|
||||
import {
|
||||
expectJiraDeploymentLocked,
|
||||
expectReadOnlyForm,
|
||||
expectWebhookAuthLocked,
|
||||
sendSavedConfig,
|
||||
} from '../__story_mockdata__/channelsEdit.play';
|
||||
import { channelsEditMocks } from '../ChannelsEdit.stories.mocks';
|
||||
|
||||
import AlertList from '../../../index';
|
||||
@@ -56,18 +62,12 @@ export const NoRead: Story = {
|
||||
|
||||
/**
|
||||
* Reading is enough to open the page, so losing `update` alone leaves it
|
||||
* standing read-only: the fields go disabled and Save is locked, not hidden.
|
||||
* standing read-only: every field goes disabled and Save is locked, not hidden.
|
||||
* The Channel type control walks the same check across every integration.
|
||||
*/
|
||||
export const NoUpdate: Story = {
|
||||
args: { revoked: [UPDATE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expectDisabled(
|
||||
await canvas.findByTestId('save-channel-button', undefined, PAGE_LOAD),
|
||||
);
|
||||
await expectDisabled(await canvas.findByTestId('slack-channel-textbox'));
|
||||
},
|
||||
play: expectReadOnlyForm,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -101,17 +101,24 @@ export const NoCreate: Story = {
|
||||
*/
|
||||
export const ReadOnly: Story = {
|
||||
args: { revoked: [UPDATE, CREATE, DELETE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
play: async (context) => {
|
||||
await expectReadOnlyForm(context);
|
||||
await expectDisabled(
|
||||
await canvas.findByTestId('save-channel-button', undefined, PAGE_LOAD),
|
||||
await within(context.canvasElement).findByTestId('test-channel-button'),
|
||||
);
|
||||
await expectDisabled(await canvas.findByTestId('test-channel-button'));
|
||||
await expectDisabled(await canvas.findByTestId('slack-channel-textbox'));
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only keeps Test: sending is a create on the collection, so a reader who
|
||||
* cannot edit the channel can still test it. The request carries the saved
|
||||
* config.
|
||||
*/
|
||||
export const NoUpdateTest: Story = {
|
||||
args: { revoked: [UPDATE] },
|
||||
play: sendSavedConfig,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same denial on a Telegram channel. Its fields are the kind the form gained
|
||||
* last, including the only toggle a settings panel renders, so this is where a
|
||||
@@ -145,6 +152,25 @@ export const TelegramReadOnly: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The same denial on a webhook saved with basic auth. The credential fields are
|
||||
* off, and so is the radio that would swap them for a bearer token.
|
||||
*/
|
||||
export const WebhookReadOnly: Story = {
|
||||
args: { channelType: 'webhook', revoked: [UPDATE, CREATE, DELETE] },
|
||||
play: expectWebhookAuthLocked,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same denial on a Jira Cloud channel. Every field is off, including the
|
||||
* advanced ones, and so is the radio that would swap the Atlassian email and API
|
||||
* token for a personal access token.
|
||||
*/
|
||||
export const JiraReadOnly: Story = {
|
||||
args: { channelType: 'jira', revoked: [UPDATE, CREATE, DELETE] },
|
||||
play: expectJiraDeploymentLocked,
|
||||
};
|
||||
|
||||
/** No channel permission at all: the page is denied before the form exists. */
|
||||
export const NoChannelAccess: Story = {
|
||||
args: { revoked: NOTIFICATION_CHANNEL },
|
||||
|
||||
@@ -954,7 +954,7 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
|
||||
},
|
||||
};
|
||||
|
||||
const channelSpec = (
|
||||
export const channelSpec = (
|
||||
type: ChannelType,
|
||||
webhookAuth: WebhookAuthChoice,
|
||||
jiraDeployment: JiraDeploymentChoice,
|
||||
|
||||
@@ -120,17 +120,6 @@ export const QuickFiltersSettingsDirty: Story = {
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same panel with a banner above the shell. The banner takes 48px off the
|
||||
* layout, so this is the case where the footer used to be pushed off screen:
|
||||
* the panel is sized from the filters pane rather than the viewport, which
|
||||
* keeps Save changes reachable.
|
||||
*/
|
||||
export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/** The resource filter opened: every key the exceptions can be narrowed by. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
|
||||
@@ -182,14 +182,3 @@ export const QuickFiltersSettings: Story = {
|
||||
export const QuickFiltersSettingsDirty: Story = {
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same panel with a banner above the shell. The banner takes 48px off the
|
||||
* layout, so this is the case where the footer used to be pushed off screen:
|
||||
* the panel is sized from the filters pane rather than the viewport, which
|
||||
* keeps Save changes reachable.
|
||||
*/
|
||||
export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
@@ -59,28 +59,6 @@ export const AnomalyAlert: Story = {
|
||||
args: { alertMode: 'anomaly' },
|
||||
};
|
||||
|
||||
/**
|
||||
* An anomaly alert handed to routing policies: the channel picker leaves the
|
||||
* condition, and the banner points at the policies that route it instead.
|
||||
*/
|
||||
export const AnomalyAlertRoutingPolicies: Story = {
|
||||
args: { alertMode: 'anomaly' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('routing-policies-switch', undefined, {
|
||||
timeout: 15_000,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
canvas.queryByTestId('threshold-notification-channel-select'),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The match-type tooltip on the threshold sentence: a paragraph on what an
|
||||
* aggregated data point is, a worked example over five of them, and the docs
|
||||
|
||||
40
frontend/src/pages/CreateAlert/stories/__story_mockdata__/createAlert.play.ts
generated
Normal file
40
frontend/src/pages/CreateAlert/stories/__story_mockdata__/createAlert.play.ts
generated
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import type { createAlertMocks } from '../CreateAlert.stories.mocks';
|
||||
|
||||
type CreateAlertArgs = PageStoryArgs<typeof createAlertMocks>;
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: Pick<CreateAlertArgs, 'alertMode'>;
|
||||
}
|
||||
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/** Every rule type carries the banner; only the type picker has no form. */
|
||||
export const enableRoutingPolicies = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (args.alertMode === 'select-type') {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('routing-policies-switch', undefined, untilLoaded),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
canvas.queryByTestId('threshold-notification-channel-select'),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
};
|
||||
@@ -63,6 +63,20 @@ const expandConfigSections: Story['play'] = async ({ canvasElement }) => {
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** The ClickHouse query tab, including its info callout. */
|
||||
export const ClickHouseQuery: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText('ClickHouse Query', undefined, {
|
||||
timeout: 15000,
|
||||
}),
|
||||
);
|
||||
await screen.findByText(/ClickHouse query agent skill/i, undefined, {
|
||||
timeout: 15000,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** The create route, seeding an unsaved panel of the chosen kind. */
|
||||
export const NewPanel: Story = {
|
||||
args: { panel: 'new' },
|
||||
@@ -83,6 +97,21 @@ export const PieChartPanel: Story = {
|
||||
args: { panel: 'traffic-share' },
|
||||
};
|
||||
|
||||
/** A new area chart: the time series options with the area filled. */
|
||||
export const AreaChartPanel: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'area-chart' },
|
||||
};
|
||||
|
||||
/** A new histogram, where the display options are its buckets. */
|
||||
export const HistogramPanel: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'histogram' },
|
||||
};
|
||||
|
||||
/** A new text panel: a markdown body in place of the query builder. */
|
||||
export const TextPanel: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'text' },
|
||||
};
|
||||
|
||||
/** A list panel, where the config pane is the column editor. */
|
||||
export const ListPanel: Story = {
|
||||
args: { panel: 'recent-logs' },
|
||||
|
||||
@@ -105,6 +105,19 @@ export const ChartAppearance: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Disconnect-values in threshold mode, with an invalid duration showing the inline error. */
|
||||
export const ChartAppearanceDisconnectThreshold: Story = {
|
||||
play: async (context) => {
|
||||
await ChartAppearance.play?.(context);
|
||||
await pickSegment('panel-editor-v2-span-gaps', 'threshold');
|
||||
const input = await screen.findByTestId('panel-editor-v2-span-gaps-value');
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, '5x');
|
||||
await screen.findByText(/enter a valid duration/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** Dashed lines under a gradient fill, with the points drawn. */
|
||||
export const ChartAppearanceDashedGradient: Story = {
|
||||
play: async (context) => {
|
||||
|
||||
@@ -191,6 +191,22 @@ export const TooltipsInViewPanelModal: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** A list or table panel's search field, expanded and filled. */
|
||||
export const PanelSearchExpanded: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
// Both the table and the log list render a search trigger; expand the first.
|
||||
const [trigger] = await within(canvasElement).findAllByTestId(
|
||||
'panel-header-search-trigger',
|
||||
{},
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
await userEvent.click(trigger);
|
||||
const input = await screen.findByTestId('panel-header-search-input');
|
||||
|
||||
await userEvent.type(input, 'error');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The dashboard's own Actions menu, open: rename, clone, lock, full screen and
|
||||
* delete, the menu the toolbar's Actions button carries.
|
||||
@@ -243,6 +259,34 @@ export const SectionActionsMenu: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Delete dashboard, the confirm dialog the Actions menu opens. */
|
||||
export const DeleteDashboardDialog: Story = {
|
||||
play: async (context) => {
|
||||
await ActionsMenu.play?.(context);
|
||||
// The row stays inert until its authz checks resolve, so one click can
|
||||
// land too early; keep clicking until the confirm dialog is up.
|
||||
await waitFor(
|
||||
async () => {
|
||||
const item = screen.queryByTestId('dashboard-action-delete');
|
||||
if (item) {
|
||||
await userEvent.click(item);
|
||||
}
|
||||
screen.getByText('This action cannot be undone.');
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Rename section, the title dialog a section menu opens. */
|
||||
export const RenameSectionDialog: Story = {
|
||||
play: async (context) => {
|
||||
await SectionActionsMenu.play?.(context);
|
||||
await userEvent.click(await screen.findByText('Rename section'));
|
||||
await screen.findByRole('dialog', { name: /rename section/i });
|
||||
},
|
||||
};
|
||||
|
||||
const openNewPanelPicker = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
|
||||
@@ -97,3 +97,12 @@ export const NavSettingsMenu: Story = {
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
};
|
||||
|
||||
/** The command palette, opened with the same shortcut the shell listens for. */
|
||||
export const CommandPalette: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.keyboard('{Meta>}k{/Meta}');
|
||||
await screen.findByPlaceholderText('Search…', undefined, { timeout: 10000 });
|
||||
await screen.findByText('Go to Home');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
copyPodMetadata,
|
||||
copyPodName,
|
||||
openDrawerLogDetails,
|
||||
openDrawerLogsTab,
|
||||
openTableOptions,
|
||||
suggestFilterKeys,
|
||||
@@ -63,6 +64,12 @@ export const DetailsDrawerLogsTab: StoryObj<PodsArgs> = {
|
||||
play: openDrawerLogsTab,
|
||||
};
|
||||
|
||||
/** A log opened from the pod's logs tab, over the details drawer. */
|
||||
export const DetailsDrawerLogDetails: StoryObj<PodsArgs> = {
|
||||
args: { drawer: true },
|
||||
play: openDrawerLogDetails,
|
||||
};
|
||||
|
||||
/** The copy button beside the pod name in the drawer, which toasts the copy. */
|
||||
export const PodNameCopiedToast: StoryObj<PodsArgs> = {
|
||||
args: { drawer: true },
|
||||
|
||||
@@ -32,6 +32,28 @@ export const openDrawerLogsTab = async ({
|
||||
await drawer.findAllByText(/handled request in/, {}, { timeout: 10000 });
|
||||
};
|
||||
|
||||
export const openDrawerLogDetails = async ({
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!args.drawer || args.dataState !== 'loaded' || args.telemetryRows === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const drawer = within(
|
||||
await screen.findByRole('dialog', {}, { timeout: 10000 }),
|
||||
);
|
||||
|
||||
await userEvent.click(await drawer.findByText('Logs', {}, { timeout: 10000 }));
|
||||
const [line] = await drawer.findAllByText(
|
||||
/handled request in/,
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
await userEvent.click(line);
|
||||
await screen.findByTestId('log-detail-drawer', {}, { timeout: 10000 });
|
||||
};
|
||||
|
||||
export const copyPodName = async ({ args }: PlayContext): Promise<void> => {
|
||||
if (!args.drawer) {
|
||||
return;
|
||||
|
||||
@@ -117,6 +117,43 @@ export const ConnectAccount: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Azure's connect dialog, which the AWS regions form does not open. */
|
||||
export const AzureConnectAccount: Story = {
|
||||
args: { provider: 'azure', accounts: 0 },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/integrate now/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByRole('dialog', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** The GCP region combobox, opened from the connect drawer. */
|
||||
export const GcpRegionDropdown: Story = {
|
||||
args: { provider: 'gcp', accounts: 0 },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/integrate now/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
'gcp-deployment-region-select',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** The regions an already-connected account collects from. */
|
||||
export const EditAccount: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type GroupSave,
|
||||
MAPPING_GROUP_MAX,
|
||||
spanMapperGroupsResponse,
|
||||
spanMappersResponse,
|
||||
wideConditionGroupsResponse,
|
||||
} from './__story_mockdata__/attributeMapping';
|
||||
|
||||
@@ -28,6 +29,15 @@ export const wideConditionGroup = rest.get(
|
||||
res(ctx.status(200), ctx.json(wideConditionGroupsResponse())),
|
||||
);
|
||||
|
||||
export const groupMappers = rest.get(
|
||||
'http://localhost/api/v1/span_mapper_groups/:groupId/span_mappers',
|
||||
(req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(spanMappersResponse(String(req.params.groupId))),
|
||||
),
|
||||
);
|
||||
|
||||
export const attributeMappingMocks = defineStoryMocks({
|
||||
controls: {
|
||||
groups: countControl('Mapping groups', {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import LLMObservabilityPage from '../../index';
|
||||
import {
|
||||
attributeMappingMocks,
|
||||
groupMappers,
|
||||
wideConditionGroup,
|
||||
} from './AttributeMapping.stories.mocks';
|
||||
|
||||
@@ -79,6 +80,29 @@ export const GroupFormDrawer: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The drawer that adds a mapping to the first group. */
|
||||
export const AddMappingDrawer: Story = {
|
||||
parameters: { msw: { handlers: [groupMappers] } },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
const [firstGroup] = await canvas.findAllByTestId(
|
||||
/^group-expand-/,
|
||||
{},
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
|
||||
await userEvent.click(firstGroup);
|
||||
await userEvent.click(
|
||||
await canvas.findByRole(
|
||||
'button',
|
||||
{ name: /add mapping/i },
|
||||
{ timeout: 15000 },
|
||||
),
|
||||
);
|
||||
await screen.findByText('New custom mapping');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A group toggled off and saved: the toast announcing the saved changes, raised
|
||||
* once the PATCH and the list refetch answer. Set Save changes to `error`,
|
||||
|
||||
@@ -5,10 +5,15 @@
|
||||
|
||||
import type {
|
||||
ListSpanMapperGroups200,
|
||||
ListSpanMappers200,
|
||||
SpantypesSpanMapperGroupConditionKeyDTO,
|
||||
SpantypesSpanMapperGroupDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { SpantypesSpanMapperOriginDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
SpantypesFieldContextDTO,
|
||||
SpantypesSpanMapperOperationDTO,
|
||||
SpantypesSpanMapperOriginDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const ORG_ID = 'org-signoz';
|
||||
|
||||
@@ -72,6 +77,38 @@ export const spanMapperGroupsResponse = (
|
||||
data: { items: GROUPS.slice(0, count).map(group) },
|
||||
});
|
||||
|
||||
/** The mappings an expanded group lists, which the groups response does not carry. */
|
||||
export const spanMappersResponse = (groupId: string): ListSpanMappers200 => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: `${groupId}-mapper-1`,
|
||||
groupId,
|
||||
name: 'gen_ai.request.model',
|
||||
enabled: true,
|
||||
origin: SpantypesSpanMapperOriginDTO.user,
|
||||
fieldContext: SpantypesFieldContextDTO.attribute,
|
||||
config: {
|
||||
sources: [
|
||||
{
|
||||
key: 'langchain.model',
|
||||
context: SpantypesFieldContextDTO.attribute,
|
||||
operation: SpantypesSpanMapperOperationDTO.copy,
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
createdAt: '2026-07-18T11:20:00Z',
|
||||
createdBy: 'anna@signoz.io',
|
||||
updatedAt: '2026-08-05T08:40:00Z',
|
||||
updatedBy: 'anna@signoz.io',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const MANY_ATTRIBUTES = [
|
||||
'langchain.model',
|
||||
'langchain.prompt_tokens',
|
||||
|
||||
@@ -168,17 +168,6 @@ export const QuickFiltersSettingsDirty: Story = {
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same panel with a banner above the shell. The banner takes 48px off the
|
||||
* layout, so this is the case where the footer used to be pushed off screen:
|
||||
* the panel is sized from the filters pane rather than the viewport, which
|
||||
* keeps Save changes reachable.
|
||||
*/
|
||||
export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/** A quick-filter value selected against the LLM span query. */
|
||||
export const QuickFilterSelected: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
|
||||
@@ -39,6 +39,34 @@ export const NothingPriced: Story = {
|
||||
args: { rules: 0 },
|
||||
};
|
||||
|
||||
/** Models seen in spans that no rule prices yet. */
|
||||
export const UnpricedModels: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'tab',
|
||||
{ name: /unpriced models/i },
|
||||
{ timeout: 15000 },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('tab', { name: /unpriced models/i, selected: true });
|
||||
},
|
||||
};
|
||||
|
||||
/** Creating a rule: the blank drawer, with the provider select enabled. */
|
||||
export const ModelCostDrawerCreate: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /add model cost/i },
|
||||
{ timeout: 15000 },
|
||||
),
|
||||
);
|
||||
await screen.findByTestId('drawer-provider-select');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The first pricing rule's menu, open over the table: edit the rule or drop it.
|
||||
*/
|
||||
|
||||
@@ -116,6 +116,16 @@ export const AddProcessor: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Severity Parser is the type whose mapping fields are CSV inputs. */
|
||||
export const AddSeverityParserProcessor: Story = {
|
||||
play: async (context): Promise<void> => {
|
||||
await AddProcessor.play?.(context);
|
||||
await userEvent.click(await screen.findByText('Grok'));
|
||||
await userEvent.click(await screen.findByTitle('Severity Parser'));
|
||||
await screen.findByText('Parse Severity Value From');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The logs a pipeline matches, over the interval the preview asks for, with the
|
||||
* simulation of its processors one click away.
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
copyLogDetailsLink,
|
||||
copyLogLinkFromRow,
|
||||
dirtyQuickFiltersSettings,
|
||||
openFormatMenu,
|
||||
openLogContext,
|
||||
openLogDetails,
|
||||
openLogDetailsMenu,
|
||||
@@ -82,6 +83,12 @@ export const RawFormat: Story = {
|
||||
args: { format: 'raw' },
|
||||
};
|
||||
|
||||
/** The format popover: the format list, the max-lines stepper, and the font-size control. */
|
||||
export const FormatMenuOpen: Story = {
|
||||
args: { format: 'raw' },
|
||||
play: openFormatMenu,
|
||||
};
|
||||
|
||||
/** An explorer with no configured quick filters. */
|
||||
export const NoQuickFilters: Story = {
|
||||
args: { quickFilters: 0 },
|
||||
@@ -164,17 +171,6 @@ export const QuickFiltersSettingsDirty: Story = {
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same panel with a banner above the shell. The banner takes 48px off the
|
||||
* layout, so this is the case where the footer used to be pushed off screen:
|
||||
* the panel is sized from the filters pane rather than the viewport, which
|
||||
* keeps Save changes reachable.
|
||||
*/
|
||||
export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the explorer renders, held open at once: the quick filters'
|
||||
* severity, host and service values on the left, and the drawer's move to
|
||||
|
||||
@@ -122,6 +122,25 @@ export const openLogDetailsMenu = async ({
|
||||
await screen.findByText('Copy link to log');
|
||||
};
|
||||
|
||||
/** The format options sit in the list view's toolbar. */
|
||||
export const openFormatMenu = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (args.view !== 'list') {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'periscope-btn-format-options',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByTestId('max-lines-per-row', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const openQuickFiltersSettings = async ({
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { MessagingQueuesViewTypeOptions } from 'pages/MessagingQueues/MessagingQueuesUtils';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -98,6 +98,21 @@ export const DropRate: Story = {
|
||||
args: { view: MessagingQueuesViewTypeOptions.DropRate },
|
||||
};
|
||||
|
||||
/** The custom-interval input inside the evaluation-time dropdown. */
|
||||
export const DropRateIntervalOpen: Story = {
|
||||
args: { view: MessagingQueuesViewTypeOptions.DropRate },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText('10ms', undefined, untilLoaded),
|
||||
);
|
||||
await screen.findByPlaceholderText(
|
||||
'Enter custom time (ms)',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The broker, producer, consumer and JVM metrics kafka itself reports. */
|
||||
export const MetricView: Story = {
|
||||
args: { view: MessagingQueuesViewTypeOptions.MetricPage },
|
||||
|
||||
@@ -85,24 +85,6 @@ export const NoUsage: Story = {
|
||||
args: { signals: [], quickFilters: 0, savedViews: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* A window from the meter's beta phase, which a cloud tenant is warned about
|
||||
* because the numbers from before 22 August 2025 are not billable.
|
||||
*/
|
||||
export const BeforeMeterLaunch: Story = {
|
||||
args: { timeRange: 'august-2025' },
|
||||
};
|
||||
|
||||
/** The warning shown when the selected window is shorter than one hour. */
|
||||
export const ShortWindowNotice: Story = {
|
||||
args: { timeRange: 'last-30-minutes' },
|
||||
};
|
||||
|
||||
/** The returning-user meter view after the billing notice was dismissed. */
|
||||
export const BillingNoticeDismissed: Story = {
|
||||
args: { billingNotice: false },
|
||||
};
|
||||
|
||||
/** Meter usage where only logs are currently ingesting. */
|
||||
export const PartialIngestion: Story = {
|
||||
args: { signals: ['logs'] },
|
||||
@@ -124,14 +106,3 @@ export const QuickFiltersSettingsDirty: Story = {
|
||||
args: { tab: 'explorer' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same panel with a banner above the shell. The banner takes 48px off the
|
||||
* layout, so this is the case where the footer used to be pushed off screen:
|
||||
* the panel is sized from the filters pane rather than the viewport, which
|
||||
* keeps Save changes reachable.
|
||||
*/
|
||||
export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { tab: 'explorer', banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { renderAtRoute } from '@/storybook/renderAtRoute';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -76,3 +76,26 @@ export const DownloadMenu: Story = {
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
};
|
||||
|
||||
/** The apdex threshold popover, opened from the overview's settings control. */
|
||||
export const ApDexSettingsOpen: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const trigger = await waitFor(
|
||||
() => {
|
||||
const found = canvasElement.querySelector<HTMLElement>(
|
||||
'.ap-dex-settings-popover-content button',
|
||||
);
|
||||
|
||||
if (!found) {
|
||||
throw new Error('Apdex settings control is not rendered');
|
||||
}
|
||||
|
||||
return found;
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
|
||||
await userEvent.click(trigger);
|
||||
await screen.findByText(/apdex threshold/i);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -613,6 +613,22 @@ const RULE_UPDATED_AT = '2026-08-20T09:24:00Z';
|
||||
|
||||
const RULE_EFFECTIVE_FROM = '2026-08-20T09:29:00Z';
|
||||
|
||||
const isPendingRule = (index: number, pending: boolean): boolean =>
|
||||
pending && index % 4 === 0;
|
||||
|
||||
/** Whether the drawer's metric has a rule that has not taken effect yet. */
|
||||
export const hasPendingRule = (
|
||||
type: DrawerMetricType,
|
||||
count: number,
|
||||
pending: boolean,
|
||||
): boolean => {
|
||||
const index = REDUCTION_RULES.findIndex(
|
||||
({ metricName }) => metricName === drawerMetricName(type),
|
||||
);
|
||||
|
||||
return index >= 0 && index < count && isPendingRule(index, pending);
|
||||
};
|
||||
|
||||
const ruleOf = (
|
||||
seed: ReductionRuleSeed,
|
||||
index: number,
|
||||
@@ -627,7 +643,7 @@ const ruleOf = (
|
||||
labels: seed.labels,
|
||||
// A rule takes about five minutes to take effect; until then the table
|
||||
// badges it as pending.
|
||||
active: !(pending && index % 4 === 0),
|
||||
active: !isPendingRule(index, pending),
|
||||
effectiveFrom: RULE_EFFECTIVE_FROM,
|
||||
ingestedSeries: timeseries,
|
||||
retainedSeries: Math.round(timeseries * (1 - seed.reduction)),
|
||||
|
||||
@@ -86,6 +86,11 @@ export const onboardingV2Mocks = defineStoryMocks({
|
||||
'http://localhost/api/v1/roles',
|
||||
response.json(() => rolesListResponse(0)),
|
||||
),
|
||||
// Add Credit Card creates the subscription; an empty redirect keeps the
|
||||
// story from opening a billing tab.
|
||||
rest.post('http://localhost/api/v1/subscriptions', (_req, res, ctx) =>
|
||||
res(ctx.json({ status: 'success', data: { redirectURL: '' } })),
|
||||
),
|
||||
rest.post('http://localhost/api/v2/users', (_req, res, ctx) => {
|
||||
if (values.invite === 'loading') {
|
||||
return res(ctx.delay('infinite'));
|
||||
|
||||
@@ -1,82 +1,20 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import OnboardingPageV2 from '../OnboardingPageV2';
|
||||
import { onboardingV2Mocks } from './OnboardingPageV2.stories.mocks';
|
||||
import {
|
||||
onboardingV2Mocks,
|
||||
SETUP_STEPS,
|
||||
type SetupStep,
|
||||
} from './OnboardingPageV2.stories.mocks';
|
||||
openAddCreditCard,
|
||||
openInviteModal,
|
||||
searchWithoutResults,
|
||||
sendInvite,
|
||||
walkToStep,
|
||||
} from './__story_mockdata__/onboardingV2.play';
|
||||
|
||||
type OnboardingV2Args = PageStoryArgs<typeof onboardingV2Mocks>;
|
||||
|
||||
type Canvas = ReturnType<typeof within>;
|
||||
|
||||
/** The catalogue mounts before the first group of sources paints. */
|
||||
const untilRendered = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* Every option is a button wrapping its own logo, so the accessible name carries
|
||||
* the label twice and matches the other sources with the label as a prefix
|
||||
* (`Java`, `JavaScript`). The label's own text node is the one thing that is
|
||||
* exact.
|
||||
*/
|
||||
const chooseOption = async (canvas: Canvas, label: string): Promise<void> => {
|
||||
const option = await canvas.findByText(label, undefined, untilRendered);
|
||||
|
||||
await userEvent.click(option.closest('button') as HTMLElement);
|
||||
};
|
||||
|
||||
const answerDataSource = (canvas: Canvas): Promise<void> =>
|
||||
chooseOption(canvas, 'Java');
|
||||
|
||||
const answerFramework = (canvas: Canvas): Promise<void> =>
|
||||
chooseOption(canvas, 'Spring Boot');
|
||||
|
||||
const answerEnvironment = (canvas: Canvas): Promise<void> =>
|
||||
chooseOption(canvas, 'VM');
|
||||
|
||||
const openConfigureProduct = async (canvas: Canvas): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await canvas.findByRole(
|
||||
'button',
|
||||
{ name: /configure your product/i },
|
||||
untilRendered,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const ANSWER_STEP = [
|
||||
answerDataSource,
|
||||
answerFramework,
|
||||
answerEnvironment,
|
||||
openConfigureProduct,
|
||||
];
|
||||
|
||||
const advanceToSetupStep = async (
|
||||
canvasElement: HTMLElement,
|
||||
step: SetupStep,
|
||||
): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// A source that arrived through the param is already on the setup
|
||||
// instructions, with no question left to answer.
|
||||
if (!canvas.queryByText('Select your data source')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const answers = ANSWER_STEP.slice(0, SETUP_STEPS.indexOf(step));
|
||||
|
||||
// Sequential: each answer is what renders the question the next one reads.
|
||||
await answers.reduce(
|
||||
(walked, answer) => walked.then(() => answer(canvas)),
|
||||
Promise.resolve(),
|
||||
);
|
||||
};
|
||||
|
||||
const pageStory = storyMocks(onboardingV2Mocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
@@ -89,16 +27,7 @@ const meta = {
|
||||
title: 'Pages/Onboarding/Add Data Source',
|
||||
tags: ['play'],
|
||||
component: OnboardingPageV2,
|
||||
/**
|
||||
* Destructuring `mount` is what makes the step control a control: Storybook
|
||||
* re-runs a play function on an arg change only for a story that asks to be
|
||||
* remounted, and otherwise re-renders the one it already walked.
|
||||
*/
|
||||
play: async ({ mount, args, canvasElement }): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
await advanceToSetupStep(canvasElement, args.step);
|
||||
},
|
||||
play: walkToStep,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<OnboardingV2Args>;
|
||||
@@ -137,35 +66,12 @@ export const SourcePreselected: Story = {
|
||||
|
||||
/** The one thing the header offers besides support: bringing the team along. */
|
||||
export const InviteTeammate: Story = {
|
||||
play: async ({ mount, args, canvasElement }): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
await advanceToSetupStep(canvasElement, args.step);
|
||||
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole('button', { name: /invite a teammate/i }),
|
||||
);
|
||||
|
||||
// antd portals the modal out of the story root.
|
||||
await screen.findByText('Invite a team member', undefined, untilRendered);
|
||||
},
|
||||
play: openInviteModal,
|
||||
};
|
||||
|
||||
/** A search nothing answers, where the catalogue offers to add the source instead. */
|
||||
export const NoSearchResults: Story = {
|
||||
play: async ({ mount, canvasElement }): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.type(
|
||||
await canvas.findByPlaceholderText('Search', undefined, untilRendered),
|
||||
'ledger',
|
||||
);
|
||||
|
||||
// The search is debounced, so the miss lands a beat after the last keystroke.
|
||||
await canvas.findByText(/no results for ledger/i, undefined, untilRendered);
|
||||
},
|
||||
play: searchWithoutResults,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -174,48 +80,14 @@ export const NoSearchResults: Story = {
|
||||
* is accepted; Invite result changes how it answers.
|
||||
*/
|
||||
export const InvitesSentToast: Story = {
|
||||
play: async ({ mount, args, canvasElement }): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /invite a teammate/i },
|
||||
untilRendered,
|
||||
),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', undefined, untilRendered);
|
||||
const [email] = await within(dialog).findAllByTestId(
|
||||
/^invite-email-/,
|
||||
undefined,
|
||||
untilRendered,
|
||||
);
|
||||
|
||||
await userEvent.type(email, 'jon@winterfell.dev');
|
||||
const [roles] = await within(dialog).findAllByRole('combobox');
|
||||
|
||||
await userEvent.click(roles);
|
||||
const viewers = await screen.findAllByText(
|
||||
'viewer',
|
||||
undefined,
|
||||
untilRendered,
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
viewers[viewers.length - 1].closest(
|
||||
'.ant-select-item-option',
|
||||
) as HTMLElement,
|
||||
);
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await userEvent.click(
|
||||
await within(dialog).findByRole('button', { name: /send invites/i }),
|
||||
);
|
||||
|
||||
if (args.invite === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/invites sent successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
play: sendInvite,
|
||||
};
|
||||
|
||||
/**
|
||||
* A cloud trial without premium support, whose Contact Support in the header asks
|
||||
* for a card to unlock chat support instead of opening the chat.
|
||||
*/
|
||||
export const FacingIssuesAddCard: Story = {
|
||||
args: { support: 'add-card' },
|
||||
play: openAddCreditCard,
|
||||
};
|
||||
|
||||
227
frontend/src/pages/OnboardingPageV2/stories/__story_mockdata__/onboardingV2.play.ts
generated
Normal file
227
frontend/src/pages/OnboardingPageV2/stories/__story_mockdata__/onboardingV2.play.ts
generated
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
onboardingV2Mocks,
|
||||
SETUP_STEPS,
|
||||
type SetupStep,
|
||||
} from '../OnboardingPageV2.stories.mocks';
|
||||
|
||||
type OnboardingV2Args = PageStoryArgs<typeof onboardingV2Mocks>;
|
||||
|
||||
interface PlayContext {
|
||||
mount: () => Promise<unknown>;
|
||||
args: OnboardingV2Args;
|
||||
canvasElement: HTMLElement;
|
||||
}
|
||||
|
||||
type Canvas = ReturnType<typeof within>;
|
||||
|
||||
/** The catalogue mounts before the first group of sources paints. */
|
||||
const untilRendered = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* Every option is a button wrapping its own logo, so the accessible name carries
|
||||
* the label twice and matches the other sources with the label as a prefix
|
||||
* (`Java`, `JavaScript`). The label's own text node is the one thing that is
|
||||
* exact.
|
||||
*/
|
||||
const chooseOption = async (canvas: Canvas, label: string): Promise<void> => {
|
||||
const option = await canvas.findByText(label, undefined, untilRendered);
|
||||
|
||||
await userEvent.click(option.closest('button') as HTMLElement);
|
||||
};
|
||||
|
||||
const answerDataSource = (canvas: Canvas): Promise<void> =>
|
||||
chooseOption(canvas, 'Java');
|
||||
|
||||
const answerFramework = (canvas: Canvas): Promise<void> =>
|
||||
chooseOption(canvas, 'Spring Boot');
|
||||
|
||||
const answerEnvironment = (canvas: Canvas): Promise<void> =>
|
||||
chooseOption(canvas, 'VM');
|
||||
|
||||
const openConfigureProduct = async (canvas: Canvas): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await canvas.findByRole(
|
||||
'button',
|
||||
{ name: /configure your product/i },
|
||||
untilRendered,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const ANSWER_STEP = [
|
||||
answerDataSource,
|
||||
answerFramework,
|
||||
answerEnvironment,
|
||||
openConfigureProduct,
|
||||
];
|
||||
|
||||
const advanceToSetupStep = async (
|
||||
canvasElement: HTMLElement,
|
||||
step: SetupStep,
|
||||
): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// A source that arrived through the param is already on the setup
|
||||
// instructions, with no question left to answer.
|
||||
if (!canvas.queryByText('Select your data source')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const answers = ANSWER_STEP.slice(0, SETUP_STEPS.indexOf(step));
|
||||
|
||||
// Sequential: each answer is what renders the question the next one reads.
|
||||
await answers.reduce(
|
||||
(walked, answer) => walked.then(() => answer(canvas)),
|
||||
Promise.resolve(),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Destructuring `mount` is what makes the step control a control: Storybook
|
||||
* re-runs a play function on an arg change only for a story that asks to be
|
||||
* remounted, and otherwise re-renders the one it already walked.
|
||||
*/
|
||||
export const walkToStep = async ({
|
||||
mount,
|
||||
args,
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
await advanceToSetupStep(canvasElement, args.step);
|
||||
};
|
||||
|
||||
export const openInviteModal = async ({
|
||||
mount,
|
||||
args,
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
await advanceToSetupStep(canvasElement, args.step);
|
||||
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole('button', { name: /invite a teammate/i }),
|
||||
);
|
||||
|
||||
// antd portals the modal out of the story root.
|
||||
await screen.findByText('Invite a team member', undefined, untilRendered);
|
||||
};
|
||||
|
||||
export const searchWithoutResults = async ({
|
||||
mount,
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.type(
|
||||
await canvas.findByPlaceholderText('Search', undefined, untilRendered),
|
||||
'ledger',
|
||||
);
|
||||
|
||||
// The search is debounced, so the miss lands a beat after the last keystroke.
|
||||
await canvas.findByText(/no results for ledger/i, undefined, untilRendered);
|
||||
};
|
||||
|
||||
export const sendInvite = async ({
|
||||
mount,
|
||||
args,
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /invite a teammate/i },
|
||||
untilRendered,
|
||||
),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', undefined, untilRendered);
|
||||
const [email] = await within(dialog).findAllByTestId(
|
||||
/^invite-email-/,
|
||||
undefined,
|
||||
untilRendered,
|
||||
);
|
||||
|
||||
await userEvent.type(email, 'jon@winterfell.dev');
|
||||
const [roles] = await within(dialog).findAllByRole('combobox');
|
||||
|
||||
await userEvent.click(roles);
|
||||
const viewers = await screen.findAllByText('viewer', undefined, untilRendered);
|
||||
|
||||
await userEvent.click(
|
||||
viewers[viewers.length - 1].closest('.ant-select-item-option') as HTMLElement,
|
||||
);
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await userEvent.click(
|
||||
await within(dialog).findByRole('button', { name: /send invites/i }),
|
||||
);
|
||||
|
||||
if (args.invite === 'success') {
|
||||
await waitFor(
|
||||
() => expect(screen.getByText(/invites sent successfully/i)).toBeVisible(),
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Banners that turn the cloud license into a self-hosted one. */
|
||||
const SELF_HOSTED_BANNERS: readonly OnboardingV2Args['banner'][] = [
|
||||
'license-expired',
|
||||
'license-terminated',
|
||||
];
|
||||
|
||||
/** Only a cloud workspace without premium support is offered the card. */
|
||||
const offersAddCard = (args: OnboardingV2Args): boolean =>
|
||||
args.support === 'add-card' &&
|
||||
args.license === 'cloud' &&
|
||||
!SELF_HOSTED_BANNERS.includes(args.banner);
|
||||
|
||||
const ADD_CARD_DIALOG = { name: /Add Credit Card for Chat Support/i };
|
||||
|
||||
export const openAddCreditCard = async ({
|
||||
mount,
|
||||
args,
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
if (!offersAddCard(args)) {
|
||||
await canvas.findByRole(
|
||||
'button',
|
||||
{ name: /invite a teammate/i },
|
||||
untilRendered,
|
||||
);
|
||||
await expect(
|
||||
screen.queryByRole('dialog', ADD_CARD_DIALOG),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByRole(
|
||||
'button',
|
||||
{ name: /contact support/i },
|
||||
untilRendered,
|
||||
),
|
||||
);
|
||||
|
||||
// antd portals the modal out of the story root.
|
||||
await screen.findByRole('dialog', ADD_CARD_DIALOG, untilRendered);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -35,3 +36,20 @@ export const ExpiredLink: Story = {
|
||||
// The deliberate 410 is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/** The mismatch callout, after the two password fields disagree and blur. */
|
||||
export const PasswordMismatch: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const password = await screen.findByTestId('password', undefined, {
|
||||
timeout: 15000,
|
||||
});
|
||||
const confirm = await screen.findByPlaceholderText(
|
||||
/confirm your new password/i,
|
||||
);
|
||||
|
||||
await userEvent.type(password, 'password123');
|
||||
await userEvent.type(confirm, 'password456');
|
||||
await userEvent.click(canvasElement);
|
||||
await screen.findByText(/passwords don't match/i);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -46,8 +46,3 @@ export const Default: Story = {};
|
||||
export const PaginatedOperations: Story = {
|
||||
args: { operations: 120 },
|
||||
};
|
||||
|
||||
/** The documented 2,500-operation threshold is exceeded by one entry point. */
|
||||
export const ThresholdWarning: Story = {
|
||||
args: { operations: 2501 },
|
||||
};
|
||||
|
||||
@@ -197,30 +197,8 @@ export const openMember = async (
|
||||
await userEvent.click(cell.closest('tr') as HTMLElement);
|
||||
};
|
||||
|
||||
/**
|
||||
* Selects `viewer` in the roles dropdown at `index`. The option's content ignores
|
||||
* pointer events, so the click goes to the option around it. A click on the
|
||||
* label closes the dropdown, where Escape would close the dialog too.
|
||||
*/
|
||||
const pickRole = async (index: number): Promise<void> => {
|
||||
const comboboxes = await screen.findAllByRole(
|
||||
'combobox',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(comboboxes[index]);
|
||||
const options = await screen.findAllByText('viewer', undefined, untilLoaded);
|
||||
|
||||
await userEvent.click(
|
||||
options[options.length - 1].closest('.ant-select-item-option') as HTMLElement,
|
||||
);
|
||||
await userEvent.click(await screen.findByText('Email address'));
|
||||
};
|
||||
|
||||
export const sendInvites = async (
|
||||
export const openInviteForm = async (
|
||||
canvasElement: HTMLElement,
|
||||
emails: string[],
|
||||
): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
@@ -229,6 +207,49 @@ export const sendInvites = async (
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
// The form opens with a row per seat, each carrying its own email field.
|
||||
await screen.findAllByTestId(/^invite-email-/, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
/** A click on the label closes the dropdown, where Escape would close the dialog too. */
|
||||
export const closeRoles = async (): Promise<void> => {
|
||||
await userEvent.click(await screen.findByText('Email address'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks `role` in the open roles dropdown. The option's content ignores pointer
|
||||
* events, so the click goes to the option around it.
|
||||
*/
|
||||
export const pickOpenRole = async (role: string): Promise<void> => {
|
||||
const options = await screen.findAllByText(role, undefined, untilLoaded);
|
||||
|
||||
await userEvent.click(
|
||||
options[options.length - 1].closest('.ant-select-item-option') as HTMLElement,
|
||||
);
|
||||
};
|
||||
|
||||
/** Opens the roles dropdown of the invite row at `index`. */
|
||||
export const openInviteRoleDropdown = async (index: number): Promise<void> => {
|
||||
const comboboxes = await screen.findAllByRole(
|
||||
'combobox',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(comboboxes[index]);
|
||||
};
|
||||
|
||||
const pickRole = async (index: number): Promise<void> => {
|
||||
await openInviteRoleDropdown(index);
|
||||
await pickOpenRole('viewer');
|
||||
await closeRoles();
|
||||
};
|
||||
|
||||
export const sendInvites = async (
|
||||
canvasElement: HTMLElement,
|
||||
emails: string[],
|
||||
): Promise<void> => {
|
||||
await openInviteForm(canvasElement);
|
||||
const fields = await screen.findAllByTestId(
|
||||
/^invite-email-/,
|
||||
undefined,
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
membersMocks,
|
||||
openMember,
|
||||
openResetLink,
|
||||
sendInvites,
|
||||
untilLoaded,
|
||||
} from './Members.stories.mocks';
|
||||
copyInviteLink,
|
||||
copyResetLink,
|
||||
deleteMember,
|
||||
failResetLink,
|
||||
filterDeletedOnly,
|
||||
filterPendingOnly,
|
||||
openAssignRoles,
|
||||
openDeleteMemberConfirm,
|
||||
openEditMember,
|
||||
openFilterMenu,
|
||||
openInviteMembers,
|
||||
openInviteRoles,
|
||||
openPendingMember,
|
||||
openRevokeInviteConfirm,
|
||||
revokeInvite,
|
||||
saveMemberName,
|
||||
searchNoResults,
|
||||
selectInviteRoles,
|
||||
sendInvitesPartiallyRefused,
|
||||
sendInvitesToAll,
|
||||
} from './__story_mockdata__/members.play';
|
||||
import { membersMocks } from './Members.stories.mocks';
|
||||
|
||||
import SettingsPage from '../../Settings';
|
||||
|
||||
@@ -39,15 +54,6 @@ type Story = StoryObj<MembersArgs>;
|
||||
/** One more than the twenty rows a page holds. */
|
||||
const OVER_ONE_PAGE = 21;
|
||||
|
||||
const openPendingMember = async ({
|
||||
canvasElement,
|
||||
}: {
|
||||
canvasElement: HTMLElement;
|
||||
}): Promise<void> => {
|
||||
await openMember(canvasElement, /podrick payne/i);
|
||||
await screen.findByText(/invited on/i, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
/**
|
||||
* Everyone with a seat in the workspace: who they are, whether they have taken
|
||||
* up their invite, and when they joined.
|
||||
@@ -61,68 +67,22 @@ export const AllPending: Story = {
|
||||
|
||||
/** Interaction: the status menu is open with the real member counts. */
|
||||
export const FilterMenuOpen: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/all members/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText(/pending invites/i, undefined, untilLoaded);
|
||||
},
|
||||
play: openFilterMenu,
|
||||
};
|
||||
|
||||
/** Interaction: the pending-invite filter narrows the real member table. */
|
||||
export const PendingOnly: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/all members/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByText(/^pending invites/i, undefined, untilLoaded),
|
||||
);
|
||||
// One badge per pending row.
|
||||
await screen.findAllByText('INVITED', undefined, untilLoaded);
|
||||
},
|
||||
play: filterPendingOnly,
|
||||
};
|
||||
|
||||
/** Interaction: the deleted-member filter exposes the read-only member rows. */
|
||||
export const DeletedOnly: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/all members/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await userEvent.click(
|
||||
// The unfiltered table already shows a DELETED badge, so the menu option
|
||||
// is matched on its own count suffix.
|
||||
await screen.findByText(/^deleted ⎯/i, undefined, untilLoaded),
|
||||
);
|
||||
await screen.findAllByText('DELETED', undefined, untilLoaded);
|
||||
},
|
||||
play: filterDeletedOnly,
|
||||
};
|
||||
|
||||
/** Empty: searching for a non-member renders the table's supported empty state. */
|
||||
export const SearchNoResults: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.type(
|
||||
await within(canvasElement).findByPlaceholderText(
|
||||
/search by name or email/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
'no-such-member',
|
||||
);
|
||||
await screen.findByText(/no results for/i, undefined, untilLoaded);
|
||||
},
|
||||
play: searchNoResults,
|
||||
};
|
||||
|
||||
/** More members than one page holds, which is where the pager appears. */
|
||||
@@ -132,10 +92,7 @@ export const Paginated: Story = {
|
||||
|
||||
/** A member opened up: the name and roles that can be changed from here. */
|
||||
export const EditMember: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
await screen.findByPlaceholderText(/enter name/i, undefined, untilLoaded);
|
||||
},
|
||||
play: openEditMember,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -154,51 +111,32 @@ export const ExpiredInvite: Story = {
|
||||
|
||||
/** Interaction: deleting an active member is guarded by its confirmation dialog. */
|
||||
export const DeleteMemberConfirm: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Delete Member' }),
|
||||
);
|
||||
await screen.findByRole('dialog', { name: 'Delete Member' });
|
||||
},
|
||||
play: openDeleteMemberConfirm,
|
||||
};
|
||||
|
||||
/** Interaction: revoking a pending invite uses its distinct confirmation copy. */
|
||||
export const RevokeInviteConfirm: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openPendingMember({ canvasElement });
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Revoke Invite' }),
|
||||
);
|
||||
await screen.findByRole('dialog', { name: 'Revoke Invite' });
|
||||
},
|
||||
play: openRevokeInviteConfirm,
|
||||
};
|
||||
|
||||
/**
|
||||
* The roles a member can be given. The dropdown opens off the inner combobox,
|
||||
* not the wrapper the click lands on first.
|
||||
*/
|
||||
/** The roles a member can be given. */
|
||||
export const AssignRoles: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
await userEvent.click(await screen.findByRole('combobox'));
|
||||
await screen.findByText('oncall-responder', undefined, untilLoaded);
|
||||
},
|
||||
play: openAssignRoles,
|
||||
};
|
||||
|
||||
/** The form that mails seats out: an email and a role per row. */
|
||||
export const InviteMembers: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/invite member/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
// The form opens with a row per seat, each carrying its own email field.
|
||||
await screen.findAllByTestId(/^invite-email-/, undefined, untilLoaded);
|
||||
},
|
||||
play: openInviteMembers,
|
||||
};
|
||||
|
||||
/** The invite form with the first row's roles dropdown open. */
|
||||
export const InviteRolesOpen: Story = {
|
||||
play: openInviteRoles,
|
||||
};
|
||||
|
||||
/** The invite form after three roles were picked on the first row. */
|
||||
export const InviteRolesSelected: Story = {
|
||||
play: selectInviteRoles,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -206,34 +144,14 @@ export const InviteMembers: Story = {
|
||||
* to `error` or `loading` to see the form instead.
|
||||
*/
|
||||
export const InvitesSentToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await sendInvites(canvasElement, [
|
||||
'bran@nightswatch.io',
|
||||
'hodor@nightswatch.io',
|
||||
]);
|
||||
if (args.invite === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/invites sent successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
play: sendInvitesToAll,
|
||||
};
|
||||
|
||||
/** One address refused out of two: the warning that some invites failed. */
|
||||
export const InvitesPartiallyFailedToast: Story = {
|
||||
// The 409 for the refused address is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await sendInvites(canvasElement, [
|
||||
'bran@nightswatch.io',
|
||||
'hodor@blocked.example',
|
||||
]);
|
||||
if (args.invite === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/some invites failed/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
play: sendInvitesPartiallyRefused,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -241,27 +159,7 @@ export const InvitesPartiallyFailedToast: Story = {
|
||||
* member to `error` or `loading` to see the drawer instead.
|
||||
*/
|
||||
export const MemberUpdatedToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
const name = await screen.findByPlaceholderText(
|
||||
/enter name/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(name).toHaveValue('Jon Snow'), untilLoaded);
|
||||
await userEvent.type(name, ' Targaryen');
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Save Member Details' }),
|
||||
);
|
||||
if (args.memberUpdate === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/member details updated successfully/i),
|
||||
).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
play: saveMemberName,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -269,22 +167,7 @@ export const MemberUpdatedToast: Story = {
|
||||
* the member to `error` or `loading` to see the dialog instead.
|
||||
*/
|
||||
export const MemberDeletedToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Delete Member' }),
|
||||
);
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Delete Member' });
|
||||
|
||||
await userEvent.click(
|
||||
within(dialog).getByRole('button', { name: 'Delete Member' }),
|
||||
);
|
||||
if (args.memberDelete === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/member deleted successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
play: deleteMember,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -292,22 +175,7 @@ export const MemberDeletedToast: Story = {
|
||||
* Deleting the member to `error` or `loading` to see the dialog instead.
|
||||
*/
|
||||
export const InviteRevokedToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await openPendingMember({ canvasElement });
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Revoke Invite' }),
|
||||
);
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Revoke Invite' });
|
||||
|
||||
await userEvent.click(
|
||||
within(dialog).getByRole('button', { name: 'Revoke Invite' }),
|
||||
);
|
||||
if (args.memberDelete === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/invite revoked successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
play: revokeInvite,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -315,13 +183,7 @@ export const InviteRevokedToast: Story = {
|
||||
* confirming it.
|
||||
*/
|
||||
export const ResetLinkCopiedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResetLink(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /^copy$/i }, untilLoaded),
|
||||
);
|
||||
await screen.findByText(/reset link copied to clipboard/i);
|
||||
},
|
||||
play: copyResetLink,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -329,16 +191,7 @@ export const ResetLinkCopiedToast: Story = {
|
||||
* confirming it.
|
||||
*/
|
||||
export const InviteLinkCopiedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openPendingMember({ canvasElement });
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /invite link/i }, untilLoaded),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /^copy$/i }, untilLoaded),
|
||||
);
|
||||
await screen.findByText(/invite link copied to clipboard/i);
|
||||
},
|
||||
play: copyInviteLink,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -347,14 +200,5 @@ export const InviteLinkCopiedToast: Story = {
|
||||
*/
|
||||
export const ResetLinkFailedToast: Story = {
|
||||
args: { resetLink: 'no-token' },
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await openResetLink(canvasElement);
|
||||
if (args.resetLink === 'no-token') {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/failed to generate password reset link/i),
|
||||
).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
play: failResetLink,
|
||||
};
|
||||
|
||||
293
frontend/src/pages/Settings/Members/stories/__story_mockdata__/members.play.ts
generated
Normal file
293
frontend/src/pages/Settings/Members/stories/__story_mockdata__/members.play.ts
generated
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
closeRoles,
|
||||
membersMocks,
|
||||
openInviteForm,
|
||||
openInviteRoleDropdown,
|
||||
openMember,
|
||||
openResetLink,
|
||||
pickOpenRole,
|
||||
sendInvites,
|
||||
untilLoaded,
|
||||
} from '../Members.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: PageStoryArgs<typeof membersMocks>;
|
||||
}
|
||||
|
||||
const openStatusFilter = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/all members/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const openFilterMenu = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openStatusFilter(canvasElement);
|
||||
await screen.findByText(/pending invites/i, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const filterPendingOnly = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openStatusFilter(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByText(/^pending invites/i, undefined, untilLoaded),
|
||||
);
|
||||
// One badge per pending row.
|
||||
await screen.findAllByText('INVITED', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const filterDeletedOnly = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openStatusFilter(canvasElement);
|
||||
await userEvent.click(
|
||||
// The unfiltered table already shows a DELETED badge, so the menu option
|
||||
// is matched on its own count suffix.
|
||||
await screen.findByText(/^deleted ⎯/i, undefined, untilLoaded),
|
||||
);
|
||||
await screen.findAllByText('DELETED', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const searchNoResults = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.type(
|
||||
await within(canvasElement).findByPlaceholderText(
|
||||
/search by name or email/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
'no-such-member',
|
||||
);
|
||||
await screen.findByText(/no results for/i, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const openEditMember = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
await screen.findByPlaceholderText(/enter name/i, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const openPendingMember = async ({
|
||||
canvasElement,
|
||||
}: Pick<PlayContext, 'canvasElement'>): Promise<void> => {
|
||||
await openMember(canvasElement, /podrick payne/i);
|
||||
await screen.findByText(/invited on/i, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const openDeleteMemberConfirm = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Delete Member' }),
|
||||
);
|
||||
await screen.findByRole('dialog', { name: 'Delete Member' });
|
||||
};
|
||||
|
||||
export const openRevokeInviteConfirm = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openPendingMember({ canvasElement });
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Revoke Invite' }),
|
||||
);
|
||||
await screen.findByRole('dialog', { name: 'Revoke Invite' });
|
||||
};
|
||||
|
||||
export const openAssignRoles = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
// The dropdown opens off the inner combobox, not the wrapper the click lands on first.
|
||||
await userEvent.click(await screen.findByRole('combobox'));
|
||||
await screen.findByText('oncall-responder', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const openInviteMembers = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openInviteForm(canvasElement);
|
||||
};
|
||||
|
||||
export const openInviteRoles = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openInviteForm(canvasElement);
|
||||
await openInviteRoleDropdown(0);
|
||||
await screen.findAllByText('viewer', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const selectInviteRoles = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openInviteForm(canvasElement);
|
||||
await openInviteRoleDropdown(0);
|
||||
for (const role of ['viewer', 'editor', 'oncall-responder']) {
|
||||
await pickOpenRole(role);
|
||||
}
|
||||
await closeRoles();
|
||||
// The open list titles its options too, so the check is scoped to the selected tags.
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
document.querySelector(
|
||||
'.ant-select-selection-item[title="oncall-responder"]',
|
||||
),
|
||||
).toBeVisible(),
|
||||
untilLoaded,
|
||||
);
|
||||
};
|
||||
|
||||
export const sendInvitesToAll = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await sendInvites(canvasElement, [
|
||||
'bran@nightswatch.io',
|
||||
'hodor@nightswatch.io',
|
||||
]);
|
||||
if (args.invite === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/invites sent successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const sendInvitesPartiallyRefused = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await sendInvites(canvasElement, [
|
||||
'bran@nightswatch.io',
|
||||
'hodor@blocked.example',
|
||||
]);
|
||||
if (args.invite === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/some invites failed/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const saveMemberName = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
const name = await screen.findByPlaceholderText(
|
||||
/enter name/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(name).toHaveValue('Jon Snow'), untilLoaded);
|
||||
await userEvent.type(name, ' Targaryen');
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Save Member Details' }),
|
||||
);
|
||||
if (args.memberUpdate === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/member details updated successfully/i),
|
||||
).toBeVisible(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteMember = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Delete Member' }),
|
||||
);
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: 'Delete Member',
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
within(dialog).getByRole('button', { name: 'Delete Member' }),
|
||||
);
|
||||
if (args.memberDelete === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/member deleted successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const revokeInvite = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openPendingMember({ canvasElement });
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Revoke Invite' }),
|
||||
);
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: 'Revoke Invite',
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
within(dialog).getByRole('button', { name: 'Revoke Invite' }),
|
||||
);
|
||||
if (args.memberDelete === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/invite revoked successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const copyResetLink = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openResetLink(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /^copy$/i }, untilLoaded),
|
||||
);
|
||||
await screen.findByText(/reset link copied to clipboard/i);
|
||||
};
|
||||
|
||||
export const copyInviteLink = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openPendingMember({ canvasElement });
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /invite link/i }, untilLoaded),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /^copy$/i }, untilLoaded),
|
||||
);
|
||||
await screen.findByText(/invite link copied to clipboard/i);
|
||||
};
|
||||
|
||||
export const failResetLink = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await openResetLink(canvasElement);
|
||||
if (args.resetLink === 'no-token') {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/failed to generate password reset link/i),
|
||||
).toBeVisible(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,20 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { singleSignOnMocks } from './SingleSignOn.stories.mocks';
|
||||
import {
|
||||
deleteDomain,
|
||||
expandAttributeMapping,
|
||||
expandRoleMappings,
|
||||
openAddDomain,
|
||||
openConfigureDomain,
|
||||
openDeleteDomain,
|
||||
openProviderSelector,
|
||||
submitEmptyProvider,
|
||||
updateDisplayName,
|
||||
} from './__story_mockdata__/singleSignOn.play';
|
||||
|
||||
import SettingsPage from '../../Settings';
|
||||
|
||||
@@ -30,23 +40,6 @@ export default meta;
|
||||
|
||||
type Story = StoryObj<SingleSignOnArgs>;
|
||||
|
||||
/** The table fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/** Row actions repeat per domain, so a story that opens one takes the first. */
|
||||
const clickFirst = async (
|
||||
canvasElement: HTMLElement,
|
||||
testId: string,
|
||||
): Promise<void> => {
|
||||
const [action] = await within(canvasElement).findAllByTestId(
|
||||
testId,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(action);
|
||||
};
|
||||
|
||||
/**
|
||||
* The org's display name and the email domains that sign in through an identity
|
||||
* provider, with whether each one is allowed to fall back to a password.
|
||||
@@ -75,88 +68,46 @@ export const SsoNotEnforced: Story = {
|
||||
|
||||
/** The form behind a domain's provider: what SigNoz sends the IdP and reads back. */
|
||||
export const ConfigureDomain: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickFirst(canvasElement, 'auth-domain-configure');
|
||||
await screen.findByText(/attribute mapping/i, undefined, untilLoaded);
|
||||
},
|
||||
play: openConfigureDomain,
|
||||
};
|
||||
|
||||
/** Adding a domain: the provider chosen first decides the rest of the form. */
|
||||
export const AddDomain: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'auth-domain-add',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
},
|
||||
play: openAddDomain,
|
||||
};
|
||||
|
||||
/** Interaction: adding a domain opens the real provider selector. */
|
||||
export const ProviderSelectorOpen: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'auth-domain-add',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText(
|
||||
/configure authentication method/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
play: openProviderSelector,
|
||||
};
|
||||
|
||||
/** Interaction: advanced role mappings expand from a configured identity provider. */
|
||||
export const RoleMappingsExpanded: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickFirst(canvasElement, 'auth-domain-configure');
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('role-mapping-header', undefined, untilLoaded),
|
||||
);
|
||||
await screen.findByText(/group to role mappings/i, undefined, untilLoaded);
|
||||
},
|
||||
play: expandRoleMappings,
|
||||
};
|
||||
|
||||
/**
|
||||
* Interaction: the SAML attribute mapping open on a configured domain. Set
|
||||
* Identity provider to `oidc` for its claim mapping; Google has neither.
|
||||
*/
|
||||
export const AttributeMappingExpanded: Story = {
|
||||
play: expandAttributeMapping,
|
||||
};
|
||||
|
||||
/** Interaction: the OIDC claim mapping open on a configured domain. */
|
||||
export const ClaimMappingExpanded: Story = {
|
||||
args: { provider: 'oidc' },
|
||||
play: expandAttributeMapping,
|
||||
};
|
||||
|
||||
/** Validation: submitting a new provider without required fields leaves form errors visible. */
|
||||
export const CreateDomainValidation: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'auth-domain-add',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
'authn-provider-configure-google',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('auth-domain-save', undefined, untilLoaded),
|
||||
);
|
||||
await screen.findByText(/domain is required/i, undefined, untilLoaded);
|
||||
},
|
||||
play: submitEmptyProvider,
|
||||
};
|
||||
|
||||
/** What removing a domain warns about before its members lose SSO. */
|
||||
export const DeleteDomain: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickFirst(canvasElement, 'auth-domain-delete');
|
||||
await screen.findByTestId(
|
||||
'auth-domain-delete-confirm',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
play: openDeleteDomain,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -164,22 +115,7 @@ export const DeleteDomain: Story = {
|
||||
* update to `error` or `loading` to see the modal instead.
|
||||
*/
|
||||
export const DisplayNameUpdatedToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
const input = await within(canvasElement).findByLabelText(
|
||||
/display name/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'Night Watch');
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole('button', { name: /^submit$/i }),
|
||||
);
|
||||
if (args.displayNameUpdate === 'success') {
|
||||
await waitFor(() => expect(screen.getByText(/^success$/i)).toBeVisible());
|
||||
}
|
||||
},
|
||||
play: updateDisplayName,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -187,19 +123,5 @@ export const DisplayNameUpdatedToast: Story = {
|
||||
* `loading` to see the modal instead.
|
||||
*/
|
||||
export const DomainDeletedToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await clickFirst(canvasElement, 'auth-domain-delete');
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
'auth-domain-delete-confirm',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
if (args.domainDelete === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/domain deleted successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
play: deleteDomain,
|
||||
};
|
||||
|
||||
161
frontend/src/pages/Settings/SingleSignOn/stories/__story_mockdata__/singleSignOn.play.ts
generated
Normal file
161
frontend/src/pages/Settings/SingleSignOn/stories/__story_mockdata__/singleSignOn.play.ts
generated
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import type { singleSignOnMocks } from '../SingleSignOn.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: PageStoryArgs<typeof singleSignOnMocks>;
|
||||
}
|
||||
|
||||
/** The table fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/** Row actions repeat per domain, so a story that opens one takes the first. */
|
||||
const clickFirst = async (
|
||||
canvasElement: HTMLElement,
|
||||
testId: string,
|
||||
): Promise<void> => {
|
||||
const [action] = await within(canvasElement).findAllByTestId(
|
||||
testId,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(action);
|
||||
};
|
||||
|
||||
/** Google maps no IdP attributes, so only SAML and OIDC have this section. */
|
||||
const MAPPING_SECTION = {
|
||||
saml: { header: /attribute mapping \(advanced\)/i, field: 'email-attribute' },
|
||||
oidc: { header: /claim mapping \(advanced\)/i, field: 'email-claim' },
|
||||
google: null,
|
||||
} as const;
|
||||
|
||||
export const openConfigureDomain = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await clickFirst(canvasElement, 'auth-domain-configure');
|
||||
await screen.findByText(/role mapping \(advanced\)/i, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const openAddDomain = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'auth-domain-add',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const openProviderSelector = async (
|
||||
context: PlayContext,
|
||||
): Promise<void> => {
|
||||
await openAddDomain(context);
|
||||
await screen.findByText(
|
||||
/configure authentication method/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
};
|
||||
|
||||
export const expandRoleMappings = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await clickFirst(canvasElement, 'auth-domain-configure');
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('role-mapping-header', undefined, untilLoaded),
|
||||
);
|
||||
await screen.findByText(/group to role mappings/i, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const expandAttributeMapping = async (
|
||||
context: PlayContext,
|
||||
): Promise<void> => {
|
||||
await openConfigureDomain(context);
|
||||
const section = MAPPING_SECTION[context.args.provider];
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(screen.getByText(section.header));
|
||||
await waitFor(() =>
|
||||
expect(document.getElementById(section.field)).toBeVisible(),
|
||||
);
|
||||
};
|
||||
|
||||
export const submitEmptyProvider = async (
|
||||
context: PlayContext,
|
||||
): Promise<void> => {
|
||||
await openAddDomain(context);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
'authn-provider-configure-google',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('auth-domain-save', undefined, untilLoaded),
|
||||
);
|
||||
await screen.findByText(/domain is required/i, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const openDeleteDomain = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await clickFirst(canvasElement, 'auth-domain-delete');
|
||||
await screen.findByTestId(
|
||||
'auth-domain-delete-confirm',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
};
|
||||
|
||||
export const updateDisplayName = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const input = await within(canvasElement).findByLabelText(
|
||||
/display name/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'Night Watch');
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole('button', { name: /^submit$/i }),
|
||||
);
|
||||
if (args.displayNameUpdate === 'success') {
|
||||
await waitFor(() => expect(screen.getByText(/^success$/i)).toBeVisible());
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteDomain = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await clickFirst(canvasElement, 'auth-domain-delete');
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
'auth-domain-delete-confirm',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
if (args.domainDelete === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/domain deleted successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -100,6 +100,10 @@ export const workspaceMocks = defineStoryMocks({
|
||||
)
|
||||
: res(ctx.status(204));
|
||||
}),
|
||||
|
||||
rest.post('http://localhost/api/v1/subscriptions', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: { redirectURL: '' } })),
|
||||
),
|
||||
],
|
||||
config: () => ({ route: ROUTES.SETTINGS }),
|
||||
});
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
copyLicenseKey,
|
||||
openAddCreditCardFromConflict,
|
||||
openCustomDomainMenu,
|
||||
updateCustomDomain,
|
||||
} from './__story_mockdata__/workspace.play';
|
||||
import { workspaceMocks } from './Workspace.stories.mocks';
|
||||
|
||||
import SettingsPage from '../../Settings';
|
||||
@@ -61,16 +66,7 @@ export const NoColdStorage: Story = {
|
||||
* of them is the active one.
|
||||
*/
|
||||
export const CustomDomainMenu: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'custom-domain-menu-trigger',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
play: openCustomDomainMenu,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -79,38 +75,21 @@ export const CustomDomainMenu: Story = {
|
||||
* instead.
|
||||
*/
|
||||
export const CustomDomainUpdatedToast: Story = {
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /edit workspace link/i },
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
const input = await screen.findByRole('textbox');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'watchers');
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /apply changes/i }),
|
||||
);
|
||||
if (args.domainUpdate === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/workspace url is being updated/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
play: updateCustomDomain,
|
||||
};
|
||||
|
||||
/**
|
||||
* A workspace link change the instance refuses with a 409: the edit modal swaps
|
||||
* Apply Changes for Contact Support, which on a cloud trial without a card opens
|
||||
* the Add Credit Card modal.
|
||||
*/
|
||||
export const CustomDomainConflictAddCard: Story = {
|
||||
args: { domainUpdate: 'error', support: 'add-card' },
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: openAddCreditCardFromConflict,
|
||||
};
|
||||
|
||||
/** The license key copied: the toast confirming it. */
|
||||
export const LicenseKeyCopiedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'license-key-row-copy-btn',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByText(/license key copied to clipboard/i);
|
||||
},
|
||||
play: copyLicenseKey,
|
||||
};
|
||||
|
||||
109
frontend/src/pages/Settings/Workspace/stories/__story_mockdata__/workspace.play.ts
generated
Normal file
109
frontend/src/pages/Settings/Workspace/stories/__story_mockdata__/workspace.play.ts
generated
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { accessFor } from '@/storybook/access/access';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import type { workspaceMocks } from '../Workspace.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: PageStoryArgs<typeof workspaceMocks>;
|
||||
}
|
||||
|
||||
const untilLoaded = { timeout: 10000 };
|
||||
|
||||
/** The domain card only renders for an admin on cloud. */
|
||||
const canEditDomain = ({ args }: Pick<PlayContext, 'args'>): boolean =>
|
||||
args.license === 'cloud' &&
|
||||
accessFor(args.access, args.permissions, args.revoked).legacyRole ===
|
||||
USER_ROLES.ADMIN;
|
||||
|
||||
const submitNewSubdomain = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /edit workspace link/i },
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
const input = await screen.findByRole('textbox');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'watchers');
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /apply changes/i }),
|
||||
);
|
||||
};
|
||||
|
||||
export const openCustomDomainMenu = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'custom-domain-menu-trigger',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByRole('menu');
|
||||
};
|
||||
|
||||
export const updateCustomDomain = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await submitNewSubdomain(canvasElement);
|
||||
if (args.domainUpdate === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/workspace url is being updated/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const copyLicenseKey = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'license-key-row-copy-btn',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText(/license key copied to clipboard/i);
|
||||
};
|
||||
|
||||
export const openAddCreditCardFromConflict = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canEditDomain({ args })) {
|
||||
return;
|
||||
}
|
||||
|
||||
await submitNewSubdomain(canvasElement);
|
||||
|
||||
if (args.domainUpdate !== 'error') {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /contact support/i }, untilLoaded),
|
||||
);
|
||||
|
||||
// Only a plan without premium support is offered the modal over the chat widget.
|
||||
if (args.support === 'add-card') {
|
||||
await screen.findByRole(
|
||||
'dialog',
|
||||
{ name: /Add Credit Card for Chat Support/i },
|
||||
untilLoaded,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
timeSeriesPoints,
|
||||
} from '@/storybook/msw/__story_mockdata__/queryRange';
|
||||
|
||||
import { traceFunnelsResponse } from '../../TracesModulePage/stories/__story_mockdata__/traces';
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
|
||||
import {
|
||||
focusSpanIndex,
|
||||
@@ -46,6 +48,7 @@ import {
|
||||
traceDetailFieldValues,
|
||||
traceFlamegraphResponse,
|
||||
traceSummaryResponse,
|
||||
traceThreadResponse,
|
||||
TRACE_SCENARIOS,
|
||||
type TraceScenario,
|
||||
traceWaterfallResponse,
|
||||
@@ -60,6 +63,8 @@ const EXPORT_STATES = ['success', 'loading', 'error'] as const;
|
||||
|
||||
type ExportState = (typeof EXPORT_STATES)[number];
|
||||
|
||||
const TABS = [TraceDetailsTab.Overview, TraceDetailsTab.Thread] as const;
|
||||
|
||||
const PANEL_POSITIONS = [
|
||||
SpanDetailVariant.DOCKED_RIGHT,
|
||||
SpanDetailVariant.DOCKED,
|
||||
@@ -125,6 +130,13 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
'Marks the payment and shipping spans as errored, each with an exception event, which is what the error counts and the red bars read.',
|
||||
value: true,
|
||||
}),
|
||||
tab: choiceControl<TraceDetailsTab>('Tab', {
|
||||
group: TRACE,
|
||||
description:
|
||||
'The tab the page opens on. `thread` lists the LLM calls as a conversation, and shows its no-AI-spans state outside the `ai` scenario.',
|
||||
options: TABS,
|
||||
value: TraceDetailsTab.Overview,
|
||||
}),
|
||||
missingSpans: toggleControl('Missing spans', {
|
||||
group: TRACE,
|
||||
description:
|
||||
@@ -207,6 +219,11 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/traces/:traceId/thread',
|
||||
response.json(() => traceThreadResponse(trace)),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v3/traces/:traceId/flamegraph',
|
||||
response.json(() => traceFlamegraphResponse(trace)),
|
||||
@@ -221,6 +238,11 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
}),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/trace-funnels/list',
|
||||
response.json(() => traceFunnelsResponse(3)),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v1/span_percentile',
|
||||
response.json(spanPercentilesResponse),
|
||||
@@ -306,11 +328,28 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
),
|
||||
];
|
||||
},
|
||||
config: (values) => ({
|
||||
route: values.spanPanel
|
||||
? `/trace/${STORY_TRACE_ID}?spanId=${spanIdAt(focusSpanIndex(values.scenario, values.spans))}`
|
||||
: `/trace/${STORY_TRACE_ID}`,
|
||||
}),
|
||||
config: (values) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (values.tab !== TraceDetailsTab.Overview) {
|
||||
params.set('tab', values.tab);
|
||||
}
|
||||
|
||||
if (values.spanPanel) {
|
||||
params.set(
|
||||
'spanId',
|
||||
spanIdAt(focusSpanIndex(values.scenario, values.spans)),
|
||||
);
|
||||
}
|
||||
|
||||
const search = params.toString();
|
||||
|
||||
return {
|
||||
route: search
|
||||
? `/trace/${STORY_TRACE_ID}?${search}`
|
||||
: `/trace/${STORY_TRACE_ID}`,
|
||||
};
|
||||
},
|
||||
effect: (values) => {
|
||||
set(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION, values.panelPosition);
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
traceDetailsMocks,
|
||||
untilLoaded,
|
||||
} from './TraceDetailsV3.stories.mocks';
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import TraceDetailsV3 from '../index';
|
||||
|
||||
type TraceDetailsArgs = PageStoryArgs<typeof traceDetailsMocks>;
|
||||
@@ -119,6 +120,22 @@ export const AiTrace: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The same agent run on the Thread tab: each LLM call's messages in order, with
|
||||
* the tool call and its response, the model's reasoning folded into a thinking
|
||||
* block, and the span panel open on the Claude call.
|
||||
*/
|
||||
export const AiThread: Story = {
|
||||
args: { scenario: 'ai', spans: AI_SPANS, tab: TraceDetailsTab.Thread },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await within(canvasElement).findAllByText(
|
||||
/Let me check the upgrade notes/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns on Highlight errors, which writes `has_error = true` into the filter and
|
||||
* expands the bar to show it.
|
||||
@@ -248,6 +265,60 @@ export const SpanPercentileOpen: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The analytics panel the header toggle opens. */
|
||||
export const AnalyticsOpen: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: 'Analytics' },
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByTestId('trace-analytics-panel', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** The download progress panel, held open by an export that never answers. */
|
||||
export const TraceDownload: Story = {
|
||||
args: { exportState: 'loading' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await startDownload(canvasElement);
|
||||
await screen.findByTestId('trace-download-panel', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
const openAddSpanToFunnel = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
const [button] = await within(canvasElement).findAllByTestId(
|
||||
'span-add-to-funnel-btn',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await fireEvent.click(button);
|
||||
await screen.findByText('Add span to funnel', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
/** The funnel picker a span row's add-to-funnel action opens. */
|
||||
export const AddSpanToFunnel: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openAddSpanToFunnel(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/** The name field behind "Create new funnel" in that picker. */
|
||||
export const AddSpanToFunnelCreateStep: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openAddSpanToFunnel(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /create new funnel/i }),
|
||||
);
|
||||
await screen.findByTestId('create-funnel-name-input');
|
||||
},
|
||||
};
|
||||
|
||||
/** The span has no logs of its own but its trace does, so the Logs tab points to the trace's. */
|
||||
export const SpanPanelTraceOnlyLogs: Story = {
|
||||
args: { traceOnlyLogs: true },
|
||||
|
||||
39
frontend/src/pages/TraceDetailsV3/stories/__story_mockdata__/traceDetails.play.ts
generated
Normal file
39
frontend/src/pages/TraceDetailsV3/stories/__story_mockdata__/traceDetails.play.ts
generated
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { screen } from 'storybook/test';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { TraceDetailsTab } from '../../constants';
|
||||
import {
|
||||
type traceDetailsMocks,
|
||||
untilLoaded,
|
||||
} from '../TraceDetailsV3.stories.mocks';
|
||||
|
||||
type TraceDetailsArgs = PageStoryArgs<typeof traceDetailsMocks>;
|
||||
|
||||
interface PlayContext {
|
||||
args: Pick<TraceDetailsArgs, 'missingSpans' | 'tab' | 'dataState'>;
|
||||
}
|
||||
|
||||
/** The header reads the flag off the summary, and only the overview tab shows it. */
|
||||
export const showsMissingSpansBanner = async ({
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (
|
||||
!args.missingSpans ||
|
||||
args.tab !== TraceDetailsTab.Overview ||
|
||||
args.dataState !== 'loaded'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await screen.findByText(
|
||||
/this trace has missing spans/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
};
|
||||
@@ -4,12 +4,16 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
AiobservabilitytypesInputMessagesDTO,
|
||||
AiobservabilitytypesOutputMessagesDTO,
|
||||
GetFlamegraph200,
|
||||
GetTraceAggregations200,
|
||||
GetTraceSummary200,
|
||||
GetTraceThread200,
|
||||
GetWaterfallV4200,
|
||||
SpantypesFlamegraphSpanDTO,
|
||||
SpantypesSpanAggregationDTO,
|
||||
SpantypesThreadSpanDTO,
|
||||
SpantypesWaterfallSpanDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { SpantypesSpanAggregationTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -28,8 +32,26 @@ interface SpanTemplate {
|
||||
/** `response_status_code`; failing spans default to 503, the rest to 200. */
|
||||
statusCode?: string;
|
||||
attributes: Record<string, string | number | boolean>;
|
||||
/** The LLM call's messages. Only spans that carry them are in the thread. */
|
||||
messages?: {
|
||||
input: AiobservabilitytypesInputMessagesDTO;
|
||||
output: AiobservabilitytypesOutputMessagesDTO;
|
||||
};
|
||||
}
|
||||
|
||||
const SUPPORT_SYSTEM_PROMPT =
|
||||
'You are the SigNoz support agent. Answer from the docs, cite the page you used, and open a ticket only when the docs do not cover the problem.';
|
||||
|
||||
const SUPPORT_QUESTION =
|
||||
'My checkout traces stopped showing up after I upgraded the collector to 0.110. What changed?';
|
||||
|
||||
const SEARCH_DOCS_CALL = {
|
||||
type: 'tool_call',
|
||||
id: 'call_search_docs_1',
|
||||
name: 'search_docs',
|
||||
arguments: { query: 'otel collector 0.110 breaking changes traces', limit: 3 },
|
||||
};
|
||||
|
||||
/** Walked in order, so the tree keeps the same services at the same depths. */
|
||||
const SPAN_TEMPLATES: SpanTemplate[] = [
|
||||
{
|
||||
@@ -182,6 +204,28 @@ const AI_TEMPLATES: SpanTemplate[] = [
|
||||
'gen_ai.usage.cache_creation.input_tokens': 0,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.00566,
|
||||
},
|
||||
messages: {
|
||||
input: [
|
||||
{
|
||||
role: 'system',
|
||||
parts: [{ type: 'text', content: SUPPORT_SYSTEM_PROMPT }],
|
||||
},
|
||||
{ role: 'user', parts: [{ type: 'text', content: SUPPORT_QUESTION }] },
|
||||
],
|
||||
output: [
|
||||
{
|
||||
role: 'assistant',
|
||||
finish_reason: 'tool_call',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
content: 'Let me check the upgrade notes for that release.',
|
||||
},
|
||||
SEARCH_DOCS_CALL,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
@@ -202,6 +246,52 @@ const AI_TEMPLATES: SpanTemplate[] = [
|
||||
'gen_ai.usage.cache_creation.input_tokens': 1300,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.01853,
|
||||
},
|
||||
messages: {
|
||||
input: [
|
||||
{
|
||||
role: 'system',
|
||||
parts: [{ type: 'text', content: SUPPORT_SYSTEM_PROMPT }],
|
||||
},
|
||||
{ role: 'user', parts: [{ type: 'text', content: SUPPORT_QUESTION }] },
|
||||
{ role: 'assistant', parts: [SEARCH_DOCS_CALL] },
|
||||
{
|
||||
role: 'tool',
|
||||
parts: [
|
||||
{
|
||||
type: 'tool_call_response',
|
||||
id: 'call_search_docs_1',
|
||||
response: {
|
||||
results: [
|
||||
{
|
||||
title: 'Collector 0.110 upgrade notes',
|
||||
snippet:
|
||||
'The otlp exporter now sends over gRPC on 4317 by default. Pipelines that pointed it at the HTTP port 4318 must set the endpoint explicitly.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
output: [
|
||||
{
|
||||
role: 'assistant',
|
||||
finish_reason: 'stop',
|
||||
parts: [
|
||||
{
|
||||
type: 'reasoning',
|
||||
content:
|
||||
'The traces stopped right after the upgrade, and the notes say the otlp exporter changed its default transport. If their exporter still points at 4318, every export now fails against a port that expects HTTP. That fits better than a sampling change, which would thin the traces out rather than stop them.',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
content:
|
||||
'Collector 0.110 changed the default transport of the `otlp` exporter to gRPC on port **4317**. If your exporter still points at `:4318`, every export fails and no traces arrive.\n\nSet the endpoint explicitly:\n\n```yaml\nexporters:\n otlp:\n endpoint: ingest.example.signoz.cloud:4317\n```\n\nSource: Collector 0.110 upgrade notes.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
service: 'docs-api',
|
||||
@@ -247,6 +337,39 @@ const AI_TEMPLATES: SpanTemplate[] = [
|
||||
'gen_ai.usage.reasoning.output_tokens': 1152,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.007524,
|
||||
},
|
||||
messages: {
|
||||
input: [
|
||||
{
|
||||
role: 'system',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
content:
|
||||
'You are the triage agent. Write a one-line summary and a priority for the support queue.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'user', parts: [{ type: 'text', content: SUPPORT_QUESTION }] },
|
||||
],
|
||||
output: [
|
||||
{
|
||||
role: 'assistant',
|
||||
finish_reason: 'stop',
|
||||
parts: [
|
||||
{
|
||||
type: 'reasoning',
|
||||
content:
|
||||
'No data loss beyond the gap, and the fix is a config change the docs already describe, so this is not urgent.',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
content:
|
||||
'Summary: traces stopped after a collector upgrade to 0.110, caused by the otlp exporter default port. Priority: P3.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -426,6 +549,43 @@ const waterfallSpan = (span: BuiltSpan): SpantypesWaterfallSpanDTO => ({
|
||||
trace_state: '',
|
||||
});
|
||||
|
||||
const threadSpan = (span: BuiltSpan): SpantypesThreadSpanDTO => ({
|
||||
span_id: span.spanId,
|
||||
trace_id: STORY_TRACE_ID,
|
||||
parent_span_id: span.parentSpanId,
|
||||
time_unix: Math.round(span.startMs),
|
||||
duration_nano: Math.round(span.durationMs * 1_000_000),
|
||||
name: span.template.name,
|
||||
kind_string: span.template.kind,
|
||||
has_error: span.hasError,
|
||||
status_code_string: span.hasError ? 'Error' : 'Ok',
|
||||
status_message: span.hasError ? 'upstream unavailable' : '',
|
||||
attributes: span.template.attributes,
|
||||
resource: resourceOf(span),
|
||||
events: eventsOf(span) ?? [],
|
||||
references: [],
|
||||
formattedInput: span.template.messages?.input ?? [],
|
||||
formattedOutput: span.template.messages?.output ?? [],
|
||||
formatterWarnings: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* The spans with messages, in start order, on one page: no cursors, so the
|
||||
* list never asks for another. A scenario with no LLM calls answers empty,
|
||||
* which is the tab's no-AI-spans state.
|
||||
*/
|
||||
export const traceThreadResponse = (
|
||||
options: TraceOptions,
|
||||
): GetTraceThread200 => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
spans: buildSpans(options)
|
||||
.filter(({ template }) => template.messages)
|
||||
.sort((a, b) => a.startMs - b.startMs)
|
||||
.map(threadSpan),
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* `totalSpansCount` equals what the response carries, which is what puts the
|
||||
* waterfall in its all-loaded mode: every parent expands and no further window
|
||||
|
||||
@@ -7,7 +7,9 @@ import { type TracesArgs, tracesMocks } from './TracesModulePage.stories.mocks';
|
||||
import {
|
||||
dirtyQuickFiltersSettings,
|
||||
loadNextListPage,
|
||||
openCreateFunnel,
|
||||
openQuickFiltersSettings,
|
||||
openRenameFunnel,
|
||||
showRootSpansNextPage,
|
||||
suggestBooleanValues,
|
||||
suggestFilterKeys,
|
||||
@@ -82,6 +84,18 @@ export const Funnels: Story = {
|
||||
args: { tab: 'funnels' },
|
||||
};
|
||||
|
||||
/** The name field for a funnel that does not exist yet. */
|
||||
export const CreateFunnel: Story = {
|
||||
args: { tab: 'funnels' },
|
||||
play: openCreateFunnel,
|
||||
};
|
||||
|
||||
/** The rename field for a funnel already on the list. */
|
||||
export const RenameFunnel: Story = {
|
||||
args: { tab: 'funnels' },
|
||||
play: openRenameFunnel,
|
||||
};
|
||||
|
||||
/**
|
||||
* A fresh workspace: nothing ingested in the window and no quick filters
|
||||
* configured yet, so the table and the filter panel both show their empty
|
||||
@@ -118,17 +132,6 @@ export const QuickFiltersSettingsDirty: Story = {
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same panel with a banner above the shell. The banner takes 48px off the
|
||||
* layout, so this is the case where the footer used to be pushed off screen:
|
||||
* the panel is sized from the filters pane rather than the viewport, which
|
||||
* keeps Save changes reachable.
|
||||
*/
|
||||
export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/** The filter focused before anything is typed: span and resource keys together. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: suggestFilterKeys,
|
||||
|
||||
153
frontend/src/pages/TracesModulePage/stories/__story_mockdata__/spanDrawer.play.ts
generated
Normal file
153
frontend/src/pages/TracesModulePage/stories/__story_mockdata__/spanDrawer.play.ts
generated
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
interface MountContext {
|
||||
mount: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
/** The page fetches before it renders its rows, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
// The quick filters list the same span name, so the row is found by its test
|
||||
// id; the table renders its rows a few at a time, so it waits for this one.
|
||||
const findOrdersRow = (): Promise<HTMLElement> =>
|
||||
waitFor(async (): Promise<HTMLElement> => {
|
||||
const match = screen
|
||||
.getAllByTestId(/^traces-table-row-/)
|
||||
.find((candidate) => candidate.textContent?.includes('POST /api/orders'));
|
||||
|
||||
await expect(match).toBeDefined();
|
||||
|
||||
return match as HTMLElement;
|
||||
}, untilLoaded);
|
||||
|
||||
const openDrawer = async (): Promise<HTMLElement> => {
|
||||
const row = await findOrdersRow();
|
||||
|
||||
await userEvent.click(within(row).getByText('POST /api/orders'));
|
||||
|
||||
return screen.findByTestId('span-details-drawer');
|
||||
};
|
||||
|
||||
const openLogsTab = async (): Promise<HTMLElement> => {
|
||||
const drawer = await openDrawer();
|
||||
|
||||
await userEvent.click(within(drawer).getByRole('tab', { name: /Logs/ }));
|
||||
|
||||
return drawer;
|
||||
};
|
||||
|
||||
export const showsSpanDetails = async (): Promise<void> => {
|
||||
const drawer = await openDrawer();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(/of total exec time/, undefined, untilLoaded),
|
||||
).toBeVisible();
|
||||
};
|
||||
|
||||
export const openSpanDetails = async ({
|
||||
mount,
|
||||
}: MountContext): Promise<void> => {
|
||||
await mount();
|
||||
await showsSpanDetails();
|
||||
};
|
||||
|
||||
export const hoverOrdersRow = async ({
|
||||
mount,
|
||||
}: MountContext): Promise<void> => {
|
||||
await mount();
|
||||
await userEvent.hover(await findOrdersRow());
|
||||
|
||||
await expect(await screen.findByTestId('span-row-open-trace')).toBeVisible();
|
||||
};
|
||||
|
||||
export const showsSpanLogs = async ({ mount }: MountContext): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
(
|
||||
await within(drawer).findAllByText(
|
||||
/payment provider timed out/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
)
|
||||
)[0],
|
||||
).toBeVisible();
|
||||
};
|
||||
|
||||
export const showsTraceOnlyLogs = async ({
|
||||
mount,
|
||||
}: MountContext): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
'No logs found for selected span. View logs for the current trace.',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
};
|
||||
|
||||
export const showsNoLogs = async ({ mount }: MountContext): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
'No logs found for this trace.',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
};
|
||||
|
||||
export const showsWithoutTraceSummary = async ({
|
||||
mount,
|
||||
}: MountContext): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
'No logs found for this span.',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
within(drawer).queryByText(/of total exec time/),
|
||||
).not.toBeInTheDocument();
|
||||
};
|
||||
|
||||
export const showsTraceSummaryLoading = async ({
|
||||
mount,
|
||||
}: MountContext): Promise<void> => {
|
||||
await mount();
|
||||
await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await screen.findByTestId('open-in-explorer-button'),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
};
|
||||
|
||||
export const showsLogsError = async ({
|
||||
mount,
|
||||
}: MountContext): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
/Something went wrong/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
};
|
||||
@@ -189,3 +189,47 @@ export const showFilterSyntaxError = async ({
|
||||
|
||||
await showFilterErrors(canvasElement, 'has_error = true AND');
|
||||
};
|
||||
|
||||
export const openCreateFunnel = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (args.tab !== 'funnels') {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /new funnel/i },
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByRole('dialog', { name: /create new funnel/i });
|
||||
};
|
||||
|
||||
/** Row actions exist only once the list holds a funnel. */
|
||||
export const openRenameFunnel = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (args.tab !== 'funnels' || args.funnels === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = await waitFor(() => {
|
||||
const found = canvasElement.querySelector<HTMLElement>(
|
||||
'.funnel-item__action-icon',
|
||||
);
|
||||
|
||||
if (!found) {
|
||||
throw new Error('Funnel row actions are not rendered');
|
||||
}
|
||||
|
||||
return found;
|
||||
}, untilLoaded);
|
||||
|
||||
await userEvent.click(action);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Rename' }));
|
||||
await screen.findByRole('dialog', { name: /rename funnel/i });
|
||||
};
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { tracesMocks } from '../TracesModulePage.stories.mocks';
|
||||
import {
|
||||
hoverOrdersRow,
|
||||
openSpanDetails,
|
||||
showsLogsError,
|
||||
showsNoLogs,
|
||||
showsSpanLogs,
|
||||
showsTraceOnlyLogs,
|
||||
showsTraceSummaryLoading,
|
||||
showsWithoutTraceSummary,
|
||||
} from '../__story_mockdata__/spanDrawer.play';
|
||||
|
||||
import TracesModulePage from '../../TracesModulePage';
|
||||
|
||||
@@ -31,55 +39,12 @@ export default meta;
|
||||
|
||||
type Story = StoryObj<TracesArgs>;
|
||||
|
||||
/** The page fetches before it renders its rows, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
// The quick filters list the same span name, so the row is found by its test
|
||||
// id; the table renders its rows a few at a time, so it waits for this one.
|
||||
const findOrdersRow = (): Promise<HTMLElement> =>
|
||||
waitFor((): HTMLElement => {
|
||||
const match = screen
|
||||
.getAllByTestId(/^traces-table-row-/)
|
||||
.find((candidate) => candidate.textContent?.includes('POST /api/orders'));
|
||||
|
||||
expect(match).toBeDefined();
|
||||
|
||||
return match as HTMLElement;
|
||||
}, untilLoaded);
|
||||
|
||||
const openDrawer = async (): Promise<HTMLElement> => {
|
||||
const row = await findOrdersRow();
|
||||
|
||||
await userEvent.click(within(row).getByText('POST /api/orders'));
|
||||
|
||||
return screen.findByTestId('span-details-drawer');
|
||||
};
|
||||
|
||||
const openLogsTab = async (): Promise<HTMLElement> => {
|
||||
const drawer = await openDrawer();
|
||||
|
||||
await userEvent.click(within(drawer).getByRole('tab', { name: /Logs/ }));
|
||||
|
||||
return drawer;
|
||||
};
|
||||
|
||||
/**
|
||||
* The drawer on a failing span: its summary with % exec time, attributes, the
|
||||
* exception event and a linked span, with up and down stepping through the page.
|
||||
*/
|
||||
export const Default: Story = {
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openDrawer();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
/of total exec time/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
play: openSpanDetails,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -87,65 +52,25 @@ export const Default: Story = {
|
||||
* tab, without opening the drawer.
|
||||
*/
|
||||
export const OpenTraceIcon: Story = {
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
await userEvent.hover(await findOrdersRow());
|
||||
|
||||
await expect(await screen.findByTestId('span-row-open-trace')).toBeVisible();
|
||||
},
|
||||
play: hoverOrdersRow,
|
||||
};
|
||||
|
||||
/** The Logs tab with the span's logs, between the logs just before and after them. */
|
||||
export const Logs: Story = {
|
||||
args: { spanLogs: 'span' },
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
(
|
||||
await within(drawer).findAllByText(
|
||||
/payment provider timed out/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
)
|
||||
)[0],
|
||||
).toBeVisible();
|
||||
},
|
||||
play: showsSpanLogs,
|
||||
};
|
||||
|
||||
/** The span has no logs of its own but its trace does, so the tab points to the trace's. */
|
||||
export const TraceOnlyLogs: Story = {
|
||||
args: { spanLogs: 'trace only' },
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
'No logs found for selected span. View logs for the current trace.',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
play: showsTraceOnlyLogs,
|
||||
};
|
||||
|
||||
/** Neither the span nor its trace has logs. */
|
||||
export const NoLogs: Story = {
|
||||
args: { spanLogs: 'none' },
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
'No logs found for this trace.',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
play: showsNoLogs,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -156,34 +81,13 @@ export const WithoutTraceSummary: Story = {
|
||||
args: { traceSummary: 'not found', spanLogs: 'none' },
|
||||
// The deliberate 404 is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
'No logs found for this span.',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
within(drawer).queryByText(/of total exec time/),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
play: showsWithoutTraceSummary,
|
||||
};
|
||||
|
||||
/** The trace summary never answers: the Logs tab waits and its explorer link is off. */
|
||||
export const TraceSummaryLoading: Story = {
|
||||
args: { traceSummary: 'loading' },
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await screen.findByTestId('open-in-explorer-button'),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
},
|
||||
play: showsTraceSummaryLoading,
|
||||
};
|
||||
|
||||
/** The log queries fail while the rest of the drawer renders. */
|
||||
@@ -191,16 +95,5 @@ export const LogsError: Story = {
|
||||
args: { spanLogs: 'error' },
|
||||
// The deliberate 500 is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ mount }): Promise<void> => {
|
||||
await mount();
|
||||
const drawer = await openLogsTab();
|
||||
|
||||
await expect(
|
||||
await within(drawer).findByText(
|
||||
/Something went wrong/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
play: showsLogsError,
|
||||
};
|
||||
|
||||
@@ -949,7 +949,7 @@ export function QueryBuilderProvider({
|
||||
searchParams?: Record<string, unknown>,
|
||||
redirectingUrl?: (typeof ROUTES)[keyof typeof ROUTES],
|
||||
shouldNotStringify?: boolean,
|
||||
newTab?: boolean,
|
||||
navigateOptions?: { newTab?: boolean; replace?: boolean },
|
||||
) => {
|
||||
const queryType =
|
||||
!query.queryType || !Object.values(EQueryType).includes(query.queryType)
|
||||
@@ -1001,14 +1001,18 @@ export function QueryBuilderProvider({
|
||||
);
|
||||
|
||||
if (searchParams) {
|
||||
Object.keys(searchParams).forEach((param) =>
|
||||
Object.keys(searchParams).forEach((param) => {
|
||||
if (searchParams[param] === null) {
|
||||
urlQuery.delete(param);
|
||||
return;
|
||||
}
|
||||
urlQuery.set(
|
||||
param,
|
||||
shouldNotStringify
|
||||
? (searchParams[param] as string)
|
||||
: JSON.stringify(searchParams[param]),
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
}
|
||||
// Remove Hidden Filters from URL query parameters on query change
|
||||
urlQuery.delete(QueryParams.activeLogId);
|
||||
@@ -1017,7 +1021,7 @@ export function QueryBuilderProvider({
|
||||
? `${redirectingUrl}?${urlQuery}`
|
||||
: `${location.pathname}?${urlQuery}`;
|
||||
|
||||
safeNavigate(generatedUrl, { newTab });
|
||||
safeNavigate(generatedUrl, navigateOptions);
|
||||
},
|
||||
[location.pathname, safeNavigate],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { act, AllTheProviders, renderHook } from 'tests/test-utils';
|
||||
|
||||
describe('redirectWithQueryBuilderData search params', () => {
|
||||
it('removes params passed as null and JSON-encodes the rest', () => {
|
||||
const { result } = renderHook(() => useQueryBuilder(), {
|
||||
wrapper: ({ children }): JSX.Element => (
|
||||
<AllTheProviders
|
||||
initialRoute={`/unconstrained?${QueryParams.relativeTime}=6h&${QueryParams.viewName}=kept`}
|
||||
>
|
||||
{children}
|
||||
</AllTheProviders>
|
||||
),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.redirectWithQueryBuilderData(initialQueriesMap.logs, {
|
||||
[QueryParams.relativeTime]: null,
|
||||
[QueryParams.startTime]: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
const [url] = safeNavigateMock.mock.lastCall ?? [];
|
||||
expect(url).toContain(`${QueryParams.startTime}=1000`);
|
||||
expect(url).not.toContain(`${QueryParams.relativeTime}=`);
|
||||
expect(url).toContain(`${QueryParams.viewName}=kept`);
|
||||
});
|
||||
|
||||
it('passes navigate options to safeNavigate', () => {
|
||||
const { result } = renderHook(() => useQueryBuilder(), {
|
||||
wrapper: ({ children }): JSX.Element => (
|
||||
<AllTheProviders initialRoute="/unconstrained">{children}</AllTheProviders>
|
||||
),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.redirectWithQueryBuilderData(
|
||||
initialQueriesMap.logs,
|
||||
{ [QueryParams.startTime]: 1000 },
|
||||
undefined,
|
||||
undefined,
|
||||
{ replace: false },
|
||||
);
|
||||
});
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenLastCalledWith(expect.any(String), {
|
||||
replace: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { GlobalMockArgs } from '@/storybook/globals';
|
||||
import { renderAtRoute } from '@/storybook/renderAtRoute';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import CreateAlertPage from 'pages/CreateAlert';
|
||||
import { enableRoutingPolicies } from 'pages/CreateAlert/stories/__story_mockdata__/createAlert.play';
|
||||
import { createAlertMocks } from 'pages/CreateAlert/stories/CreateAlert.stories.mocks';
|
||||
import MeterExplorerPage from 'pages/MeterExplorer/MeterExplorerPage';
|
||||
import { meterMocks } from 'pages/MeterExplorer/stories/MeterExplorer.stories.mocks';
|
||||
import MetricsExplorerPage from 'pages/MetricsExplorer/MetricsExplorerPage';
|
||||
import { metricsMocks } from 'pages/MetricsExplorer/stories/MetricsExplorerPage.stories.mocks';
|
||||
import ServiceTopLevelOperations from 'pages/ServiceTopLevelOperations';
|
||||
import { serviceTopLevelOperationsMocks } from 'pages/ServiceTopLevelOperations/stories/ServiceTopLevelOperations.stories.mocks';
|
||||
import SettingsPage from 'pages/Settings/Settings';
|
||||
import { billingMocks } from 'pages/Settings/Billing/stories/Billing.stories.mocks';
|
||||
import TraceDetailsV3 from 'pages/TraceDetailsV3';
|
||||
import { showsMissingSpansBanner } from 'pages/TraceDetailsV3/stories/__story_mockdata__/traceDetails.play';
|
||||
import { traceDetailsMocks } from 'pages/TraceDetailsV3/stories/TraceDetailsV3.stories.mocks';
|
||||
|
||||
import {
|
||||
showsCancelSubscription,
|
||||
showsClickHouseInfo,
|
||||
showsMeterBeforeLaunch,
|
||||
showsMeterBillingNotice,
|
||||
showsMeterShortWindow,
|
||||
showsPendingActivation,
|
||||
showsTopLevelOperationsThreshold,
|
||||
} from './__story_mockdata__/pageBanners.play';
|
||||
|
||||
type TraceDetailsArgs = PageStoryArgs<typeof traceDetailsMocks>;
|
||||
type MetricsArgs = PageStoryArgs<typeof metricsMocks>;
|
||||
type BillingArgs = PageStoryArgs<typeof billingMocks>;
|
||||
type CreateAlertArgs = PageStoryArgs<typeof createAlertMocks>;
|
||||
type MeterArgs = PageStoryArgs<typeof meterMocks>;
|
||||
type TopLevelOperationsArgs = PageStoryArgs<
|
||||
typeof serviceTopLevelOperationsMocks
|
||||
>;
|
||||
|
||||
const traceDetails = storyMocks(traceDetailsMocks, { layout: 'app' });
|
||||
const metrics = storyMocks(metricsMocks, { layout: 'app' });
|
||||
const billing = storyMocks(billingMocks, { layout: 'app' });
|
||||
const createAlert = storyMocks(createAlertMocks, { layout: 'app' });
|
||||
const meter = storyMocks(meterMocks, { layout: 'app' });
|
||||
const topLevelOperations = storyMocks(serviceTopLevelOperationsMocks, {
|
||||
layout: 'app',
|
||||
});
|
||||
|
||||
/**
|
||||
* Every banner and notice a page renders inside its own content, one per story on
|
||||
* the page that shows it. The shell's own banners are under Shell.
|
||||
*
|
||||
* Route: each story uses its page's own.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Foundations/Banners/Page',
|
||||
tags: ['play'],
|
||||
} satisfies Meta<GlobalMockArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
/** "This trace has missing spans": the backend never received some of the trace's spans. */
|
||||
export const MissingSpans: StoryObj<TraceDetailsArgs> = {
|
||||
...traceDetails,
|
||||
args: { ...traceDetails.args, missingSpans: true },
|
||||
render: renderAtRoute(ROUTES.TRACE_DETAIL, TraceDetailsV3),
|
||||
play: showsMissingSpansBanner,
|
||||
};
|
||||
|
||||
/**
|
||||
* "Volume changes take effect within about 5 minutes": the metric's reduction
|
||||
* rule was saved moments ago and is not active yet.
|
||||
*/
|
||||
export const PendingActivation: StoryObj<MetricsArgs> = {
|
||||
...metrics,
|
||||
args: { ...metrics.args, drawer: 'details', drawerMetric: 'histogram' },
|
||||
render: () => <MetricsExplorerPage />,
|
||||
play: showsPendingActivation,
|
||||
};
|
||||
|
||||
/** "Cancel your subscription": at the foot of Billing on an activated cloud workspace. */
|
||||
export const CancelSubscription: StoryObj<BillingArgs> = {
|
||||
...billing,
|
||||
// The banner sits below the fold of the default 1200px viewport.
|
||||
parameters: { ...billing.parameters, sbshot: { viewport: { height: 1400 } } },
|
||||
render: () => <SettingsPage />,
|
||||
play: showsCancelSubscription,
|
||||
};
|
||||
|
||||
/**
|
||||
* "Use Routing Policies for dynamic routing": under the thresholds of every rule
|
||||
* type, here switched on, which takes the channel picker off the condition.
|
||||
*/
|
||||
export const RoutingPolicies: StoryObj<CreateAlertArgs> = {
|
||||
...createAlert,
|
||||
args: { ...createAlert.args, alertMode: 'anomaly' },
|
||||
render: () => <CreateAlertPage />,
|
||||
play: enableRoutingPolicies,
|
||||
};
|
||||
|
||||
/** "Learn to write faster, optimized queries": above the ClickHouse query editor. */
|
||||
export const ClickHouseQuery: StoryObj<CreateAlertArgs> = {
|
||||
...createAlert,
|
||||
args: { ...createAlert.args, alertMode: 'logs' },
|
||||
render: () => <CreateAlertPage />,
|
||||
play: showsClickHouseInfo,
|
||||
};
|
||||
|
||||
/** "Billing is calculated in UTC": above the breakdown until it is dismissed. */
|
||||
export const MeterBillingNotice: StoryObj<MeterArgs> = {
|
||||
...meter,
|
||||
render: () => <MeterExplorerPage />,
|
||||
play: showsMeterBillingNotice,
|
||||
};
|
||||
|
||||
/** The same notice closed, which the app remembers across visits. */
|
||||
export const MeterBillingNoticeDismissed: StoryObj<MeterArgs> = {
|
||||
...meter,
|
||||
args: { ...meter.args, billingNotice: false },
|
||||
render: () => <MeterExplorerPage />,
|
||||
play: showsMeterBillingNotice,
|
||||
};
|
||||
|
||||
/** "Meter module data is accurate only from 22nd August 2025": a cloud window from the beta phase. */
|
||||
export const MeterBeforeLaunch: StoryObj<MeterArgs> = {
|
||||
...meter,
|
||||
args: { ...meter.args, timeRange: 'august-2025' },
|
||||
render: () => <MeterExplorerPage />,
|
||||
play: showsMeterBeforeLaunch,
|
||||
};
|
||||
|
||||
/** "Meter metrics data is aggregated over 1 hour period": a window shorter than an hour. */
|
||||
export const MeterShortWindow: StoryObj<MeterArgs> = {
|
||||
...meter,
|
||||
args: { ...meter.args, timeRange: 'last-30-minutes' },
|
||||
render: () => <MeterExplorerPage />,
|
||||
play: showsMeterShortWindow,
|
||||
};
|
||||
|
||||
/**
|
||||
* "SigNoz calculates the RED metrics using the entry-point spans": above the list
|
||||
* of top level operations.
|
||||
*/
|
||||
export const TopLevelOperationsThreshold: StoryObj<TopLevelOperationsArgs> = {
|
||||
...topLevelOperations,
|
||||
render: renderAtRoute(
|
||||
ROUTES.SERVICE_TOP_LEVEL_OPERATIONS,
|
||||
ServiceTopLevelOperations,
|
||||
),
|
||||
play: showsTopLevelOperationsThreshold,
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { renderAtRoute } from '@/storybook/renderAtRoute';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { GlobalMockArgs } from '@/storybook/globals';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import {
|
||||
openAddCardModal,
|
||||
raiseToast,
|
||||
} from '../__story_mockdata__/bottomStrip.play';
|
||||
import CreateAlertPage from 'pages/CreateAlert';
|
||||
import { createAlertMocks } from 'pages/CreateAlert/stories/CreateAlert.stories.mocks';
|
||||
import DashboardPage from 'pages/DashboardPage/DashboardPage';
|
||||
import PanelEditorPage from 'pages/DashboardPage/PanelEditorPage/PanelEditorPage';
|
||||
import { panelEditorMocks } from 'pages/DashboardPage/PanelEditorPage/stories/PanelEditorPage.stories.mocks';
|
||||
import { dashboardMocks } from 'pages/DashboardPage/stories/DashboardPage.stories.mocks';
|
||||
import DashboardsListPage from 'pages/DashboardsListPage/DashboardsListPage';
|
||||
import { dashboardsListMocks } from 'pages/DashboardsListPage/stories/DashboardsListPage.stories.mocks';
|
||||
import { openRenameDialog } from 'pages/DashboardsListPage/stories/__story_mockdata__/dashboardsList.play';
|
||||
import HomePage from 'pages/HomePage/HomePage';
|
||||
import { homeMocks } from 'pages/HomePage/stories/HomePage.stories.mocks';
|
||||
import InfrastructureMonitoringPage from 'pages/InfrastructureMonitoring/InfrastructureMonitoringPage';
|
||||
import { podsMocks } from 'pages/InfrastructureMonitoring/stories/Kubernetes.stories.mocks';
|
||||
import { openDrawerLogsTab } from 'pages/InfrastructureMonitoring/stories/__story_mockdata__/pods.play';
|
||||
import LogsModulePage from 'pages/LogsModulePage/LogsModulePage';
|
||||
import { logsMocks } from 'pages/LogsModulePage/stories/LogsModulePage.stories.mocks';
|
||||
import { dirtyQuickFiltersSettings as dirtyLogsQuickFilters } from 'pages/LogsModulePage/stories/__story_mockdata__/logs.play';
|
||||
import MetricsExplorerPage from 'pages/MetricsExplorer/MetricsExplorerPage';
|
||||
import { metricsMocks } from 'pages/MetricsExplorer/stories/MetricsExplorerPage.stories.mocks';
|
||||
import TraceDetailsV3 from 'pages/TraceDetailsV3';
|
||||
import { traceDetailsMocks } from 'pages/TraceDetailsV3/stories/TraceDetailsV3.stories.mocks';
|
||||
import TracesModulePage from 'pages/TracesModulePage/TracesModulePage';
|
||||
import { tracesMocks } from 'pages/TracesModulePage/stories/TracesModulePage.stories.mocks';
|
||||
import { showsSpanDetails } from 'pages/TracesModulePage/stories/__story_mockdata__/spanDrawer.play';
|
||||
import { dirtyQuickFiltersSettings as dirtyTracesQuickFilters } from 'pages/TracesModulePage/stories/__story_mockdata__/traces.play';
|
||||
|
||||
import { withTrialBanner } from './__story_mockdata__/hostBanners.play';
|
||||
|
||||
type HomeArgs = PageStoryArgs<typeof homeMocks>;
|
||||
type CreateAlertArgs = PageStoryArgs<typeof createAlertMocks>;
|
||||
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
|
||||
type MetricsArgs = PageStoryArgs<typeof metricsMocks>;
|
||||
type LogsArgs = PageStoryArgs<typeof logsMocks>;
|
||||
type TraceDetailsArgs = PageStoryArgs<typeof traceDetailsMocks>;
|
||||
type TracesArgs = PageStoryArgs<typeof tracesMocks>;
|
||||
type PodsArgs = PageStoryArgs<typeof podsMocks>;
|
||||
type DashboardsListArgs = PageStoryArgs<typeof dashboardsListMocks>;
|
||||
type PanelEditorArgs = PageStoryArgs<typeof panelEditorMocks>;
|
||||
|
||||
const home = storyMocks(homeMocks, { route: ROUTES.HOME, layout: 'app' });
|
||||
const createAlert = storyMocks(createAlertMocks, { layout: 'app' });
|
||||
const dashboard = storyMocks(dashboardMocks, { layout: 'app' });
|
||||
const metrics = storyMocks(metricsMocks, { layout: 'app' });
|
||||
const logs = storyMocks(logsMocks, { layout: 'app' });
|
||||
const traceDetails = storyMocks(traceDetailsMocks, { layout: 'app' });
|
||||
const traces = storyMocks(tracesMocks, { layout: 'app' });
|
||||
const pods = storyMocks(podsMocks, { layout: 'app' });
|
||||
const dashboardsList = storyMocks(dashboardsListMocks, { layout: 'app' });
|
||||
const panelEditor = storyMocks(panelEditorMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The trial-expiry banner on every host that pins something to the bottom of the
|
||||
* shell or opens an overlay, one story per host.
|
||||
*
|
||||
* Route: each story uses its host page's own.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Foundations/Banners/Shell Hosts',
|
||||
tags: ['play'],
|
||||
args: { banner: 'trial-expiry' },
|
||||
} satisfies Meta<GlobalMockArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
/** Home: nothing fixed to the bottom but the strip. */
|
||||
export const Home: StoryObj<HomeArgs> = {
|
||||
...home,
|
||||
render: () => <HomePage />,
|
||||
play: withTrialBanner(),
|
||||
};
|
||||
|
||||
/** Create alert: the fixed footer with the save and test actions. */
|
||||
export const AlertsCreate: StoryObj<CreateAlertArgs> = {
|
||||
...createAlert,
|
||||
render: () => <CreateAlertPage />,
|
||||
play: withTrialBanner(),
|
||||
};
|
||||
|
||||
/** A locked dashboard: the lock indicator fixed to the bottom of the grid. */
|
||||
export const DashboardLocked: StoryObj<DashboardArgs> = {
|
||||
...dashboard,
|
||||
args: { ...dashboard.args, locked: true },
|
||||
render: renderAtRoute(ROUTES.DASHBOARD, DashboardPage),
|
||||
play: withTrialBanner(),
|
||||
};
|
||||
|
||||
/** Metrics summary: the pagination fixed to the bottom of the list. */
|
||||
export const MetricsSummary: StoryObj<MetricsArgs> = {
|
||||
...metrics,
|
||||
render: () => <MetricsExplorerPage />,
|
||||
play: withTrialBanner(),
|
||||
};
|
||||
|
||||
/** Logs explorer: the list and the frequency chart under the query builder. */
|
||||
export const LogsExplorer: StoryObj<LogsArgs> = {
|
||||
...logs,
|
||||
render: () => <LogsModulePage />,
|
||||
play: withTrialBanner(),
|
||||
};
|
||||
|
||||
/** Trace details: the waterfall and the span panel filling the page. */
|
||||
export const TraceDetails: StoryObj<TraceDetailsArgs> = {
|
||||
...traceDetails,
|
||||
render: renderAtRoute(ROUTES.TRACE_DETAIL, TraceDetailsV3),
|
||||
play: withTrialBanner(),
|
||||
};
|
||||
|
||||
/** Logs quick filters settings: the action footer with an unsaved change. */
|
||||
export const QuickFiltersSettingsLogs: StoryObj<LogsArgs> = {
|
||||
...logs,
|
||||
render: () => <LogsModulePage />,
|
||||
play: withTrialBanner(dirtyLogsQuickFilters),
|
||||
};
|
||||
|
||||
/** Traces quick filters settings: the action footer with an unsaved change. */
|
||||
export const QuickFiltersSettingsTraces: StoryObj<TracesArgs> = {
|
||||
...traces,
|
||||
render: () => <TracesModulePage />,
|
||||
play: withTrialBanner(dirtyTracesQuickFilters),
|
||||
};
|
||||
|
||||
/** Span drawer: the details drawer opened from a row of the traces list. */
|
||||
export const SpanDrawer: StoryObj<TracesArgs> = {
|
||||
...traces,
|
||||
render: () => <TracesModulePage />,
|
||||
play: withTrialBanner(showsSpanDetails),
|
||||
};
|
||||
|
||||
/** Pods details drawer: the drawer over the list, on its logs tab. */
|
||||
export const PodsDetailsDrawer: StoryObj<PodsArgs> = {
|
||||
...pods,
|
||||
args: { ...pods.args, drawer: true },
|
||||
render: () => <InfrastructureMonitoringPage />,
|
||||
play: withTrialBanner(openDrawerLogsTab),
|
||||
};
|
||||
|
||||
/** Dashboards list: the rename dialog opened from a row. */
|
||||
export const RenameDashboardDialog: StoryObj<DashboardsListArgs> = {
|
||||
...dashboardsList,
|
||||
render: () => <DashboardsListPage />,
|
||||
play: withTrialBanner(openRenameDialog),
|
||||
};
|
||||
|
||||
/** Add Credit Card modal opened from the strip's Support. */
|
||||
export const AddCreditCardModal: StoryObj<GlobalMockArgs> = {
|
||||
args: { noz: true, support: 'add-card' },
|
||||
parameters: { signoz: { route: ROUTES.HOME, layout: 'app' } },
|
||||
render: () => <div />,
|
||||
play: withTrialBanner(openAddCardModal),
|
||||
};
|
||||
|
||||
/** A toast raised over the strip. */
|
||||
export const Toast: StoryObj<GlobalMockArgs> = {
|
||||
args: { noz: true, support: 'pylon' },
|
||||
parameters: { signoz: { route: ROUTES.HOME, layout: 'app' } },
|
||||
render: () => <div />,
|
||||
play: withTrialBanner(raiseToast),
|
||||
};
|
||||
|
||||
/** Panel editor: a full-screen route with no bottom strip. */
|
||||
export const PanelEditor: StoryObj<PanelEditorArgs> = {
|
||||
...panelEditor,
|
||||
render: renderAtRoute(ROUTES.DASHBOARD_PANEL_EDITOR, PanelEditorPage),
|
||||
play: withTrialBanner(),
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import HomePage from 'pages/HomePage/HomePage';
|
||||
import { homeMocks } from 'pages/HomePage/stories/HomePage.stories.mocks';
|
||||
|
||||
import {
|
||||
dismissNoAuthBanner,
|
||||
forgetNoAuthDismissal,
|
||||
showsBanner,
|
||||
} from './__story_mockdata__/banners.play';
|
||||
|
||||
type HomeArgs = PageStoryArgs<typeof homeMocks>;
|
||||
|
||||
const pageStory = storyMocks(homeMocks, { route: ROUTES.HOME, layout: 'app' });
|
||||
|
||||
/**
|
||||
* Every banner the shell can show, one per story, on Home. The `Banner`
|
||||
* control switches between them on any page story.
|
||||
*
|
||||
* Route: every page inside the app shell; these stories use `/home`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Foundations/Banners/Shell',
|
||||
tags: ['play'],
|
||||
component: HomePage,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
beforeEach: forgetNoAuthDismissal,
|
||||
play: showsBanner,
|
||||
} satisfies Meta<HomeArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<HomeArgs>;
|
||||
|
||||
/** A cloud trial with three days left. */
|
||||
export const TrialExpiry: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
};
|
||||
|
||||
/** The last payment failed; the workspace is suspended in seven days. */
|
||||
export const PaymentFailed: Story = {
|
||||
args: { banner: 'payment-failed' },
|
||||
};
|
||||
|
||||
/** Self-hosted license past its end date. */
|
||||
export const LicenseExpired: Story = {
|
||||
args: { banner: 'license-expired' },
|
||||
};
|
||||
|
||||
/** Self-hosted license terminated; enterprise features are off. */
|
||||
export const LicenseTerminated: Story = {
|
||||
args: { banner: 'license-terminated' },
|
||||
};
|
||||
|
||||
/** Self-hosted license cancelled. */
|
||||
export const LicenseCancelled: Story = {
|
||||
args: { banner: 'license-cancelled' },
|
||||
};
|
||||
|
||||
/** Self-hosted license defaulted on its bill. */
|
||||
export const LicenseDefaulted: Story = {
|
||||
args: { banner: 'license-defaulted' },
|
||||
};
|
||||
|
||||
/** Self-hosted evaluation license past its trial. */
|
||||
export const EvaluationExpired: Story = {
|
||||
args: { banner: 'evaluation-expired' },
|
||||
};
|
||||
|
||||
/** An expired license during a trial: both banners at once. */
|
||||
export const LicenseExpiredWithTrial: Story = {
|
||||
args: { banner: 'license-expired-with-trial' },
|
||||
};
|
||||
|
||||
/** An expired license with a failed payment: both banners at once. */
|
||||
export const LicenseExpiredWithPaymentFailed: Story = {
|
||||
args: { banner: 'license-expired-with-payment-failed' },
|
||||
};
|
||||
|
||||
/** Impersonation mode: authentication is off. */
|
||||
export const NoAuth: Story = {
|
||||
args: { banner: 'no-auth' },
|
||||
};
|
||||
|
||||
/** The impersonation banner closed; it stays closed across reloads. */
|
||||
export const NoAuthDismissed: Story = {
|
||||
args: { banner: 'no-auth' },
|
||||
play: dismissNoAuthBanner,
|
||||
};
|
||||
80
frontend/src/storybook/foundations/Banners/__story_mockdata__/banners.play.ts
generated
Normal file
80
frontend/src/storybook/foundations/Banners/__story_mockdata__/banners.play.ts
generated
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import removeLocalStorageKey from 'api/browser/localstorage/remove';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import type { AppShellArgs } from '@/storybook/globals/appShellMocks';
|
||||
|
||||
type Banner = AppShellArgs['banner'];
|
||||
|
||||
interface PlayContext {
|
||||
args: Pick<AppShellArgs, 'banner'>;
|
||||
}
|
||||
|
||||
export const NO_AUTH_STORAGE_KEY = 'no-auth-banner-v1';
|
||||
|
||||
const NO_AUTH_BANNER = 'no-auth-banner';
|
||||
|
||||
/** The shell renders its banners after the license and the trial settle. */
|
||||
const untilLoaded = { timeout: 10_000 };
|
||||
|
||||
const TRIAL = /You are in free trial period/;
|
||||
const PAYMENT_FAILED = /Your bill payment has failed/;
|
||||
const LICENSE_EXPIRED = /Your SigNoz license has expired/;
|
||||
|
||||
const BANNER_TEXT: Record<Exclude<Banner, 'none' | 'no-auth'>, RegExp[]> = {
|
||||
'trial-expiry': [TRIAL],
|
||||
'payment-failed': [PAYMENT_FAILED],
|
||||
'license-expired': [LICENSE_EXPIRED],
|
||||
'license-terminated': [/Your SigNoz license is terminated/],
|
||||
'license-cancelled': [/Your SigNoz license is cancelled/],
|
||||
'license-defaulted': [/Your SigNoz license is defaulted/],
|
||||
'evaluation-expired': [/Your SigNoz trial has ended/],
|
||||
'license-expired-with-trial': [LICENSE_EXPIRED, TRIAL],
|
||||
'license-expired-with-payment-failed': [LICENSE_EXPIRED, PAYMENT_FAILED],
|
||||
};
|
||||
|
||||
/** A dismissal is kept in `localStorage`, so it would outlive the story. */
|
||||
export const forgetNoAuthDismissal = (): void => {
|
||||
removeLocalStorageKey(NO_AUTH_STORAGE_KEY);
|
||||
};
|
||||
|
||||
export const showsBanner = async ({ args }: PlayContext): Promise<void> => {
|
||||
if (args.banner === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.banner === 'no-auth') {
|
||||
await screen.findByTestId(NO_AUTH_BANNER, undefined, untilLoaded);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
BANNER_TEXT[args.banner].map((text) =>
|
||||
screen.findByText(text, undefined, untilLoaded),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const dismissNoAuthBanner = async ({
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (args.banner !== 'no-auth') {
|
||||
await showsBanner({ args });
|
||||
return;
|
||||
}
|
||||
|
||||
const banner = await screen.findByTestId(
|
||||
NO_AUTH_BANNER,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(within(banner).getByRole('button'));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId(NO_AUTH_BANNER)).not.toBeInTheDocument(),
|
||||
);
|
||||
};
|
||||
41
frontend/src/storybook/foundations/Banners/__story_mockdata__/hostBanners.play.ts
generated
Normal file
41
frontend/src/storybook/foundations/Banners/__story_mockdata__/hostBanners.play.ts
generated
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import type { AppShellArgs } from '@/storybook/globals/appShellMocks';
|
||||
|
||||
import { showsBanner } from './banners.play';
|
||||
|
||||
interface HostPlayContext<TArgs extends Pick<AppShellArgs, 'banner'>> {
|
||||
mount: () => Promise<unknown>;
|
||||
args: TArgs;
|
||||
canvasElement: HTMLElement;
|
||||
}
|
||||
|
||||
type OpenOverlay<TArgs extends Pick<AppShellArgs, 'banner'>> = (context: {
|
||||
args: TArgs;
|
||||
canvasElement: HTMLElement;
|
||||
}) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Mounts the host, waits for the trial banner, then opens the host's own
|
||||
* overlay. `mount` is destructured so a control change replays all of it.
|
||||
*/
|
||||
export const withTrialBanner =
|
||||
<TArgs extends Pick<AppShellArgs, 'banner'>>(
|
||||
openOverlay?: OpenOverlay<TArgs>,
|
||||
) =>
|
||||
async ({
|
||||
mount,
|
||||
args,
|
||||
canvasElement,
|
||||
}: HostPlayContext<TArgs>): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
if (args.banner === 'trial-expiry') {
|
||||
await showsBanner({ args });
|
||||
}
|
||||
|
||||
await openOverlay?.({ args, canvasElement });
|
||||
};
|
||||
167
frontend/src/storybook/foundations/Banners/__story_mockdata__/pageBanners.play.ts
generated
Normal file
167
frontend/src/storybook/foundations/Banners/__story_mockdata__/pageBanners.play.ts
generated
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { expect, screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import type { GlobalMockArgs } from '@/storybook/globals';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import type { createAlertMocks } from 'pages/CreateAlert/stories/CreateAlert.stories.mocks';
|
||||
import type { meterMocks } from 'pages/MeterExplorer/stories/MeterExplorer.stories.mocks';
|
||||
import { hasPendingRule } from 'pages/MetricsExplorer/stories/__story_mockdata__/metricsExplorer';
|
||||
import type { metricsMocks } from 'pages/MetricsExplorer/stories/MetricsExplorerPage.stories.mocks';
|
||||
|
||||
type CreateAlertArgs = PageStoryArgs<typeof createAlertMocks>;
|
||||
type MeterArgs = PageStoryArgs<typeof meterMocks>;
|
||||
type MetricsArgs = PageStoryArgs<typeof metricsMocks>;
|
||||
|
||||
/** The pages fetch before they render, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/** A restricted license banner swaps the cloud license for a self-hosted one. */
|
||||
const isCloud = ({
|
||||
license,
|
||||
banner,
|
||||
}: Pick<GlobalMockArgs, 'license' | 'banner'>): boolean =>
|
||||
license === 'cloud' && !/^(license|evaluation)-/.test(banner);
|
||||
|
||||
export const showsPendingActivation = async ({
|
||||
args,
|
||||
}: {
|
||||
args: Pick<
|
||||
MetricsArgs,
|
||||
| 'tab'
|
||||
| 'drawer'
|
||||
| 'drawerMetric'
|
||||
| 'rules'
|
||||
| 'pendingRules'
|
||||
| 'volumeControl'
|
||||
| 'license'
|
||||
| 'dataState'
|
||||
>;
|
||||
}): Promise<void> => {
|
||||
const licensed = args.license === 'cloud' || args.license === 'enterprise';
|
||||
|
||||
if (
|
||||
!licensed ||
|
||||
!args.volumeControl ||
|
||||
args.tab !== 'summary' ||
|
||||
args.drawer !== 'details' ||
|
||||
args.dataState !== 'loaded' ||
|
||||
!hasPendingRule(args.drawerMetric, args.rules, args.pendingRules)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await screen.findByTestId(
|
||||
'volume-control-pending-banner',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
};
|
||||
|
||||
export const showsCancelSubscription = async ({
|
||||
args,
|
||||
}: {
|
||||
args: Pick<GlobalMockArgs, 'license' | 'banner' | 'dataState'>;
|
||||
}): Promise<void> => {
|
||||
if (!isCloud(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await screen.findByTestId('cancel-subscription-btn', undefined, untilLoaded);
|
||||
|
||||
// The usage table pushes the banner down once it lands, so the scroll waits
|
||||
// for it. A revoked read permission never renders it.
|
||||
if (args.dataState !== 'loading') {
|
||||
await screen
|
||||
.findByText(/Billing metrics are updated once every 24 hours/, undefined, {
|
||||
timeout: 5_000,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
// The button is swapped once its permission check answers, so it is looked up
|
||||
// again.
|
||||
screen.getByTestId('cancel-subscription-btn').scrollIntoView({
|
||||
block: 'center',
|
||||
});
|
||||
};
|
||||
|
||||
export const showsClickHouseInfo = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: {
|
||||
canvasElement: HTMLElement;
|
||||
args: Pick<CreateAlertArgs, 'alertMode'>;
|
||||
}): Promise<void> => {
|
||||
// The picker has no query section, and anomaly rules disable the tab.
|
||||
if (args.alertMode === 'select-type' || args.alertMode === 'anomaly') {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'clickhouse-tab',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
const link = await screen.findByText(
|
||||
/Learn to write faster, optimized queries/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
link.scrollIntoView({ block: 'center' });
|
||||
};
|
||||
|
||||
type MeterNoticeArgs = Pick<
|
||||
MeterArgs,
|
||||
'tab' | 'timeRange' | 'license' | 'banner' | 'billingNotice'
|
||||
>;
|
||||
|
||||
/** The notices sit on the Meter tab and are decided before any data lands. */
|
||||
const meterNotice =
|
||||
(text: RegExp, isShown: (args: MeterNoticeArgs) => boolean) =>
|
||||
async ({ args }: { args: MeterNoticeArgs }): Promise<void> => {
|
||||
if (args.tab !== 'meter') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isShown(args)) {
|
||||
await screen.findByText(text, undefined, untilLoaded);
|
||||
return;
|
||||
}
|
||||
|
||||
await screen.findByTestId(
|
||||
'meter-breakdown-section-total',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
await expect(screen.queryByText(text)).not.toBeInTheDocument();
|
||||
};
|
||||
|
||||
export const showsMeterBillingNotice = meterNotice(
|
||||
/Billing is calculated in UTC/,
|
||||
({ billingNotice }) => billingNotice,
|
||||
);
|
||||
|
||||
export const showsMeterBeforeLaunch = meterNotice(
|
||||
/Meter module data is accurate only from 22nd August 2025/,
|
||||
(args) => isCloud(args) && args.timeRange === 'august-2025',
|
||||
);
|
||||
|
||||
export const showsMeterShortWindow = meterNotice(
|
||||
/Meter metrics data is aggregated over 1 hour period/,
|
||||
({ timeRange }) => timeRange === 'last-30-minutes',
|
||||
);
|
||||
|
||||
export const showsTopLevelOperationsThreshold = async (): Promise<void> => {
|
||||
await screen.findByText(
|
||||
/SigNoz calculates the RED metrics for a service/,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
};
|
||||
@@ -1,18 +1,17 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import type { GlobalMockArgs } from '../globals';
|
||||
import {
|
||||
openAddCardModal,
|
||||
raiseToast,
|
||||
} from './__story_mockdata__/bottomStrip.play';
|
||||
|
||||
/** A page that puts nothing in the strip, so the shell is all there is. */
|
||||
function EmptyPage(): JSX.Element {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
/** The shell renders its entry points after the global config and the license settle. */
|
||||
const untilLoaded = { timeout: 10_000 };
|
||||
|
||||
/**
|
||||
* The strip along the foot of the shell: what the page puts on the left, the
|
||||
* build version when it puts nothing, and the shell's own Ask Noz and Support
|
||||
@@ -54,14 +53,7 @@ export const AddCard: Story = {
|
||||
/** The Add Credit Card modal, opened from the strip's Support. */
|
||||
export const AddCardModal: Story = {
|
||||
args: { noz: true, support: 'add-card' },
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('bottom-strip-support', undefined, untilLoaded),
|
||||
);
|
||||
await screen.findByRole('dialog', {
|
||||
name: /Add Credit Card for Chat Support/i,
|
||||
});
|
||||
},
|
||||
play: openAddCardModal,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -77,12 +69,6 @@ export const Tooltips: Story = {
|
||||
args: { noz: true, support: 'pylon', tooltipsOpen: true },
|
||||
};
|
||||
|
||||
/** Raises a toast and waits for it, so the shot is taken with it on screen. */
|
||||
const raiseToast: NonNullable<Story['play']> = async () => {
|
||||
toast.success('Service account deleted');
|
||||
await screen.findByText('Service account deleted', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
/**
|
||||
* A toast above the strip. Sonner pins itself to the foot of the viewport, so
|
||||
* without an offset it lands on top of Ask Noz and Support.
|
||||
|
||||
@@ -64,6 +64,70 @@ export const LongMultilineToast: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** A request still in flight: the loading toast stays until the promise settles. */
|
||||
export const PromiseLoadingToast: Story = {
|
||||
args: { state: 'promise-loading-toast' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByTestId('show-feedback-toast'),
|
||||
);
|
||||
await expect(
|
||||
await screen.findByText('Enabling the alert rule'),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** A failed request: the rejected toast keeps a Dismiss action, stacked top-center. */
|
||||
export const PromiseErrorToast: Story = {
|
||||
args: { state: 'promise-error-toast' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByTestId('show-feedback-toast'),
|
||||
);
|
||||
await expect(
|
||||
await screen.findByText('Could not clone the panel'),
|
||||
).toBeVisible();
|
||||
await screen.findByRole('button', { name: 'Dismiss' });
|
||||
},
|
||||
};
|
||||
|
||||
/** A warning that stays up, matching invites that only partly succeed. */
|
||||
export const WarningToast: Story = {
|
||||
args: { state: 'warning-toast' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByTestId('show-feedback-toast'),
|
||||
);
|
||||
await expect(await screen.findByText('Some invites failed')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** An info toast pinned to the bottom-right stack, as export cancellation does. */
|
||||
export const InfoToast: Story = {
|
||||
args: { state: 'info-toast' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByTestId('show-feedback-toast'),
|
||||
);
|
||||
await expect(await screen.findByText('Export cancelled')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** An error toast with a description and a Dismiss action, bottom-right. */
|
||||
export const DangerWithDescription: Story = {
|
||||
args: { state: 'danger-description-toast' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByTestId('show-feedback-toast'),
|
||||
);
|
||||
await expect(
|
||||
await screen.findByText('Failed to update account settings'),
|
||||
).toBeVisible();
|
||||
await screen.findByText(/does not have permission/i);
|
||||
await screen.findByRole('button', { name: 'Dismiss' });
|
||||
},
|
||||
};
|
||||
|
||||
/** Mutation: the inline application alert preserves its action affordance. */
|
||||
export const InlineAlert: Story = {
|
||||
args: { state: 'inline-alert' },
|
||||
|
||||
@@ -10,6 +10,11 @@ export type FeedbackState =
|
||||
| 'success-toast'
|
||||
| 'actionable-error-toast'
|
||||
| 'long-toast'
|
||||
| 'promise-loading-toast'
|
||||
| 'promise-error-toast'
|
||||
| 'warning-toast'
|
||||
| 'info-toast'
|
||||
| 'danger-description-toast'
|
||||
| 'inline-alert'
|
||||
| 'confirmation'
|
||||
| 'toast-over-confirmation';
|
||||
@@ -21,6 +26,10 @@ interface FeedbackFixtureProps {
|
||||
const longMessage =
|
||||
'We could not apply the retention policy because the selected storage tier is unavailable. Review the workspace settings, then try again.';
|
||||
|
||||
const pendingSave = new Promise<void>(() => undefined);
|
||||
|
||||
const dismissAction = { label: 'Dismiss', onClick: (): void => undefined };
|
||||
|
||||
function FeedbackFixture({ state }: FeedbackFixtureProps): JSX.Element {
|
||||
const [isConfirmationOpen, setIsConfirmationOpen] = useState(false);
|
||||
|
||||
@@ -37,13 +46,62 @@ function FeedbackFixture({ state }: FeedbackFixtureProps): JSX.Element {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === 'promise-loading-toast') {
|
||||
toast.promise(pendingSave, {
|
||||
loading: 'Enabling the alert rule',
|
||||
success: 'Alert rule enabled',
|
||||
error: 'Could not enable the alert rule',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === 'promise-error-toast') {
|
||||
toast.promise(Promise.reject(new Error('failed')), {
|
||||
loading: 'Cloning the panel',
|
||||
success: 'Panel cloned',
|
||||
error: { message: 'Could not clone the panel', action: dismissAction },
|
||||
position: 'top-center',
|
||||
duration: Infinity,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === 'warning-toast') {
|
||||
toast.warning('Some invites failed', { duration: Infinity });
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === 'info-toast') {
|
||||
toast.info('Export cancelled', {
|
||||
position: 'bottom-right',
|
||||
duration: Infinity,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === 'danger-description-toast') {
|
||||
toast.error('Failed to update account settings', {
|
||||
description:
|
||||
'The service account does not have permission to change this connection.',
|
||||
action: dismissAction,
|
||||
position: 'bottom-right',
|
||||
duration: Infinity,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(longMessage, { duration: Infinity });
|
||||
};
|
||||
|
||||
const isToastStory =
|
||||
state === 'success-toast' ||
|
||||
state === 'actionable-error-toast' ||
|
||||
state === 'long-toast';
|
||||
state === 'long-toast' ||
|
||||
state === 'promise-loading-toast' ||
|
||||
state === 'promise-error-toast' ||
|
||||
state === 'warning-toast' ||
|
||||
state === 'info-toast' ||
|
||||
state === 'danger-description-toast';
|
||||
const confirmationTitle = 'Delete environment';
|
||||
|
||||
return (
|
||||
|
||||
25
frontend/src/storybook/foundations/__story_mockdata__/bottomStrip.play.ts
generated
Normal file
25
frontend/src/storybook/foundations/__story_mockdata__/bottomStrip.play.ts
generated
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
/** The shell renders its entry points after the global config and the license settle. */
|
||||
const untilLoaded = { timeout: 10_000 };
|
||||
|
||||
export const openAddCardModal = async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('bottom-strip-support', undefined, untilLoaded),
|
||||
);
|
||||
await screen.findByRole('dialog', {
|
||||
name: /Add Credit Card for Chat Support/i,
|
||||
});
|
||||
};
|
||||
|
||||
/** Raises a toast and waits for it, so the shot is taken with it on screen. */
|
||||
export const raiseToast = async (): Promise<void> => {
|
||||
toast.success('Service account deleted');
|
||||
await screen.findByText('Service account deleted', undefined, untilLoaded);
|
||||
};
|
||||
@@ -39,6 +39,11 @@ const BANNERS = [
|
||||
'payment-failed',
|
||||
'license-expired',
|
||||
'license-terminated',
|
||||
'license-cancelled',
|
||||
'license-defaulted',
|
||||
'evaluation-expired',
|
||||
'license-expired-with-trial',
|
||||
'license-expired-with-payment-failed',
|
||||
'no-auth',
|
||||
] as const;
|
||||
|
||||
@@ -153,56 +158,76 @@ const licensedBanner = (
|
||||
};
|
||||
};
|
||||
|
||||
const trialExpiryContext = (): Partial<IAppContext> => {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
return {
|
||||
trialInfo: {
|
||||
...baseTrialInfo,
|
||||
onTrial: true,
|
||||
trialStart: nowInSeconds - 27 * DAY_IN_SECONDS,
|
||||
trialEnd: nowInSeconds + 3 * DAY_IN_SECONDS,
|
||||
workSpaceBlock: false,
|
||||
trialConvertedToSubscription: false,
|
||||
gracePeriodEnd: -1,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const withPaymentFailed = (license: LicenseResModel): LicenseResModel => ({
|
||||
...license,
|
||||
eventQueue: {
|
||||
...license.eventQueue,
|
||||
event: LicenseEvent.DEFAULT,
|
||||
scheduledAt: new Date(Date.now() + 7 * DAY_IN_SECONDS * 1000).toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
// The restricted-workspace banner needs a self-hosted license; the cloud
|
||||
// platform redirects to the restricted page instead.
|
||||
const withRestricted =
|
||||
(state: LicenseState) =>
|
||||
(license: LicenseResModel): LicenseResModel => ({
|
||||
...license,
|
||||
platform: LicensePlatform.SELF_HOSTED,
|
||||
state,
|
||||
});
|
||||
|
||||
const RESTRICTED_STATES: Partial<Record<Banner, LicenseState>> = {
|
||||
'license-expired': LicenseState.EXPIRED,
|
||||
'license-terminated': LicenseState.TERMINATED,
|
||||
'license-cancelled': LicenseState.CANCELLED,
|
||||
'license-defaulted': LicenseState.DEFAULTED,
|
||||
'evaluation-expired': LicenseState.EVALUATION_EXPIRED,
|
||||
};
|
||||
|
||||
const bannerContext = (
|
||||
banner: Banner,
|
||||
activeLicense: LicenseResModel | null,
|
||||
): Partial<IAppContext> => {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
switch (banner) {
|
||||
case 'trial-expiry':
|
||||
return {
|
||||
trialInfo: {
|
||||
...baseTrialInfo,
|
||||
onTrial: true,
|
||||
trialStart: nowInSeconds - 27 * DAY_IN_SECONDS,
|
||||
trialEnd: nowInSeconds + 3 * DAY_IN_SECONDS,
|
||||
workSpaceBlock: false,
|
||||
trialConvertedToSubscription: false,
|
||||
gracePeriodEnd: -1,
|
||||
},
|
||||
};
|
||||
return trialExpiryContext();
|
||||
|
||||
case 'payment-failed':
|
||||
return licensedBanner(activeLicense, (license) => ({
|
||||
...license,
|
||||
eventQueue: {
|
||||
...license.eventQueue,
|
||||
event: LicenseEvent.DEFAULT,
|
||||
scheduledAt: new Date(
|
||||
Date.now() + 7 * DAY_IN_SECONDS * 1000,
|
||||
).toISOString(),
|
||||
},
|
||||
}));
|
||||
return licensedBanner(activeLicense, withPaymentFailed);
|
||||
|
||||
// Both restricted-workspace banners need a self-hosted license; the cloud
|
||||
// platform never reaches that branch.
|
||||
case 'license-expired':
|
||||
return licensedBanner(activeLicense, (license) => ({
|
||||
...license,
|
||||
platform: LicensePlatform.SELF_HOSTED,
|
||||
state: LicenseState.EXPIRED,
|
||||
}));
|
||||
case 'license-expired-with-trial':
|
||||
return {
|
||||
...trialExpiryContext(),
|
||||
...licensedBanner(activeLicense, withRestricted(LicenseState.EXPIRED)),
|
||||
};
|
||||
|
||||
case 'license-terminated':
|
||||
return licensedBanner(activeLicense, (license) => ({
|
||||
...license,
|
||||
platform: LicensePlatform.SELF_HOSTED,
|
||||
state: LicenseState.TERMINATED,
|
||||
}));
|
||||
case 'license-expired-with-payment-failed':
|
||||
return licensedBanner(activeLicense, (license) =>
|
||||
withRestricted(LicenseState.EXPIRED)(withPaymentFailed(license)),
|
||||
);
|
||||
|
||||
default:
|
||||
return {};
|
||||
default: {
|
||||
const state = RESTRICTED_STATES[banner];
|
||||
|
||||
return state ? licensedBanner(activeLicense, withRestricted(state)) : {};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -254,7 +279,7 @@ export const appShellMocks = defineStoryMocks({
|
||||
banner: choiceControl<Banner>('Banner', {
|
||||
group: APP_SHELL,
|
||||
description:
|
||||
'License, trial and no-auth banners above the shell. The license ones need a license to read, so they override an unlicensed License control.',
|
||||
'License, trial and no-auth banners above the shell. The license ones need a license to read, so they override an unlicensed License control. The `-with-` values turn two banners on at once.',
|
||||
options: BANNERS,
|
||||
value: 'none',
|
||||
}),
|
||||
|
||||
@@ -312,7 +312,7 @@ export type QueryBuilderContextType = {
|
||||
searchParams?: Record<string, unknown>,
|
||||
redirectToUrl?: (typeof ROUTES)[keyof typeof ROUTES],
|
||||
shallStringify?: boolean,
|
||||
newTab?: boolean,
|
||||
navigateOptions?: { newTab?: boolean; replace?: boolean },
|
||||
) => void;
|
||||
handleRunQuery: () => void;
|
||||
resetQuery: (newCurrentQuery?: QueryState) => void;
|
||||
|
||||
28
go.mod
28
go.mod
@@ -5,9 +5,9 @@ go 1.25.7
|
||||
require (
|
||||
dario.cat/mergo v1.0.2
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.6
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.47.0
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/SigNoz/clickhouse-go-mock v0.14.0
|
||||
github.com/SigNoz/clickhouse-go-mock v0.15.1
|
||||
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd
|
||||
github.com/SigNoz/signoz-otel-collector v0.144.6
|
||||
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.2
|
||||
@@ -75,13 +75,13 @@ require (
|
||||
go.opentelemetry.io/collector/pdata v1.54.0
|
||||
go.opentelemetry.io/contrib/config v0.10.0
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.63.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0
|
||||
go.opentelemetry.io/otel v1.45.0
|
||||
go.opentelemetry.io/otel/metric v1.45.0
|
||||
go.opentelemetry.io/otel/sdk v1.45.0
|
||||
go.opentelemetry.io/otel/trace v1.45.0
|
||||
go.uber.org/multierr v1.11.0
|
||||
go.uber.org/zap v1.27.1
|
||||
go.uber.org/zap v1.28.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
|
||||
golang.org/x/net v0.58.0
|
||||
@@ -173,11 +173,11 @@ require (
|
||||
cloud.google.com/go/auth v0.18.2 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/ClickHouse/ch-go v0.71.0
|
||||
github.com/ClickHouse/ch-go v0.73.0
|
||||
github.com/Masterminds/squirrel v1.5.4 // indirect
|
||||
github.com/Yiling-J/theine-go v0.6.2 // indirect
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/armon/go-metrics v0.4.1 // indirect
|
||||
github.com/beevik/etree v1.6.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
@@ -190,7 +190,7 @@ require (
|
||||
github.com/dennwc/varint v1.0.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/edsrzf/mmap-go v1.2.0 // indirect
|
||||
github.com/elastic/lunes v0.2.0 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
@@ -233,7 +233,7 @@ require (
|
||||
github.com/hashicorp/go-msgpack/v2 v2.1.5 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
|
||||
github.com/hashicorp/go-version v1.8.0 // indirect
|
||||
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/hashicorp/memberlist v0.5.4 // indirect
|
||||
@@ -254,7 +254,7 @@ require (
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||
github.com/leodido/go-syslog/v4 v4.3.0 // indirect
|
||||
github.com/leodido/ragel-machinery v0.0.0-20190525184631-5f46317e436b // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||
github.com/magefile/mage v1.15.0 // indirect
|
||||
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
|
||||
@@ -275,9 +275,9 @@ require (
|
||||
github.com/open-feature/go-sdk v1.17.0
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal v0.144.0 // indirect
|
||||
github.com/openfga/openfga v1.14.1
|
||||
github.com/paulmach/orb v0.12.0 // indirect
|
||||
github.com/paulmach/orb v0.13.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.26 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.27 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/pressly/goose/v3 v3.27.0 // indirect
|
||||
@@ -293,7 +293,7 @@ require (
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/segmentio/backo-go v1.0.1 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.25.12 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.5 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c // indirect
|
||||
github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92 // indirect
|
||||
@@ -308,8 +308,8 @@ require (
|
||||
github.com/swaggest/openapi-go v0.2.60
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
|
||||
github.com/valyala/fastjson v1.6.7 // indirect
|
||||
github.com/vjeantet/grok v1.0.1 // indirect
|
||||
|
||||
78
go.sum
78
go.sum
@@ -79,10 +79,10 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgv
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
|
||||
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
|
||||
github.com/ClickHouse/ch-go v0.73.0 h1:jsHiGRbQ3sz+gekvDFJF29LWDo5dzbJm5s1h8TWVP2M=
|
||||
github.com/ClickHouse/ch-go v0.73.0/go.mod h1:wkFIxrqlXeRJ9cn3r5Fz5Qen9jl5aTMPuGZeuJpANNY=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.47.0 h1:ZDAzrnKSOPTIsm4tdUNfrii2yc8dk4SVRLC77BR7Z5Q=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.47.0/go.mod h1:sPj7C7UYQ2MWHcfX+4eGN6nwnCqwUKfgO6PcwKpd6K8=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
|
||||
@@ -94,8 +94,8 @@ github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA4
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/SigNoz/clickhouse-go-mock v0.14.0 h1:yYoGMJk0UDVvLCk12dover/zoRIuznW3BfoqUhMoJSY=
|
||||
github.com/SigNoz/clickhouse-go-mock v0.14.0/go.mod h1:tXmSXVLWz7N/yVLCianszmNIN4cHozXvwEXCOLzzqzI=
|
||||
github.com/SigNoz/clickhouse-go-mock v0.15.1 h1:ifoh/3Jx6+Zt4ptuSeUnZrOW5tVy/WSgzGpAh+w4NaY=
|
||||
github.com/SigNoz/clickhouse-go-mock v0.15.1/go.mod h1:r1qiBP8tA1KG7bmuCg0wrt0YxBMWylL7FPTBbx9wTTs=
|
||||
github.com/SigNoz/expr v1.17.7-beta h1:FyZkleM5dTQ0O6muQfwGpoH5A2ohmN/XTasRCO72gAA=
|
||||
github.com/SigNoz/expr v1.17.7-beta/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd h1:Bk43AsDYe0fhkbj57eGXx8H3ZJ4zhmQXBnrW523ktj8=
|
||||
@@ -116,8 +116,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF
|
||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0=
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/antchfx/xmlquery v1.5.0 h1:uAi+mO40ZWfyU6mlUBxRVvL6uBNZ6LMU4M3+mQIBV4c=
|
||||
github.com/antchfx/xmlquery v1.5.0/go.mod h1:lJfWRXzYMK1ss32zm1GQV3gMIW/HFey3xDZmkP1SuNc=
|
||||
github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
|
||||
@@ -255,15 +255,15 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84=
|
||||
github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
|
||||
github.com/elastic/go-grok v0.3.1 h1:WEhUxe2KrwycMnlvMimJXvzRa7DoByJB4PVUIE1ZD/U=
|
||||
@@ -596,8 +596,8 @@ github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
|
||||
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
|
||||
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
@@ -676,7 +676,6 @@ github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
||||
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
@@ -712,8 +711,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/leodido/ragel-machinery v0.0.0-20190525184631-5f46317e436b h1:11UHH39z1RhZ5dc4y4r/4koJo6IYFgTRMe/LlwRTEw0=
|
||||
github.com/leodido/ragel-machinery v0.0.0-20190525184631-5f46317e436b/go.mod h1:WZxr2/6a/Ar9bMDc2rN/LJrE/hF6bXE4LPyDSIxwAfg=
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
|
||||
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
|
||||
@@ -768,10 +767,10 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/moby/api v1.53.0 h1:PihqG1ncw4W+8mZs69jlwGXdaYBeb5brF6BL7mPIS/w=
|
||||
github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
|
||||
github.com/moby/moby/client v0.2.2 h1:Pt4hRMCAIlyjL3cr8M5TrXCwKzguebPAc2do2ur7dEM=
|
||||
github.com/moby/moby/client v0.2.2/go.mod h1:2EkIPVNCqR05CMIzL1mfA07t0HvVUUOl85pasRz/GmQ=
|
||||
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
|
||||
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -780,7 +779,6 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
@@ -842,9 +840,8 @@ github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYr
|
||||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
|
||||
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
|
||||
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||
github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw=
|
||||
github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k=
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
@@ -855,8 +852,8 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8
|
||||
github.com/perses/spec v0.1.2 h1:yGoygcR3ZusuGDCmRMwsVXCMvMwi1qZndKV6NYNreEw=
|
||||
github.com/perses/spec v0.1.2/go.mod h1:NoGI5jmGwRdkdPgyYSZJTBL4/Py+dqIPKS2QV8NOvGE=
|
||||
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
|
||||
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
|
||||
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
|
||||
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
@@ -965,8 +962,8 @@ github.com/sethvargo/go-password v0.2.0 h1:BTDl4CC/gjf/axHMaDQtw507ogrXLci6XRiLc
|
||||
github.com/sethvargo/go-password v0.2.0/go.mod h1:Ym4Mr9JXLBycr02MFuVQ/0JHidNetSgbzutTr3zsYXE=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/shirou/gopsutil/v4 v4.25.12 h1:e7PvW/0RmJ8p8vPGJH4jvNkOyLmbkXgXW4m6ZPic6CY=
|
||||
github.com/shirou/gopsutil/v4 v4.25.12/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
|
||||
github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c h1:aqg5Vm5dwtvL+YgDpBcK1ITf3o96N/K7/wsRXQnUTEs=
|
||||
@@ -1044,14 +1041,13 @@ github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
|
||||
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
||||
@@ -1093,12 +1089,8 @@ github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAh
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
|
||||
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
|
||||
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
|
||||
@@ -1120,7 +1112,6 @@ go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dY
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
|
||||
go.etcd.io/etcd/client/v2 v2.305.4/go.mod h1:Ud+VUwIi9/uQHOMA+4ekToJ12lTxlv0zB/+DHwTGEbU=
|
||||
go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/gq3kiY=
|
||||
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
@@ -1259,8 +1250,8 @@ go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.63
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.63.0/go.mod h1:34csimR1lUhdT5HH4Rii9aKPrvBcnFRwxLwcevsU+Kk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.67.0 h1:c9r/G1CSw4dPI1jaNNG9RnQP+q4SvZnHciDQJVIvchU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.67.0/go.mod h1:gO9smoZe9KnZcJCqcB0lMmQ4Z5VEifYmjMTpnwtTSuQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||
go.opentelemetry.io/contrib/otelconf v0.18.0 h1:ciF2Gf00BWs0DnexKFZXcxg9kJ8r3SUW1LOzW3CsKA8=
|
||||
go.opentelemetry.io/contrib/otelconf v0.18.0/go.mod h1:FcP7k+JLwBLdOxS6qY6VQ/4b5VBntI6L6o80IMwhAeI=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.39.0 h1:PI7pt9pkSnimWcp5sQhUA9OzLbc3Ba4sL+VEUTNsxrk=
|
||||
@@ -1332,8 +1323,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
|
||||
go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
@@ -1350,7 +1341,6 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
@@ -22,7 +23,12 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
}, handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceSession,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}))).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -39,7 +45,12 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
}, handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceSession,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}))).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -56,7 +67,12 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
}, handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceSession,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}))).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -73,7 +89,12 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
}, handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceSession,
|
||||
Verb: coretypes.VerbDelete,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}))).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -90,7 +111,12 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
}, handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceSession,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}))).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -110,7 +136,12 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusUnavailableForLegalReasons},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
}, handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceSession,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}))).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -127,7 +158,12 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusUnavailableForLegalReasons},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
}, handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceSession,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}))).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -457,6 +457,13 @@ func emptyResultFor(kind qbtypes.RequestType, queryName string) *qbtypes.Result
|
||||
|
||||
// executeWithContext executes the query with query window and step context for partial value detection.
|
||||
func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string, args []any) (*qbtypes.Result, error) {
|
||||
result, _, err := q.executeSkippingRaw(ctx, query, args, 0)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// executeSkippingRaw is executeWithContext for a raw query that drops its first
|
||||
// skip rows undecoded. It returns how many rows were skipped.
|
||||
func (q *builderQuery[T]) executeSkippingRaw(ctx context.Context, query string, args []any, skip int) (*qbtypes.Result, int, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: q.spec.Signal.StringValue(),
|
||||
instrumentationtypes.QueryDuration: instrumentationtypes.DurationBucket(q.fromMS, q.toMS),
|
||||
@@ -475,19 +482,19 @@ func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string,
|
||||
rows, err := q.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, errors.Newf(errors.TypeTimeout, errors.CodeTimeout, "Query timed out").
|
||||
return nil, 0, errors.Newf(errors.TypeTimeout, errors.CodeTimeout, "Query timed out").
|
||||
WithAdditional("Try refining your search by adding relevant resource attributes filtering")
|
||||
}
|
||||
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
return nil, errors.Newf(
|
||||
return nil, 0, errors.Newf(
|
||||
errors.TypeInternal,
|
||||
errors.CodeInternal,
|
||||
"Something went wrong on our end. It's not you, it's us. Our team is notified about it. Reach out to support if issue persists.",
|
||||
)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
@@ -501,9 +508,15 @@ func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string,
|
||||
kind = qbtypes.RequestTypeTimeSeries
|
||||
}
|
||||
|
||||
payload, err := consume(rows, kind, queryWindow, q.spec.StepInterval, q.spec.Name)
|
||||
var payload any
|
||||
skipped := 0
|
||||
if skip > 0 {
|
||||
payload, skipped, err = readAsRawSkipping(rows, q.spec.Name, skip)
|
||||
} else {
|
||||
payload, err = consume(rows, kind, queryWindow, q.spec.StepInterval, q.spec.Name)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// TODO: This should move to readAsRaw function in consume.go but for now we are keeping it here since it's only relevant for traces
|
||||
@@ -523,7 +536,7 @@ func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string,
|
||||
BytesScanned: totalBytes,
|
||||
DurationMS: uint64(elapsed.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}, skipped, nil
|
||||
}
|
||||
|
||||
func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Result, error) {
|
||||
@@ -623,7 +636,7 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
|
||||
}
|
||||
}
|
||||
// Execute with proper context for partial value detection
|
||||
res, err := q.executeWithContext(ctx, stmt.Query, stmt.Args)
|
||||
res, skipped, err := q.executeSkippingRaw(ctx, stmt.Query, stmt.Args, offsetLeft)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -631,13 +644,10 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
|
||||
totalBytes += res.Stats.BytesScanned
|
||||
|
||||
rawRows := res.Value.(*qbtypes.RawData).Rows
|
||||
need -= len(rawRows)
|
||||
need -= skipped + len(rawRows)
|
||||
offsetLeft -= skipped
|
||||
|
||||
for _, rr := range rawRows {
|
||||
if offsetLeft > 0 { // client-requested initial offset
|
||||
offsetLeft--
|
||||
continue
|
||||
}
|
||||
rows = append(rows, rr)
|
||||
if len(rows) >= reqLimit { // page filled
|
||||
break
|
||||
|
||||
@@ -521,13 +521,26 @@ func derefValue(v any) any {
|
||||
}
|
||||
|
||||
func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
|
||||
data, _, err := readAsRawSkipping(rows, queryName, 0)
|
||||
return data, err
|
||||
}
|
||||
|
||||
// readAsRawSkipping advances past the first skip rows without scanning them, so
|
||||
// rows dropped for an offset are never decoded. It returns how many were skipped.
|
||||
func readAsRawSkipping(rows driver.Rows, queryName string, skip int) (*qbtypes.RawData, int, error) {
|
||||
colNames := rows.Columns()
|
||||
colTypes := rows.ColumnTypes()
|
||||
colCnt := len(colNames)
|
||||
|
||||
var outRows []*qbtypes.RawRow
|
||||
skipped := 0
|
||||
|
||||
for rows.Next() {
|
||||
if skipped < skip {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// fresh copy of the scan slice (otherwise the driver reuses pointers)
|
||||
scan := make([]any, colCnt)
|
||||
for i := range colTypes {
|
||||
@@ -535,7 +548,7 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
|
||||
}
|
||||
|
||||
if err := rows.Scan(scan...); err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rr := qbtypes.RawRow{
|
||||
@@ -567,13 +580,13 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
|
||||
outRows = append(outRows, &rr)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return &qbtypes.RawData{
|
||||
QueryName: queryName,
|
||||
Rows: outRows,
|
||||
}, nil
|
||||
}, skipped, nil
|
||||
}
|
||||
|
||||
// mergeSpanAttributeColumns merges (attributes_string, attributes_number, attributes_bool, resources_string) into
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
)
|
||||
@@ -254,3 +256,41 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAsRawSkipping(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
values []string
|
||||
skip int
|
||||
wantSkipped int
|
||||
wantIDs []string
|
||||
}{
|
||||
{name: "NoSkip", values: []string{"a", "b", "c"}, skip: 0, wantSkipped: 0, wantIDs: []string{"a", "b", "c"}},
|
||||
{name: "SkipInsideResult", values: []string{"d", "e", "f", "g"}, skip: 2, wantSkipped: 2, wantIDs: []string{"f", "g"}},
|
||||
{name: "SkipWholeResult", values: []string{"h", "i"}, skip: 2, wantSkipped: 2, wantIDs: nil},
|
||||
{name: "SkipPastResult", values: []string{"j"}, skip: 5, wantSkipped: 1, wantIDs: nil},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
values := make([][]any, len(testCase.values))
|
||||
for i, value := range testCase.values {
|
||||
values[i] = []any{value}
|
||||
}
|
||||
rows := cmock.NewRows([]cmock.ColumnType{{Name: "id", Type: "String"}}, values)
|
||||
for i := 0; i < testCase.skip && i < len(values); i++ {
|
||||
rows.RowError(i, errors.New(errors.TypeInternal, errors.CodeInternal, "skipped row was scanned"))
|
||||
}
|
||||
|
||||
data, skipped, err := readAsRawSkipping(rows, "A", testCase.skip)
|
||||
require.NoError(t, err)
|
||||
|
||||
var ids []string
|
||||
for _, row := range data.Rows {
|
||||
ids = append(ids, row.Data["id"].(string))
|
||||
}
|
||||
assert.Equal(t, testCase.wantSkipped, skipped)
|
||||
assert.Equal(t, testCase.wantIDs, ids)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,8 +114,8 @@ def pytest_addoption(parser: pytest.Parser):
|
||||
help="clickhouse version",
|
||||
)
|
||||
parser.addoption(
|
||||
"--schema-migrator-version",
|
||||
"--signoz-otel-collector-version",
|
||||
action="store",
|
||||
default="v0.144.15",
|
||||
help="schema migrator version",
|
||||
default="v0.144.16-rc.1",
|
||||
help="signoz-otel-collector version, used to run clickhouse migrations",
|
||||
)
|
||||
|
||||
2
tests/fixtures/metrics.py
vendored
2
tests/fixtures/metrics.py
vendored
@@ -990,7 +990,7 @@ def insert_reduced_metrics_to_clickhouse(
|
||||
) -> None:
|
||||
"""Insert reduced series into distributed_time_series_v4_reduced and 60s
|
||||
buckets into the reduced samples tables. These tables exist only when
|
||||
the schema migrator version includes the metrics cardinality-control
|
||||
the signoz-otel-collector version includes the metrics cardinality-control
|
||||
migration."""
|
||||
if time_series:
|
||||
conn.insert(
|
||||
|
||||
39
tests/fixtures/migrator.py
vendored
39
tests/fixtures/migrator.py
vendored
@@ -19,37 +19,28 @@ def create_migrator( # pylint: disable=too-many-arguments,too-many-positional-a
|
||||
"""
|
||||
Factory function for running schema migrations. Accepts optional
|
||||
env_overrides to customize the migrator environment; the release comes
|
||||
from the --schema-migrator-version option.
|
||||
from the --signoz-otel-collector-version option.
|
||||
"""
|
||||
|
||||
def create() -> None:
|
||||
migrator_version = request.config.getoption("--schema-migrator-version")
|
||||
collector_version = request.config.getoption("--signoz-otel-collector-version")
|
||||
client = docker.from_env()
|
||||
|
||||
environment = dict(env_overrides) if env_overrides else {}
|
||||
environment = {
|
||||
"SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN": clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN"],
|
||||
"SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER": "cluster",
|
||||
"SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_REPLICATION": "true",
|
||||
"SIGNOZ_OTEL_COLLECTOR_TIMEOUT": "10m",
|
||||
**(env_overrides or {}),
|
||||
}
|
||||
|
||||
container = client.containers.run(
|
||||
image=f"signoz/signoz-schema-migrator:{migrator_version}",
|
||||
command=f"sync --replication=true --cluster-name=cluster --up= --dsn={clickhouse.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN']}",
|
||||
detach=True,
|
||||
auto_remove=False,
|
||||
network=network.id,
|
||||
environment=environment,
|
||||
)
|
||||
|
||||
result = container.wait()
|
||||
|
||||
if result["StatusCode"] != 0:
|
||||
logs = container.logs().decode(encoding="utf-8")
|
||||
container.remove()
|
||||
print(logs)
|
||||
raise RuntimeError("failed to run migrations on clickhouse")
|
||||
|
||||
container.remove()
|
||||
|
||||
container = client.containers.run(
|
||||
image=f"signoz/signoz-schema-migrator:{migrator_version}",
|
||||
command=f"async --replication=true --cluster-name=cluster --up= --dsn={clickhouse.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN']}",
|
||||
image=f"signoz/signoz-otel-collector:{collector_version}",
|
||||
entrypoint="/bin/sh",
|
||||
command=[
|
||||
"-c",
|
||||
"/signoz-otel-collector migrate bootstrap && /signoz-otel-collector migrate sync up && /signoz-otel-collector migrate async up",
|
||||
],
|
||||
detach=True,
|
||||
auto_remove=False,
|
||||
network=network.id,
|
||||
|
||||
@@ -9,7 +9,12 @@ from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.querier import (
|
||||
BuilderQuery,
|
||||
OrderBy,
|
||||
RequestType,
|
||||
TelemetryFieldKey,
|
||||
assert_identical_query_response,
|
||||
get_rows,
|
||||
make_query_request,
|
||||
)
|
||||
|
||||
@@ -498,3 +503,64 @@ def test_logs_list_with_order_by(
|
||||
ids = [row["data"]["attributes_string"].get("id", "") for row in rows]
|
||||
|
||||
assert ids == expected_order
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"limit,offset",
|
||||
[
|
||||
pytest.param(2, 0, id="first_chunk"),
|
||||
pytest.param(2, 1, id="first_into_second_chunk"),
|
||||
pytest.param(2, 2, id="second_chunk"),
|
||||
pytest.param(2, 3, id="second_into_third_chunk"),
|
||||
pytest.param(2, 4, id="third_chunk"),
|
||||
pytest.param(4, 5, id="last_partial_page"),
|
||||
pytest.param(2, 6, id="offset_past_end"),
|
||||
],
|
||||
)
|
||||
def test_logs_list_offset_across_chunks(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> None:
|
||||
"""
|
||||
A 4h list runs as 1h, 2h and 1h chunks, newest first. Two logs sit in each
|
||||
chunk, so the offset lands in, or crosses into, every chunk.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
minutes_ago = [10, 20, 90, 150, 200, 220]
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=now - timedelta(minutes=minutes),
|
||||
resources={"service.name": "chunked-offset-service"},
|
||||
body=f"chunked-log-{minutes}",
|
||||
)
|
||||
for minutes in minutes_ago
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(hours=4)).timestamp() * 1000),
|
||||
end_ms=int((now + timedelta(seconds=1)).timestamp() * 1000),
|
||||
request_type=RequestType.RAW,
|
||||
queries=[
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
name="A",
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
filter_expression="service.name = 'chunked-offset-service'",
|
||||
order=[OrderBy(TelemetryFieldKey("timestamp"), "desc"), OrderBy(TelemetryFieldKey("id"), "desc")],
|
||||
).to_dict()
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
expected_bodies = [f"chunked-log-{minutes}" for minutes in minutes_ago][offset : offset + limit]
|
||||
assert [row["data"]["body"] for row in get_rows(response)] == expected_bodies
|
||||
|
||||
@@ -917,6 +917,69 @@ def test_traces_list_pagination(
|
||||
assert [row["data"]["span_id"] for row in rows] == expected_ids
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"limit,offset",
|
||||
[
|
||||
pytest.param(2, 0, id="first_chunk"),
|
||||
pytest.param(2, 1, id="first_into_second_chunk"),
|
||||
pytest.param(2, 2, id="second_chunk"),
|
||||
pytest.param(2, 3, id="second_into_third_chunk"),
|
||||
pytest.param(2, 4, id="third_chunk"),
|
||||
pytest.param(4, 5, id="last_partial_page"),
|
||||
pytest.param(2, 6, id="offset_past_end"),
|
||||
],
|
||||
)
|
||||
def test_traces_list_offset_across_chunks(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> None:
|
||||
"""
|
||||
A 4h list runs as 1h, 2h and 1h chunks, newest first. Two spans sit in each
|
||||
chunk, so the offset lands in, or crosses into, every chunk.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
minutes_ago = [10, 20, 90, 150, 200, 220]
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(minutes=minutes),
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name=f"chunked-span-{minutes}",
|
||||
resources={"service.name": "chunked-offset-service"},
|
||||
)
|
||||
for minutes in minutes_ago
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(hours=4)).timestamp() * 1000),
|
||||
end_ms=int((now + timedelta(seconds=1)).timestamp() * 1000),
|
||||
request_type=RequestType.RAW,
|
||||
queries=[
|
||||
BuilderQuery(
|
||||
signal="traces",
|
||||
name="A",
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
filter_expression="service.name = 'chunked-offset-service'",
|
||||
order=[OrderBy(TelemetryFieldKey("timestamp"), "desc")],
|
||||
).to_dict()
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
expected_names = [f"chunked-span-{minutes}" for minutes in minutes_ago][offset : offset + limit]
|
||||
assert [row["data"]["name"] for row in get_rows(response)] == expected_names
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Time window
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user