Compare commits

...

19 Commits

Author SHA1 Message Date
Vinícius Lourenço
aab0d86f24 test(storybook): move the remaining logs, traces and pods plays into their play files 2026-11-13 18:56:17 -03:00
Vinícius Lourenço
27f7e4ef2d test(stories): add sso attribute and claim mapping expanded stories 2026-11-13 18:50:42 -03:00
Vinícius Lourenço
7acebd8e4e test(storybook): collect page banner and notice stories under foundations 2026-11-13 18:50:42 -03:00
Vinícius Lourenço
101dc51eeb test(storybook): add shell banner host stories for fixed footers, drawers and dialogs 2026-11-13 18:50:41 -03:00
Vinícius Lourenço
2de8bc7e66 test(storybook): add shell banner stories for every license, trial and no-auth state 2026-11-13 18:49:42 -03:00
Vinícius Lourenço
542e0bfdd9 test(stories): add query builder add-on stories with few and many values 2026-11-13 18:49:42 -03:00
Vinícius Lourenço
90fdef2991 test(storybook): cover the trace details thread tab 2026-11-13 18:49:42 -03:00
Vinícius Lourenço
0f75302284 test(storybook): cover workspace domain conflict and onboarding facing issues 2026-11-13 18:49:42 -03:00
Vinícius Lourenço
f7e17e55fb test(storybook): cover invite roles and move members plays to a play file 2026-11-13 18:49:41 -03:00
Vinícius Lourenço
d88f8e9b2f test(storybook): add changelog modal stories for cloud and self-hosted 2026-11-13 18:49:41 -03:00
Vinícius Lourenço
0c069c71e7 test(stories): cover the channel edit form, editable and read-only 2026-11-13 18:49:41 -03:00
Vinícius Lourenço
80232c03d6 test(stories): add K8s drawer depth and pods log details stories 2026-11-13 18:49:41 -03:00
Manika Malhotra
b56c2c6350 test(stories): cover the states the component migration changes 2026-11-13 18:49:41 -03:00
Tushar Vats
ca7daf8ea9 fix(querier): skip offset rows without decoding them (#13564)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Raw lists sorted by timestamp (logs: `timestamp, id`) fetch `offset +
limit` rows per chunk and drop the first `offset` in Go. Every dropped
row was first decoded into a `RawRow` (body + attribute maps), so deep
pages loaded every skipped row into memory and could OOM signoz-0.
- `readAsRawSkipping` now steps past the skipped rows with `rows.Next()`
without `Scan`, so they are never decoded. Memory stays at about one
page plus one ClickHouse block.
- `executeWindowList` passes the remaining offset to each chunk and
counts the skipped rows against `need` and `offsetLeft`.

#### Issues closed by this PR

Fixes: SigNoz/events-pod#270
Part of: SigNoz/events-pod#269

#### Additional Information

- Skipped rows still cross the wire. Pushing `OFFSET` into ClickHouse is
point 1 of the issue.
- `TestReadAsRawSkipping` uses `clickhouse-go-mock` rows (v0.15.1,
bumped in SigNoz/signoz#13580), whose `Next()` advances like the real
driver. Each skipped row is marked with `RowError`, so the test fails if
a skipped row is ever scanned.
- New integration tests `test_traces_list_offset_across_chunks` and
`test_logs_list_offset_across_chunks` page a 4h list with offsets
landing in, and crossing into, each of its three time chunks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 19:20:30 +00:00
Swapnil Nakade
d038407475 feat(session): add resource defs to session routes (#13563)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- add resource defs to session routes
- why? - so that auditor can pick up these routes for auditing
- session routes are pre-auth so audit events will be dropped at the
zero org id condition
- rotate and delete session (when session is active) routes are audited

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
contributes to https://github.com/SigNoz/keystone-pod/issues/10
2026-11-13 17:16:26 +00:00
Tushar Vats
da3719ec7e chore(deps): bump clickhouse-go-mock to v0.15.1 (#13580)
#### Description

- Bumps `github.com/SigNoz/clickhouse-go-mock` v0.14.0 → v0.15.1. Its
`Rows.Next()` now advances like the real driver (skipping a row with
`Next()` and no `Scan()` works), and the new `Rows.RowError` fails
`Scan` of a chosen row (SigNoz/clickhouse-go-mock#14).
- v0.15.1 requires clickhouse-go v2.47.0, so this also raises
**production** clickhouse-go 2.44.0 → 2.47.0 and ch-go 0.71.0 → 0.73.0,
with transitive bumps (zap 1.28.0, otelhttp 0.69.0, gopsutil 4.26.5,
lz4, orb, brotli, purego, go-version, go-sysconf, numcpus, plan9stats).
- Kept apart from SigNoz/signoz#13564 so the driver upgrade can be
reverted on its own.

#### Issues closed by this PR

Part of: SigNoz/events-pod#270

#### Additional Information

- clickhouse-go 2.45–2.47: positional binding now skips `?` inside
string literals, quoted identifiers and comments (`--`, `/* */`, `#`)
(ClickHouse/clickhouse-go#1879, #1888). Our builders emit no SQL
comments and no `?` inside quotes, and values inlined by
`pkg/clickhousesql` that contain `?` were previously bound as
placeholders; now they stay literal. `\?` handling is unchanged. Other
fixes: JSON column serialization version (#1850), DateTime/DateTime64
time zones (#1882), connection eviction on wrapped net errors (#1869).
- ch-go 0.73.0 drops the obsolete nested-exception flag
(ClickHouse/ch-go#1159, marked breaking): `proto.Exception.Nested` is
gone. We only use ch-go's error-code constants, and clickhouse-go's own
`Exception` is unchanged between 2.44.0 and 2.47.0.
- Tested: `go build ./...`, `go test ./...` (all pass), `go vet ./...`
(same 38 findings as `main`, none new). Integration suites
`querierlogs`, `queriertraces` and `querier_json_body` against a server
built with these versions: 624 pass (5 first hit ClickHouse memory
limits in the shared local test container while inserting fixtures, and
passed on rerun).
- Follow-up: #13564 switches `TestReadAsRawSkipping` to `cmock` +
`RowError` and drops its mockery driver mocks once this merges.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-11-13 17:13:24 +00:00
Gaurav Tewari
99745b7dba fix(ai-assistant): open explorer chips on the answer's time range (#13363)
#### Description
- Noz's explorer chips carried the query but not its time window, so the
Explorer opened on the current picker value (e.g. Last 6 hours) instead
of the window the answer was computed for.
- Chips now apply the action's `start`/`end` as an absolute range:
off-page via `startTime`/`endTime` on the Explorer URL; on an
already-open Explorer by also setting the global time and dropping the
stale `relativeTime`.
- Without a valid range nothing changes: query and filters hand over,
time stays as is.

#### Issues closed by this PR
Closes SigNoz/nerve-pod#174

#### Screenshots / Screen Recordings
Prev -


https://github.com/user-attachments/assets/9d54125f-ed32-486e-acc2-2d1c59963fa5

 


Now -




https://github.com/user-attachments/assets/1ce75dc0-d62a-430c-b458-ca1036459955




#### Additional Information
- Stacked on #13557, which carries the `redirectWithQueryBuilderData`
change this relies on.
- The Explorers' global time is Redux-only, so `useDispatch` goes behind
the existing `no-restricted-imports` escape hatch.
- Time params are not written with nuqs: the Explorers' time is Redux +
react-router URL writes, and a second nuqs writer adds a history entry
or leaves react-router with a stale `relativeTime` (see
`utils/timeUrlParams.ts`). Once the Explorers move to
`store/globalTime`, the chip can call `setSelectedTime` and drop its URL
handling.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-13 17:10:07 +00:00
Swapnil Nakade
fb60ae1594 refactor: using signoz otel collector image for ch migration (#13517)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- using signoz otel collector image for ch migration
- adding auditquerier in test suite in ci
- added v0.144.16-rc.1 temporarily
2026-11-13 16:53:14 +00:00
Gaurav Tewari
d099467285 feat(query-builder): drop null params and forward navigate options on redirect (#13557)
#### Description
- `redirectWithQueryBuilderData` deletes a search param passed as `null`
instead of writing `"null"`, so one navigation can swap params (e.g.
`relativeTime` for `startTime`/`endTime`).
- Its trailing `newTab` flag becomes `navigateOptions: { newTab, replace
}`, passed straight to `safeNavigate`.

#### Issues closed by this PR

#### Screenshots / Screen Recordings

#### Additional Information
- Split out of #13363. Stacked on #13556 only to keep #13363's diff
clean; it doesn't depend on it.
- The `source: null` reset in `CreateAlertV2/QuerySection` now removes
`source` instead of writing `source=null`; its readers only compare
against `DASHBOARDS`, so behavior is the same.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-13 15:51:53 +00:00
93 changed files with 4131 additions and 1000 deletions

View File

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

View File

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

View File

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

View File

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

View 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.',
};

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

View File

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

View File

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

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

View 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',
]);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -90,7 +90,7 @@ export const useHandleExplorerTabChange = (): {
},
redirectToUrl,
undefined,
newTab,
{ newTab },
);
},
[panelType, getUpdateQuery, redirectWithQueryBuilderData, viewKey],

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -954,7 +954,7 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
},
};
const channelSpec = (
export const channelSpec = (
type: ChannelType,
webhookAuth: WebhookAuthChoice,
jiraDeployment: JiraDeploymentChoice,

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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