mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-11 02:41:04 +00:00
Compare commits
2 Commits
test/compo
...
srikanth/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4fba9b8ad | ||
|
|
d4bd8be09c |
9
.github/CODEOWNERS
vendored
9
.github/CODEOWNERS
vendored
@@ -209,10 +209,6 @@ go.mod @therealpandey
|
||||
/frontend/src/types/api/alerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/providers/Alert.tsx @SigNoz/pulse-frontend
|
||||
/frontend/src/constants/alerts.ts @SigNoz/pulse-frontend
|
||||
/frontend/src/container/PlannedDowntime/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/alerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/Home/AlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/Home/__tests__/AlertRules.test.tsx @SigNoz/pulse-frontend
|
||||
|
||||
## Notification Channels
|
||||
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
|
||||
@@ -221,11 +217,6 @@ go.mod @therealpandey
|
||||
/frontend/src/container/FormAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/notificationChannels/ @SigNoz/pulse-frontend
|
||||
|
||||
## List filter + views (dashboards and alerts lists)
|
||||
/frontend/src/components/ListFilter/ @SigNoz/pulse-frontend
|
||||
/frontend/src/components/ListViews/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/listViews/ @SigNoz/pulse-frontend
|
||||
|
||||
## OpenAPI Schema - Generated
|
||||
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
|
||||
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv
|
||||
|
||||
2
.github/workflows/build-community.yaml
vendored
2
.github/workflows/build-community.yaml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.25
|
||||
GO_VERSION: 1.24
|
||||
GO_NAME: signoz-community
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: community-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
|
||||
2
.github/workflows/build-enterprise.yaml
vendored
2
.github/workflows/build-enterprise.yaml
vendored
@@ -89,7 +89,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.25
|
||||
GO_VERSION: 1.24
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: enterprise-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
GO_BUILD_CONTEXT: ./cmd/enterprise
|
||||
|
||||
2
.github/workflows/build-staging.yaml
vendored
2
.github/workflows/build-staging.yaml
vendored
@@ -95,7 +95,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.25
|
||||
GO_VERSION: 1.24
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: staging-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
GO_BUILD_CONTEXT: ./cmd/enterprise
|
||||
|
||||
20
.github/workflows/goci.yaml
vendored
20
.github/workflows/goci.yaml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_TEST_CONTEXT: ./...
|
||||
GO_VERSION: 1.25
|
||||
GO_VERSION: 1.24
|
||||
fmt:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.25
|
||||
GO_VERSION: 1.24
|
||||
lint:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.25
|
||||
GO_VERSION: 1.24
|
||||
deps:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.25
|
||||
GO_VERSION: 1.24
|
||||
semconv-generated:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.24"
|
||||
- name: check-semconv-generated-files
|
||||
run: make semconv-check
|
||||
build:
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.24"
|
||||
- name: qemu-install
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: aarch64-install
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.24"
|
||||
- name: generate-openapi
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate openapi
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.24"
|
||||
- name: generate-authz
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate authz
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.24"
|
||||
- name: generate-web-settings
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate config web-settings
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.24"
|
||||
- name: generate-transaction-groups
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate config transaction-groups
|
||||
|
||||
4
.github/workflows/gor-signoz-community.yaml
vendored
4
.github/workflows/gor-signoz-community.yaml
vendored
@@ -66,7 +66,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.24"
|
||||
- name: cross-compilation-tools
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.24"
|
||||
|
||||
# copy the caches from build
|
||||
- name: get-sha
|
||||
|
||||
4
.github/workflows/gor-signoz.yaml
vendored
4
.github/workflows/gor-signoz.yaml
vendored
@@ -75,7 +75,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.24"
|
||||
- name: cross-compilation-tools
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.24"
|
||||
|
||||
# copy the caches from build
|
||||
- name: get-sha
|
||||
|
||||
@@ -10231,10 +10231,8 @@ components:
|
||||
having:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5Having'
|
||||
description: A filter on the formula values, referencing the formula by
|
||||
its name or as __result, for example F1 > 0. A time series keeps only
|
||||
the points that match and drops a series with none left; a scalar result
|
||||
keeps only the rows that match.
|
||||
description: Accepted but currently ignored; the computed values are returned
|
||||
unfiltered.
|
||||
legend:
|
||||
description: The display name template for each series; {{field}} placeholders
|
||||
are replaced by label values.
|
||||
@@ -32731,305 +32729,6 @@ paths:
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_anomaly_request_rate:
|
||||
description: 'anomaly is the one function with named arguments. seasonality
|
||||
says which past periods predict the current value: hourly, daily
|
||||
or weekly, daily when absent. z_score_threshold is how many standard
|
||||
deviations from the prediction count as an anomaly. It has no default:
|
||||
a request without it gets bounds of zero width, so send it. 3 is
|
||||
the value the UI sends. The response carries, next to the values,
|
||||
a predicted series, an upper and a lower bound series and an anomaly
|
||||
score series. The scoring runs in the enterprise build; the community
|
||||
build returns the series unchanged.'
|
||||
summary: 'anomaly: request rate scored against its daily pattern'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: http.server.duration.count
|
||||
spaceAggregation: sum
|
||||
timeAggregation: rate
|
||||
functions:
|
||||
- args:
|
||||
- name: seasonality
|
||||
value: daily
|
||||
- name: z_score_threshold
|
||||
value: 3
|
||||
name: anomaly
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: service.name
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 300s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_cumulativesum_errors_so_far:
|
||||
description: cumulativeSum replaces each point with the sum of every
|
||||
point up to and including it, so a per-step count becomes a running
|
||||
total whose first point is the first count. It takes no arguments.
|
||||
The total resets with the window, so a longer time range gives a
|
||||
larger final value; it is a view of the window, not a counter.
|
||||
summary: 'cumulativeSum: 5xx responses since the start of the window'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- alias: server_errors
|
||||
expression: count()
|
||||
filter:
|
||||
expression: http.response.status_code >= 500
|
||||
functions:
|
||||
- name: cumulativeSum
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: service.name
|
||||
name: A
|
||||
signal: traces
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_cutoffmin_hide_idle_hosts:
|
||||
description: The four threshold functions take one number. cutOffMin
|
||||
and cutOffMax remove the points below or above the threshold, so
|
||||
an idle host disappears from the chart instead of drawing a line
|
||||
along the bottom. clampMin and clampMax keep the points and replace
|
||||
the value with the threshold, which caps spikes without hiding them.
|
||||
system.cpu.utilization is a ratio, so 0.05 is 5%.
|
||||
summary: 'cutOffMin: drop hosts below 5% CPU from the chart'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: system.cpu.utilization
|
||||
spaceAggregation: avg
|
||||
timeAggregation: avg
|
||||
functions:
|
||||
- args:
|
||||
- value: 0.05
|
||||
name: cutOffMin
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: host.name
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_ewma_smoothed_latency:
|
||||
description: 'ewma3, ewma5 and ewma7 replace each point with an exponentially
|
||||
weighted average of the point and the points before it. The number
|
||||
in the name is the window the default weight is derived from: alpha
|
||||
= 2 / (n + 1), so 0.5, 0.333 and 0.25. One optional argument overrides
|
||||
alpha; a value close to 1 follows the raw series, a value close
|
||||
to 0 smooths harder. 0.3 here smooths a little more than ewma5''s
|
||||
default.'
|
||||
summary: 'ewma5: smoothed p95 latency'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: http.server.duration.bucket
|
||||
spaceAggregation: p95
|
||||
functions:
|
||||
- args:
|
||||
- value: 0.3
|
||||
name: ewma5
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: service.name
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_fillzero_sparse_errors:
|
||||
description: A count over sparse events returns a point only for steps
|
||||
that had a match, so a chart of failed spans has gaps where nothing
|
||||
failed. fillZero adds a zero at every missing step. With no arguments
|
||||
the server fills the request window at the query's step, which is
|
||||
the common case. The three optional arguments are start, end and
|
||||
step in milliseconds, for callers that want to fill a different
|
||||
grid.
|
||||
summary: 'fillZero: a zero at every step with no failed spans'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- alias: failed_spans
|
||||
expression: count()
|
||||
filter:
|
||||
expression: has_error = true
|
||||
functions:
|
||||
- name: fillZero
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: service.name
|
||||
name: A
|
||||
signal: traces
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_log10_network_bytes:
|
||||
description: log2 and log10 replace each value with its logarithm,
|
||||
so a loopback device moving kilobytes and an uplink moving gigabytes
|
||||
fit on one chart. They take no arguments. Zero and negative values
|
||||
have no logarithm and are not drawn. absolute, also without arguments,
|
||||
is the usual companion for a signed series before a logarithm.
|
||||
summary: 'log10: network throughput across devices that differ by
|
||||
orders of magnitude'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: system.network.io
|
||||
spaceAggregation: sum
|
||||
timeAggregation: rate
|
||||
filter:
|
||||
expression: direction = 'transmit'
|
||||
functions:
|
||||
- name: log10
|
||||
groupBy:
|
||||
- fieldContext: attribute
|
||||
name: device
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_median_despiked_request_rate:
|
||||
description: median3, median5 and median7 replace each point with
|
||||
the median of the surrounding 3, 5 or 7 points. A spike that lasts
|
||||
one step vanishes, a shift that lasts longer than half the window
|
||||
stays. The first and last half window, 2 points each for median5,
|
||||
have no full window and keep their values, so the newest spike stays
|
||||
visible; a series shorter than the window is returned unchanged.
|
||||
They take no arguments. Use them when the raw series is noisy and
|
||||
the chart is for a trend, not for catching the spike.
|
||||
summary: 'median5: request rate without one-step spikes'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: http.server.duration.count
|
||||
spaceAggregation: sum
|
||||
timeAggregation: rate
|
||||
functions:
|
||||
- name: median5
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: service.name
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_runningdiff_disk_growth:
|
||||
description: 'runningDiff turns a gauge into the change between consecutive
|
||||
points, so a filesystem usage gauge becomes bytes written per step.
|
||||
It takes no arguments and drops the first point of the window, since
|
||||
that point has nothing to difference against; the server reads one
|
||||
step before the window to keep the first interval. Functions apply
|
||||
in order: clampMin with 0 runs on the differences and replaces the
|
||||
negative value a cleanup or a volume reset produces with zero.'
|
||||
summary: 'runningDiff then clampMin: bytes written to disk per step'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: system.filesystem.usage
|
||||
spaceAggregation: sum
|
||||
timeAggregation: latest
|
||||
filter:
|
||||
expression: state = 'used'
|
||||
functions:
|
||||
- name: runningDiff
|
||||
- args:
|
||||
- value: 0
|
||||
name: clampMin
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: host.name
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_timeshift_week_over_week:
|
||||
description: timeShift moves every point of its query forward by the
|
||||
argument, in seconds, so a series read from the past lands on the
|
||||
current timestamps. B here reads the hour exactly seven days (604800
|
||||
s) before the requested window and comes back on this week's timestamps,
|
||||
so it overlays A on a chart and the formula can subtract it point
|
||||
by point. The argument is positive; it says how far back to read.
|
||||
summary: 'timeShift: this week''s error logs next to last week''s'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- alias: errors
|
||||
expression: count()
|
||||
filter:
|
||||
expression: severity_text = 'ERROR'
|
||||
name: A
|
||||
signal: logs
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
- spec:
|
||||
aggregations:
|
||||
- alias: errors
|
||||
expression: count()
|
||||
filter:
|
||||
expression: severity_text = 'ERROR'
|
||||
functions:
|
||||
- args:
|
||||
- value: 604800
|
||||
name: timeShift
|
||||
name: B
|
||||
signal: logs
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
- spec:
|
||||
expression: A - B
|
||||
name: F1
|
||||
type: builder_formula
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
logs_raw:
|
||||
summary: 'Raw: fetch raw log records'
|
||||
value:
|
||||
|
||||
@@ -7079,7 +7079,7 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
*/
|
||||
functions?: Querybuildertypesv5FunctionDTO[] | null;
|
||||
/**
|
||||
* @description A filter on the formula values, referencing the formula by its name or as __result, for example F1 > 0. A time series keeps only the points that match and drops a series with none left; a scalar result keeps only the rows that match.
|
||||
* @description Accepted but currently ignored; the computed values are returned unfiltered.
|
||||
*/
|
||||
having?: Querybuildertypesv5HavingDTO;
|
||||
/**
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
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.',
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
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(),
|
||||
);
|
||||
};
|
||||
@@ -98,15 +98,6 @@ export const noSavedFiltersHandler = rest.get(
|
||||
),
|
||||
);
|
||||
|
||||
export const noSavedTracesFiltersHandler = rest.get(
|
||||
'http://localhost/api/v2/quick_filters/traces',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(quickFiltersResponse(QuickfiltertypesSourceDTO.traces, [])),
|
||||
),
|
||||
);
|
||||
|
||||
export const loadingFiltersHandlers = [
|
||||
rest.get('http://localhost/api/v2/quick_filters/logs', (_req, res, ctx) =>
|
||||
res(ctx.delay('infinite')),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -17,9 +16,8 @@ import {
|
||||
queryBuilder,
|
||||
selectedServiceQueryBuilder,
|
||||
noSavedFiltersHandler,
|
||||
noSavedTracesFiltersHandler,
|
||||
} from './QuickFilters.stories.mocks';
|
||||
import { FiltersType, QuickFiltersSource } from '../types';
|
||||
import { QuickFiltersSource } from '../types';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Quick Filters',
|
||||
@@ -112,29 +110,6 @@ 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
|
||||
|
||||
@@ -21,15 +21,14 @@ import {
|
||||
useListDashboardsForUserV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import {
|
||||
getListRulesV3QueryKey,
|
||||
useListRulesV3,
|
||||
getListRulesQueryKey,
|
||||
useListRules,
|
||||
} from 'api/generated/services/rules';
|
||||
import type {
|
||||
DashboardtypesListedDashboardForUserV2DTO,
|
||||
ListDashboardsForUserV2200,
|
||||
ListDashboardsForUserV2Params,
|
||||
ListRulesV3200,
|
||||
ListRulesV3Params,
|
||||
ListRules200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useQueryService } from 'hooks/useQueryService';
|
||||
@@ -180,9 +179,6 @@ const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
|
||||
*/
|
||||
const DASHBOARD_LIST_PARAMS: ListDashboardsForUserV2Params = { limit: 1000 };
|
||||
|
||||
/** Same as dashboards. 200 is the most the rules list returns per call. */
|
||||
const ALERT_RULE_LIST_PARAMS: ListRulesV3Params = { limit: 200 };
|
||||
|
||||
function dashboardTitle(
|
||||
dashboard: DashboardtypesListedDashboardForUserV2DTO,
|
||||
): string {
|
||||
@@ -759,7 +755,7 @@ export default function ChatInput({
|
||||
data: alertsResponse,
|
||||
isLoading: isAlertsLoading,
|
||||
isError: isAlertsError,
|
||||
} = useListRulesV3(ALERT_RULE_LIST_PARAMS, {
|
||||
} = useListRules({
|
||||
query: {
|
||||
enabled: isContextPickerOpen && activeContextCategory === 'Alerts',
|
||||
staleTime: Infinity,
|
||||
@@ -808,10 +804,10 @@ export default function ChatInput({
|
||||
}
|
||||
}
|
||||
if (ctx.type === 'alert' && ctx.resourceId) {
|
||||
const cached = queryClient.getQueryData<ListRulesV3200>(
|
||||
getListRulesV3QueryKey(ALERT_RULE_LIST_PARAMS),
|
||||
const cached = queryClient.getQueryData<ListRules200>(
|
||||
getListRulesQueryKey(),
|
||||
);
|
||||
const rule = cached?.data.rules.find((r) => r.id === ctx.resourceId);
|
||||
const rule = cached?.data?.find((r) => r.id === ctx.resourceId);
|
||||
if (rule?.alert) {
|
||||
return rule.alert;
|
||||
}
|
||||
@@ -839,8 +835,8 @@ export default function ChatInput({
|
||||
value: dashboardTitle(dashboard),
|
||||
})) ?? [],
|
||||
Alerts:
|
||||
alertsResponse?.data.rules
|
||||
.filter((alertRule) => Boolean(alertRule.alert))
|
||||
alertsResponse?.data
|
||||
?.filter((alertRule) => Boolean(alertRule.alert))
|
||||
.map((alertRule) => ({
|
||||
id: alertRule.id,
|
||||
value: alertRule.alert,
|
||||
@@ -1063,7 +1059,6 @@ export default function ChatInput({
|
||||
color="secondary"
|
||||
size="sm"
|
||||
id={`ai-context-tab-${category}`}
|
||||
testId={`ai-context-tab-${category}`}
|
||||
// Single stable panel id shared by every tab: only the
|
||||
// active category's tabpanel is rendered, so per-category
|
||||
// `aria-controls` ids would point at nonexistent nodes
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import {
|
||||
alertRulesFixture,
|
||||
listRulesV3Response,
|
||||
} from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useListDashboardsForUserV2: (): unknown => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
getListDashboardsForUserV2QueryKey: (): string[] => ['dashboards'],
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useQueryService', () => ({
|
||||
useQueryService: (): unknown => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/useSpeechRecognition', () => ({
|
||||
useSpeechRecognition: (): unknown => ({
|
||||
isListening: false,
|
||||
isSupported: false,
|
||||
permission: 'prompt',
|
||||
start: jest.fn(),
|
||||
discard: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
|
||||
useAIAssistantAnalyticsContext: (): unknown => ({
|
||||
threadId: undefined,
|
||||
page: '/',
|
||||
mode: 'sidepane',
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import ChatInput from '../ChatInput';
|
||||
|
||||
describe('ChatInput — alerts context picker', () => {
|
||||
it('lists the rules from one v3 rules request', async () => {
|
||||
const [first, second] = alertRulesFixture;
|
||||
const requests: string[] = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
requests.push(req.url.search);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(listRulesV3Response([first, second], req.url.searchParams)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ChatInput onSend={jest.fn()} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
|
||||
await userEvent.click(await screen.findByTestId('ai-context-tab-Alerts'));
|
||||
|
||||
await expect(screen.findByText(first.alert)).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText(second.alert)).toBeInTheDocument();
|
||||
expect(requests).toStrictEqual(['?limit=200']);
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,8 @@ jest.mock('api/generated/services/dashboard', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/rules', () => ({
|
||||
useListRulesV3: (): unknown => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
getListRulesV3QueryKey: (): string[] => ['rules'],
|
||||
useListRules: (): unknown => ({ data: [], isLoading: false, isError: false }),
|
||||
getListRulesQueryKey: (): string[] => ['rules'],
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useQueryService', () => ({
|
||||
|
||||
@@ -18,7 +18,7 @@ import { buildCreateAlertRulePayload, validateCreateAlertState } from './utils';
|
||||
import './styles.scss';
|
||||
import {
|
||||
invalidateGetRuleByID,
|
||||
invalidateListRulesV3,
|
||||
invalidateListRules,
|
||||
} from 'api/generated/services/rules';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
@@ -137,7 +137,7 @@ function Footer(): JSX.Element {
|
||||
{
|
||||
onSuccess: () => {
|
||||
void invalidateGetRuleByID(queryClient, { id: ruleId });
|
||||
void invalidateListRulesV3(queryClient);
|
||||
void invalidateListRules(queryClient);
|
||||
|
||||
toast.success('Alert rule updated successfully');
|
||||
safeNavigate('/alerts');
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { RuletypesListableRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useListRules } from 'api/generated/services/rules';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { AppLink } from 'lib/router/AppLink';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import beaconUrl from '@/assets/Icons/beacon.svg';
|
||||
|
||||
import { getItemIcon } from '../constants';
|
||||
import { useHomeAlertRules } from './useHomeAlertRules';
|
||||
|
||||
export default function AlertRules({
|
||||
onUpdateChecklistDoneItem,
|
||||
@@ -26,19 +28,49 @@ export default function AlertRules({
|
||||
loadingUserPreferences: boolean;
|
||||
}): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const [rulesExist, setRulesExist] = useState(false);
|
||||
|
||||
const [sortedAlertRules, setSortedAlertRules] = useState<RuletypesRuleDTO[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const location = useAppLocation();
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
// Fetch Alerts
|
||||
const { rules: alertRules, isError, isLoading } = useHomeAlertRules();
|
||||
const rulesExist = alertRules.length > 0;
|
||||
const {
|
||||
data: alerts,
|
||||
isError,
|
||||
isLoading,
|
||||
} = useListRules({
|
||||
query: { cacheTime: 0 },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (rulesExist && !loadingUserPreferences) {
|
||||
const rules = alerts?.data ?? [];
|
||||
setRulesExist(rules.length > 0);
|
||||
|
||||
const sortedRules = [...rules].sort((a, b) => {
|
||||
// First, prioritize firing alerts
|
||||
if (a.state === 'firing' && b.state !== 'firing') {
|
||||
return -1;
|
||||
}
|
||||
if (a.state !== 'firing' && b.state === 'firing') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Then sort by updatedAt timestamp
|
||||
return (
|
||||
new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
if (sortedRules.length > 0 && !loadingUserPreferences) {
|
||||
onUpdateChecklistDoneItem('SETUP_ALERTS');
|
||||
}
|
||||
}, [rulesExist, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
|
||||
setSortedAlertRules(sortedRules.slice(0, 5));
|
||||
}, [alerts, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
|
||||
const emptyStateCard = (): JSX.Element => (
|
||||
<div className="empty-state-container">
|
||||
@@ -63,7 +95,7 @@ export default function AlertRules({
|
||||
className="periscope-btn secondary"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={(): void => {
|
||||
void logEvent('Homepage: Create alert rule clicked', {});
|
||||
logEvent('Homepage: Create alert rule clicked', {});
|
||||
}}
|
||||
>
|
||||
Create Alert Rule
|
||||
@@ -74,7 +106,7 @@ export default function AlertRules({
|
||||
type="link"
|
||||
className="learn-more-link"
|
||||
onClick={(): void => {
|
||||
void logEvent('Homepage: Learn more clicked', {
|
||||
logEvent('Homepage: Learn more clicked', {
|
||||
source: 'Alert Rules',
|
||||
});
|
||||
|
||||
@@ -93,13 +125,26 @@ export default function AlertRules({
|
||||
</div>
|
||||
);
|
||||
|
||||
const onEditHandler = (record: RuletypesListableRuleDTO) => (): void => {
|
||||
void logEvent('Homepage: Alert clicked', {
|
||||
const onEditHandler = (record: RuletypesRuleDTO) => (): void => {
|
||||
logEvent('Homepage: Alert clicked', {
|
||||
ruleId: record.id,
|
||||
ruleName: record.alert,
|
||||
ruleState: record.state,
|
||||
});
|
||||
|
||||
const compositeQuery = mapQueryDataFromApi(
|
||||
toCompositeMetricQuery(record.condition.compositeQuery),
|
||||
);
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
const panelType = record.condition.compositeQuery.panelType;
|
||||
if (panelType) {
|
||||
params.set(QueryParams.panelTypes, panelType);
|
||||
}
|
||||
|
||||
params.set(QueryParams.ruleId, record.id);
|
||||
|
||||
navigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
|
||||
@@ -108,12 +153,11 @@ export default function AlertRules({
|
||||
const renderAlertRules = (): JSX.Element => (
|
||||
<div className="alert-rules-container home-data-item-container">
|
||||
<div className="alert-rules-list">
|
||||
{alertRules.map((rule) => (
|
||||
{sortedAlertRules.map((rule) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="alert-rule-item home-data-item"
|
||||
data-testid={`home-alert-rule-${rule.id}`}
|
||||
key={rule.id}
|
||||
onClick={onEditHandler(rule)}
|
||||
onKeyDown={(e): void => {
|
||||
@@ -195,7 +239,7 @@ export default function AlertRules({
|
||||
type="link"
|
||||
className="periscope-btn link learn-more-link"
|
||||
onClick={(): void => {
|
||||
void logEvent('Homepage: All alert rules clicked', {});
|
||||
logEvent('Homepage: All alert rules clicked', {});
|
||||
}}
|
||||
>
|
||||
All Alert Rules <ArrowRight size={12} />
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useListRulesV3 } from 'api/generated/services/rules';
|
||||
import {
|
||||
type ListRulesV3200,
|
||||
type ListRulesV3Params,
|
||||
RuletypesAlertStateDTO,
|
||||
type RuletypesListableRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
|
||||
|
||||
const HOME_ALERT_RULES_LIMIT = 5;
|
||||
|
||||
const FIRING_RULES_PARAMS: ListRulesV3Params = {
|
||||
states: [RuletypesAlertStateDTO.firing],
|
||||
limit: HOME_ALERT_RULES_LIMIT,
|
||||
};
|
||||
|
||||
interface HomeAlertRules {
|
||||
rules: RuletypesListableRuleDTO[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export function useHomeAlertRules(): HomeAlertRules {
|
||||
const firing = useListRulesV3(FIRING_RULES_PARAMS, {
|
||||
query: {
|
||||
cacheTime: 0,
|
||||
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
|
||||
queryFn: ({ signal }) =>
|
||||
GeneratedAPIInstance<ListRulesV3200>({
|
||||
url: '/api/v3/rules',
|
||||
method: 'GET',
|
||||
params: FIRING_RULES_PARAMS,
|
||||
paramsSerializer: { indexes: null },
|
||||
signal,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const firingRules = firing.data?.data.rules;
|
||||
const latest = useListRulesV3(
|
||||
{ limit: HOME_ALERT_RULES_LIMIT },
|
||||
{
|
||||
query: {
|
||||
cacheTime: 0,
|
||||
enabled:
|
||||
firingRules !== undefined && firingRules.length < HOME_ALERT_RULES_LIMIT,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const rules = useMemo(() => {
|
||||
const firingIds = new Set(firingRules?.map(({ id }) => id));
|
||||
const others = (latest.data?.data.rules ?? []).filter(
|
||||
({ id }) => !firingIds.has(id),
|
||||
);
|
||||
return [...(firingRules ?? []), ...others].slice(0, HOME_ALERT_RULES_LIMIT);
|
||||
}, [firingRules, latest.data]);
|
||||
|
||||
return {
|
||||
rules,
|
||||
isLoading: firing.isLoading || latest.isLoading,
|
||||
isError: firing.isError || latest.isError,
|
||||
};
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import {
|
||||
RuletypesAlertStateDTO,
|
||||
type RuletypesRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import {
|
||||
alertRulesFixture,
|
||||
listRulesV3Response,
|
||||
} from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import AlertRules from '../AlertRules/AlertRules';
|
||||
|
||||
jest.mock('lib/router/navigation', () => ({
|
||||
...jest.requireActual('lib/router/navigation'),
|
||||
navigate: jest.fn(),
|
||||
}));
|
||||
|
||||
const rule = (id: string, state: RuletypesAlertStateDTO): RuletypesRuleDTO => ({
|
||||
...alertRulesFixture[0],
|
||||
id,
|
||||
alert: `${id} rule`,
|
||||
state,
|
||||
});
|
||||
|
||||
const burning = rule('burning', RuletypesAlertStateDTO.firing);
|
||||
const quiet = rule('quiet', RuletypesAlertStateDTO.inactive);
|
||||
const waiting = rule('waiting', RuletypesAlertStateDTO.pending);
|
||||
|
||||
const serveRules = (
|
||||
rules: RuletypesRuleDTO[],
|
||||
rulesForLatest = rules,
|
||||
): string[] => {
|
||||
const requestedStates: string[] = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
const states = req.url.searchParams.getAll('states').join();
|
||||
requestedStates.push(states);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
listRulesV3Response(
|
||||
states === 'firing' ? rules : rulesForLatest,
|
||||
req.url.searchParams,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
return requestedStates;
|
||||
};
|
||||
|
||||
const renderAlertRules = (): void => {
|
||||
render(
|
||||
<AlertRules
|
||||
onUpdateChecklistDoneItem={jest.fn()}
|
||||
loadingUserPreferences={false}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
const renderedRuleIds = (): (string | undefined)[] =>
|
||||
screen.getAllByTestId(/^home-alert-rule-/).map((row) => row.dataset.testid);
|
||||
|
||||
describe('Home alert rules', () => {
|
||||
it('lists firing rules first, then the latest others, and opens the clicked rule', async () => {
|
||||
const requestedStates = serveRules([quiet, burning, waiting]);
|
||||
renderAlertRules();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('home-alert-rule-waiting'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(renderedRuleIds()).toStrictEqual([
|
||||
'home-alert-rule-burning',
|
||||
'home-alert-rule-quiet',
|
||||
'home-alert-rule-waiting',
|
||||
]);
|
||||
expect(requestedStates).toStrictEqual(['firing', '']);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(screen.getByTestId('home-alert-rule-burning'));
|
||||
expect(navigate).toHaveBeenCalledWith(
|
||||
`${ROUTES.ALERT_OVERVIEW}?${QueryParams.ruleId}=burning`,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips the latest rules request when 5 rules are firing', async () => {
|
||||
const firing = Array.from({ length: 5 }, (_, index) =>
|
||||
rule(`firing-${index}`, RuletypesAlertStateDTO.firing),
|
||||
);
|
||||
const requestedStates = serveRules([quiet, ...firing]);
|
||||
renderAlertRules();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('home-alert-rule-firing-4'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(renderedRuleIds()).toStrictEqual(
|
||||
firing.map(({ id }) => `home-alert-rule-${id}`),
|
||||
);
|
||||
expect(requestedStates).toStrictEqual(['firing']);
|
||||
});
|
||||
|
||||
it('shows a rule once when it stops firing between the two requests', async () => {
|
||||
const settled = { ...burning, state: RuletypesAlertStateDTO.inactive };
|
||||
serveRules([burning], [settled, quiet]);
|
||||
renderAlertRules();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('home-alert-rule-quiet'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(renderedRuleIds()).toStrictEqual([
|
||||
'home-alert-rule-burning',
|
||||
'home-alert-rule-quiet',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -16,42 +16,13 @@ import {
|
||||
} from './K8sBaseDetails.stories.mocks';
|
||||
import type { K8sBaseDetailsProps } from './types';
|
||||
import {
|
||||
DrawerHistoryEntry,
|
||||
getDrawerHistoryDepth,
|
||||
useDrawerHistoryStore,
|
||||
pushDrawerHistory,
|
||||
resetDrawerHistory,
|
||||
} from './useDrawerHistoryStore';
|
||||
|
||||
type DrawerArgs = PageStoryArgs<typeof drawerMocks> &
|
||||
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,
|
||||
}));
|
||||
K8sBaseDetailsProps<unknown>;
|
||||
|
||||
const route = `/infrastructure-monitoring/kubernetes?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=pods&${INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM}=${POD_UID}`;
|
||||
|
||||
@@ -62,13 +33,19 @@ const meta = {
|
||||
decorators: [
|
||||
(Story, context): JSX.Element => {
|
||||
// The back control appears only for a resource opened from another
|
||||
const steps =
|
||||
context.args.earlierSteps ?? (context.args.openedFromANode ? 1 : 0);
|
||||
if (getDrawerHistoryDepth() !== steps) {
|
||||
useDrawerHistoryStore.setState({
|
||||
entries: TRAIL.slice(TRAIL.length - steps),
|
||||
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',
|
||||
});
|
||||
}
|
||||
if (!context.args.openedFromANode && depth > 0) {
|
||||
resetDrawerHistory();
|
||||
}
|
||||
|
||||
return <Story />;
|
||||
},
|
||||
@@ -83,19 +60,3 @@ 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,
|
||||
};
|
||||
|
||||
@@ -16,11 +16,11 @@ import {
|
||||
useDeleteDowntimeScheduleByID,
|
||||
useListDowntimeSchedules,
|
||||
} from 'api/generated/services/downtimeschedules';
|
||||
import { useListRules } from 'api/generated/services/rules';
|
||||
import type { AlertmanagertypesPlannedMaintenanceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ColumnSelector } from 'components/TanStackTableView';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import dayjs from 'dayjs';
|
||||
import { useListAllRules } from 'hooks/alerts/useListAllRules';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
@@ -55,7 +55,9 @@ export function PlannedDowntime(): JSX.Element {
|
||||
isError,
|
||||
isLoading,
|
||||
isFetching: isFetchingAlerts,
|
||||
} = useListAllRules({ cacheTime: 0 });
|
||||
} = useListRules({
|
||||
query: { cacheTime: 0 },
|
||||
});
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const { user } = useAppContext();
|
||||
@@ -71,7 +73,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
const downtimeSchedules = useListDowntimeSchedules();
|
||||
const alertOptions = React.useMemo(
|
||||
() =>
|
||||
alertsData?.map((i) => ({
|
||||
alertsData?.data?.map((i) => ({
|
||||
label: i.alert,
|
||||
value: i.id,
|
||||
})),
|
||||
@@ -179,7 +181,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
};
|
||||
|
||||
const refetchAllSchedules = (): void => {
|
||||
void downtimeSchedules.refetch();
|
||||
downtimeSchedules.refetch();
|
||||
};
|
||||
|
||||
const {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { fireEvent, screen } from '@testing-library/react';
|
||||
import type {
|
||||
ListDowntimeSchedules200,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesListableRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AlertmanagertypesRepeatOnDTO,
|
||||
@@ -127,19 +126,19 @@ jest.mock('api/generated/services/downtimeschedules', () => ({
|
||||
}));
|
||||
|
||||
let mockRulesState: {
|
||||
data?: Pick<RuletypesListableRuleDTO, 'id' | 'alert'>[];
|
||||
data?: { data: Array<{ id: number | string; alert: string }> };
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
} = {
|
||||
data: [],
|
||||
data: { data: [] },
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
};
|
||||
|
||||
jest.mock('hooks/alerts/useListAllRules', () => ({
|
||||
useListAllRules: (): typeof mockRulesState => mockRulesState,
|
||||
jest.mock('api/generated/services/rules', () => ({
|
||||
useListRules: (): typeof mockRulesState => mockRulesState,
|
||||
}));
|
||||
|
||||
describe('PlannedDowntime Component', () => {
|
||||
@@ -152,7 +151,7 @@ describe('PlannedDowntime Component', () => {
|
||||
});
|
||||
mockDowntimeQueryResult.data = MOCK_DOWNTIME_RESPONSE;
|
||||
mockRulesState = {
|
||||
data: [],
|
||||
data: { data: [] },
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
@@ -363,7 +362,7 @@ describe('PlannedDowntime Component', () => {
|
||||
|
||||
it('shows "Unknown rule" when scoped rule IDs cannot be resolved (Bug 56)', async () => {
|
||||
mockRulesState = {
|
||||
data: [],
|
||||
data: { data: [] },
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
@@ -388,7 +387,14 @@ describe('PlannedDowntime Component', () => {
|
||||
|
||||
it('shows resolved alert rule name when scoped rule ID exists', async () => {
|
||||
mockRulesState = {
|
||||
data: [{ id: 'rule-42', alert: 'Disk Space High' }],
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: 'rule-42',
|
||||
alert: 'Disk Space High',
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { tags } from '@lezer/highlight';
|
||||
import { copilotDarkStyle } from '@uiw/codemirror-theme-copilot';
|
||||
import { githubLightStyle } from '@uiw/codemirror-theme-github';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { act, render, screen } from 'tests/test-utils';
|
||||
|
||||
import SavedViewHighlightedExpression from '../SavedViewHighlightedExpression';
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseIsDarkMode = useIsDarkMode as jest.MockedFunction<
|
||||
typeof useIsDarkMode
|
||||
>;
|
||||
|
||||
const EXPRESSION = "service.name = 'frontend'";
|
||||
|
||||
function stringColor(styles: typeof copilotDarkStyle): string {
|
||||
return (styles ?? []).find(({ tag }) =>
|
||||
(Array.isArray(tag) ? tag : [tag]).includes(tags.string),
|
||||
)?.color as string;
|
||||
}
|
||||
|
||||
describe('SavedViewHighlightedExpression', () => {
|
||||
it.each([
|
||||
['dark', true, copilotDarkStyle],
|
||||
['light', false, githubLightStyle],
|
||||
])('colours a string value with the %s theme', (_, isDarkMode, styles) => {
|
||||
mockUseIsDarkMode.mockReturnValue(isDarkMode);
|
||||
|
||||
render(<SavedViewHighlightedExpression expression={EXPRESSION} />);
|
||||
|
||||
expect(screen.getByText("'frontend'")).toHaveStyle({
|
||||
color: stringColor(styles),
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the new theme colours when the theme changes', () => {
|
||||
let setIsDarkMode: (isDarkMode: boolean) => void = () => {};
|
||||
function useSwitchableDarkMode(): boolean {
|
||||
const [isDarkMode, setter] = useState(true);
|
||||
setIsDarkMode = setter;
|
||||
return isDarkMode;
|
||||
}
|
||||
mockUseIsDarkMode.mockImplementation(useSwitchableDarkMode);
|
||||
render(<SavedViewHighlightedExpression expression={EXPRESSION} />);
|
||||
|
||||
act(() => setIsDarkMode(false));
|
||||
|
||||
expect(screen.getByText("'frontend'")).toHaveStyle({
|
||||
color: stringColor(githubLightStyle),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,19 +5,11 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import {
|
||||
AuthtypesTransactionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { userEvent } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -230,31 +222,6 @@ describe('SavedViewHoverCard', () => {
|
||||
|
||||
expect(card()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not open when the row menu is pressed before the delay', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
await renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS / 2);
|
||||
await user.click(within(row('Errors')).getByTestId('saved-views-row-menu'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
|
||||
expect(card()).toBeNull();
|
||||
});
|
||||
|
||||
it('closes on escape, leaving nothing of the popover behind', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
await renderPanel();
|
||||
await openCard('Errors');
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
expect(card()).toBeNull();
|
||||
expect(
|
||||
document.querySelector('[data-radix-popper-content-wrapper]'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('content', () => {
|
||||
@@ -345,78 +312,6 @@ describe('SavedViewHoverCard', () => {
|
||||
await waitFor(() => expect(fetches).toBe(2));
|
||||
});
|
||||
|
||||
it('shows a skeleton, not the failure, while the view loads', async () => {
|
||||
let requested = false;
|
||||
let release: () => void = () => undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, async (_req, res, ctx) => {
|
||||
requested = true;
|
||||
await gate;
|
||||
return res(ctx.json({ status: 'success', data: errors }));
|
||||
}),
|
||||
);
|
||||
await renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
await waitFor(() => expect(requested).toBe(true));
|
||||
|
||||
expect(
|
||||
screen.getByTestId('saved-view-hover-card-loading'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-view-hover-card-error')).toBeNull();
|
||||
|
||||
release();
|
||||
await screen.findByTestId('saved-view-hover-card-query');
|
||||
});
|
||||
|
||||
it('shows a skeleton while the read check is pending', async () => {
|
||||
let release: () => void = () => undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const isRead = (transaction: AuthtypesTransactionDTO): boolean =>
|
||||
transaction.relation === 'read';
|
||||
const isReadAll = (transaction: AuthtypesTransactionDTO): boolean =>
|
||||
isRead(transaction) && transaction.object.selector === '*';
|
||||
// Read on every view would skip the per-view check, so it is denied.
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
if (
|
||||
payload.some(
|
||||
(transaction) => isRead(transaction) && !isReadAll(transaction),
|
||||
)
|
||||
) {
|
||||
await gate;
|
||||
}
|
||||
return res(
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map((transaction) => !isReadAll(transaction)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
await renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('saved-view-hover-card-loading'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-view-hover-card-query')).toBeNull();
|
||||
|
||||
release();
|
||||
await screen.findByTestId('saved-view-hover-card-query');
|
||||
});
|
||||
|
||||
it('says the view failed to load when its fetch fails', async () => {
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) =>
|
||||
|
||||
@@ -339,45 +339,6 @@ describe('SavedViews - AuthZ', () => {
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
|
||||
it('disables save as new without create, keeping update', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(SavedViewCreatePermission));
|
||||
const { history } = renderHeader(viewUrl(PATH, errors));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('Errors'),
|
||||
);
|
||||
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
);
|
||||
await userEvent.click(await screen.findByTestId('saved-views-save-changes'));
|
||||
|
||||
await expect(
|
||||
settledRow('saved-views-save-changes-item-save-as-new'),
|
||||
).resolves.toHaveAttribute('data-disabled');
|
||||
await expect(
|
||||
settledRow('saved-views-save-changes-item-update'),
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
|
||||
it('offers clear on a view it cannot read', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
'No view access',
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says no access when the check denies read, even if the server returns the view', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
|
||||
@@ -3,7 +3,6 @@ import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { userEvent } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
@@ -76,39 +75,4 @@ describe('SavedViewsHeader last used view', () => {
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not bring the view back once it is cleared', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
await waitForView('Errors');
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-clear'));
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
|
||||
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
|
||||
});
|
||||
|
||||
it('forgets a last used view that no longer loads', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) ?? '{}',
|
||||
),
|
||||
).toStrictEqual({}),
|
||||
);
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
});
|
||||
|
||||
it('starts on a bare explorer when the stored last used views are unreadable', async () => {
|
||||
localStorage.setItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS, '{not json');
|
||||
mockSavedViewsApi([errors]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { act, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
@@ -23,7 +19,7 @@ import {
|
||||
urlParam,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
import { API, mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
@@ -47,27 +43,6 @@ function renderHeader(
|
||||
);
|
||||
}
|
||||
|
||||
// Edits the current query without running it, so it is not the staged one.
|
||||
function EditCurrentQuery(): JSX.Element {
|
||||
const { currentQuery, handleSetQueryData } = useQueryBuilder();
|
||||
return (
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
testId="edit-current-query"
|
||||
onClick={(): void =>
|
||||
handleSetQueryData(0, {
|
||||
...currentQuery.builder.queryData[0],
|
||||
filter: { expression: 'unstaged = true' },
|
||||
})
|
||||
}
|
||||
>
|
||||
Edit query
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const modal = (): HTMLElement => screen.getByTestId('save-view-modal');
|
||||
const isModalOpen = (): boolean => !!screen.queryByTestId('save-view-modal');
|
||||
|
||||
@@ -185,76 +160,6 @@ describe('SavedViewsHeader saving', () => {
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not create a view from a blank name submitted with Enter', async () => {
|
||||
const requests = mockSavedViewsApi([]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await clickCreate();
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), ' {enter}');
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
|
||||
|
||||
expect(requests.created).toHaveLength(0);
|
||||
expect(isModalOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores Enter while the view is being saved', async () => {
|
||||
let creates = 0;
|
||||
mockSavedViewsApi([]);
|
||||
server.use(
|
||||
rest.post(API, (_req, res, ctx) => {
|
||||
creates += 1;
|
||||
return res(
|
||||
ctx.delay(400),
|
||||
ctx.status(201),
|
||||
ctx.json({ status: 'success', data: { id: 'view-new' } }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await clickCreate();
|
||||
await submitName('Slow checkout');
|
||||
await waitFor(() => expect(creates).toBe(1));
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), '{enter}');
|
||||
|
||||
await waitFor(() => expect(isModalOpen()).toBe(false));
|
||||
expect(creates).toBe(1);
|
||||
});
|
||||
|
||||
it('saves the query that was run, not edits made since', async () => {
|
||||
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
const { history } = renderWithExplorerProviders(
|
||||
<>
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
/>
|
||||
<EditCurrentQuery />
|
||||
</>,
|
||||
explorerUrl(PATH, {}),
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('edit-current-query'));
|
||||
await clickCreate();
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() => expect(requests.created).toHaveLength(1));
|
||||
const body = requests.created[0] as {
|
||||
spec: { queries: { spec: { filter?: { expression?: string } } }[] };
|
||||
};
|
||||
expect(body.spec.queries[0].spec.filter?.expression ?? '').not.toContain(
|
||||
'unstaged',
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify('view-new'),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
decodeURIComponent(urlParam(history, QueryParams.compositeQuery) ?? ''),
|
||||
).not.toContain('unstaged');
|
||||
});
|
||||
|
||||
it('cancel closes the modal without saving', async () => {
|
||||
const requests = mockSavedViewsApi([]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
@@ -326,37 +231,6 @@ describe('SavedViewsHeader saving', () => {
|
||||
expect(requests.created).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('update that fails reports the error and never says the view was updated', async () => {
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const requests = mockSavedViewsApi([errors], { updateStatus: 500 });
|
||||
await openDirtyView(errors);
|
||||
|
||||
await chooseFromSaveMenu('Update selected view');
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
expect(requests.updated).toHaveLength(1);
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update keeps the color of the open view', async () => {
|
||||
const base = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const colored = {
|
||||
...base,
|
||||
spec: { ...base.spec, display: { color: '#e5484d' } },
|
||||
};
|
||||
const requests = mockSavedViewsApi([colored]);
|
||||
await openDirtyView(colored);
|
||||
|
||||
await chooseFromSaveMenu('Update selected view');
|
||||
|
||||
await waitFor(() => expect(requests.updated).toHaveLength(1));
|
||||
const { body } = requests.updated[0] as {
|
||||
body: { spec: { display?: { color?: string } } };
|
||||
};
|
||||
expect(body.spec.display?.color).toBe('#e5484d');
|
||||
});
|
||||
|
||||
describe('view name toggle with unsaved changes', () => {
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
@@ -221,75 +219,6 @@ describe('SavedViewsPanel list', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('lists only the views of its own source', async () => {
|
||||
mockSavedViewsApi([
|
||||
mine,
|
||||
makeView({
|
||||
id: 'view-9',
|
||||
displayName: 'Noisy logs',
|
||||
source: SavedviewtypesSourceDTO.logs,
|
||||
}),
|
||||
]);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByTestId('saved-views-list');
|
||||
expect(rowNames('saved-views-created-by-me')).toStrictEqual(['Errors']);
|
||||
});
|
||||
|
||||
describe('last used view', () => {
|
||||
const lastUsedViews = (): unknown =>
|
||||
JSON.parse(localStorage.getItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) ?? '{}');
|
||||
const logsEntry = { logs: { key: 'logs-view', value: 'Noisy logs' } };
|
||||
|
||||
it('is remembered under the panel source, keeping the other sources', async () => {
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
|
||||
JSON.stringify(logsEntry),
|
||||
);
|
||||
mockSavedViewsApi([mine, alsoMine]);
|
||||
renderPanel();
|
||||
await screen.findByTestId('saved-views-list');
|
||||
await waitFor(() => expectUsable(rowName('Slow spans')));
|
||||
|
||||
await userEvent.click(rowName('Slow spans'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(lastUsedViews()).toStrictEqual({
|
||||
...logsEntry,
|
||||
traces: { key: alsoMine.id, value: 'Slow spans' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('is filed under the source of the panel it was opened from', async () => {
|
||||
const logsView = makeView({
|
||||
id: 'view-9',
|
||||
displayName: 'Noisy logs',
|
||||
source: SavedviewtypesSourceDTO.logs,
|
||||
dataSource: DataSource.LOGS,
|
||||
});
|
||||
mockSavedViewsApi([logsView]);
|
||||
renderWithExplorerProviders(
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.logs}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
explorerUrl(ROUTES.LOGS_EXPLORER, {}),
|
||||
DataSource.LOGS,
|
||||
);
|
||||
await screen.findByTestId('saved-views-list');
|
||||
await waitFor(() => expectUsable(rowName('Noisy logs')));
|
||||
|
||||
await userEvent.click(rowName('Noisy logs'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(lastUsedViews()).toStrictEqual({
|
||||
logs: { key: logsView.id, value: 'Noisy logs' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('offers to create a view when there are none, and opens it', async () => {
|
||||
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
const { history } = renderPanel();
|
||||
|
||||
@@ -4,13 +4,11 @@ import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { API, mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectRowUsable,
|
||||
explorerUrl,
|
||||
@@ -183,87 +181,6 @@ describe('SavedViewsPanel row menu', () => {
|
||||
await waitFor(() => expect(row('Slow checkout spans')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renames from the stored view, not a stale list row', async () => {
|
||||
const views = [errors, slow];
|
||||
const requests = mockSavedViewsApi(views);
|
||||
renderPanelWithHeader();
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const stored = makeView({
|
||||
id: slow.id,
|
||||
displayName: 'Slow spans',
|
||||
expression: 'duration_nano > 5000',
|
||||
});
|
||||
views[1] = stored;
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Edit details');
|
||||
const name = screen.getByTestId('save-view-name');
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, 'Slow checkout spans');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
await waitFor(() => expect(requests.updated).toHaveLength(1));
|
||||
expect(requests.updated[0].body).toMatchObject({
|
||||
spec: { ...stored.spec, displayName: 'Slow checkout spans' },
|
||||
});
|
||||
});
|
||||
|
||||
it('renames from the list row when the view cannot be read', async () => {
|
||||
const requests = mockSavedViewsApi([errors, slow]);
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(403), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Edit details');
|
||||
const name = screen.getByTestId('save-view-name');
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, 'Slow checkout spans');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
await waitFor(() => expect(requests.updated).toHaveLength(1));
|
||||
expect(requests.updated[0].body).toMatchObject({
|
||||
spec: { ...slow.spec, displayName: 'Slow checkout spans' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not rename when the stored view fails to load', async () => {
|
||||
const requests = mockSavedViewsApi([errors, slow]);
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Edit details');
|
||||
const name = screen.getByTestId('save-view-name');
|
||||
await userEvent.type(name, ' 2');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(requests.updated).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores Enter while the stored view loads', async () => {
|
||||
const requests = mockSavedViewsApi([errors, slow], { getDelay: 300 });
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Edit details');
|
||||
const name = screen.getByTestId('save-view-name');
|
||||
await userEvent.type(name, ' 2{enter}');
|
||||
await userEvent.type(name, '{enter}');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
'View updated',
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
expect(requests.updated).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows the new name in the header when the open view is renamed', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderPanelWithHeader(viewUrl(PATH, errors));
|
||||
@@ -319,19 +236,4 @@ describe('SavedViewsPanel row menu', () => {
|
||||
);
|
||||
await waitFor(() => expect(chip()).toHaveTextContent('My view'));
|
||||
});
|
||||
|
||||
it('keeps the open view when another view is deleted', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
const { history } = renderPanelWithHeader(viewUrl(PATH, errors));
|
||||
await waitFor(() => expect(chip()).toHaveTextContent('Errors'));
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Delete');
|
||||
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
|
||||
|
||||
await waitFor(() => expect(row('Slow spans')).toBeUndefined());
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify(errors.id),
|
||||
);
|
||||
expect(chip()).toHaveTextContent('Errors');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import store from 'store';
|
||||
import { act, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectRowUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
renderWithExplorerProviders,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const mockCopy: jest.MockedFunction<(text: string) => void> = jest.fn();
|
||||
|
||||
// copy-to-clipboard falls back to window.prompt in jsdom.
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: (): [unknown, typeof mockCopy] => [null, mockCopy],
|
||||
}));
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
const slow = makeView({ id: 'view-2', displayName: 'Slow spans' });
|
||||
|
||||
// A store whose global time can change while the panel stays mounted.
|
||||
function renderPanelWithTime(selectedTime: GlobalReducer['selectedTime']): {
|
||||
setTime: (time: Partial<GlobalReducer>) => void;
|
||||
} {
|
||||
let globalTime: GlobalReducer = {
|
||||
...store.getState().globalTime,
|
||||
selectedTime,
|
||||
};
|
||||
const timeStore = configureStore([thunk])(() => ({
|
||||
...store.getState(),
|
||||
globalTime,
|
||||
}));
|
||||
|
||||
renderWithExplorerProviders(
|
||||
<Provider store={timeStore}>
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
/>
|
||||
</Provider>,
|
||||
explorerUrl(PATH, {}),
|
||||
);
|
||||
|
||||
return {
|
||||
setTime: (time): void => {
|
||||
globalTime = { ...globalTime, ...time };
|
||||
act(() => {
|
||||
timeStore.dispatch({ type: 'TEST_TIME_CHANGED' });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const row = (name: string): HTMLElement =>
|
||||
screen
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((element) => element.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
const relativeTimeOf = (link: string): string | null =>
|
||||
new URL(link, window.location.origin).searchParams.get(
|
||||
QueryParams.relativeTime,
|
||||
);
|
||||
|
||||
describe('SavedViewsPanel time range', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockCopy.mockClear();
|
||||
mockSavedViewsApi([slow]);
|
||||
});
|
||||
|
||||
it('moves the row link to the new time range when it changes', async () => {
|
||||
const { setTime } = renderPanelWithTime('1h');
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const name = within(row('Slow spans')).getByTestId('saved-views-row-name');
|
||||
expect(relativeTimeOf(name.getAttribute('href') as string)).toBe('1h');
|
||||
|
||||
setTime({ selectedTime: '6h' });
|
||||
|
||||
expect(relativeTimeOf(name.getAttribute('href') as string)).toBe('6h');
|
||||
});
|
||||
|
||||
it('copies a link in the time range that is selected when it is copied', async () => {
|
||||
const { setTime } = renderPanelWithTime('1h');
|
||||
await screen.findByTestId('saved-views-list');
|
||||
setTime({ selectedTime: '6h' });
|
||||
|
||||
await userEvent.click(
|
||||
within(row('Slow spans')).getByTestId('saved-views-row-menu'),
|
||||
);
|
||||
const copyLink = await screen.findByTestId(
|
||||
'saved-views-row-menu-item-copyLink',
|
||||
);
|
||||
await waitFor(() => expectRowUsable(copyLink), MENU_CHECK_TIMEOUT);
|
||||
await userEvent.click(copyLink);
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledTimes(1);
|
||||
expect(relativeTimeOf(mockCopy.mock.calls[0][0])).toBe('6h');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
getGetSavedViewQueryOptions,
|
||||
updateSavedView,
|
||||
useDeleteSavedView,
|
||||
useUpdateSavedView,
|
||||
} from 'api/generated/services/saved-view';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
@@ -46,39 +45,8 @@ export function useSavedViewRowActions(
|
||||
const timeSearch = useTimeSearchParams();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const logSavedViewEvent = useSavedViewLogEvent(source);
|
||||
// The update replaces the whole spec, so it starts from the stored view, not
|
||||
// the list row, which can be stale. Without read on the view, the list row is
|
||||
// the only copy there is.
|
||||
const { mutateAsync: renameSavedView, isLoading: isRenaming } = useMutation(
|
||||
async ({
|
||||
view,
|
||||
displayName,
|
||||
}: {
|
||||
view: SavedviewtypesSavedViewDTO;
|
||||
displayName: string;
|
||||
}) => {
|
||||
let { spec } = view;
|
||||
try {
|
||||
({
|
||||
data: { spec },
|
||||
} = await queryClient.fetchQuery(
|
||||
getGetSavedViewQueryOptions({ id: view.id }),
|
||||
));
|
||||
} catch (error) {
|
||||
if ((error as ErrorType<unknown>).response?.status !== 403) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await updateSavedView(
|
||||
{ id: view.id },
|
||||
{
|
||||
source,
|
||||
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
|
||||
spec: { ...spec, displayName },
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
|
||||
useUpdateSavedView();
|
||||
const { mutateAsync: deleteSavedView, isLoading: isDeleting } =
|
||||
useDeleteSavedView();
|
||||
|
||||
@@ -109,7 +77,14 @@ export function useSavedViewRowActions(
|
||||
displayName: string,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await renameSavedView({ view, displayName });
|
||||
await updateSavedView({
|
||||
pathParams: { id: view.id },
|
||||
data: {
|
||||
source,
|
||||
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
|
||||
spec: { ...view.spec, displayName },
|
||||
},
|
||||
});
|
||||
await invalidateSavedViews(queryClient, source, view.id);
|
||||
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
logSavedViewEvent(SavedViewEvents.ViewDetailsEdited, {
|
||||
@@ -121,7 +96,7 @@ export function useSavedViewRowActions(
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[renameSavedView, source, queryClient, logSavedViewEvent],
|
||||
[updateSavedView, source, queryClient, logSavedViewEvent],
|
||||
);
|
||||
|
||||
// The deleted view's own query is left alone: refetching it would fail
|
||||
|
||||
@@ -127,48 +127,6 @@ describe('hasUnsavedViewChanges', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is clean while no query is staged', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery: null,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is dirty when the columns are reordered', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery: staged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options: { ...options, selectColumns: [columns[1], columns[0]] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is dirty when a column is swapped for another', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery: staged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options: {
|
||||
...options,
|
||||
selectColumns: [
|
||||
columns[0],
|
||||
{ name: 'duration_nano', key: 'duration_nano' } as TelemetryFieldKey,
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a traces view without columns as the default columns', () => {
|
||||
const bare = viewFrom(
|
||||
toSavedViewSpec({
|
||||
@@ -226,21 +184,6 @@ describe('hasUnsavedViewChanges', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['format', { format: 'table' as LogViewMode }],
|
||||
['font size', { fontSize: FontSize.LARGE }],
|
||||
])('is dirty when only the %s changes', (_, change) => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: logsView,
|
||||
stagedQuery: logsStaged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
options: { selectColumns: shownColumns, ...formatting, ...change },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a view without formatting as the default formatting', () => {
|
||||
const bare = viewFrom(
|
||||
toSavedViewSpec({
|
||||
@@ -352,137 +295,6 @@ describe('hasUnsavedViewChanges', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['point', 'attribute'],
|
||||
['spanfield', 'span'],
|
||||
['logfield', 'log'],
|
||||
['tracefield', 'trace'],
|
||||
])(
|
||||
'reads a group-by staged as %s and stored as %s as the same field',
|
||||
(legacyContext, storedContext) => {
|
||||
const base = viewFrom(
|
||||
toSavedViewSpec({
|
||||
query: query(DataSource.METRICS, ''),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
displayName: 'By os',
|
||||
}),
|
||||
);
|
||||
const [storedQuery] = base.spec.queries;
|
||||
const storedView = {
|
||||
...base,
|
||||
spec: {
|
||||
...base.spec,
|
||||
queries: [
|
||||
{
|
||||
...storedQuery,
|
||||
spec: {
|
||||
...storedQuery.spec,
|
||||
groupBy: [
|
||||
{
|
||||
name: 'os.type',
|
||||
fieldContext: storedContext,
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as SavedviewtypesSavedViewDTO;
|
||||
const opened = stagedFrom(storedView, DataSource.METRICS);
|
||||
const stagedLegacy = {
|
||||
...opened,
|
||||
builder: {
|
||||
...opened.builder,
|
||||
queryData: opened.builder.queryData.map((queryData) => ({
|
||||
...queryData,
|
||||
groupBy: [{ key: 'os.type', dataType: 'string', type: legacyContext }],
|
||||
})),
|
||||
},
|
||||
} as Query;
|
||||
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: storedView,
|
||||
stagedQuery: stagedLegacy,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
sourcePage: SavedviewtypesSourceDTO.metrics,
|
||||
}),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
// The query still staged after a save keeps the builder's values, while
|
||||
// the server returns the view without them.
|
||||
describe('right after saving a query that carries builder defaults', () => {
|
||||
const withPatch = (
|
||||
dataSource: DataSource,
|
||||
patch: Partial<Query['builder']['queryData'][number]>,
|
||||
): Query => {
|
||||
const base = query(dataSource, '');
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: base.builder.queryData.map((queryData) => ({
|
||||
...queryData,
|
||||
...patch,
|
||||
})),
|
||||
},
|
||||
} as Query;
|
||||
};
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a zero offset on a list',
|
||||
DataSource.LOGS,
|
||||
SavedviewtypesSourceDTO.logs,
|
||||
PANEL_TYPES.LIST,
|
||||
{ offset: 0 },
|
||||
],
|
||||
[
|
||||
'an empty group-by description',
|
||||
DataSource.TRACES,
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
{
|
||||
groupBy: [
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
'a function without arguments',
|
||||
DataSource.METRICS,
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
{ functions: [{ name: 'absolute', args: [], namedArgs: {} }] },
|
||||
],
|
||||
])('is clean with %s', (_, dataSource, sourcePage, panelType, patch) => {
|
||||
const saved = withPatch(
|
||||
dataSource,
|
||||
patch as Partial<Query['builder']['queryData'][number]>,
|
||||
);
|
||||
const savedView = viewFrom(
|
||||
toSavedViewSpec({ query: saved, panelType, displayName: 'Saved' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: savedView,
|
||||
stagedQuery: saved,
|
||||
panelType,
|
||||
sourcePage,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list order', () => {
|
||||
const withOrderBy = (
|
||||
staged: Query,
|
||||
|
||||
@@ -80,66 +80,6 @@ describe('toSavedViewSpec', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the description and unit of a column, naming it by name before key', () => {
|
||||
const spec = toSavedViewSpec({
|
||||
query: tracesQuery(),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName: 'Errors',
|
||||
options: {
|
||||
selectColumns: [
|
||||
{
|
||||
name: 'duration_nano',
|
||||
key: 'durationNano',
|
||||
description: 'Span duration',
|
||||
unit: 'ns',
|
||||
},
|
||||
] as unknown as TelemetryFieldKey[],
|
||||
format: 'raw' as LogViewMode,
|
||||
maxLines: 2,
|
||||
fontSize: FontSize.SMALL,
|
||||
},
|
||||
});
|
||||
|
||||
expect(spec.selectedFields).toStrictEqual([
|
||||
{ name: 'duration_nano', description: 'Span duration', unit: 'ns' },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
PANEL_TYPES.LIST,
|
||||
SavedviewtypesPanelTypeDTO.list,
|
||||
Querybuildertypesv5RequestTypeDTO.raw,
|
||||
],
|
||||
[
|
||||
PANEL_TYPES.TABLE,
|
||||
SavedviewtypesPanelTypeDTO.table,
|
||||
Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
],
|
||||
[
|
||||
PANEL_TYPES.TRACE,
|
||||
SavedviewtypesPanelTypeDTO.trace,
|
||||
Querybuildertypesv5RequestTypeDTO.trace,
|
||||
],
|
||||
[
|
||||
PANEL_TYPES.PIE,
|
||||
SavedviewtypesPanelTypeDTO.graph,
|
||||
Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
],
|
||||
])(
|
||||
'stores the %s panel as panel type %s with request type %s',
|
||||
(panelType, storedPanelType, requestType) => {
|
||||
const spec = toSavedViewSpec({
|
||||
query: tracesQuery(),
|
||||
panelType,
|
||||
displayName: 'Errors',
|
||||
});
|
||||
|
||||
expect(spec.panelType).toBe(storedPanelType);
|
||||
expect(spec.requestType).toBe(requestType);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the colour in display', () => {
|
||||
const spec = toSavedViewSpec({
|
||||
query: tracesQuery(),
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import {
|
||||
alertRulesFixture,
|
||||
listRulesV3Response,
|
||||
} from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { useListAllRules } from '../useListAllRules';
|
||||
|
||||
const TOTAL = 250;
|
||||
|
||||
const rules = Array.from({ length: TOTAL }, (_, index) => ({
|
||||
...alertRulesFixture[0],
|
||||
id: `rule-${index}`,
|
||||
alert: `Rule ${index}`,
|
||||
}));
|
||||
|
||||
describe('useListAllRules', () => {
|
||||
it('fetches every page past the 200-rule cap', async () => {
|
||||
const offsets: string[] = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
offsets.push(req.url.searchParams.get('offset') ?? '');
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(listRulesV3Response(rules, req.url.searchParams)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useListAllRules(), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toHaveLength(TOTAL);
|
||||
expect(result.current.data?.[TOTAL - 1]?.id).toBe(`rule-${TOTAL - 1}`);
|
||||
expect(offsets.sort()).toStrictEqual(['0', '200']);
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import {
|
||||
getListRulesV3QueryKey,
|
||||
listRulesV3,
|
||||
} from 'api/generated/services/rules';
|
||||
import type { RuletypesListableRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { range } from 'lodash-es';
|
||||
|
||||
// The list API clamps any larger limit to 200.
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
export const LIST_ALL_RULES_QUERY_KEY = [...getListRulesV3QueryKey(), 'all'];
|
||||
|
||||
function fetchPage(offset: number): ReturnType<typeof listRulesV3> {
|
||||
return listRulesV3({ limit: PAGE_SIZE, offset });
|
||||
}
|
||||
|
||||
// TODO(AshwinBhatkal): temporary. Replace with server-side search plus a lookup of saved rules by ID once
|
||||
// /api/v3/rules can filter by IDs: https://github.com/SigNoz/pulse-pod/issues/715
|
||||
async function fetchAllRules(): Promise<RuletypesListableRuleDTO[]> {
|
||||
const first = await fetchPage(0);
|
||||
const rest = await Promise.all(
|
||||
range(PAGE_SIZE, first.data.total, PAGE_SIZE).map(fetchPage),
|
||||
);
|
||||
|
||||
return [...first.data.rules, ...rest.flatMap((page) => page.data.rules)];
|
||||
}
|
||||
|
||||
export function useListAllRules(
|
||||
options?: Omit<
|
||||
UseQueryOptions<RuletypesListableRuleDTO[], Error>,
|
||||
'queryKey' | 'queryFn'
|
||||
>,
|
||||
): UseQueryResult<RuletypesListableRuleDTO[], Error> {
|
||||
return useQuery<RuletypesListableRuleDTO[], Error>({
|
||||
queryKey: LIST_ALL_RULES_QUERY_KEY,
|
||||
queryFn: fetchAllRules,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -139,35 +139,6 @@ 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' },
|
||||
@@ -260,23 +231,6 @@ 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> => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
getGetRuleByIDQueryKey,
|
||||
getGetRuleHistoryTimelineQueryOptions,
|
||||
invalidateGetRuleByID,
|
||||
invalidateListRulesV3,
|
||||
invalidateListRules,
|
||||
updateRuleByID,
|
||||
useGetRuleByID,
|
||||
useGetRuleHistoryOverallStatus,
|
||||
@@ -443,7 +443,7 @@ export const useAlertRuleClone = ({
|
||||
message: `Success`,
|
||||
});
|
||||
|
||||
await invalidateListRulesV3(queryClient);
|
||||
await invalidateListRules(queryClient);
|
||||
|
||||
params.set(QueryParams.ruleId, response.data.id);
|
||||
navigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
|
||||
@@ -494,7 +494,7 @@ export const useAlertRuleUpdate = ({
|
||||
data: { ...existingRule.data, alert: intermediateName },
|
||||
});
|
||||
}
|
||||
void invalidateListRulesV3(queryClient);
|
||||
void invalidateListRules(queryClient);
|
||||
notifications.success({ message: 'Alert renamed successfully' });
|
||||
},
|
||||
onError: (error) => {
|
||||
|
||||
@@ -21,10 +21,6 @@ import {
|
||||
WEBHOOK_AUTHS,
|
||||
type WebhookAuthChoice,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
import {
|
||||
recordChannelTestRequest,
|
||||
resetChannelTestRequests,
|
||||
} from './__story_mockdata__/channelsEdit';
|
||||
|
||||
const STORY_CHANNEL_ID = '1';
|
||||
|
||||
@@ -91,13 +87,6 @@ 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'
|
||||
@@ -106,5 +95,4 @@ export const channelsEditMocks = defineStoryMocks({
|
||||
),
|
||||
],
|
||||
config: () => ({ route: `/alerts/channels/edit/${STORY_CHANNEL_ID}` }),
|
||||
effect: () => resetChannelTestRequests(),
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ 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';
|
||||
@@ -37,10 +36,8 @@ 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 = { play: expectEditableForm };
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Mutation: clearing the required webhook URL surfaces the form's validation feedback. */
|
||||
export const InvalidRequiredFields: Story = {
|
||||
@@ -113,25 +110,21 @@ 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,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -140,7 +133,6 @@ export const WebhookWithoutAuth: Story = {
|
||||
*/
|
||||
export const Opsgenie: Story = {
|
||||
args: { channelType: 'opsgenie' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -149,7 +141,6 @@ export const Opsgenie: Story = {
|
||||
*/
|
||||
export const Email: Story = {
|
||||
args: { channelType: 'email' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -158,13 +149,11 @@ 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,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -174,8 +163,7 @@ export const GoogleChat: Story = {
|
||||
*/
|
||||
export const Jira: Story = {
|
||||
args: { channelType: 'jira' },
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await expectEditableForm({ canvasElement, args });
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
// the advanced fields have their own card now, so nothing has to be
|
||||
// expanded before they can be read
|
||||
await expect(
|
||||
@@ -195,8 +183,7 @@ export const Jira: Story = {
|
||||
*/
|
||||
export const JiraSelfHosted: Story = {
|
||||
args: { channelType: 'jira', jiraDeployment: 'selfhosted' },
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await expectEditableForm({ canvasElement, args });
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
await canvas.findByTestId('jira-pat-textbox', undefined, {
|
||||
@@ -215,7 +202,6 @@ export const JiraSelfHosted: Story = {
|
||||
*/
|
||||
export const JiraServiceManagementOps: Story = {
|
||||
args: { channelType: 'jsmops' },
|
||||
play: expectEditableForm,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -224,11 +210,9 @@ 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,
|
||||
};
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* 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),
|
||||
});
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import type { AlertmanagertypesTestableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/** Every test send the form made since the story mounted, oldest first. */
|
||||
const testRequests: AlertmanagertypesTestableNotificationChannelDTO[] = [];
|
||||
|
||||
export const recordChannelTestRequest = (
|
||||
request: AlertmanagertypesTestableNotificationChannelDTO,
|
||||
): void => {
|
||||
testRequests.push(request);
|
||||
};
|
||||
|
||||
export const channelTestRequests =
|
||||
(): readonly AlertmanagertypesTestableNotificationChannelDTO[] => testRequests;
|
||||
|
||||
export const resetChannelTestRequests = (): void => {
|
||||
testRequests.length = 0;
|
||||
};
|
||||
@@ -13,12 +13,6 @@ 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';
|
||||
@@ -62,12 +56,18 @@ export const NoRead: Story = {
|
||||
|
||||
/**
|
||||
* Reading is enough to open the page, so losing `update` alone leaves it
|
||||
* standing read-only: every field goes disabled and Save is locked, not hidden.
|
||||
* The Channel type control walks the same check across every integration.
|
||||
* standing read-only: the fields go disabled and Save is locked, not hidden.
|
||||
*/
|
||||
export const NoUpdate: Story = {
|
||||
args: { revoked: [UPDATE] },
|
||||
play: expectReadOnlyForm,
|
||||
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'));
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -101,22 +101,15 @@ export const NoCreate: Story = {
|
||||
*/
|
||||
export const ReadOnly: Story = {
|
||||
args: { revoked: [UPDATE, CREATE, DELETE] },
|
||||
play: async (context) => {
|
||||
await expectReadOnlyForm(context);
|
||||
await expectDisabled(
|
||||
await within(context.canvasElement).findByTestId('test-channel-button'),
|
||||
);
|
||||
},
|
||||
};
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
/**
|
||||
* 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,
|
||||
await expectDisabled(
|
||||
await canvas.findByTestId('save-channel-button', undefined, PAGE_LOAD),
|
||||
);
|
||||
await expectDisabled(await canvas.findByTestId('test-channel-button'));
|
||||
await expectDisabled(await canvas.findByTestId('slack-channel-textbox'));
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -152,25 +145,6 @@ 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 },
|
||||
|
||||
@@ -17,8 +17,10 @@ import {
|
||||
FIRST_DOWNTIME_NAME,
|
||||
} from './__story_mockdata__/plannedDowntime';
|
||||
|
||||
import { alertRulesPage } from '../../AlertRules/stories/__story_mockdata__/ruleViews';
|
||||
import { RULE_MAX } from '../../stories/__story_mockdata__/alerts';
|
||||
import {
|
||||
alertRulesResponse,
|
||||
RULE_MAX,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
import { AlertListSubTabs, AlertListTabs } from '../../types';
|
||||
|
||||
const LIST = 'Planned downtime · list';
|
||||
@@ -84,20 +86,18 @@ export const plannedDowntimeMocks = defineStoryMocks({
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
if (values.rulesState === 'error') {
|
||||
return res(ctx.status(500), ctx.json({ status: 'error' }));
|
||||
}
|
||||
return res(
|
||||
ctx.json(
|
||||
alertRulesPage(
|
||||
values.silencedRules,
|
||||
{ severity: 'mixed', state: 'mixed' },
|
||||
req.url.searchParams,
|
||||
rest.get('http://localhost/api/v2/rules', (_req, res, ctx) =>
|
||||
values.rulesState === 'error'
|
||||
? res(ctx.status(500), ctx.json({ status: 'error' }))
|
||||
: res(
|
||||
ctx.json(
|
||||
alertRulesResponse(values.silencedRules, {
|
||||
severity: 'mixed',
|
||||
state: 'mixed',
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
config: () => ({
|
||||
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.PLANNED_DOWNTIME}`,
|
||||
|
||||
@@ -978,7 +978,7 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
|
||||
},
|
||||
};
|
||||
|
||||
export const channelSpec = (
|
||||
const channelSpec = (
|
||||
type: ChannelType,
|
||||
webhookAuth: WebhookAuthChoice,
|
||||
jiraDeployment: JiraDeploymentChoice,
|
||||
|
||||
@@ -63,20 +63,6 @@ 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' },
|
||||
@@ -97,21 +83,6 @@ export const PieChartPanel: Story = {
|
||||
args: { panel: 'traffic-share' },
|
||||
};
|
||||
|
||||
/** A new area chart: the time series options with the area filled. */
|
||||
export const AreaChartPanel: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'area-chart' },
|
||||
};
|
||||
|
||||
/** A new histogram, where the display options are its buckets. */
|
||||
export const HistogramPanel: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'histogram' },
|
||||
};
|
||||
|
||||
/** A new text panel: a markdown body in place of the query builder. */
|
||||
export const TextPanel: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'text' },
|
||||
};
|
||||
|
||||
/** A list panel, where the config pane is the column editor. */
|
||||
export const ListPanel: Story = {
|
||||
args: { panel: 'recent-logs' },
|
||||
|
||||
@@ -105,19 +105,6 @@ export const ChartAppearance: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Disconnect-values in threshold mode, with an invalid duration showing the inline error. */
|
||||
export const ChartAppearanceDisconnectThreshold: Story = {
|
||||
play: async (context) => {
|
||||
await ChartAppearance.play?.(context);
|
||||
await pickSegment('panel-editor-v2-span-gaps', 'threshold');
|
||||
const input = await screen.findByTestId('panel-editor-v2-span-gaps-value');
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, '5x');
|
||||
await screen.findByText(/enter a valid duration/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** Dashed lines under a gradient fill, with the points drawn. */
|
||||
export const ChartAppearanceDashedGradient: Story = {
|
||||
play: async (context) => {
|
||||
|
||||
@@ -191,22 +191,6 @@ 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.
|
||||
@@ -259,34 +243,6 @@ 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> => {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { listRulesV3Response } from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest } from 'msw';
|
||||
|
||||
import {
|
||||
@@ -157,13 +156,11 @@ export const homeMocks = defineStoryMocks({
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v3/rules',
|
||||
response.json((req) =>
|
||||
listRulesV3Response(
|
||||
buildAlertRules(values.alertRules),
|
||||
req.url.searchParams,
|
||||
),
|
||||
),
|
||||
'http://localhost/api/v2/rules',
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: buildAlertRules(values.alertRules),
|
||||
})),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
|
||||
@@ -97,20 +97,3 @@ export const NavSettingsMenu: Story = {
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
};
|
||||
|
||||
/** Impersonation mode: the warning banner above the page content. */
|
||||
export const NoAuthBanner: Story = {
|
||||
args: { banner: 'no-auth' },
|
||||
play: async (): Promise<void> => {
|
||||
await screen.findByTestId('no-auth-banner', undefined, { timeout: 10000 });
|
||||
},
|
||||
};
|
||||
|
||||
/** 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');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -69,28 +69,6 @@ export const DetailsDrawerLogsTab: StoryObj<PodsArgs> = {
|
||||
},
|
||||
};
|
||||
|
||||
/** A log opened from the pod's logs tab, over the details drawer. */
|
||||
export const DetailsDrawerLogDetails: StoryObj<PodsArgs> = {
|
||||
args: { drawer: true },
|
||||
play: async () => {
|
||||
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 });
|
||||
},
|
||||
};
|
||||
|
||||
/** The copy button beside the pod name in the drawer, which toasts the copy. */
|
||||
export const PodNameCopiedToast: StoryObj<PodsArgs> = {
|
||||
args: { drawer: true },
|
||||
|
||||
@@ -117,43 +117,6 @@ export const ConnectAccount: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Azure's connect dialog, which the AWS regions form does not open. */
|
||||
export const AzureConnectAccount: Story = {
|
||||
args: { provider: 'azure', accounts: 0 },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/integrate now/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByRole('dialog', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** The GCP region combobox, opened from the connect drawer. */
|
||||
export const GcpRegionDropdown: Story = {
|
||||
args: { provider: 'gcp', accounts: 0 },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/integrate now/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
'gcp-deployment-region-select',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** The regions an already-connected account collects from. */
|
||||
export const EditAccount: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
type GroupSave,
|
||||
MAPPING_GROUP_MAX,
|
||||
spanMapperGroupsResponse,
|
||||
spanMappersResponse,
|
||||
wideConditionGroupsResponse,
|
||||
} from './__story_mockdata__/attributeMapping';
|
||||
|
||||
@@ -29,15 +28,6 @@ export const wideConditionGroup = rest.get(
|
||||
res(ctx.status(200), ctx.json(wideConditionGroupsResponse())),
|
||||
);
|
||||
|
||||
export const groupMappers = rest.get(
|
||||
'http://localhost/api/v1/span_mapper_groups/:groupId/span_mappers',
|
||||
(req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(spanMappersResponse(String(req.params.groupId))),
|
||||
),
|
||||
);
|
||||
|
||||
export const attributeMappingMocks = defineStoryMocks({
|
||||
controls: {
|
||||
groups: countControl('Mapping groups', {
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import LLMObservabilityPage from '../../index';
|
||||
import {
|
||||
attributeMappingMocks,
|
||||
groupMappers,
|
||||
wideConditionGroup,
|
||||
} from './AttributeMapping.stories.mocks';
|
||||
|
||||
@@ -80,29 +79,6 @@ export const GroupFormDrawer: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The drawer that adds a mapping to the first group. */
|
||||
export const AddMappingDrawer: Story = {
|
||||
parameters: { msw: { handlers: [groupMappers] } },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
const [firstGroup] = await canvas.findAllByTestId(
|
||||
/^group-expand-/,
|
||||
{},
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
|
||||
await userEvent.click(firstGroup);
|
||||
await userEvent.click(
|
||||
await canvas.findByRole(
|
||||
'button',
|
||||
{ name: /add mapping/i },
|
||||
{ timeout: 15000 },
|
||||
),
|
||||
);
|
||||
await screen.findByText('New custom mapping');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A group toggled off and saved: the toast announcing the saved changes, raised
|
||||
* once the PATCH and the list refetch answer. Set Save changes to `error`,
|
||||
|
||||
@@ -5,15 +5,10 @@
|
||||
|
||||
import type {
|
||||
ListSpanMapperGroups200,
|
||||
ListSpanMappers200,
|
||||
SpantypesSpanMapperGroupConditionKeyDTO,
|
||||
SpantypesSpanMapperGroupDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
SpantypesFieldContextDTO,
|
||||
SpantypesSpanMapperOperationDTO,
|
||||
SpantypesSpanMapperOriginDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { SpantypesSpanMapperOriginDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const ORG_ID = 'org-signoz';
|
||||
|
||||
@@ -77,38 +72,6 @@ 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',
|
||||
|
||||
@@ -39,34 +39,6 @@ export const NothingPriced: Story = {
|
||||
args: { rules: 0 },
|
||||
};
|
||||
|
||||
/** Models seen in spans that no rule prices yet. */
|
||||
export const UnpricedModels: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'tab',
|
||||
{ name: /unpriced models/i },
|
||||
{ timeout: 15000 },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('tab', { name: /unpriced models/i, selected: true });
|
||||
},
|
||||
};
|
||||
|
||||
/** Creating a rule: the blank drawer, with the provider select enabled. */
|
||||
export const ModelCostDrawerCreate: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /add model cost/i },
|
||||
{ timeout: 15000 },
|
||||
),
|
||||
);
|
||||
await screen.findByTestId('drawer-provider-select');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The first pricing rule's menu, open over the table: edit the rule or drop it.
|
||||
*/
|
||||
|
||||
@@ -116,16 +116,6 @@ 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.
|
||||
|
||||
@@ -106,21 +106,6 @@ 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: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'periscope-btn-format-options',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByTestId('max-lines-per-row', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** An explorer with no configured quick filters. */
|
||||
export const NoQuickFilters: Story = {
|
||||
args: { quickFilters: 0 },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { MessagingQueuesViewTypeOptions } from 'pages/MessagingQueues/MessagingQueuesUtils';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -98,21 +98,6 @@ 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 },
|
||||
|
||||
@@ -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, waitFor, within } from 'storybook/test';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -76,26 +76,3 @@ 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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -86,11 +86,6 @@ export const onboardingV2Mocks = defineStoryMocks({
|
||||
'http://localhost/api/v1/roles',
|
||||
response.json(() => rolesListResponse(0)),
|
||||
),
|
||||
// Add Credit Card creates the subscription; an empty redirect keeps the
|
||||
// story from opening a billing tab.
|
||||
rest.post('http://localhost/api/v1/subscriptions', (_req, res, ctx) =>
|
||||
res(ctx.json({ status: 'success', data: { redirectURL: '' } })),
|
||||
),
|
||||
rest.post('http://localhost/api/v2/users', (_req, res, ctx) => {
|
||||
if (values.invite === 'loading') {
|
||||
return res(ctx.delay('infinite'));
|
||||
|
||||
@@ -1,20 +1,82 @@
|
||||
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 {
|
||||
openAddCreditCard,
|
||||
openInviteModal,
|
||||
searchWithoutResults,
|
||||
sendInvite,
|
||||
walkToStep,
|
||||
} from './__story_mockdata__/onboardingV2.play';
|
||||
onboardingV2Mocks,
|
||||
SETUP_STEPS,
|
||||
type SetupStep,
|
||||
} from './OnboardingPageV2.stories.mocks';
|
||||
|
||||
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' });
|
||||
|
||||
/**
|
||||
@@ -27,7 +89,16 @@ const meta = {
|
||||
title: 'Pages/Onboarding/Add Data Source',
|
||||
tags: ['play'],
|
||||
component: OnboardingPageV2,
|
||||
play: walkToStep,
|
||||
/**
|
||||
* 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);
|
||||
},
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<OnboardingV2Args>;
|
||||
@@ -66,12 +137,35 @@ export const SourcePreselected: Story = {
|
||||
|
||||
/** The one thing the header offers besides support: bringing the team along. */
|
||||
export const InviteTeammate: Story = {
|
||||
play: openInviteModal,
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
/** A search nothing answers, where the catalogue offers to add the source instead. */
|
||||
export const NoSearchResults: Story = {
|
||||
play: searchWithoutResults,
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -80,14 +174,48 @@ export const NoSearchResults: Story = {
|
||||
* is accepted; Invite result changes how it answers.
|
||||
*/
|
||||
export const InvitesSentToast: Story = {
|
||||
play: sendInvite,
|
||||
};
|
||||
play: async ({ mount, args, canvasElement }): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
/**
|
||||
* 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,
|
||||
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(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
onboardingV2Mocks,
|
||||
SETUP_STEPS,
|
||||
type SetupStep,
|
||||
} from '../OnboardingPageV2.stories.mocks';
|
||||
|
||||
type OnboardingV2Args = PageStoryArgs<typeof onboardingV2Mocks>;
|
||||
|
||||
interface PlayContext {
|
||||
mount: () => Promise<unknown>;
|
||||
args: OnboardingV2Args;
|
||||
canvasElement: HTMLElement;
|
||||
}
|
||||
|
||||
type Canvas = ReturnType<typeof within>;
|
||||
|
||||
/** The catalogue mounts before the first group of sources paints. */
|
||||
const untilRendered = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* Every option is a button wrapping its own logo, so the accessible name carries
|
||||
* the label twice and matches the other sources with the label as a prefix
|
||||
* (`Java`, `JavaScript`). The label's own text node is the one thing that is
|
||||
* exact.
|
||||
*/
|
||||
const chooseOption = async (canvas: Canvas, label: string): Promise<void> => {
|
||||
const option = await canvas.findByText(label, undefined, untilRendered);
|
||||
|
||||
await userEvent.click(option.closest('button') as HTMLElement);
|
||||
};
|
||||
|
||||
const answerDataSource = (canvas: Canvas): Promise<void> =>
|
||||
chooseOption(canvas, 'Java');
|
||||
|
||||
const answerFramework = (canvas: Canvas): Promise<void> =>
|
||||
chooseOption(canvas, 'Spring Boot');
|
||||
|
||||
const answerEnvironment = (canvas: Canvas): Promise<void> =>
|
||||
chooseOption(canvas, 'VM');
|
||||
|
||||
const openConfigureProduct = async (canvas: Canvas): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await canvas.findByRole(
|
||||
'button',
|
||||
{ name: /configure your product/i },
|
||||
untilRendered,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const ANSWER_STEP = [
|
||||
answerDataSource,
|
||||
answerFramework,
|
||||
answerEnvironment,
|
||||
openConfigureProduct,
|
||||
];
|
||||
|
||||
const advanceToSetupStep = async (
|
||||
canvasElement: HTMLElement,
|
||||
step: SetupStep,
|
||||
): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// A source that arrived through the param is already on the setup
|
||||
// instructions, with no question left to answer.
|
||||
if (!canvas.queryByText('Select your data source')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const answers = ANSWER_STEP.slice(0, SETUP_STEPS.indexOf(step));
|
||||
|
||||
// Sequential: each answer is what renders the question the next one reads.
|
||||
await answers.reduce(
|
||||
(walked, answer) => walked.then(() => answer(canvas)),
|
||||
Promise.resolve(),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Destructuring `mount` is what makes the step control a control: Storybook
|
||||
* re-runs a play function on an arg change only for a story that asks to be
|
||||
* remounted, and otherwise re-renders the one it already walked.
|
||||
*/
|
||||
export const walkToStep = async ({
|
||||
mount,
|
||||
args,
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
await advanceToSetupStep(canvasElement, args.step);
|
||||
};
|
||||
|
||||
export const openInviteModal = async ({
|
||||
mount,
|
||||
args,
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
await advanceToSetupStep(canvasElement, args.step);
|
||||
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole('button', { name: /invite a teammate/i }),
|
||||
);
|
||||
|
||||
// antd portals the modal out of the story root.
|
||||
await screen.findByText('Invite a team member', undefined, untilRendered);
|
||||
};
|
||||
|
||||
export const searchWithoutResults = async ({
|
||||
mount,
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.type(
|
||||
await canvas.findByPlaceholderText('Search', undefined, untilRendered),
|
||||
'ledger',
|
||||
);
|
||||
|
||||
// The search is debounced, so the miss lands a beat after the last keystroke.
|
||||
await canvas.findByText(/no results for ledger/i, undefined, untilRendered);
|
||||
};
|
||||
|
||||
export const sendInvite = async ({
|
||||
mount,
|
||||
args,
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /invite a teammate/i },
|
||||
untilRendered,
|
||||
),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', undefined, untilRendered);
|
||||
const [email] = await within(dialog).findAllByTestId(
|
||||
/^invite-email-/,
|
||||
undefined,
|
||||
untilRendered,
|
||||
);
|
||||
|
||||
await userEvent.type(email, 'jon@winterfell.dev');
|
||||
const [roles] = await within(dialog).findAllByRole('combobox');
|
||||
|
||||
await userEvent.click(roles);
|
||||
const viewers = await screen.findAllByText('viewer', undefined, untilRendered);
|
||||
|
||||
await userEvent.click(
|
||||
viewers[viewers.length - 1].closest('.ant-select-item-option') as HTMLElement,
|
||||
);
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await userEvent.click(
|
||||
await within(dialog).findByRole('button', { name: /send invites/i }),
|
||||
);
|
||||
|
||||
if (args.invite === 'success') {
|
||||
await waitFor(
|
||||
() => expect(screen.getByText(/invites sent successfully/i)).toBeVisible(),
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Banners that turn the cloud license into a self-hosted one. */
|
||||
const SELF_HOSTED_BANNERS: readonly OnboardingV2Args['banner'][] = [
|
||||
'license-expired',
|
||||
'license-terminated',
|
||||
];
|
||||
|
||||
/** Only a cloud workspace without premium support is offered the card. */
|
||||
const offersAddCard = (args: OnboardingV2Args): boolean =>
|
||||
args.support === 'add-card' &&
|
||||
args.license === 'cloud' &&
|
||||
!SELF_HOSTED_BANNERS.includes(args.banner);
|
||||
|
||||
const ADD_CARD_DIALOG = { name: /Add Credit Card for Chat Support/i };
|
||||
|
||||
export const openAddCreditCard = async ({
|
||||
mount,
|
||||
args,
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await mount();
|
||||
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
if (!offersAddCard(args)) {
|
||||
await canvas.findByRole(
|
||||
'button',
|
||||
{ name: /invite a teammate/i },
|
||||
untilRendered,
|
||||
);
|
||||
await expect(
|
||||
screen.queryByRole('dialog', ADD_CARD_DIALOG),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByRole(
|
||||
'button',
|
||||
{ name: /contact support/i },
|
||||
untilRendered,
|
||||
),
|
||||
);
|
||||
|
||||
// antd portals the modal out of the story root.
|
||||
await screen.findByRole('dialog', ADD_CARD_DIALOG, untilRendered);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -36,20 +35,3 @@ 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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -197,8 +197,30 @@ export const openMember = async (
|
||||
await userEvent.click(cell.closest('tr') as HTMLElement);
|
||||
};
|
||||
|
||||
export const openInviteForm = async (
|
||||
/**
|
||||
* 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 (
|
||||
canvasElement: HTMLElement,
|
||||
emails: string[],
|
||||
): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
@@ -207,49 +229,6 @@ export const openInviteForm = async (
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
// The form opens with a row per seat, each carrying its own email field.
|
||||
await screen.findAllByTestId(/^invite-email-/, undefined, untilLoaded);
|
||||
};
|
||||
|
||||
/** A click on the label closes the dropdown, where Escape would close the dialog too. */
|
||||
export const closeRoles = async (): Promise<void> => {
|
||||
await userEvent.click(await screen.findByText('Email address'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks `role` in the open roles dropdown. The option's content ignores pointer
|
||||
* events, so the click goes to the option around it.
|
||||
*/
|
||||
export const pickOpenRole = async (role: string): Promise<void> => {
|
||||
const options = await screen.findAllByText(role, undefined, untilLoaded);
|
||||
|
||||
await userEvent.click(
|
||||
options[options.length - 1].closest('.ant-select-item-option') as HTMLElement,
|
||||
);
|
||||
};
|
||||
|
||||
/** Opens the roles dropdown of the invite row at `index`. */
|
||||
export const openInviteRoleDropdown = async (index: number): Promise<void> => {
|
||||
const comboboxes = await screen.findAllByRole(
|
||||
'combobox',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(comboboxes[index]);
|
||||
};
|
||||
|
||||
const pickRole = async (index: number): Promise<void> => {
|
||||
await openInviteRoleDropdown(index);
|
||||
await pickOpenRole('viewer');
|
||||
await closeRoles();
|
||||
};
|
||||
|
||||
export const sendInvites = async (
|
||||
canvasElement: HTMLElement,
|
||||
emails: string[],
|
||||
): Promise<void> => {
|
||||
await openInviteForm(canvasElement);
|
||||
const fields = await screen.findAllByTestId(
|
||||
/^invite-email-/,
|
||||
undefined,
|
||||
|
||||
@@ -1,31 +1,16 @@
|
||||
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 {
|
||||
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';
|
||||
membersMocks,
|
||||
openMember,
|
||||
openResetLink,
|
||||
sendInvites,
|
||||
untilLoaded,
|
||||
} from './Members.stories.mocks';
|
||||
|
||||
import SettingsPage from '../../Settings';
|
||||
|
||||
@@ -54,6 +39,15 @@ 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.
|
||||
@@ -67,22 +61,68 @@ export const AllPending: Story = {
|
||||
|
||||
/** Interaction: the status menu is open with the real member counts. */
|
||||
export const FilterMenuOpen: Story = {
|
||||
play: openFilterMenu,
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
/** Interaction: the pending-invite filter narrows the real member table. */
|
||||
export const PendingOnly: Story = {
|
||||
play: filterPendingOnly,
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
/** Interaction: the deleted-member filter exposes the read-only member rows. */
|
||||
export const DeletedOnly: Story = {
|
||||
play: filterDeletedOnly,
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
/** Empty: searching for a non-member renders the table's supported empty state. */
|
||||
export const SearchNoResults: Story = {
|
||||
play: searchNoResults,
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
/** More members than one page holds, which is where the pager appears. */
|
||||
@@ -92,7 +132,10 @@ export const Paginated: Story = {
|
||||
|
||||
/** A member opened up: the name and roles that can be changed from here. */
|
||||
export const EditMember: Story = {
|
||||
play: openEditMember,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openMember(canvasElement, /jon snow/i);
|
||||
await screen.findByPlaceholderText(/enter name/i, undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -111,32 +154,51 @@ export const ExpiredInvite: Story = {
|
||||
|
||||
/** Interaction: deleting an active member is guarded by its confirmation dialog. */
|
||||
export const DeleteMemberConfirm: Story = {
|
||||
play: openDeleteMemberConfirm,
|
||||
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' });
|
||||
},
|
||||
};
|
||||
|
||||
/** Interaction: revoking a pending invite uses its distinct confirmation copy. */
|
||||
export const RevokeInviteConfirm: Story = {
|
||||
play: openRevokeInviteConfirm,
|
||||
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' });
|
||||
},
|
||||
};
|
||||
|
||||
/** The roles a member can be given. */
|
||||
/**
|
||||
* The roles a member can be given. The dropdown opens off the inner combobox,
|
||||
* not the wrapper the click lands on first.
|
||||
*/
|
||||
export const AssignRoles: Story = {
|
||||
play: openAssignRoles,
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
/** The form that mails seats out: an email and a role per row. */
|
||||
export const InviteMembers: Story = {
|
||||
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,
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -144,14 +206,34 @@ export const InviteRolesSelected: Story = {
|
||||
* to `error` or `loading` to see the form instead.
|
||||
*/
|
||||
export const InvitesSentToast: Story = {
|
||||
play: sendInvitesToAll,
|
||||
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(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** 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: sendInvitesPartiallyRefused,
|
||||
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(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -159,7 +241,27 @@ export const InvitesPartiallyFailedToast: Story = {
|
||||
* member to `error` or `loading` to see the drawer instead.
|
||||
*/
|
||||
export const MemberUpdatedToast: Story = {
|
||||
play: saveMemberName,
|
||||
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(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -167,7 +269,22 @@ export const MemberUpdatedToast: Story = {
|
||||
* the member to `error` or `loading` to see the dialog instead.
|
||||
*/
|
||||
export const MemberDeletedToast: Story = {
|
||||
play: deleteMember,
|
||||
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(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -175,7 +292,22 @@ export const MemberDeletedToast: Story = {
|
||||
* Deleting the member to `error` or `loading` to see the dialog instead.
|
||||
*/
|
||||
export const InviteRevokedToast: Story = {
|
||||
play: revokeInvite,
|
||||
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(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -183,7 +315,13 @@ export const InviteRevokedToast: Story = {
|
||||
* confirming it.
|
||||
*/
|
||||
export const ResetLinkCopiedToast: Story = {
|
||||
play: copyResetLink,
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -191,7 +329,16 @@ export const ResetLinkCopiedToast: Story = {
|
||||
* confirming it.
|
||||
*/
|
||||
export const InviteLinkCopiedToast: Story = {
|
||||
play: copyInviteLink,
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -200,5 +347,14 @@ export const InviteLinkCopiedToast: Story = {
|
||||
*/
|
||||
export const ResetLinkFailedToast: Story = {
|
||||
args: { resetLink: 'no-token' },
|
||||
play: failResetLink,
|
||||
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(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
/**
|
||||
* 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(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -100,10 +100,6 @@ export const workspaceMocks = defineStoryMocks({
|
||||
)
|
||||
: res(ctx.status(204));
|
||||
}),
|
||||
|
||||
rest.post('http://localhost/api/v1/subscriptions', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: { redirectURL: '' } })),
|
||||
),
|
||||
],
|
||||
config: () => ({ route: ROUTES.SETTINGS }),
|
||||
});
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
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';
|
||||
@@ -66,7 +61,16 @@ export const NoColdStorage: Story = {
|
||||
* of them is the active one.
|
||||
*/
|
||||
export const CustomDomainMenu: Story = {
|
||||
play: openCustomDomainMenu,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'custom-domain-menu-trigger',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -75,21 +79,38 @@ export const CustomDomainMenu: Story = {
|
||||
* instead.
|
||||
*/
|
||||
export const CustomDomainUpdatedToast: Story = {
|
||||
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,
|
||||
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(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** The license key copied: the toast confirming it. */
|
||||
export const LicenseKeyCopiedToast: Story = {
|
||||
play: copyLicenseKey,
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { accessFor } from '@/storybook/access/access';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import type { workspaceMocks } from '../Workspace.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: PageStoryArgs<typeof workspaceMocks>;
|
||||
}
|
||||
|
||||
const untilLoaded = { timeout: 10000 };
|
||||
|
||||
/** The domain card only renders for an admin on cloud. */
|
||||
const canEditDomain = ({ args }: Pick<PlayContext, 'args'>): boolean =>
|
||||
args.license === 'cloud' &&
|
||||
accessFor(args.access, args.permissions, args.revoked).legacyRole ===
|
||||
USER_ROLES.ADMIN;
|
||||
|
||||
const submitNewSubdomain = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /edit workspace link/i },
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
const input = await screen.findByRole('textbox');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'watchers');
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /apply changes/i }),
|
||||
);
|
||||
};
|
||||
|
||||
export const openCustomDomainMenu = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'custom-domain-menu-trigger',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByRole('menu');
|
||||
};
|
||||
|
||||
export const updateCustomDomain = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await submitNewSubdomain(canvasElement);
|
||||
if (args.domainUpdate === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/workspace url is being updated/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const copyLicenseKey = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'license-key-row-copy-btn',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText(/license key copied to clipboard/i);
|
||||
};
|
||||
|
||||
export const openAddCreditCardFromConflict = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
if (!canEditDomain({ args })) {
|
||||
return;
|
||||
}
|
||||
|
||||
await submitNewSubdomain(canvasElement);
|
||||
|
||||
if (args.domainUpdate !== 'error') {
|
||||
return;
|
||||
}
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /contact support/i }, untilLoaded),
|
||||
);
|
||||
|
||||
// Only a plan without premium support is offered the modal over the chat widget.
|
||||
if (args.support === 'add-card') {
|
||||
await screen.findByRole(
|
||||
'dialog',
|
||||
{ name: /Add Credit Card for Chat Support/i },
|
||||
untilLoaded,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
timeSeriesPoints,
|
||||
} from '@/storybook/msw/__story_mockdata__/queryRange';
|
||||
|
||||
import { traceFunnelsResponse } from '../../TracesModulePage/stories/__story_mockdata__/traces';
|
||||
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
|
||||
import {
|
||||
focusSpanIndex,
|
||||
@@ -222,11 +221,6 @@ 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),
|
||||
|
||||
@@ -248,72 +248,6 @@ export const SpanPercentileOpen: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Spans the backend never received, shown as the missing-spans callout. */
|
||||
export const MissingSpans: Story = {
|
||||
args: { missingSpans: true },
|
||||
play: async (): Promise<void> => {
|
||||
await screen.findByText(
|
||||
/this trace has missing spans/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** 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 },
|
||||
|
||||
@@ -123,43 +123,6 @@ 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: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /new funnel/i },
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByRole('dialog', { name: /create new funnel/i });
|
||||
},
|
||||
};
|
||||
|
||||
/** The rename field for a funnel already on the list. */
|
||||
export const RenameFunnel: Story = {
|
||||
args: { tab: 'funnels' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
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 });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -64,70 +64,6 @@ export const LongMultilineToast: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** A request still in flight: the loading toast stays until the promise settles. */
|
||||
export const PromiseLoadingToast: Story = {
|
||||
args: { state: 'promise-loading-toast' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByTestId('show-feedback-toast'),
|
||||
);
|
||||
await expect(
|
||||
await screen.findByText('Enabling the alert rule'),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** A failed request: the rejected toast keeps a Dismiss action, stacked top-center. */
|
||||
export const PromiseErrorToast: Story = {
|
||||
args: { state: 'promise-error-toast' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByTestId('show-feedback-toast'),
|
||||
);
|
||||
await expect(
|
||||
await screen.findByText('Could not clone the panel'),
|
||||
).toBeVisible();
|
||||
await screen.findByRole('button', { name: 'Dismiss' });
|
||||
},
|
||||
};
|
||||
|
||||
/** A warning that stays up, matching invites that only partly succeed. */
|
||||
export const WarningToast: Story = {
|
||||
args: { state: 'warning-toast' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByTestId('show-feedback-toast'),
|
||||
);
|
||||
await expect(await screen.findByText('Some invites failed')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** An info toast pinned to the bottom-right stack, as export cancellation does. */
|
||||
export const InfoToast: Story = {
|
||||
args: { state: 'info-toast' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByTestId('show-feedback-toast'),
|
||||
);
|
||||
await expect(await screen.findByText('Export cancelled')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** An error toast with a description and a Dismiss action, bottom-right. */
|
||||
export const DangerWithDescription: Story = {
|
||||
args: { state: 'danger-description-toast' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByTestId('show-feedback-toast'),
|
||||
);
|
||||
await expect(
|
||||
await screen.findByText('Failed to update account settings'),
|
||||
).toBeVisible();
|
||||
await screen.findByText(/does not have permission/i);
|
||||
await screen.findByRole('button', { name: 'Dismiss' });
|
||||
},
|
||||
};
|
||||
|
||||
/** Mutation: the inline application alert preserves its action affordance. */
|
||||
export const InlineAlert: Story = {
|
||||
args: { state: 'inline-alert' },
|
||||
|
||||
@@ -10,11 +10,6 @@ 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';
|
||||
@@ -26,10 +21,6 @@ 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);
|
||||
|
||||
@@ -46,62 +37,13 @@ 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 === 'promise-loading-toast' ||
|
||||
state === 'promise-error-toast' ||
|
||||
state === 'warning-toast' ||
|
||||
state === 'info-toast' ||
|
||||
state === 'danger-description-toast';
|
||||
state === 'long-toast';
|
||||
const confirmationTitle = 'Delete environment';
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,20 +21,6 @@ describe('buildExplorerNavigationUrl', () => {
|
||||
expect(url).toContain(`${QueryParams.viewKey}=`);
|
||||
});
|
||||
|
||||
it('encodes the composite query for a reader that decodes it, even with a percent sign', () => {
|
||||
const query = {
|
||||
queryType: 'builder',
|
||||
filter: { expression: "body LIKE '%timeout%'" },
|
||||
} as never;
|
||||
|
||||
const url = buildExplorerNavigationUrl(ROUTES.LOGS_EXPLORER, query, {});
|
||||
|
||||
const param = new URL(url, window.location.origin).searchParams.get(
|
||||
QueryParams.compositeQuery,
|
||||
) as string;
|
||||
expect(JSON.parse(decodeURIComponent(param))).toStrictEqual(query);
|
||||
});
|
||||
|
||||
it('JSON-encodes panelTypes so the explorer opens the right view', () => {
|
||||
const url = buildExplorerNavigationUrl(
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -707,9 +706,7 @@ func TestEmailRejected(t *testing.T) {
|
||||
|
||||
// Send the alert to mock SMTP server.
|
||||
retry, err := e.Notify(context.Background(), firingAlert)
|
||||
var smtpErr *textproto.Error
|
||||
require.ErrorAs(t, err, &smtpErr)
|
||||
assert.Equal(t, 501, smtpErr.Code)
|
||||
require.ErrorContains(t, err, "501 5.5.4 Rejected!")
|
||||
assert.True(t, retry)
|
||||
require.NoError(t, srv.Shutdown(ctx))
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
Description: "Execute a composite query over a time range. Supports builder queries (traces, logs, metrics), formulas, trace operators, PromQL, and ClickHouse SQL.",
|
||||
Request: new(qbtypes.QueryRangeRequest),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: append([]handler.OpenAPIExample{
|
||||
RequestExamples: []handler.OpenAPIExample{
|
||||
{
|
||||
Name: "traces_time_series",
|
||||
Summary: "Time series: count spans grouped by service",
|
||||
@@ -598,7 +598,7 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
},
|
||||
},
|
||||
},
|
||||
}, queryRangeFunctionExamples()...),
|
||||
},
|
||||
Response: new(qbtypes.QueryRangeResponse),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
package signozapiserver
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/http/handler"
|
||||
|
||||
// timeSeriesExample wraps builder query specs in a one hour time series
|
||||
// request, the shape every function example shares.
|
||||
func timeSeriesExample(name, summary, description string, queries ...map[string]any) handler.OpenAPIExample {
|
||||
list := make([]any, 0, len(queries))
|
||||
for _, query := range queries {
|
||||
list = append(list, query)
|
||||
}
|
||||
return handler.OpenAPIExample{
|
||||
Name: name,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Value: map[string]any{
|
||||
"schemaVersion": "v1",
|
||||
"start": 1640995200000,
|
||||
"end": 1640998800000,
|
||||
"requestType": "time_series",
|
||||
"compositeQuery": map[string]any{"queries": list},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func builderQueryEnvelope(spec map[string]any) map[string]any {
|
||||
return map[string]any{"type": "builder_query", "spec": spec}
|
||||
}
|
||||
|
||||
// function builds a function entry with positional arguments; anomaly is the
|
||||
// one function that takes named arguments and is written out in full.
|
||||
func function(name string, args ...any) map[string]any {
|
||||
list := make([]any, 0, len(args))
|
||||
for _, arg := range args {
|
||||
list = append(list, map[string]any{"value": arg})
|
||||
}
|
||||
entry := map[string]any{"name": name}
|
||||
if len(list) > 0 {
|
||||
entry["args"] = list
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// queryRangeFunctionExamples shows every function a builder query can carry,
|
||||
// what its arguments mean and the values they take. Functions run in the
|
||||
// order listed, after the aggregation and before formulas.
|
||||
func queryRangeFunctionExamples() []handler.OpenAPIExample {
|
||||
return []handler.OpenAPIExample{
|
||||
timeSeriesExample(
|
||||
"function_timeshift_week_over_week",
|
||||
"timeShift: this week's error logs next to last week's",
|
||||
"timeShift moves every point of its query forward by the argument, in seconds, so a series read from the past lands on the current timestamps. B here reads the hour exactly seven days (604800 s) before the requested window and comes back on this week's timestamps, so it overlays A on a chart and the formula can subtract it point by point. The argument is positive; it says how far back to read.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"aggregations": []any{map[string]any{"expression": "count()", "alias": "errors"}},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
|
||||
}),
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "B",
|
||||
"signal": "logs",
|
||||
"aggregations": []any{map[string]any{"expression": "count()", "alias": "errors"}},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
|
||||
"functions": []any{function("timeShift", 604800)},
|
||||
}),
|
||||
map[string]any{
|
||||
"type": "builder_formula",
|
||||
"spec": map[string]any{"name": "F1", "expression": "A - B"},
|
||||
},
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_fillzero_sparse_errors",
|
||||
"fillZero: a zero at every step with no failed spans",
|
||||
"A count over sparse events returns a point only for steps that had a match, so a chart of failed spans has gaps where nothing failed. fillZero adds a zero at every missing step. With no arguments the server fills the request window at the query's step, which is the common case. The three optional arguments are start, end and step in milliseconds, for callers that want to fill a different grid.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"aggregations": []any{map[string]any{"expression": "count()", "alias": "failed_spans"}},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "has_error = true"},
|
||||
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("fillZero")},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_cutoffmin_hide_idle_hosts",
|
||||
"cutOffMin: drop hosts below 5% CPU from the chart",
|
||||
"The four threshold functions take one number. cutOffMin and cutOffMax remove the points below or above the threshold, so an idle host disappears from the chart instead of drawing a line along the bottom. clampMin and clampMax keep the points and replace the value with the threshold, which caps spikes without hiding them. system.cpu.utilization is a ratio, so 0.05 is 5%.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "system.cpu.utilization", "timeAggregation": "avg", "spaceAggregation": "avg"},
|
||||
},
|
||||
"stepInterval": "60s",
|
||||
"groupBy": []any{map[string]any{"name": "host.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("cutOffMin", 0.05)},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_runningdiff_disk_growth",
|
||||
"runningDiff then clampMin: bytes written to disk per step",
|
||||
"runningDiff turns a gauge into the change between consecutive points, so a filesystem usage gauge becomes bytes written per step. It takes no arguments and drops the first point of the window, since that point has nothing to difference against; the server reads one step before the window to keep the first interval. Functions apply in order: clampMin with 0 runs on the differences and replaces the negative value a cleanup or a volume reset produces with zero.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "system.filesystem.usage", "timeAggregation": "latest", "spaceAggregation": "sum"},
|
||||
},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "state = 'used'"},
|
||||
"groupBy": []any{map[string]any{"name": "host.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("runningDiff"), function("clampMin", 0)},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_ewma_smoothed_latency",
|
||||
"ewma5: smoothed p95 latency",
|
||||
"ewma3, ewma5 and ewma7 replace each point with an exponentially weighted average of the point and the points before it. The number in the name is the window the default weight is derived from: alpha = 2 / (n + 1), so 0.5, 0.333 and 0.25. One optional argument overrides alpha; a value close to 1 follows the raw series, a value close to 0 smooths harder. 0.3 here smooths a little more than ewma5's default.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "http.server.duration.bucket", "spaceAggregation": "p95"},
|
||||
},
|
||||
"stepInterval": "60s",
|
||||
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("ewma5", 0.3)},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_median_despiked_request_rate",
|
||||
"median5: request rate without one-step spikes",
|
||||
"median3, median5 and median7 replace each point with the median of the surrounding 3, 5 or 7 points. A spike that lasts one step vanishes, a shift that lasts longer than half the window stays. The first and last half window, 2 points each for median5, have no full window and keep their values, so the newest spike stays visible; a series shorter than the window is returned unchanged. They take no arguments. Use them when the raw series is noisy and the chart is for a trend, not for catching the spike.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "http.server.duration.count", "timeAggregation": "rate", "spaceAggregation": "sum"},
|
||||
},
|
||||
"stepInterval": "60s",
|
||||
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("median5")},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_cumulativesum_errors_so_far",
|
||||
"cumulativeSum: 5xx responses since the start of the window",
|
||||
"cumulativeSum replaces each point with the sum of every point up to and including it, so a per-step count becomes a running total whose first point is the first count. It takes no arguments. The total resets with the window, so a longer time range gives a larger final value; it is a view of the window, not a counter.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"aggregations": []any{map[string]any{"expression": "count()", "alias": "server_errors"}},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "http.response.status_code >= 500"},
|
||||
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("cumulativeSum")},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_log10_network_bytes",
|
||||
"log10: network throughput across devices that differ by orders of magnitude",
|
||||
"log2 and log10 replace each value with its logarithm, so a loopback device moving kilobytes and an uplink moving gigabytes fit on one chart. They take no arguments. Zero and negative values have no logarithm and are not drawn. absolute, also without arguments, is the usual companion for a signed series before a logarithm.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "system.network.io", "timeAggregation": "rate", "spaceAggregation": "sum"},
|
||||
},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "direction = 'transmit'"},
|
||||
"groupBy": []any{map[string]any{"name": "device", "fieldContext": "attribute"}},
|
||||
"functions": []any{function("log10")},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_anomaly_request_rate",
|
||||
"anomaly: request rate scored against its daily pattern",
|
||||
"anomaly is the one function with named arguments. seasonality says which past periods predict the current value: hourly, daily or weekly, daily when absent. z_score_threshold is how many standard deviations from the prediction count as an anomaly. It has no default: a request without it gets bounds of zero width, so send it. 3 is the value the UI sends. The response carries, next to the values, a predicted series, an upper and a lower bound series and an anomaly score series. The scoring runs in the enterprise build; the community build returns the series unchanged.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "http.server.duration.count", "timeAggregation": "rate", "spaceAggregation": "sum"},
|
||||
},
|
||||
"stepInterval": "300s",
|
||||
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
|
||||
"functions": []any{
|
||||
map[string]any{
|
||||
"name": "anomaly",
|
||||
"args": []any{
|
||||
map[string]any{"name": "seasonality", "value": "daily"},
|
||||
map[string]any{"name": "z_score_threshold", "value": 3},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
func TestQueryRangeFunctionExamplesAreValidRequests(t *testing.T) {
|
||||
for _, example := range queryRangeFunctionExamples() {
|
||||
t.Run(example.Name, func(t *testing.T) {
|
||||
raw, err := json.Marshal(example.Value)
|
||||
require.NoError(t, err)
|
||||
var req qbtypes.QueryRangeRequest
|
||||
require.NoError(t, json.Unmarshal(raw, &req))
|
||||
assert.NoError(t, req.Validate())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,12 @@ import (
|
||||
// NewValidReferences / NewSuggestionsOnLevenshteinDistance to phrase the
|
||||
// "valid <noun> are ..." list consistently across the codebase.
|
||||
const (
|
||||
NounFields = "fields"
|
||||
NounKeys = "keys"
|
||||
NounServices = "services"
|
||||
NounQueryTypes = "query types"
|
||||
NounRequestTypes = "request types"
|
||||
NounSignals = "signals"
|
||||
NounReferences = "references"
|
||||
NounFields = "fields"
|
||||
NounKeys = "keys"
|
||||
NounServices = "services"
|
||||
NounQueryTypes = "query types"
|
||||
NounSignals = "signals"
|
||||
NounReferences = "references"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -60,7 +60,7 @@ func (bc *bucketCache) GetMissRanges(
|
||||
) (cached *qbtypes.Result, missing []*qbtypes.TimeRange) {
|
||||
|
||||
// Get query window
|
||||
startMs, endMs := q.Window()
|
||||
startMs, endMs := cacheWindow(q, uint64(step.Milliseconds()))
|
||||
|
||||
bc.logger.DebugContext(ctx, "getting miss ranges", slog.String("fingerprint", q.Fingerprint()), slog.Uint64("start", startMs), slog.Uint64("end", endMs))
|
||||
|
||||
@@ -127,7 +127,7 @@ func (bc *bucketCache) GetMissRanges(
|
||||
// Put stores fresh query results in the cache.
|
||||
func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Query, step qbtypes.Step, fresh *qbtypes.Result) {
|
||||
// Get query window
|
||||
startMs, endMs := q.Window()
|
||||
startMs, endMs := cacheWindow(q, uint64(step.Milliseconds()))
|
||||
|
||||
// Calculate the flux boundary - data after this point should not be cached
|
||||
currentMs := uint64(time.Now().UnixMilli())
|
||||
@@ -152,7 +152,8 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
|
||||
}
|
||||
|
||||
// Trim the result to exclude data within flux interval
|
||||
trimmedResult := bc.trimResultToFluxBoundary(fresh, cachableEndMs)
|
||||
requestStartMs, _ := q.Window()
|
||||
trimmedResult := bc.trimResultToFluxBoundary(fresh, requestStartMs, cachableEndMs)
|
||||
if trimmedResult == nil {
|
||||
// Result type is not cacheable (raw or scalar)
|
||||
return
|
||||
@@ -222,6 +223,30 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
|
||||
}
|
||||
}
|
||||
|
||||
// lookbackSteps is how many steps before its window a query's statement
|
||||
// reads. runningDiff drops its first point, so the metrics statement reads
|
||||
// one step before the window to give the first interval a difference.
|
||||
func lookbackSteps(q qbtypes.Query) int {
|
||||
bq, ok := q.(*builderQuery[qbtypes.MetricAggregation])
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
for _, fn := range bq.spec.Functions {
|
||||
if fn.Name == qbtypes.FunctionNameRunningDiff {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// cacheWindow is the query window widened by its lookback, so a hit carries
|
||||
// the steps the statement reads before the window.
|
||||
func cacheWindow(q qbtypes.Query, stepMs uint64) (uint64, uint64) {
|
||||
startMs, endMs := q.Window()
|
||||
lookbackMs := uint64(lookbackSteps(q)) * stepMs
|
||||
return startMs - min(lookbackMs, startMs), endMs
|
||||
}
|
||||
|
||||
// generateCacheKey names the cache entry holding one grid cell of a query.
|
||||
func (bc *bucketCache) generateCacheKey(q qbtypes.Query, cellIdx uint64) string {
|
||||
return fmt.Sprintf("v6:query:%s:cellidx:%d", q.Fingerprint(), cellIdx)
|
||||
@@ -760,7 +785,7 @@ func (bc *bucketCache) deduplicateWarnings(warnings []string) []string {
|
||||
}
|
||||
|
||||
// trimResultToFluxBoundary trims the result to exclude data points beyond the flux boundary.
|
||||
func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, fluxBoundary uint64) *qbtypes.Result {
|
||||
func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, requestStartMs, fluxBoundary uint64) *qbtypes.Result {
|
||||
trimmedResult := &qbtypes.Result{
|
||||
Type: result.Type,
|
||||
Stats: result.Stats,
|
||||
@@ -790,8 +815,10 @@ func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, fluxBoun
|
||||
|
||||
// Filter values to exclude those beyond flux boundary and partial values
|
||||
for _, value := range series.Values {
|
||||
// Skip partial values - they cannot be cached
|
||||
if value.Partial {
|
||||
// Skip partial values - they cannot be cached. A lookback
|
||||
// step before the request window is flagged partial
|
||||
// against that window but is a whole step, so it stays.
|
||||
if value.Partial && uint64(value.Timestamp) >= requestStartMs {
|
||||
continue
|
||||
}
|
||||
if uint64(value.Timestamp) <= fluxBoundary {
|
||||
|
||||
@@ -88,13 +88,6 @@ func (q *builderQuery[T]) Fingerprint() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// A limit ranks the groups over the statement window, so the pieces of a
|
||||
// window rank differently from the window. Such a query is never served
|
||||
// from the cache.
|
||||
if (q.spec.Signal == telemetrytypes.SignalLogs || q.spec.Signal == telemetrytypes.SignalTraces) && len(q.spec.GroupBy) > 0 && q.spec.Limit > 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Create a deterministic fingerprint for builder queries
|
||||
// This needs to include all fields that affect the query results
|
||||
parts := []string{q.queryType.StringValue()}
|
||||
|
||||
@@ -71,7 +71,7 @@ func TestTrimResultToFluxBoundaryKeepsTheHeatmapAxis(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
trimmed := cache.trimResultToFluxBoundary(result, 1710000060000)
|
||||
trimmed := cache.trimResultToFluxBoundary(result, 0, 1710000060000)
|
||||
|
||||
tsData, ok := trimmed.Value.(*qbtypes.TimeSeriesData)
|
||||
require.True(t, ok)
|
||||
|
||||
@@ -425,12 +425,6 @@ func (q *querier) processTimeSeriesFormula(
|
||||
return nil
|
||||
}
|
||||
|
||||
formulaSeries, err = applyFormulaHaving(formula, formulaSeries)
|
||||
if err != nil {
|
||||
q.logger.ErrorContext(ctx, "failed to apply formula having", errors.Attr(err), slog.String("formula", formula.Name))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create result for formula
|
||||
formulaResult := &qbtypes.TimeSeriesData{
|
||||
QueryName: formula.Name,
|
||||
@@ -666,12 +660,6 @@ func (q *querier) processScalarFormula(
|
||||
return nil
|
||||
}
|
||||
|
||||
formulaSeries, err = applyFormulaHaving(formula, formulaSeries)
|
||||
if err != nil {
|
||||
q.logger.ErrorContext(ctx, "failed to apply formula having", errors.Attr(err), slog.String("formula", formula.Name))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply ordering (and limit) before converting to scalar format.
|
||||
formulaSeries = qbtypes.ApplySeriesLimit(formulaSeries, formula.Order, formula.Limit, formula.Offset)
|
||||
|
||||
@@ -722,35 +710,6 @@ func (q *querier) processScalarFormula(
|
||||
}
|
||||
}
|
||||
|
||||
// applyFormulaHaving keeps the values the formula having accepts, as SQL HAVING
|
||||
// keeps rows for a builder query, so a series with no values left is removed. It
|
||||
// runs before functions, order, limit, and offset, as HAVING runs before them in SQL.
|
||||
func applyFormulaHaving(formula qbtypes.QueryBuilderFormula, series []*qbtypes.TimeSeries) ([]*qbtypes.TimeSeries, error) {
|
||||
if formula.Having == nil || strings.TrimSpace(formula.Having.Expression) == "" {
|
||||
return series, nil
|
||||
}
|
||||
|
||||
keep, err := querybuilder.CompileFormulaHaving(formula.Name, formula.Having.Expression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
kept := series[:0]
|
||||
for _, s := range series {
|
||||
values := s.Values[:0]
|
||||
for _, value := range s.Values {
|
||||
if keep(value.Value) {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
if len(values) > 0 {
|
||||
s.Values = values
|
||||
kept = append(kept, s)
|
||||
}
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// filterDisabledQueries removes results for disabled queries.
|
||||
func (q *querier) filterDisabledQueries(results map[string]*qbtypes.Result, req *qbtypes.QueryRangeRequest) map[string]*qbtypes.Result {
|
||||
filtered := make(map[string]*qbtypes.Result)
|
||||
|
||||
@@ -212,14 +212,6 @@ func (q *querier) buildQueries(
|
||||
}
|
||||
|
||||
switch query.Type {
|
||||
case qbtypes.QueryTypeFormula:
|
||||
// the querier evaluates a formula after its queries run, so a bad
|
||||
// having must fail here, before they run
|
||||
if formula, ok := query.Spec.(qbtypes.QueryBuilderFormula); ok && formula.Having != nil && strings.TrimSpace(formula.Having.Expression) != "" {
|
||||
if _, err := querybuilder.CompileFormulaHaving(formula.Name, formula.Having.Expression); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
case qbtypes.QueryTypePromQL:
|
||||
promQuery, ok := query.Spec.(qbtypes.PromQuery)
|
||||
if !ok {
|
||||
@@ -822,7 +814,7 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
|
||||
|
||||
// If entire range is missing, execute normally
|
||||
if cachedResult == nil && len(missingRanges) == 1 {
|
||||
startMs, endMs := query.Window()
|
||||
startMs, endMs := cacheWindow(query, uint64(step.Milliseconds()))
|
||||
if missingRanges[0].From == startMs && missingRanges[0].To == endMs {
|
||||
sem <- struct{}{}
|
||||
result, err := query.Execute(ctx)
|
||||
@@ -906,6 +898,11 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
|
||||
|
||||
// Merge cached and fresh results
|
||||
mergedResult := q.mergeResults(cachedResult, freshResults)
|
||||
// A gap statement at the start of the cache window reads its own lookback
|
||||
// step before it, which the request's statement would not read.
|
||||
if cacheStartMs, _ := cacheWindow(query, uint64(step.Milliseconds())); lookbackSteps(query) > 0 {
|
||||
dropValuesBefore(mergedResult, cacheStartMs)
|
||||
}
|
||||
mergedResult.Stats.RowsScanned += totalStats.RowsScanned
|
||||
mergedResult.Stats.BytesScanned += totalStats.BytesScanned
|
||||
mergedResult.Stats.DurationMS += totalStats.DurationMS
|
||||
@@ -916,6 +913,18 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
|
||||
return mergedResult, nil
|
||||
}
|
||||
|
||||
func dropValuesBefore(result *qbtypes.Result, fromMs uint64) {
|
||||
data, ok := result.Value.(*qbtypes.TimeSeriesData)
|
||||
if !ok || data == nil {
|
||||
return
|
||||
}
|
||||
for _, aggBucket := range data.Aggregations {
|
||||
for _, series := range aggBucket.Series {
|
||||
series.Values = slices.DeleteFunc(series.Values, func(value *qbtypes.TimeSeriesValue) bool { return uint64(value.Timestamp) < fromMs })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createRangedQuery creates a copy of the query with a different time range.
|
||||
func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query, timeRange qbtypes.TimeRange) qbtypes.Query {
|
||||
// this is called in a goroutine, so we create a copy of the query to avoid race conditions
|
||||
@@ -930,25 +939,32 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
|
||||
copy(argsCopy, qt.args)
|
||||
return newchSQLQuery(q.logger, q.telemetryStore, queryCopy, argsCopy, timeRange, qt.kind, qt.vars)
|
||||
|
||||
// A builder query's window was moved into the shifted clock when it was
|
||||
// built, so a range taken from that window must not be shifted again.
|
||||
case *builderQuery[qbtypes.TraceAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
// reuse the original query's statement builder and type so an AI query
|
||||
// keeps its AI builder and cache key
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, qt.builderConfig)
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, qt.builderConfig)
|
||||
|
||||
case *builderQuery[qbtypes.LogAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
shiftStmtBuilder := q.logStmtBuilder
|
||||
if qt.spec.Source == telemetrytypes.SourceAudit {
|
||||
shiftStmtBuilder = q.auditStmtBuilder
|
||||
}
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, shiftStmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, q.builderConfig)
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, shiftStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, q.builderConfig)
|
||||
|
||||
case *builderQuery[qbtypes.MetricAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
if qt.spec.Source == telemetrytypes.SourceMeter {
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.meterStmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, builderConfig{})
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.meterStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
}
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.metricStmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, builderConfig{})
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.metricStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
case *traceOperatorQuery:
|
||||
specCopy := qt.spec.Copy()
|
||||
return &traceOperatorQuery{
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/havingexpression/grammar"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
// CompileFormulaHaving turns the having expression of a formula into a filter on
|
||||
// its values. The querier computes a formula in Go after ClickHouse returns, so the
|
||||
// having cannot run as SQL; it gets the same validation and messages as a
|
||||
// builder-query having, with the formula name and __result as the only references.
|
||||
func CompileFormulaHaving(formulaName, expression string) (func(value float64) bool, error) {
|
||||
rewriter := &HavingExpressionRewriter{columnMap: map[string]string{
|
||||
formulaName: qbtypes.DefaultOrderByKey,
|
||||
qbtypes.DefaultOrderByKey: qbtypes.DefaultOrderByKey,
|
||||
}}
|
||||
_, tree, err := rewriter.parseAndValidate(expression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return compileOrExpression(tree.Expression().OrExpression()), nil
|
||||
}
|
||||
|
||||
func compileOrExpression(ctx grammar.IOrExpressionContext) func(float64) bool {
|
||||
andExpressions := ctx.AllAndExpression()
|
||||
conditions := make([]func(float64) bool, len(andExpressions))
|
||||
for i, andExpression := range andExpressions {
|
||||
conditions[i] = compileAndExpression(andExpression)
|
||||
}
|
||||
|
||||
return func(value float64) bool {
|
||||
for _, condition := range conditions {
|
||||
if condition(value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func compileAndExpression(ctx grammar.IAndExpressionContext) func(float64) bool {
|
||||
primaries := ctx.AllPrimary()
|
||||
conditions := make([]func(float64) bool, len(primaries))
|
||||
for i, primary := range primaries {
|
||||
conditions[i] = compilePrimary(primary)
|
||||
}
|
||||
|
||||
return func(value float64) bool {
|
||||
for _, condition := range conditions {
|
||||
if !condition(value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func compilePrimary(ctx grammar.IPrimaryContext) func(float64) bool {
|
||||
var condition func(float64) bool
|
||||
if ctx.OrExpression() != nil {
|
||||
condition = compileOrExpression(ctx.OrExpression())
|
||||
} else {
|
||||
condition = compileComparison(ctx.Comparison())
|
||||
}
|
||||
|
||||
if ctx.NOT() == nil {
|
||||
return condition
|
||||
}
|
||||
return func(value float64) bool { return !condition(value) }
|
||||
}
|
||||
|
||||
func compileComparison(ctx grammar.IComparisonContext) func(float64) bool {
|
||||
left := compileOperand(ctx.Operand(0))
|
||||
|
||||
if ctx.IN() != nil {
|
||||
signedNumbers := ctx.InList().AllSignedNumber()
|
||||
list := make([]float64, len(signedNumbers))
|
||||
for i, signedNumber := range signedNumbers {
|
||||
list[i] = parseHavingNumber(signedNumber.GetText())
|
||||
}
|
||||
notIn := ctx.NOT() != nil
|
||||
return func(value float64) bool { return slices.Contains(list, left(value)) != notIn }
|
||||
}
|
||||
|
||||
right := compileOperand(ctx.Operand(1))
|
||||
compOp := ctx.CompOp()
|
||||
switch {
|
||||
case compOp.EQUALS() != nil:
|
||||
return func(value float64) bool { return left(value) == right(value) }
|
||||
case compOp.NOT_EQUALS() != nil, compOp.NEQ() != nil:
|
||||
return func(value float64) bool { return left(value) != right(value) }
|
||||
case compOp.LT() != nil:
|
||||
return func(value float64) bool { return left(value) < right(value) }
|
||||
case compOp.LE() != nil:
|
||||
return func(value float64) bool { return left(value) <= right(value) }
|
||||
case compOp.GT() != nil:
|
||||
return func(value float64) bool { return left(value) > right(value) }
|
||||
default:
|
||||
return func(value float64) bool { return left(value) >= right(value) }
|
||||
}
|
||||
}
|
||||
|
||||
func compileOperand(ctx grammar.IOperandContext) func(float64) float64 {
|
||||
if ctx.Operand() == nil {
|
||||
return compileTerm(ctx.Term())
|
||||
}
|
||||
|
||||
left := compileOperand(ctx.Operand())
|
||||
right := compileTerm(ctx.Term())
|
||||
if ctx.MINUS() != nil {
|
||||
return func(value float64) float64 { return left(value) - right(value) }
|
||||
}
|
||||
return func(value float64) float64 { return left(value) + right(value) }
|
||||
}
|
||||
|
||||
func compileTerm(ctx grammar.ITermContext) func(float64) float64 {
|
||||
if ctx.Term() == nil {
|
||||
return compileFactor(ctx.Factor())
|
||||
}
|
||||
|
||||
left := compileTerm(ctx.Term())
|
||||
right := compileFactor(ctx.Factor())
|
||||
switch {
|
||||
case ctx.SLASH() != nil:
|
||||
return func(value float64) float64 { return left(value) / right(value) }
|
||||
case ctx.PERCENT() != nil:
|
||||
return func(value float64) float64 { return math.Mod(left(value), right(value)) }
|
||||
default:
|
||||
return func(value float64) float64 { return left(value) * right(value) }
|
||||
}
|
||||
}
|
||||
|
||||
func compileFactor(ctx grammar.IFactorContext) func(float64) float64 {
|
||||
if ctx.Factor() != nil {
|
||||
inner := compileFactor(ctx.Factor())
|
||||
if ctx.MINUS() != nil {
|
||||
return func(value float64) float64 { return -inner(value) }
|
||||
}
|
||||
return inner
|
||||
}
|
||||
|
||||
if ctx.Operand() != nil {
|
||||
return compileOperand(ctx.Operand())
|
||||
}
|
||||
|
||||
if number := ctx.Atom().NUMBER(); number != nil {
|
||||
constant := parseHavingNumber(number.GetText())
|
||||
return func(float64) float64 { return constant }
|
||||
}
|
||||
|
||||
// validation rejects function calls, strings, and unknown names, so the atom
|
||||
// names the formula value
|
||||
return func(value float64) float64 { return value }
|
||||
}
|
||||
|
||||
// ParseFloat reads every form the lexer admits as a number; a literal outside the
|
||||
// float64 range becomes the nearest value ParseFloat returns.
|
||||
func parseHavingNumber(text string) float64 {
|
||||
number, _ := strconv.ParseFloat(text, 64)
|
||||
return number
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
func TestCompileFormulaHaving_Matches(t *testing.T) {
|
||||
values := []float64{-2, 0, 1, 2.5, 3, 10}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
expression string
|
||||
want []float64
|
||||
}{
|
||||
{name: "Name_GreaterThan", expression: "F1 > 2", want: []float64{2.5, 3, 10}},
|
||||
{name: "Result_LessOrEqual", expression: "__result <= 1", want: []float64{-2, 0, 1}},
|
||||
{name: "DoubleEquals", expression: "F1 == 3", want: []float64{3}},
|
||||
{name: "AngleNotEquals", expression: "F1 <> 0", want: []float64{-2, 1, 2.5, 3, 10}},
|
||||
{name: "In_Parentheses", expression: "F1 IN (0, 3, -2)", want: []float64{-2, 0, 3}},
|
||||
{name: "NotIn_Brackets", expression: "F1 NOT IN [0, 3, -2]", want: []float64{1, 2.5, 10}},
|
||||
{name: "AndBindsTighterThanOr", expression: "F1 > 0 AND F1 < 3 OR F1 = -2", want: []float64{-2, 1, 2.5}},
|
||||
{name: "ImplicitAnd", expression: "F1 > 0 F1 < 3", want: []float64{1, 2.5}},
|
||||
{name: "LowercaseKeywords", expression: "F1 > 2 and not F1 = 10", want: []float64{2.5, 3}},
|
||||
{name: "NotParenthesized", expression: "NOT (F1 >= 1 AND F1 <= 3)", want: []float64{-2, 0, 10}},
|
||||
{name: "Arithmetic", expression: "F1 * 2 - 1 > 4", want: []float64{3, 10}},
|
||||
{name: "Arithmetic_Parentheses", expression: "(F1 + 2) / 2 >= 2", want: []float64{2.5, 3, 10}},
|
||||
{name: "Modulo", expression: "F1 % 2 = 1", want: []float64{1, 3}},
|
||||
{name: "UnaryMinus", expression: "-F1 > 1", want: []float64{-2}},
|
||||
{name: "Number_Exponent", expression: "F1 >= 1e1", want: []float64{10}},
|
||||
{name: "Number_LeadingDot", expression: "F1 < .5", want: []float64{-2, 0}},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
keep, err := CompileFormulaHaving("F1", testCase.expression)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, keep)
|
||||
|
||||
got := []float64{}
|
||||
for _, value := range values {
|
||||
if keep(value) {
|
||||
got = append(got, value)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, testCase.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileFormulaHaving_Invalid(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
expression string
|
||||
wantErrMsg string
|
||||
wantAdditional []string
|
||||
}{
|
||||
{
|
||||
name: "QueryName_NotAReference",
|
||||
expression: "A > 0",
|
||||
wantErrMsg: "Invalid references in `Having` expression: [A]",
|
||||
wantAdditional: []string{"Valid references are: [F1, __result]"},
|
||||
},
|
||||
{
|
||||
name: "Aggregation_NotAReference",
|
||||
expression: "count() > 0",
|
||||
wantErrMsg: "Invalid references in `Having` expression: [count]",
|
||||
wantAdditional: []string{"Valid references are: [F1, __result]"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
keep, err := CompileFormulaHaving("F1", testCase.expression)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, keep)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
assert.ErrorContains(t, err, testCase.wantErrMsg)
|
||||
assert.Equal(t, testCase.wantAdditional, additionalMessages(err))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -237,13 +237,7 @@ func (v *havingExpressionRewriteVisitor) visitIdentifier(ctx grammar.IIdentifier
|
||||
return name
|
||||
}
|
||||
|
||||
func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string, error) {
|
||||
rewritten, _, err := r.parseAndValidate(expression)
|
||||
return rewritten, err
|
||||
}
|
||||
|
||||
// parseAndValidate is the single-pass implementation used by all RewriteFor* methods.
|
||||
// It also returns the parse tree, which CompileFormulaHaving evaluates in Go.
|
||||
// rewriteAndValidate is the single-pass implementation used by all RewriteFor* methods.
|
||||
//
|
||||
// Validation layers:
|
||||
// 1. The visitor runs on the parse tree, rewriting and collecting invalid references.
|
||||
@@ -254,7 +248,7 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
// 2. String literals in comparison-operand position → descriptive error
|
||||
// ("aggregator results are numeric").
|
||||
// 3. ANTLR syntax errors → error with messages referencing the original token names.
|
||||
func (r *HavingExpressionRewriter) parseAndValidate(expression string) (string, grammar.IQueryContext, error) {
|
||||
func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string, error) {
|
||||
original := strings.TrimSpace(expression)
|
||||
|
||||
// Parse the expression once.
|
||||
@@ -287,7 +281,7 @@ func (r *HavingExpressionRewriter) parseAndValidate(expression string) (string,
|
||||
// This is checked before invalid references so that "contains string literals" takes
|
||||
// priority when a bare string literal is also an unresolvable operand.
|
||||
if v.hasStringLiteral {
|
||||
return "", nil, errors.NewInvalidInputf(
|
||||
return "", errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"`Having` expression contains string literals",
|
||||
).WithAdditional("Aggregator results are numeric")
|
||||
@@ -323,7 +317,7 @@ func (r *HavingExpressionRewriter) parseAndValidate(expression string) (string,
|
||||
).WithAdditional(
|
||||
"Valid references are: [" + strings.Join(validKeys, ", ") + "]",
|
||||
).WithSuggestions(suggestions...)
|
||||
return "", nil, havingErr
|
||||
return "", havingErr
|
||||
}
|
||||
|
||||
// Layer 3 – ANTLR syntax errors. We parse the original expression, so error messages
|
||||
@@ -349,12 +343,12 @@ func (r *HavingExpressionRewriter) parseAndValidate(expression string) (string,
|
||||
return havingSuggestion(allSyntaxErrors[0], original)
|
||||
})
|
||||
|
||||
return "", nil, havingErr.WithSuggestiveAdditional(msgs[0], suggestions...)
|
||||
return "", havingErr.WithSuggestiveAdditional(msgs[0], suggestions...)
|
||||
}
|
||||
return "", nil, havingErr.WithAdditional(msgs...)
|
||||
return "", havingErr.WithAdditional(msgs...)
|
||||
}
|
||||
|
||||
return result, tree, nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// havingSuggestion returns a corrected expression string to show as a suggestion when
|
||||
|
||||
@@ -28,7 +28,7 @@ type QueryBuilderFormula struct {
|
||||
|
||||
Offset int `json:"offset,omitempty" description:"The number of ordered series or rows to skip before the page starts. Requires limit."`
|
||||
|
||||
Having *Having `json:"having,omitempty" description:"A filter on the formula values, referencing the formula by its name or as __result, for example F1 > 0. A time series keeps only the points that match and drops a series with none left; a scalar result keeps only the rows that match."`
|
||||
Having *Having `json:"having,omitempty" description:"Accepted but currently ignored; the computed values are returned unfiltered."`
|
||||
|
||||
Functions []Function `json:"functions,omitzero" description:"Post-processing functions applied in order to each result series."`
|
||||
|
||||
@@ -118,10 +118,6 @@ func (f QueryBuilderFormula) Validate() error {
|
||||
)
|
||||
}
|
||||
|
||||
if f.Limit < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be non-negative, got %d", f.Limit)
|
||||
}
|
||||
|
||||
// Validate functions if present
|
||||
for i, fn := range f.Functions {
|
||||
if err := fn.Validate(); err != nil {
|
||||
|
||||
@@ -23,8 +23,7 @@ func (r *RequestType) UnmarshalJSON(data []byte) error {
|
||||
*r = v
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q", s).
|
||||
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q; allowed values: %s", s, "`scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `distribution`, `heatmap`")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -499,18 +499,6 @@ func (q *QueryBuilderQuery[T]) validateOrderBy(cfg validationConfig) error {
|
||||
return q.validateOrderByForAggregation()
|
||||
}
|
||||
|
||||
// a raw order key resolves like a select field, so only an empty one is
|
||||
// invalid before resolution
|
||||
for i, order := range q.Order {
|
||||
if order.Key.Name == "" {
|
||||
orderID := fmt.Sprintf("order by clause #%d", i+1)
|
||||
if q.Name != "" {
|
||||
orderID = fmt.Sprintf("order by clause #%d in query '%s'", i+1, q.Name)
|
||||
}
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid empty key name for %s", orderID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -598,10 +586,19 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.validateRequestType(); err != nil {
|
||||
return err
|
||||
// Validate request type
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap, RequestTypeDistribution:
|
||||
opts = append(opts, GetValidationOptions(r.RequestType)...)
|
||||
default:
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid request type: %s",
|
||||
r.RequestType,
|
||||
).WithAdditional(
|
||||
"Valid request types are: raw, timeseries, scalar, heatmap, distribution",
|
||||
)
|
||||
}
|
||||
opts = append(opts, GetValidationOptions(r.RequestType)...)
|
||||
|
||||
if err := r.validateBucketCounting(); err != nil {
|
||||
return err
|
||||
@@ -642,10 +639,14 @@ func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.validateRequestType(); err != nil {
|
||||
return nil, err
|
||||
var opts []ValidationOption
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap, RequestTypeDistribution:
|
||||
opts = GetValidationOptions(r.RequestType)
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid request type: %s", r.RequestType).
|
||||
WithAdditional("Valid request types are: raw, timeseries, scalar, heatmap, distribution")
|
||||
}
|
||||
opts := GetValidationOptions(r.RequestType)
|
||||
|
||||
if err := r.validateBucketCounting(); err != nil {
|
||||
return nil, err
|
||||
@@ -702,20 +703,6 @@ func (e QueryEnvelope) Validate(opts ...ValidationOption) error {
|
||||
return validateQueryEnvelope(e, opts...)
|
||||
}
|
||||
|
||||
// Enum is the one list of request types a query can use, so the spec and the
|
||||
// suggestions agree with what is accepted.
|
||||
func (r *QueryRangeRequest) validateRequestType() error {
|
||||
if slices.Contains(RequestType{}.Enum(), any(r.RequestType)) {
|
||||
return nil
|
||||
}
|
||||
if r.RequestType == RequestTypeUnknown {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "requestType is required").
|
||||
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
|
||||
}
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request type %q is not supported", r.RequestType.StringValue()).
|
||||
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
|
||||
}
|
||||
|
||||
func (r *QueryRangeRequest) validateTimeRange() error {
|
||||
if r.RequestType == RequestTypeRawStream {
|
||||
return nil
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -527,28 +521,6 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
|
||||
wantErr: true,
|
||||
errMsg: "expression cannot be blank",
|
||||
},
|
||||
{
|
||||
name: "Formula_NegativeLimit_Rejected",
|
||||
request: QueryRangeRequest{
|
||||
Start: 1640995200000,
|
||||
End: 1640998800000,
|
||||
RequestType: RequestTypeTimeSeries,
|
||||
CompositeQuery: CompositeQuery{
|
||||
Queries: []QueryEnvelope{
|
||||
{
|
||||
Type: QueryTypeFormula,
|
||||
Spec: QueryBuilderFormula{
|
||||
Name: "F1",
|
||||
Expression: "A * 2",
|
||||
Limit: -5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid formula 'F1': limit must be non-negative, got -5",
|
||||
},
|
||||
{
|
||||
name: "promql with empty query should return error",
|
||||
request: QueryRangeRequest{
|
||||
@@ -724,31 +696,6 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
|
||||
wantErr: true,
|
||||
errMsg: "raw request type is not supported for metric queries",
|
||||
},
|
||||
{
|
||||
name: "Raw_EmptyOrderKey_Rejected",
|
||||
request: QueryRangeRequest{
|
||||
Start: 1640995200000,
|
||||
End: 1640998800000,
|
||||
RequestType: RequestTypeRaw,
|
||||
CompositeQuery: CompositeQuery{
|
||||
Queries: []QueryEnvelope{
|
||||
{
|
||||
Type: QueryTypeBuilder,
|
||||
Spec: QueryBuilderQuery[LogAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Order: []OrderBy{
|
||||
{Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "timestamp"}}, Direction: OrderDirectionDesc},
|
||||
{Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: ""}}, Direction: OrderDirectionAsc},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid query 'B': invalid empty key name for order by clause #2 in query 'B'",
|
||||
},
|
||||
{
|
||||
name: "timeseries request with group by timestamp should return error",
|
||||
request: QueryRangeRequest{
|
||||
@@ -1460,44 +1407,6 @@ func TestRequestType_IsAggregation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRangeRequest_ValidateRequestType(t *testing.T) {
|
||||
validRequestTypes := []string{"valid request types are `scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `heatmap`, `distribution`"}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
requestType string
|
||||
message string
|
||||
}{
|
||||
{
|
||||
name: "Missing_Required",
|
||||
requestType: "",
|
||||
message: "requestType is required",
|
||||
},
|
||||
{
|
||||
name: "Timeseries_Unknown",
|
||||
requestType: `"requestType": "timeseries",`,
|
||||
message: `unknown request type "timeseries"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
body := fmt.Sprintf(`{"schemaVersion": "v1", "start": 1640995200000, "end": 1640998800000, %s "compositeQuery": {"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs"}}]}}`, testCase.requestType)
|
||||
|
||||
var request QueryRangeRequest
|
||||
err := json.Unmarshal([]byte(body), &request)
|
||||
if err == nil {
|
||||
err = request.Validate()
|
||||
}
|
||||
require.Error(t, err)
|
||||
|
||||
got := errors.AsJSON(err)
|
||||
assert.Equal(t, testCase.message, got.Message)
|
||||
assert.Equal(t, validRequestTypes, got.Suggestions)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonAggregationFieldsSkipped(t *testing.T) {
|
||||
// Fields that only apply to aggregation queries (groupBy, having, aggregations)
|
||||
// should be silently skipped for non-aggregation request types.
|
||||
|
||||
481
tests/e2e/drivers/saved-views.ts
generated
481
tests/e2e/drivers/saved-views.ts
generated
@@ -1,481 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
type Locator,
|
||||
type Page,
|
||||
type Request,
|
||||
type Response,
|
||||
} from '@playwright/test';
|
||||
|
||||
import { BASE_PATH } from './base-path';
|
||||
import { collectRequests } from './console';
|
||||
import { dismissQuickFiltersAnnouncement } from './logs/explorer';
|
||||
import {
|
||||
filterExpressions,
|
||||
isQueryRangeRequest,
|
||||
queryRangeBody,
|
||||
} from './saved-views/query';
|
||||
import { SAVED_VIEWS_API, seededView } from './saved-views/spec';
|
||||
import type {
|
||||
GettableSavedView,
|
||||
PostableSavedView,
|
||||
SavedViewSource,
|
||||
SeededView,
|
||||
} from './saved-views/spec.types';
|
||||
|
||||
import { driver, kit } from './kit';
|
||||
import {
|
||||
createViaApi,
|
||||
getViaApi,
|
||||
removeTrackedSavedViews,
|
||||
statusViaApi,
|
||||
trackSavedView,
|
||||
} from './saved-views/api';
|
||||
|
||||
// The saved views header and list on the explorers: the sliding list on logs,
|
||||
// traces and meter, the pinned one on metrics.
|
||||
|
||||
export const MY_VIEW = 'My view';
|
||||
|
||||
const EXPLORER_PATH: Record<SavedViewSource, string> = {
|
||||
logs: '/logs/logs-explorer',
|
||||
traces: '/traces-explorer',
|
||||
metrics: '/metrics-explorer/explorer',
|
||||
meter: '/meter/explorer',
|
||||
};
|
||||
|
||||
export function explorerPathname(source: SavedViewSource): string {
|
||||
return `${BASE_PATH}${EXPLORER_PATH[source]}`;
|
||||
}
|
||||
|
||||
/** Row-menu items by the suffix of their `saved-views-row-menu-item-<key>` test id. */
|
||||
export type RowAction = 'copyLink' | 'copyId' | 'editDetails' | 'delete';
|
||||
|
||||
/** Save-menu items by the suffix of their `saved-views-save-changes-item-<key>` test id. */
|
||||
export type SaveAction = 'update' | 'save-as-new';
|
||||
|
||||
export type LinkGesture = 'modifier' | 'middle';
|
||||
|
||||
export interface OpenedTab {
|
||||
page: Page;
|
||||
savedViews: SavedViews;
|
||||
}
|
||||
|
||||
export type ExplorerTab = 'list' | 'timeseries' | 'table';
|
||||
|
||||
export interface SavedViews {
|
||||
/** Created over the page's own session; deleted when the test ends. */
|
||||
seed(view: PostableSavedView): Promise<SeededView>;
|
||||
/** The stored view, read straight from the API. */
|
||||
get(id: string): Promise<GettableSavedView>;
|
||||
/** The status of `GET /api/v2/saved_views/<id>`, for asserting a view is gone. */
|
||||
status(id: string): Promise<number>;
|
||||
|
||||
/** On a bare url by default, which is `relativeTime=1h` and nothing else. */
|
||||
openExplorer(
|
||||
source?: SavedViewSource,
|
||||
params?: Record<string, string>,
|
||||
): Promise<void>;
|
||||
/** Reloads and waits for the explorer to send the filter's query. */
|
||||
reload(filter?: string, requestType?: string): Promise<void>;
|
||||
/** The explorer's next query that carries `filter`, of `requestType` when given. */
|
||||
queryRan(filter: string, requestType?: string): Promise<Response>;
|
||||
/** The current url. */
|
||||
url(): URL;
|
||||
/** A query param of the current url, where the open view and time range live. */
|
||||
param(name: string): string | null;
|
||||
/** The id in the `viewKey` param, or null while no view is open. */
|
||||
activeViewId(): string | null;
|
||||
|
||||
header(): Locator;
|
||||
name(): Locator;
|
||||
createButton(): Locator;
|
||||
clearButton(): Locator;
|
||||
discardButton(): Locator;
|
||||
saveChangesButton(): Locator;
|
||||
saveAction(action: SaveAction): Locator;
|
||||
/** The view is open and nothing differs from what is stored. */
|
||||
expectClean(displayName: string): Promise<void>;
|
||||
/** The view is open and the explorer holds changes to it. */
|
||||
expectDirty(displayName: string): Promise<void>;
|
||||
/** No view is open. */
|
||||
expectNoView(): Promise<void>;
|
||||
toast(text: string): Locator;
|
||||
|
||||
openListButton(): Locator;
|
||||
panel(): Locator;
|
||||
searchInput(): Locator;
|
||||
openList(): Promise<void>;
|
||||
/** Narrows the list, and waits for exactly `rows` rows when given. */
|
||||
search(text: string, rows?: number): Promise<void>;
|
||||
rows(): Locator;
|
||||
row(id: string): Locator;
|
||||
rowLink(id: string): Locator;
|
||||
/** The row link's href, resolved against the current page. */
|
||||
rowHref(id: string): Promise<URL>;
|
||||
/**
|
||||
* Searches for the view, opens it from its row and waits for the explorer's
|
||||
* query for it. Resolves with that query's response.
|
||||
*/
|
||||
open(view: SeededView): Promise<Response>;
|
||||
|
||||
rowMenuButton(id: string): Locator;
|
||||
rowAction(action: RowAction): Locator;
|
||||
openRowMenu(id: string): Promise<void>;
|
||||
/** Opens the row's menu and picks an action once its permission check settles. */
|
||||
chooseRowAction(id: string, action: RowAction): Promise<void>;
|
||||
/** Opens the row's menu and picks Copy link, returning the copied text. */
|
||||
copyLink(id: string): Promise<string>;
|
||||
/**
|
||||
* Ctrl or Cmd click, or a middle click, on the row link. Resolves once the
|
||||
* tab exists, not once it has loaded; the driver is bound to the new tab.
|
||||
*/
|
||||
openInNewTab(id: string, gesture?: LinkGesture): Promise<OpenedTab>;
|
||||
/**
|
||||
* Opens `url` in a new tab of the same browser context and waits for the
|
||||
* explorer's query for `view`.
|
||||
*/
|
||||
openLink(url: URL | string, view: SeededView): Promise<OpenedTab>;
|
||||
/** Tabs the browser context has opened since this driver was built. */
|
||||
newTabCount(): number;
|
||||
rename(id: string, displayName: string): Promise<void>;
|
||||
/** Confirms the delete dialog and waits for the DELETE. */
|
||||
deleteView(id: string): Promise<void>;
|
||||
|
||||
modal(): Locator;
|
||||
modalNameInput(): Locator;
|
||||
modalSubmit(): Locator;
|
||||
deleteConfirm(): Locator;
|
||||
/** Create from the header: names the view, saves it and returns its id. */
|
||||
createFromHeader(displayName: string): Promise<string>;
|
||||
/** Types into the query bar, runs it and waits for the query to carry it. */
|
||||
applyFilter(filter: string): Promise<void>;
|
||||
queryBar(): Locator;
|
||||
/** Header cells of the logs table with a title: the state indicator has none. */
|
||||
logsColumnHeaders(): Locator;
|
||||
openLogsFormatMenu(): Promise<void>;
|
||||
closeLogsFormatMenu(): Promise<void>;
|
||||
/** The checked format in the open menu: Raw, Default or Column. */
|
||||
selectedLogsFormat(): Locator;
|
||||
logsMaxLines(): Locator;
|
||||
logsFontSize(): Locator;
|
||||
explorerTab(tab: ExplorerTab): Locator;
|
||||
/** Changes when the document reloads and not when the app navigates in place. */
|
||||
documentId(): Promise<number>;
|
||||
/** Update selected view, waiting for the PUT and the toast. */
|
||||
update(): Promise<void>;
|
||||
discard(): Promise<void>;
|
||||
|
||||
/** `GET /api/v2/saved_views/<id>` requests the page has made so far. */
|
||||
viewGets(id: string): Request[];
|
||||
}
|
||||
|
||||
/** The driver on any page of the same context, such as a tab a link opened. */
|
||||
export function savedViewsFor(page: Page): SavedViews {
|
||||
const requests = collectRequests(page);
|
||||
let newTabs = 0;
|
||||
page.context().on('page', () => {
|
||||
newTabs += 1;
|
||||
});
|
||||
const tid = (id: string): Locator => page.getByTestId(id);
|
||||
const formatMenu = (): Locator => page.locator('.format-options-popover');
|
||||
const viewPath = (id: string): string =>
|
||||
`${BASE_PATH}${SAVED_VIEWS_API}/${id}`;
|
||||
|
||||
// A menu row, like a header button, ignores clicks until its permission
|
||||
// check settles, and stays enabled-looking meanwhile.
|
||||
const settle = async (control: Locator): Promise<void> => {
|
||||
await expect(control).toBeVisible();
|
||||
await expect(control).not.toHaveAttribute('aria-busy');
|
||||
await expect(control).not.toHaveAttribute('data-loading');
|
||||
await expect(control).not.toHaveAttribute('data-disabled');
|
||||
};
|
||||
const press = async (control: Locator): Promise<void> => {
|
||||
await settle(control);
|
||||
await control.click();
|
||||
};
|
||||
|
||||
const savedViews: SavedViews = {
|
||||
async seed(view) {
|
||||
const id = await createViaApi(page, view);
|
||||
trackSavedView(page, id);
|
||||
return seededView(id, view);
|
||||
},
|
||||
get: (id) => getViaApi(page, id),
|
||||
status: (id) => statusViaApi(page, id),
|
||||
|
||||
async openExplorer(source = 'logs', params = { relativeTime: '1h' }) {
|
||||
await dismissQuickFiltersAnnouncement(page);
|
||||
const search = new URLSearchParams(params).toString();
|
||||
await page.goto(`${EXPLORER_PATH[source]}${search ? `?${search}` : ''}`);
|
||||
await expect(savedViews.header()).toBeVisible();
|
||||
},
|
||||
async reload(filter, requestType) {
|
||||
await Promise.all([
|
||||
filter === undefined ? undefined : savedViews.queryRan(filter, requestType),
|
||||
page.reload(),
|
||||
]);
|
||||
await expect(savedViews.header()).toBeVisible();
|
||||
},
|
||||
queryRan: (filter, requestType) =>
|
||||
page.waitForResponse((res) => {
|
||||
const request = res.request();
|
||||
return (
|
||||
isQueryRangeRequest(request) &&
|
||||
filterExpressions(request).includes(filter) &&
|
||||
(requestType === undefined ||
|
||||
queryRangeBody(request).requestType === requestType)
|
||||
);
|
||||
}),
|
||||
url: () => new URL(page.url()),
|
||||
param: (name) => new URL(page.url()).searchParams.get(name),
|
||||
activeViewId() {
|
||||
const raw = savedViews.param('viewKey');
|
||||
return raw === null ? null : (JSON.parse(raw) as string);
|
||||
},
|
||||
|
||||
header: () => tid('saved-views-header'),
|
||||
name: () => tid('saved-views-name'),
|
||||
createButton: () => tid('saved-views-create'),
|
||||
clearButton: () => tid('saved-views-clear'),
|
||||
discardButton: () => tid('saved-views-discard'),
|
||||
saveChangesButton: () => tid('saved-views-save-changes'),
|
||||
saveAction: (action) => tid(`saved-views-save-changes-item-${action}`),
|
||||
async expectClean(displayName) {
|
||||
await expect(savedViews.name()).toHaveText(displayName);
|
||||
await expect(savedViews.clearButton()).toBeVisible();
|
||||
await expect(savedViews.discardButton()).toBeHidden();
|
||||
await expect(savedViews.saveChangesButton()).toBeHidden();
|
||||
},
|
||||
async expectDirty(displayName) {
|
||||
await expect(savedViews.name()).toHaveText(displayName);
|
||||
await expect(savedViews.discardButton()).toBeVisible();
|
||||
await expect(savedViews.saveChangesButton()).toBeVisible();
|
||||
await expect(savedViews.clearButton()).toBeHidden();
|
||||
},
|
||||
async expectNoView() {
|
||||
await expect(savedViews.name()).toHaveText(MY_VIEW);
|
||||
await expect(savedViews.createButton()).toBeVisible();
|
||||
await expect(savedViews.clearButton()).toBeHidden();
|
||||
await expect.poll(() => savedViews.param('viewKey')).toBeNull();
|
||||
},
|
||||
toast: (text) =>
|
||||
page.locator('[data-sonner-toast]').filter({ hasText: text }),
|
||||
|
||||
openListButton: () => tid('saved-views-open'),
|
||||
panel: () => tid('saved-views-panel'),
|
||||
searchInput: () => tid('saved-views-search'),
|
||||
async openList() {
|
||||
await expect(
|
||||
savedViews.openListButton().or(savedViews.panel()),
|
||||
).toBeVisible();
|
||||
if (!(await savedViews.panel().isVisible())) {
|
||||
await press(savedViews.openListButton());
|
||||
}
|
||||
await expect(savedViews.panel()).toBeVisible();
|
||||
},
|
||||
async search(text, rows) {
|
||||
await savedViews.searchInput().fill(text);
|
||||
if (rows !== undefined) {
|
||||
await expect(savedViews.rows()).toHaveCount(rows);
|
||||
}
|
||||
},
|
||||
rows: () => tid('saved-views-row'),
|
||||
row: (id) =>
|
||||
page.locator(`[data-testid="saved-views-row"][data-view-id="${id}"]`),
|
||||
rowLink: (id) => savedViews.row(id).getByTestId('saved-views-row-name'),
|
||||
async rowHref(id) {
|
||||
const href = await savedViews.rowLink(id).getAttribute('href');
|
||||
expect(href, `row ${id} has no href`).not.toBeNull();
|
||||
return new URL(href as string, page.url());
|
||||
},
|
||||
async open(view) {
|
||||
await savedViews.openList();
|
||||
await savedViews.search(view.displayName);
|
||||
const [response] = await Promise.all([
|
||||
savedViews.queryRan(view.filter, view.requestType),
|
||||
savedViews.rowLink(view.id).click(),
|
||||
]);
|
||||
await expect.poll(() => savedViews.activeViewId()).toBe(view.id);
|
||||
await expect(savedViews.name()).toHaveText(view.displayName);
|
||||
return response;
|
||||
},
|
||||
|
||||
rowMenuButton: (id) => savedViews.row(id).getByTestId('saved-views-row-menu'),
|
||||
rowAction: (action) => tid(`saved-views-row-menu-item-${action}`),
|
||||
async openRowMenu(id) {
|
||||
// The menu button has no width until the row is hovered.
|
||||
await savedViews.row(id).hover();
|
||||
await savedViews.rowMenuButton(id).click();
|
||||
},
|
||||
async chooseRowAction(id, action) {
|
||||
await savedViews.openRowMenu(id);
|
||||
await press(savedViews.rowAction(action));
|
||||
},
|
||||
async copyLink(id) {
|
||||
// Clipboard permissions are Chromium-only, so the copy is caught in the
|
||||
// page: react-use copies through a selection and execCommand, whose
|
||||
// `copy` event a capture listener sees first.
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as { __copied?: string };
|
||||
delete w.__copied;
|
||||
document.addEventListener(
|
||||
'copy',
|
||||
() => {
|
||||
w.__copied = document.getSelection()?.toString() ?? '';
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
);
|
||||
});
|
||||
await savedViews.chooseRowAction(id, 'copyLink');
|
||||
const copied = (): Promise<string | undefined> =>
|
||||
page.evaluate(() => (window as unknown as { __copied?: string }).__copied);
|
||||
await expect.poll(copied).toBeTruthy();
|
||||
return (await copied()) as string;
|
||||
},
|
||||
async openInNewTab(id, gesture = 'modifier') {
|
||||
const [tab] = await Promise.all([
|
||||
page.context().waitForEvent('page'),
|
||||
savedViews
|
||||
.rowLink(id)
|
||||
.click(
|
||||
gesture === 'modifier'
|
||||
? { modifiers: ['ControlOrMeta'] }
|
||||
: { button: 'middle' },
|
||||
),
|
||||
]);
|
||||
return { page: tab, savedViews: savedViewsFor(tab) };
|
||||
},
|
||||
async openLink(url, view) {
|
||||
const tab = await page.context().newPage();
|
||||
const opened = savedViewsFor(tab);
|
||||
await Promise.all([
|
||||
opened.queryRan(view.filter, view.requestType),
|
||||
tab.goto(url.toString()),
|
||||
]);
|
||||
return { page: tab, savedViews: opened };
|
||||
},
|
||||
newTabCount: () => newTabs,
|
||||
async rename(id, displayName) {
|
||||
await savedViews.chooseRowAction(id, 'editDetails');
|
||||
await expect(savedViews.modalNameInput()).toBeVisible();
|
||||
await savedViews.modalNameInput().fill(displayName);
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.request().method() === 'PUT' &&
|
||||
new URL(res.url()).pathname === viewPath(id),
|
||||
),
|
||||
savedViews.modalSubmit().click(),
|
||||
]);
|
||||
expect(response.ok(), `PUT ${SAVED_VIEWS_API}/${id}`).toBe(true);
|
||||
await expect(savedViews.modal()).toBeHidden();
|
||||
},
|
||||
async deleteView(id) {
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.request().method() === 'DELETE' &&
|
||||
new URL(res.url()).pathname === viewPath(id),
|
||||
),
|
||||
press(savedViews.deleteConfirm()),
|
||||
]);
|
||||
await expect(savedViews.deleteConfirm()).toBeHidden();
|
||||
},
|
||||
|
||||
modal: () => tid('save-view-modal'),
|
||||
modalNameInput: () => tid('save-view-name'),
|
||||
modalSubmit: () => tid('save-view-submit'),
|
||||
deleteConfirm: () => tid('delete-saved-view-confirm'),
|
||||
async createFromHeader(displayName) {
|
||||
await press(savedViews.createButton());
|
||||
await savedViews.modalNameInput().fill(displayName);
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.request().method() === 'POST' &&
|
||||
new URL(res.url()).pathname === `${BASE_PATH}${SAVED_VIEWS_API}`,
|
||||
),
|
||||
savedViews.modalSubmit().click(),
|
||||
]);
|
||||
expect(response.status(), `POST ${SAVED_VIEWS_API}`).toBe(201);
|
||||
const { id } = ((await response.json()) as { data: { id: string } }).data;
|
||||
trackSavedView(page, id);
|
||||
await expect(savedViews.modal()).toBeHidden();
|
||||
return id;
|
||||
},
|
||||
logsColumnHeaders: () => page.locator('thead th').filter({ hasText: /\S/ }),
|
||||
async openLogsFormatMenu() {
|
||||
await tid('periscope-btn-format-options').click();
|
||||
await expect(formatMenu()).toBeVisible();
|
||||
},
|
||||
async closeLogsFormatMenu() {
|
||||
await tid('periscope-btn-format-options').click();
|
||||
await expect(formatMenu()).toBeHidden();
|
||||
},
|
||||
selectedLogsFormat: () =>
|
||||
formatMenu()
|
||||
.locator('.menu-items .item')
|
||||
.filter({ has: page.locator('svg') }),
|
||||
logsMaxLines: () => formatMenu().getByTestId('max-lines-per-row'),
|
||||
logsFontSize: () => formatMenu().getByTestId('font-size-select'),
|
||||
explorerTab: (tab) => page.locator(`.${tab}-view-tab`),
|
||||
documentId: () => page.evaluate(() => performance.timeOrigin),
|
||||
queryBar: () =>
|
||||
page.locator('.query-where-clause-editor .cm-content').first(),
|
||||
async applyFilter(filter) {
|
||||
await Promise.all([
|
||||
savedViews.queryRan(filter),
|
||||
(async (): Promise<void> => {
|
||||
await savedViews.queryBar().click();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await page.keyboard.insertText(filter);
|
||||
await expect(savedViews.queryBar()).toHaveText(filter);
|
||||
await page.locator('[data-run-query-btn]').click();
|
||||
})(),
|
||||
]);
|
||||
},
|
||||
async update() {
|
||||
const id = savedViews.activeViewId();
|
||||
await savedViews.saveChangesButton().click();
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.request().method() === 'PUT' &&
|
||||
new URL(res.url()).pathname === viewPath(id as string),
|
||||
),
|
||||
press(savedViews.saveAction('update')),
|
||||
]);
|
||||
expect(response.ok(), `PUT ${SAVED_VIEWS_API}/${id}`).toBe(true);
|
||||
await expect(savedViews.toast('View updated')).toBeVisible();
|
||||
},
|
||||
async discard() {
|
||||
await press(savedViews.discardButton());
|
||||
},
|
||||
|
||||
viewGets: (id) =>
|
||||
requests.filter(
|
||||
(request) =>
|
||||
request.method() === 'GET' &&
|
||||
new URL(request.url()).pathname === viewPath(id),
|
||||
),
|
||||
};
|
||||
|
||||
return savedViews;
|
||||
}
|
||||
|
||||
const withSavedViews = driver(
|
||||
'savedViews',
|
||||
({ page }) => savedViewsFor(page),
|
||||
(_, { page }) => removeTrackedSavedViews(page),
|
||||
);
|
||||
|
||||
export const test = withSavedViews(kit);
|
||||
|
||||
export { expect } from './kit';
|
||||
export * from './saved-views/spec';
|
||||
export type * from './saved-views/spec.types';
|
||||
export { queryRangeBody } from './saved-views/query';
|
||||
78
tests/e2e/drivers/saved-views/api.ts
generated
78
tests/e2e/drivers/saved-views/api.ts
generated
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { authToken } from '../api';
|
||||
import { SAVED_VIEWS_API } from './spec';
|
||||
import type { GettableSavedView, PostableSavedView } from './spec.types';
|
||||
|
||||
async function bearer(page: Page): Promise<{ Authorization: string }> {
|
||||
return { Authorization: `Bearer ${await authToken(page)}` };
|
||||
}
|
||||
|
||||
export async function createViaApi(
|
||||
page: Page,
|
||||
view: PostableSavedView,
|
||||
): Promise<string> {
|
||||
const res = await page.request.post(SAVED_VIEWS_API, {
|
||||
data: view,
|
||||
headers: await bearer(page),
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`POST ${SAVED_VIEWS_API} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return ((await res.json()) as { data: { id: string } }).data.id;
|
||||
}
|
||||
|
||||
export async function getViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
): Promise<GettableSavedView> {
|
||||
const res = await page.request.get(`${SAVED_VIEWS_API}/${id}`, {
|
||||
headers: await bearer(page),
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`GET ${SAVED_VIEWS_API}/${id} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return ((await res.json()) as { data: GettableSavedView }).data;
|
||||
}
|
||||
|
||||
/** The status alone, for asserting a view is gone. */
|
||||
export async function statusViaApi(page: Page, id: string): Promise<number> {
|
||||
const res = await page.request.get(`${SAVED_VIEWS_API}/${id}`, {
|
||||
headers: await bearer(page),
|
||||
});
|
||||
return res.status();
|
||||
}
|
||||
|
||||
/** Not asserted: a test that deleted its own view must not fail on cleanup. */
|
||||
export async function deleteViaApi(page: Page, id: string): Promise<void> {
|
||||
try {
|
||||
await page.request.delete(`${SAVED_VIEWS_API}/${id}`, {
|
||||
headers: await bearer(page),
|
||||
});
|
||||
} catch {
|
||||
/* the view outlives the run rather than hiding why it failed */
|
||||
}
|
||||
}
|
||||
|
||||
const trackedByPage = new WeakMap<Page, string[]>();
|
||||
|
||||
/** Registers a view for deletion when the test owning `page` ends. */
|
||||
export function trackSavedView(page: Page, id: string): void {
|
||||
trackedByPage.set(page, [...(trackedByPage.get(page) ?? []), id]);
|
||||
}
|
||||
|
||||
export async function removeTrackedSavedViews(page: Page): Promise<void> {
|
||||
const ids = trackedByPage.get(page) ?? [];
|
||||
trackedByPage.delete(page);
|
||||
for (const id of ids) {
|
||||
await deleteViaApi(page, id);
|
||||
}
|
||||
}
|
||||
40
tests/e2e/drivers/saved-views/query.ts
generated
40
tests/e2e/drivers/saved-views/query.ts
generated
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import type { Request } from '@playwright/test';
|
||||
|
||||
import { isQueryRange } from '../query-range-mock';
|
||||
|
||||
interface QueryEnvelope {
|
||||
type: string;
|
||||
spec: {
|
||||
name?: string;
|
||||
signal?: string;
|
||||
filter?: { expression?: string };
|
||||
aggregations?: { expression?: string }[];
|
||||
groupBy?: { name: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface QueryRangeRequest {
|
||||
start: number;
|
||||
end: number;
|
||||
requestType: string;
|
||||
compositeQuery: { queries: QueryEnvelope[] };
|
||||
}
|
||||
|
||||
export function isQueryRangeRequest(request: Request): boolean {
|
||||
return request.method() === 'POST' && isQueryRange(request.url());
|
||||
}
|
||||
|
||||
export function queryRangeBody(request: Request): QueryRangeRequest {
|
||||
return request.postDataJSON() as QueryRangeRequest;
|
||||
}
|
||||
|
||||
/** The filter of every query in the request, in order. */
|
||||
export function filterExpressions(request: Request): string[] {
|
||||
return queryRangeBody(request).compositeQuery.queries.map(
|
||||
(query) => query.spec.filter?.expression ?? '',
|
||||
);
|
||||
}
|
||||
121
tests/e2e/drivers/saved-views/spec.ts
generated
121
tests/e2e/drivers/saved-views/spec.ts
generated
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
// Typed builders for the saved view spec: pure data, no Playwright. The types
|
||||
// live in `spec.types.ts`.
|
||||
//
|
||||
// `/api/v2/saved_views`: create and update take the whole spec, and an update
|
||||
// replaces it, so a field left out is reset.
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import type {
|
||||
LogsViewOptions,
|
||||
PostableSavedView,
|
||||
SavedViewColumn,
|
||||
SavedViewPanelType,
|
||||
SavedViewRequestType,
|
||||
SeededView,
|
||||
} from './spec.types';
|
||||
|
||||
export const SAVED_VIEWS_API = '/api/v2/saved_views';
|
||||
|
||||
const REQUEST_TYPE: Record<SavedViewPanelType, SavedViewRequestType> = {
|
||||
list: 'raw',
|
||||
graph: 'time_series',
|
||||
table: 'scalar',
|
||||
};
|
||||
|
||||
/** Workers and browser projects share one org, so every view name is unique. */
|
||||
export function uniqueViewName(label: string): string {
|
||||
return `e2e sv ${label} ${randomBytes(4).toString('hex')}`;
|
||||
}
|
||||
|
||||
export const LOG_COLUMNS: Record<string, SavedViewColumn> = {
|
||||
timestamp: {
|
||||
name: 'timestamp',
|
||||
signal: 'logs',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: '',
|
||||
},
|
||||
body: {
|
||||
name: 'body',
|
||||
signal: 'logs',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: '',
|
||||
},
|
||||
serviceName: {
|
||||
name: 'service.name',
|
||||
signal: 'logs',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
severityText: {
|
||||
name: 'severity_text',
|
||||
signal: 'logs',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
};
|
||||
|
||||
export function logsView({
|
||||
displayName,
|
||||
filter = '',
|
||||
panelType = 'list',
|
||||
groupBy = [],
|
||||
columns = [LOG_COLUMNS.timestamp, LOG_COLUMNS.body],
|
||||
display,
|
||||
}: LogsViewOptions): PostableSavedView {
|
||||
const isList = panelType === 'list';
|
||||
return {
|
||||
generateName: true,
|
||||
source: 'logs',
|
||||
schemaVersion: 'v2',
|
||||
spec: {
|
||||
displayName,
|
||||
panelType,
|
||||
requestType: REQUEST_TYPE[panelType],
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'logs',
|
||||
disabled: false,
|
||||
filter: { expression: filter },
|
||||
...(isList
|
||||
? {
|
||||
order: [
|
||||
{ key: { name: 'timestamp' }, direction: 'desc' },
|
||||
{ key: { name: 'id' }, direction: 'desc' },
|
||||
],
|
||||
}
|
||||
: {
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
groupBy: groupBy.map((name) => ({
|
||||
name,
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
})),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
selectedFields: isList ? columns : [],
|
||||
...(display && { display }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function seededView(id: string, view: PostableSavedView): SeededView {
|
||||
const [query] = view.spec.queries as {
|
||||
spec?: { filter?: { expression?: string } };
|
||||
}[];
|
||||
return {
|
||||
id,
|
||||
displayName: view.spec.displayName,
|
||||
filter: query?.spec?.filter?.expression ?? '',
|
||||
requestType: view.spec.requestType,
|
||||
};
|
||||
}
|
||||
69
tests/e2e/drivers/saved-views/spec.types.ts
generated
69
tests/e2e/drivers/saved-views/spec.types.ts
generated
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
// The saved view spec of `/api/v2/saved_views`.
|
||||
|
||||
export type SavedViewSource = 'logs' | 'traces' | 'metrics' | 'meter';
|
||||
export type SavedViewPanelType = 'list' | 'graph' | 'table';
|
||||
|
||||
export type SavedViewRequestType = 'raw' | 'time_series' | 'scalar';
|
||||
|
||||
export interface SavedViewColumn {
|
||||
name: string;
|
||||
signal?: string;
|
||||
fieldContext?: string;
|
||||
fieldDataType?: string;
|
||||
}
|
||||
|
||||
export interface SavedViewDisplay {
|
||||
format?: string;
|
||||
maxLines?: number;
|
||||
fontSize?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface SavedViewSpec {
|
||||
displayName: string;
|
||||
panelType: SavedViewPanelType;
|
||||
requestType: SavedViewRequestType;
|
||||
queries: Record<string, unknown>[];
|
||||
selectedFields?: SavedViewColumn[];
|
||||
display?: SavedViewDisplay;
|
||||
}
|
||||
|
||||
export interface PostableSavedView {
|
||||
generateName: true;
|
||||
source: SavedViewSource;
|
||||
schemaVersion: 'v2';
|
||||
spec: SavedViewSpec;
|
||||
}
|
||||
|
||||
export interface GettableSavedView {
|
||||
id: string;
|
||||
name: string;
|
||||
source: SavedViewSource;
|
||||
createdBy: string;
|
||||
updatedBy?: string;
|
||||
spec: SavedViewSpec;
|
||||
}
|
||||
|
||||
export interface LogsViewOptions {
|
||||
displayName: string;
|
||||
filter?: string;
|
||||
panelType?: SavedViewPanelType;
|
||||
/** Group by attributes of a graph or table view, as resource attributes. */
|
||||
groupBy?: string[];
|
||||
/** Defaults to timestamp and body, which a list view always shows. */
|
||||
columns?: SavedViewColumn[];
|
||||
display?: SavedViewDisplay;
|
||||
}
|
||||
|
||||
export interface SeededView {
|
||||
id: string;
|
||||
displayName: string;
|
||||
/** The first query's filter, which the explorer sends once the view is open. */
|
||||
filter: string;
|
||||
/** What the explorer's own query for the view asks for: raw, time_series or scalar. */
|
||||
requestType: string;
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import {
|
||||
expect,
|
||||
LOG_COLUMNS,
|
||||
logsView,
|
||||
test,
|
||||
uniqueViewName,
|
||||
} from '../../drivers/saved-views';
|
||||
|
||||
const SAVED_FILTER = "service.name = 'frontend'";
|
||||
const EDITED_FILTER = "service.name = 'cartservice'";
|
||||
|
||||
test.describe('Saved views: lifecycle on the logs explorer', () => {
|
||||
test('TC-01 a view created from the header survives a reload clean', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const displayName = uniqueViewName('create');
|
||||
await savedViews.openExplorer();
|
||||
await savedViews.expectNoView();
|
||||
await savedViews.applyFilter(SAVED_FILTER);
|
||||
|
||||
const id = await savedViews.createFromHeader(displayName);
|
||||
await expect(savedViews.toast('View created')).toBeVisible();
|
||||
await expect.poll(() => savedViews.activeViewId()).toBe(id);
|
||||
await savedViews.expectClean(displayName);
|
||||
|
||||
const stored = await savedViews.get(id);
|
||||
expect(stored.source).toBe('logs');
|
||||
expect(stored.spec.displayName).toBe(displayName);
|
||||
expect(stored.spec.panelType).toBe('list');
|
||||
expect(JSON.stringify(stored.spec.queries)).toContain(SAVED_FILTER);
|
||||
|
||||
await savedViews.reload(SAVED_FILTER);
|
||||
await savedViews.expectClean(displayName);
|
||||
await expect(savedViews.queryBar()).toHaveText(SAVED_FILTER);
|
||||
|
||||
await savedViews.openList();
|
||||
await savedViews.search(displayName);
|
||||
await expect(savedViews.row(id)).toBeVisible();
|
||||
await expect(savedViews.rowLink(id)).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
test('TC-02 an edited view turns dirty, updates, and opens with the new filter after a reload', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const view = await savedViews.seed(
|
||||
logsView({
|
||||
displayName: uniqueViewName('update'),
|
||||
filter: SAVED_FILTER,
|
||||
}),
|
||||
);
|
||||
await savedViews.openExplorer();
|
||||
await savedViews.open(view);
|
||||
await savedViews.expectClean(view.displayName);
|
||||
|
||||
await savedViews.applyFilter(EDITED_FILTER);
|
||||
await savedViews.expectDirty(view.displayName);
|
||||
|
||||
await savedViews.update();
|
||||
await savedViews.expectClean(view.displayName);
|
||||
|
||||
const stored = await savedViews.get(view.id);
|
||||
expect(JSON.stringify(stored.spec.queries)).toContain(EDITED_FILTER);
|
||||
expect(JSON.stringify(stored.spec.queries)).not.toContain(SAVED_FILTER);
|
||||
|
||||
await savedViews.reload(EDITED_FILTER);
|
||||
await savedViews.expectClean(view.displayName);
|
||||
await expect(savedViews.queryBar()).toHaveText(EDITED_FILTER);
|
||||
});
|
||||
|
||||
test('TC-03 discarding changes brings back the stored filter and tab', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const view = await savedViews.seed(
|
||||
logsView({
|
||||
displayName: uniqueViewName('discard'),
|
||||
filter: SAVED_FILTER,
|
||||
}),
|
||||
);
|
||||
await savedViews.openExplorer();
|
||||
await savedViews.open(view);
|
||||
await expect(savedViews.explorerTab('list')).toHaveClass(/active-tab/);
|
||||
|
||||
await savedViews.applyFilter(EDITED_FILTER);
|
||||
await savedViews.explorerTab('timeseries').click();
|
||||
await expect(savedViews.explorerTab('timeseries')).toHaveClass(/active-tab/);
|
||||
await savedViews.expectDirty(view.displayName);
|
||||
|
||||
await savedViews.discard();
|
||||
await savedViews.expectClean(view.displayName);
|
||||
await expect(savedViews.queryBar()).toHaveText(SAVED_FILTER);
|
||||
await expect(savedViews.explorerTab('list')).toHaveClass(/active-tab/);
|
||||
expect(savedViews.param('panelTypes')).toBe('"list"');
|
||||
|
||||
const stored = await savedViews.get(view.id);
|
||||
expect(JSON.stringify(stored.spec.queries)).toContain(SAVED_FILTER);
|
||||
});
|
||||
|
||||
test('TC-04 deleting the open view clears it without trying to load it again', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const view = await savedViews.seed(
|
||||
logsView({
|
||||
displayName: uniqueViewName('delete'),
|
||||
filter: SAVED_FILTER,
|
||||
}),
|
||||
);
|
||||
await savedViews.openExplorer();
|
||||
await savedViews.open(view);
|
||||
|
||||
await savedViews.chooseRowAction(view.id, 'delete');
|
||||
const getsBeforeDelete = savedViews.viewGets(view.id).length;
|
||||
expect(getsBeforeDelete).toBeGreaterThan(0);
|
||||
await savedViews.deleteView(view.id);
|
||||
|
||||
await expect(savedViews.toast('View deleted')).toBeVisible();
|
||||
await expect(savedViews.row(view.id)).toHaveCount(0);
|
||||
await savedViews.expectNoView();
|
||||
await expect.poll(() => savedViews.param('panelTypes')).toBeNull();
|
||||
expect(savedViews.param('viewName')).toBeNull();
|
||||
expect(savedViews.param('compositeQuery') ?? '').not.toContain('frontend');
|
||||
expect(savedViews.viewGets(view.id)).toHaveLength(getsBeforeDelete);
|
||||
expect(await savedViews.status(view.id)).toBe(404);
|
||||
});
|
||||
|
||||
test('TC-05 renaming a view keeps its columns and display settings', async ({
|
||||
savedViews,
|
||||
}) => {
|
||||
const columns = [
|
||||
LOG_COLUMNS.timestamp,
|
||||
LOG_COLUMNS.body,
|
||||
LOG_COLUMNS.serviceName,
|
||||
LOG_COLUMNS.severityText,
|
||||
];
|
||||
const headers = ['Timestamp', 'Body', 'service.name', 'severity_text'];
|
||||
const display = { format: 'table', maxLines: 3, fontSize: 'medium' };
|
||||
const view = await savedViews.seed(
|
||||
logsView({
|
||||
displayName: uniqueViewName('rename'),
|
||||
filter: SAVED_FILTER,
|
||||
columns,
|
||||
display,
|
||||
}),
|
||||
);
|
||||
const renamed = uniqueViewName('renamed');
|
||||
|
||||
const expectDisplay = async (): Promise<void> => {
|
||||
await expect(savedViews.logsColumnHeaders()).toHaveText(headers);
|
||||
await savedViews.openLogsFormatMenu();
|
||||
await expect(savedViews.selectedLogsFormat()).toHaveText('Column');
|
||||
await expect(savedViews.logsMaxLines()).toHaveValue('3');
|
||||
await expect(savedViews.logsFontSize()).toHaveText('medium');
|
||||
await savedViews.closeLogsFormatMenu();
|
||||
};
|
||||
|
||||
await savedViews.openExplorer('logs', { relativeTime: '1d' });
|
||||
await savedViews.open(view);
|
||||
await expectDisplay();
|
||||
|
||||
await savedViews.openList();
|
||||
await savedViews.search(view.displayName);
|
||||
await savedViews.rename(view.id, renamed);
|
||||
await expect(savedViews.toast('View updated')).toBeVisible();
|
||||
await expect(savedViews.name()).toHaveText(renamed);
|
||||
|
||||
const stored = await savedViews.get(view.id);
|
||||
expect(stored.spec.displayName).toBe(renamed);
|
||||
expect(stored.spec.selectedFields?.map((column) => column.name)).toEqual(
|
||||
columns.map((column) => column.name),
|
||||
);
|
||||
expect(stored.spec.display).toMatchObject(display);
|
||||
|
||||
await savedViews.reload(SAVED_FILTER);
|
||||
await savedViews.expectClean(renamed);
|
||||
await expectDisplay();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user