mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-06 16:30:42 +00:00
Compare commits
5 Commits
main
...
nv/exec-st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe45b9dcae | ||
|
|
63ee60a40c | ||
|
|
29a393e47e | ||
|
|
c58f76f998 | ||
|
|
e39ad9e221 |
6
.github/workflows/e2eci.yaml
vendored
6
.github/workflows/e2eci.yaml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
FORCE: ${{ contains(github.event.pull_request.labels.*.name, 'safe-to-e2e') }}
|
||||
run: |
|
||||
CHANGED_FILES=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename | select((startswith("tests/integration/") or startswith("frontend/src/api/generated/")) | not)')
|
||||
CHANGED_FILES=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename | select(startswith("tests/integration/") | not)')
|
||||
PATTERN='^(frontend/|tests/|\.github/workflows/e2eci\.yaml$|cmd/enterprise/Dockerfile\.with-web\.integration$)'
|
||||
|
||||
if [ "$FORCE" = "true" ]; then
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
- name: node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
node-version: lts/*
|
||||
- name: install-pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
- name: node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
node-version: lts/*
|
||||
- name: install-pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
|
||||
@@ -552,47 +552,26 @@ components:
|
||||
required:
|
||||
- apiKey
|
||||
type: object
|
||||
AlertmanagertypesChannelJiraAuth:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelJiraCloudAuth'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelJiraSelfHostedAuth'
|
||||
type: object
|
||||
AlertmanagertypesChannelJiraCloudAuth:
|
||||
AlertmanagertypesChannelJiraConfig:
|
||||
properties:
|
||||
apiToken:
|
||||
description: The Atlassian API token paired with email. A secret; returned
|
||||
by create, update and fetch by ID, never by list.
|
||||
format: password
|
||||
type: string
|
||||
email:
|
||||
description: The Atlassian account email used for basic auth.
|
||||
type: string
|
||||
required:
|
||||
- email
|
||||
- apiToken
|
||||
type: object
|
||||
AlertmanagertypesChannelJiraConfig:
|
||||
properties:
|
||||
auth:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelJiraAuth'
|
||||
description: 'The credentials: email and apiToken when deployment is cloud,
|
||||
pat when deployment is selfhosted.'
|
||||
customFields:
|
||||
additionalProperties: {}
|
||||
description: Extra issue fields keyed by Jira field ID. Stored, but not
|
||||
currently included in the issue Jira receives.
|
||||
nullable: true
|
||||
type: object
|
||||
deployment:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AlertmanagertypesJiraDeployment'
|
||||
description: 'The Jira edition: cloud for an Atlassian-hosted site, selfhosted
|
||||
for Jira Server or Data Center.'
|
||||
description:
|
||||
description: Alertmanager template for the issue description, rendered as
|
||||
markdown; defaults to the SigNoz Jira default.
|
||||
type: string
|
||||
email:
|
||||
description: The Atlassian account email used for basic auth.
|
||||
type: string
|
||||
issueType:
|
||||
description: The issue type name, such as Bug.
|
||||
type: string
|
||||
@@ -625,8 +604,8 @@ components:
|
||||
nullable: true
|
||||
type: boolean
|
||||
site:
|
||||
description: The base URL of the Jira site, for example https://example.atlassian.net
|
||||
or https://jira.example.com.
|
||||
description: The base URL of the Jira Cloud site, for example https://example.atlassian.net.
|
||||
Only Jira Cloud is supported.
|
||||
type: string
|
||||
summary:
|
||||
description: Alertmanager template for the issue summary; defaults to the
|
||||
@@ -636,21 +615,11 @@ components:
|
||||
description: The resolution name that stops an issue from being reopened.
|
||||
type: string
|
||||
required:
|
||||
- deployment
|
||||
- site
|
||||
- project
|
||||
- issueType
|
||||
- auth
|
||||
type: object
|
||||
AlertmanagertypesChannelJiraSelfHostedAuth:
|
||||
properties:
|
||||
pat:
|
||||
description: The Jira personal access token, sent as a Bearer header. A
|
||||
secret; returned by create, update and fetch by ID, never by list.
|
||||
format: password
|
||||
type: string
|
||||
required:
|
||||
- pat
|
||||
- email
|
||||
- apiToken
|
||||
type: object
|
||||
AlertmanagertypesChannelKind:
|
||||
enum:
|
||||
@@ -1186,11 +1155,6 @@ components:
|
||||
description: Comma-separated tags attached to the alert.
|
||||
type: string
|
||||
type: object
|
||||
AlertmanagertypesJiraDeployment:
|
||||
enum:
|
||||
- cloud
|
||||
- selfhosted
|
||||
type: string
|
||||
AlertmanagertypesJiraReceiverConfig:
|
||||
properties:
|
||||
custom_fields:
|
||||
@@ -1198,10 +1162,6 @@ components:
|
||||
description: Extra issue fields keyed by Jira field ID. Stored, but not
|
||||
currently included in the issue Jira receives.
|
||||
type: object
|
||||
deployment:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AlertmanagertypesJiraDeployment'
|
||||
description: The Jira edition, cloud or selfhosted; defaults to cloud.
|
||||
description:
|
||||
description: Alertmanager template for the issue description.
|
||||
type: string
|
||||
@@ -1209,8 +1169,7 @@ components:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ConfigHTTPClientConfig'
|
||||
description: HTTP client settings; basic auth carries the account email
|
||||
and API token on cloud, authorization carries the Bearer personal access
|
||||
token on self-hosted.
|
||||
and API token.
|
||||
issue_type:
|
||||
description: The issue type name.
|
||||
type: string
|
||||
@@ -1240,7 +1199,7 @@ components:
|
||||
send_resolved:
|
||||
type: boolean
|
||||
site:
|
||||
description: The Jira base URL.
|
||||
description: The Jira Cloud base URL.
|
||||
type: string
|
||||
summary:
|
||||
description: Alertmanager template for the issue summary.
|
||||
@@ -4068,9 +4027,7 @@ components:
|
||||
the query they draw.
|
||||
cursor:
|
||||
description: The opaque nextCursor value from a previous raw result, used
|
||||
to fetch the next page. Honored only by raw requests whose order starts
|
||||
with timestamp, for logs followed by id in the same direction; every other
|
||||
request ignores it.
|
||||
to fetch the next page.
|
||||
type: string
|
||||
disabled:
|
||||
description: When true, the result is excluded from the response; the query
|
||||
@@ -4107,11 +4064,10 @@ components:
|
||||
are replaced by group-by label values.
|
||||
type: string
|
||||
limit:
|
||||
description: The maximum number of rows for raw and trace requests, or of
|
||||
series or rows for grouped scalar and time_series requests, keeping the
|
||||
top entries by order. When 0, scalar and time_series requests are unlimited,
|
||||
raw requests ordered by timestamp return a page of 10000 rows to continue
|
||||
with nextCursor, and other raw and trace requests return 100 rows.
|
||||
description: The maximum number of rows for raw requests, or of series or
|
||||
rows for grouped aggregations, keeping the top entries by order. When
|
||||
0, aggregations are unlimited while raw requests ordered by timestamp
|
||||
return a page of 10000 rows; follow nextCursor for the rest.
|
||||
type: integer
|
||||
limitBy:
|
||||
allOf:
|
||||
@@ -4123,11 +4079,8 @@ components:
|
||||
operators and selectedQueryName refer to it.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of rows for raw and trace requests, or of ordered
|
||||
series or rows for scalar and time_series requests, to skip before the
|
||||
page starts. Requires limit. On raw requests the skip counts from where
|
||||
a cursor resumes, and the skipped rows are still read, so prefer cursor
|
||||
for paging.
|
||||
description: The number of rows to skip before returning results. Prefer
|
||||
cursor for raw requests.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the result. Order by a group-by field, an
|
||||
@@ -9609,10 +9562,9 @@ components:
|
||||
Querybuildertypesv5AggregationMeta:
|
||||
properties:
|
||||
buckets:
|
||||
description: The ascending upper bounds of the value buckets, set for heatmap
|
||||
and distribution results; each point then carries one count per bound
|
||||
plus a trailing overflow count in values. The first bucket starts at -Inf
|
||||
when its bound is at or under zero, else at zero.
|
||||
description: The ascending upper bounds of the value bands, set only for
|
||||
heatmap results; each point then carries one count per bound plus a trailing
|
||||
overflow count in values.
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
@@ -9767,101 +9719,6 @@ components:
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
Querybuildertypesv5Distribution:
|
||||
properties:
|
||||
counts:
|
||||
description: One count per bound in meta.buckets plus a trailing overflow
|
||||
count, summed over every step of the requested time range.
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
nullable: true
|
||||
type: array
|
||||
labels:
|
||||
description: The group-by field values that identify this distribution.
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Label'
|
||||
type: array
|
||||
required:
|
||||
- counts
|
||||
type: object
|
||||
Querybuildertypesv5DistributionAggregation:
|
||||
properties:
|
||||
alias:
|
||||
description: The alias of the aggregation, or the internal name __result_N,
|
||||
with N its index, when no alias was given.
|
||||
type: string
|
||||
distributions:
|
||||
description: One distribution per group-by combination.
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Distribution'
|
||||
nullable: true
|
||||
type: array
|
||||
index:
|
||||
description: The zero-based position of the aggregation in the query.
|
||||
type: integer
|
||||
meta:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5AggregationMeta'
|
||||
description: Display metadata for the aggregation, including the bucket
|
||||
upper bounds the counts are positional against.
|
||||
percentiles:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5DistributionPercentiles'
|
||||
description: Percentiles over the counts of every distribution added together,
|
||||
estimated the way histogram_quantile does by interpolating linearly within
|
||||
the bucket each rank falls in. Omitted when nothing was counted or meta.buckets
|
||||
is empty.
|
||||
required:
|
||||
- index
|
||||
- alias
|
||||
- distributions
|
||||
type: object
|
||||
Querybuildertypesv5DistributionData:
|
||||
properties:
|
||||
aggregations:
|
||||
description: One entry per aggregation of the query. The order is not guaranteed;
|
||||
match entries to aggregations by index.
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5DistributionAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
queryName:
|
||||
description: The name of the query that produced this result.
|
||||
type: string
|
||||
required:
|
||||
- queryName
|
||||
- aggregations
|
||||
type: object
|
||||
Querybuildertypesv5DistributionPercentiles:
|
||||
properties:
|
||||
p50:
|
||||
description: The 50th percentile.
|
||||
format: double
|
||||
type: number
|
||||
p75:
|
||||
description: The 75th percentile.
|
||||
format: double
|
||||
type: number
|
||||
p90:
|
||||
description: The 90th percentile.
|
||||
format: double
|
||||
type: number
|
||||
p95:
|
||||
description: The 95th percentile.
|
||||
format: double
|
||||
type: number
|
||||
p99:
|
||||
description: The 99th percentile.
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- p50
|
||||
- p75
|
||||
- p90
|
||||
- p95
|
||||
- p99
|
||||
type: object
|
||||
Querybuildertypesv5ExecStats:
|
||||
description: 'Execution cost of the request: rows and bytes scanned and execution
|
||||
time. A measure of cost, not of whether data exists.'
|
||||
@@ -10245,10 +10102,6 @@ components:
|
||||
description: The unique name of the formula, such as F1, used by selectedQueryName
|
||||
and by panels. Referencing a formula from another formula is not reliable.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of ordered series or rows to skip before the page
|
||||
starts. Requires limit.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the formula result.
|
||||
items:
|
||||
@@ -10274,9 +10127,7 @@ components:
|
||||
the query they draw.
|
||||
cursor:
|
||||
description: The opaque nextCursor value from a previous raw result, used
|
||||
to fetch the next page. Honored only by raw requests whose order starts
|
||||
with timestamp, for logs followed by id in the same direction; every other
|
||||
request ignores it.
|
||||
to fetch the next page.
|
||||
type: string
|
||||
disabled:
|
||||
description: When true, the result is excluded from the response; the query
|
||||
@@ -10312,11 +10163,10 @@ components:
|
||||
are replaced by group-by label values.
|
||||
type: string
|
||||
limit:
|
||||
description: The maximum number of rows for raw and trace requests, or of
|
||||
series or rows for grouped scalar and time_series requests, keeping the
|
||||
top entries by order. When 0, scalar and time_series requests are unlimited,
|
||||
raw requests ordered by timestamp return a page of 10000 rows to continue
|
||||
with nextCursor, and other raw and trace requests return 100 rows.
|
||||
description: The maximum number of rows for raw requests, or of series or
|
||||
rows for grouped aggregations, keeping the top entries by order. When
|
||||
0, aggregations are unlimited while raw requests ordered by timestamp
|
||||
return a page of 10000 rows; follow nextCursor for the rest.
|
||||
type: integer
|
||||
limitBy:
|
||||
allOf:
|
||||
@@ -10328,11 +10178,8 @@ components:
|
||||
operators and selectedQueryName refer to it.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of rows for raw and trace requests, or of ordered
|
||||
series or rows for scalar and time_series requests, to skip before the
|
||||
page starts. Requires limit. On raw requests the skip counts from where
|
||||
a cursor resumes, and the skipped rows are still read, so prefer cursor
|
||||
for paging.
|
||||
description: The number of rows to skip before returning results. Prefer
|
||||
cursor for raw requests.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the result. Order by a group-by field, an
|
||||
@@ -10394,9 +10241,7 @@ components:
|
||||
the query they draw.
|
||||
cursor:
|
||||
description: The opaque nextCursor value from a previous raw result, used
|
||||
to fetch the next page. Honored only by raw requests whose order starts
|
||||
with timestamp, for logs followed by id in the same direction; every other
|
||||
request ignores it.
|
||||
to fetch the next page.
|
||||
type: string
|
||||
disabled:
|
||||
description: When true, the result is excluded from the response; the query
|
||||
@@ -10433,11 +10278,10 @@ components:
|
||||
are replaced by group-by label values.
|
||||
type: string
|
||||
limit:
|
||||
description: The maximum number of rows for raw and trace requests, or of
|
||||
series or rows for grouped scalar and time_series requests, keeping the
|
||||
top entries by order. When 0, scalar and time_series requests are unlimited,
|
||||
raw requests ordered by timestamp return a page of 10000 rows to continue
|
||||
with nextCursor, and other raw and trace requests return 100 rows.
|
||||
description: The maximum number of rows for raw requests, or of series or
|
||||
rows for grouped aggregations, keeping the top entries by order. When
|
||||
0, aggregations are unlimited while raw requests ordered by timestamp
|
||||
return a page of 10000 rows; follow nextCursor for the rest.
|
||||
type: integer
|
||||
limitBy:
|
||||
allOf:
|
||||
@@ -10449,11 +10293,8 @@ components:
|
||||
operators and selectedQueryName refer to it.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of rows for raw and trace requests, or of ordered
|
||||
series or rows for scalar and time_series requests, to skip before the
|
||||
page starts. Requires limit. On raw requests the skip counts from where
|
||||
a cursor resumes, and the skipped rows are still read, so prefer cursor
|
||||
for paging.
|
||||
description: The number of rows to skip before returning results. Prefer
|
||||
cursor for raw requests.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the result. Order by a group-by field, an
|
||||
@@ -10515,9 +10356,7 @@ components:
|
||||
the query they draw.
|
||||
cursor:
|
||||
description: The opaque nextCursor value from a previous raw result, used
|
||||
to fetch the next page. Honored only by raw requests whose order starts
|
||||
with timestamp, for logs followed by id in the same direction; every other
|
||||
request ignores it.
|
||||
to fetch the next page.
|
||||
type: string
|
||||
disabled:
|
||||
description: When true, the result is excluded from the response; the query
|
||||
@@ -10554,11 +10393,10 @@ components:
|
||||
are replaced by group-by label values.
|
||||
type: string
|
||||
limit:
|
||||
description: The maximum number of rows for raw and trace requests, or of
|
||||
series or rows for grouped scalar and time_series requests, keeping the
|
||||
top entries by order. When 0, scalar and time_series requests are unlimited,
|
||||
raw requests ordered by timestamp return a page of 10000 rows to continue
|
||||
with nextCursor, and other raw and trace requests return 100 rows.
|
||||
description: The maximum number of rows for raw requests, or of series or
|
||||
rows for grouped aggregations, keeping the top entries by order. When
|
||||
0, aggregations are unlimited while raw requests ordered by timestamp
|
||||
return a page of 10000 rows; follow nextCursor for the rest.
|
||||
type: integer
|
||||
limitBy:
|
||||
allOf:
|
||||
@@ -10570,11 +10408,8 @@ components:
|
||||
operators and selectedQueryName refer to it.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of rows for raw and trace requests, or of ordered
|
||||
series or rows for scalar and time_series requests, to skip before the
|
||||
page starts. Requires limit. On raw requests the skip counts from where
|
||||
a cursor resumes, and the skipped rows are still read, so prefer cursor
|
||||
for paging.
|
||||
description: The number of rows to skip before returning results. Prefer
|
||||
cursor for raw requests.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the result. Order by a group-by field, an
|
||||
@@ -10675,9 +10510,8 @@ components:
|
||||
description: The unique name of the trace operator query.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of raw or trace rows, or of ordered series or rows
|
||||
for time_series and scalar requests, to skip before the page starts. Requires
|
||||
limit.
|
||||
description: The number of raw results to skip before returning. Aggregation
|
||||
requests ignore it.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the matched traces; only span_count and trace_duration
|
||||
@@ -10708,12 +10542,10 @@ components:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5TimeSeriesData'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5ScalarData'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5RawData'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5DistributionData'
|
||||
properties:
|
||||
results:
|
||||
description: 'One result per enabled query, typed by the request type: TimeSeriesData
|
||||
for time_series and heatmap, ScalarData for scalar, RawData for raw and
|
||||
DistributionData for distribution requests.'
|
||||
for time_series, ScalarData for scalar and RawData for raw requests.'
|
||||
items: {}
|
||||
nullable: true
|
||||
type: array
|
||||
@@ -10903,9 +10735,8 @@ components:
|
||||
type: object
|
||||
Querybuildertypesv5QueryRangeResponse:
|
||||
description: 'Response from the v5 query range endpoint. The data.results array
|
||||
contains typed results depending on the requestType: TimeSeriesData for time_series
|
||||
and heatmap, ScalarData for scalar, RawData for raw, or DistributionData for
|
||||
distribution requests.'
|
||||
contains typed results depending on the requestType: TimeSeriesData for time_series,
|
||||
ScalarData for scalar, or RawData for raw requests.'
|
||||
properties:
|
||||
data:
|
||||
allOf:
|
||||
@@ -11002,7 +10833,6 @@ components:
|
||||
- raw_stream
|
||||
- trace
|
||||
- heatmap
|
||||
- distribution
|
||||
type: string
|
||||
Querybuildertypesv5ScalarData:
|
||||
properties:
|
||||
@@ -11114,8 +10944,8 @@ components:
|
||||
format: double
|
||||
type: number
|
||||
values:
|
||||
description: The per-bucket counts for heatmap results, in the order of
|
||||
meta.buckets plus a trailing overflow count.
|
||||
description: The per-band counts for heatmap results, in the order of meta.buckets
|
||||
plus a trailing overflow count.
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
@@ -24220,16 +24050,14 @@ paths:
|
||||
jira:
|
||||
description: Creates one Jira Cloud issue per alert group and reopens
|
||||
it if the alert fires again within `reopenDuration`. Authenticates
|
||||
with an Atlassian account email and API token under `auth`.
|
||||
with an Atlassian account email and API token.
|
||||
summary: Jira Cloud issues
|
||||
value:
|
||||
config:
|
||||
kind: jira
|
||||
spec:
|
||||
auth:
|
||||
apiToken: XXXXXXXXXXXXXXXXXXXXXXXX
|
||||
email: oncall@example.com
|
||||
deployment: cloud
|
||||
apiToken: XXXXXXXXXXXXXXXXXXXXXXXX
|
||||
email: oncall@example.com
|
||||
issueType: Bug
|
||||
labels:
|
||||
- signoz
|
||||
@@ -24239,23 +24067,6 @@ paths:
|
||||
site: https://example.atlassian.net
|
||||
displayName: Jira platform
|
||||
name: jira-platform
|
||||
jira-selfhosted:
|
||||
description: Creates issues on a Jira Data Center or Server site.
|
||||
Authenticates with a personal access token under `auth`, sent as
|
||||
a Bearer header.
|
||||
summary: Self-hosted Jira issues
|
||||
value:
|
||||
config:
|
||||
kind: jira
|
||||
spec:
|
||||
auth:
|
||||
pat: XXXXXXXXXXXXXXXXXXXXXXXX
|
||||
deployment: selfhosted
|
||||
issueType: Bug
|
||||
project: OPS
|
||||
site: https://jira.example.com
|
||||
displayName: Jira Data Center
|
||||
name: jira-datacenter
|
||||
jsmops:
|
||||
description: Creates JSM Ops alerts through an integration API key.
|
||||
JSM Ops speaks the Opsgenie alert API, so `priority` takes P1 to
|
||||
|
||||
@@ -18,25 +18,15 @@
|
||||
"tooltip_email_to": "Enter email addresses separated by commas.",
|
||||
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
|
||||
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to.",
|
||||
"field_jira_deployment": "Jira deployment",
|
||||
"option_jira_deployment_cloud": "Jira Cloud",
|
||||
"option_jira_deployment_selfhosted": "Self-hosted Jira",
|
||||
"jira_selfhosted_tip": "Works with Jira Data Center 8.14 and newer. Create the personal access token from a dedicated service user so issues aren't reported under a personal name.",
|
||||
"jira_selfhosted_tip_link": "Learn how",
|
||||
"field_jira_site": "Site URL",
|
||||
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net, or a proxy in front of it.",
|
||||
"tooltip_jira_site_selfhosted": "Your self-hosted Jira base URL, e.g. https://jira.your-company.com, or a proxy in front of it.",
|
||||
"jira_required_fields": "Site URL, project and issue type are required",
|
||||
"jira_cloud_credentials_required": "Email and API token are required for Jira Cloud",
|
||||
"jira_pat_required": "A personal access token is required for self-hosted Jira",
|
||||
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
|
||||
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
|
||||
"jira_service_account_tip_link": "Learn how",
|
||||
"field_jira_email": "Email",
|
||||
"help_jira_email": "The Atlassian account email used for authentication.",
|
||||
"field_jira_api_token": "API token",
|
||||
"help_jira_api_token": "Create one at id.atlassian.com under Security → API tokens.",
|
||||
"field_jira_pat": "Personal access token",
|
||||
"help_jira_pat": "Create one in Jira under your profile, Personal Access Tokens. Sent as a Bearer header.",
|
||||
"field_jira_project": "Project key",
|
||||
"field_jira_issue_type": "Issue type",
|
||||
"help_jira_issue_type": "An issue type that exists in the project, e.g. Task, Bug or Incident.",
|
||||
@@ -44,7 +34,6 @@
|
||||
"help_jira_summary": "Template for the Jira issue title.",
|
||||
"field_jira_description": "Description",
|
||||
"help_jira_description": "Template for the issue description. Rendered as rich text with a status panel and links back to SigNoz.",
|
||||
"help_jira_description_selfhosted": "Template for the issue description. Rendered as Jira wiki markup with a status panel and links back to SigNoz.",
|
||||
"jira_advanced_section": "Advanced Options",
|
||||
"field_jira_priority": "Priority",
|
||||
"placeholder_jira_priority": "Leave empty to use the project default",
|
||||
|
||||
@@ -26,25 +26,15 @@
|
||||
"tooltip_email_to": "Enter email addresses separated by commas.",
|
||||
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
|
||||
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to.",
|
||||
"field_jira_deployment": "Jira deployment",
|
||||
"option_jira_deployment_cloud": "Jira Cloud",
|
||||
"option_jira_deployment_selfhosted": "Self-hosted Jira",
|
||||
"jira_selfhosted_tip": "Works with Jira Data Center 8.14 and newer. Create the personal access token from a dedicated service user so issues aren't reported under a personal name.",
|
||||
"jira_selfhosted_tip_link": "Learn how",
|
||||
"field_jira_site": "Site URL",
|
||||
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net, or a proxy in front of it.",
|
||||
"tooltip_jira_site_selfhosted": "Your self-hosted Jira base URL, e.g. https://jira.your-company.com, or a proxy in front of it.",
|
||||
"jira_required_fields": "Site URL, project and issue type are required",
|
||||
"jira_cloud_credentials_required": "Email and API token are required for Jira Cloud",
|
||||
"jira_pat_required": "A personal access token is required for self-hosted Jira",
|
||||
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
|
||||
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
|
||||
"jira_service_account_tip_link": "Learn how",
|
||||
"field_jira_email": "Email",
|
||||
"help_jira_email": "The Atlassian account email used for authentication.",
|
||||
"field_jira_api_token": "API token",
|
||||
"help_jira_api_token": "Create one at id.atlassian.com under Security → API tokens.",
|
||||
"field_jira_pat": "Personal access token",
|
||||
"help_jira_pat": "Create one in Jira under your profile, Personal Access Tokens. Sent as a Bearer header.",
|
||||
"field_jira_project": "Project key",
|
||||
"field_jira_issue_type": "Issue type",
|
||||
"help_jira_issue_type": "An issue type that exists in the project, e.g. Task, Bug or Incident.",
|
||||
@@ -52,7 +42,6 @@
|
||||
"help_jira_summary": "Template for the Jira issue title.",
|
||||
"field_jira_description": "Description",
|
||||
"help_jira_description": "Template for the issue description. Rendered as rich text with a status panel and links back to SigNoz.",
|
||||
"help_jira_description_selfhosted": "Template for the issue description. Rendered as Jira wiki markup with a status panel and links back to SigNoz.",
|
||||
"jira_advanced_section": "Advanced Options",
|
||||
"field_jira_priority": "Priority",
|
||||
"placeholder_jira_priority": "Leave empty to use the project default",
|
||||
|
||||
@@ -829,37 +829,6 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
|
||||
jira = 'jira',
|
||||
}
|
||||
export interface AlertmanagertypesChannelJiraCloudAuthDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
* @description The Atlassian API token paired with email. A secret; returned by create, update and fetch by ID, never by list.
|
||||
*/
|
||||
apiToken: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The Atlassian account email used for basic auth.
|
||||
*/
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelJiraSelfHostedAuthDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
* @description The Jira personal access token, sent as a Bearer header. A secret; returned by create, update and fetch by ID, never by list.
|
||||
*/
|
||||
pat: string;
|
||||
}
|
||||
|
||||
export type AlertmanagertypesChannelJiraAuthDTO =
|
||||
| AlertmanagertypesChannelJiraCloudAuthDTO
|
||||
| AlertmanagertypesChannelJiraSelfHostedAuthDTO;
|
||||
|
||||
export enum AlertmanagertypesJiraDeploymentDTO {
|
||||
cloud = 'cloud',
|
||||
selfhosted = 'selfhosted',
|
||||
}
|
||||
/**
|
||||
* Extra issue fields keyed by Jira field ID. Stored, but not currently included in the issue Jira receives.
|
||||
*/
|
||||
@@ -875,23 +844,26 @@ export type AlertmanagertypesChannelJiraConfigDTOCustomFields = Alertmanagertype
|
||||
|
||||
export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
/**
|
||||
* @description The credentials: email and apiToken when deployment is cloud, pat when deployment is selfhosted.
|
||||
* @type string
|
||||
* @format password
|
||||
* @description The Atlassian API token paired with email. A secret; returned by create, update and fetch by ID, never by list.
|
||||
*/
|
||||
auth: AlertmanagertypesChannelJiraAuthDTO;
|
||||
apiToken: string;
|
||||
/**
|
||||
* @type object,null
|
||||
* @description Extra issue fields keyed by Jira field ID. Stored, but not currently included in the issue Jira receives.
|
||||
*/
|
||||
customFields?: AlertmanagertypesChannelJiraConfigDTOCustomFields;
|
||||
/**
|
||||
* @description The Jira edition: cloud for an Atlassian-hosted site, selfhosted for Jira Server or Data Center.
|
||||
*/
|
||||
deployment: AlertmanagertypesJiraDeploymentDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description Alertmanager template for the issue description, rendered as markdown; defaults to the SigNoz Jira default.
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The Atlassian account email used for basic auth.
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The issue type name, such as Bug.
|
||||
@@ -934,7 +906,7 @@ export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
* @description The base URL of the Jira site, for example https://example.atlassian.net or https://jira.example.com.
|
||||
* @description The base URL of the Jira Cloud site, for example https://example.atlassian.net. Only Jira Cloud is supported.
|
||||
*/
|
||||
site: string;
|
||||
/**
|
||||
@@ -1744,17 +1716,13 @@ export interface AlertmanagertypesJiraReceiverConfigDTO {
|
||||
* @description Extra issue fields keyed by Jira field ID. Stored, but not currently included in the issue Jira receives.
|
||||
*/
|
||||
custom_fields?: AlertmanagertypesJiraReceiverConfigDTOCustomFields;
|
||||
/**
|
||||
* @description The Jira edition, cloud or selfhosted; defaults to cloud.
|
||||
*/
|
||||
deployment?: AlertmanagertypesJiraDeploymentDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description Alertmanager template for the issue description.
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @description HTTP client settings; basic auth carries the account email and API token on cloud, authorization carries the Bearer personal access token on self-hosted.
|
||||
* @description HTTP client settings; basic auth carries the account email and API token.
|
||||
*/
|
||||
http_config?: ConfigHTTPClientConfigDTO;
|
||||
/**
|
||||
@@ -1797,7 +1765,7 @@ export interface AlertmanagertypesJiraReceiverConfigDTO {
|
||||
send_resolved?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
* @description The Jira base URL.
|
||||
* @description The Jira Cloud base URL.
|
||||
*/
|
||||
site?: string;
|
||||
/**
|
||||
@@ -5305,7 +5273,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
@@ -5338,7 +5306,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
|
||||
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
@@ -5352,7 +5320,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. Requires limit. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
|
||||
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -5697,7 +5665,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
@@ -5730,7 +5698,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
|
||||
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
@@ -5744,7 +5712,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. Requires limit. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
|
||||
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -5877,7 +5845,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
@@ -5910,7 +5878,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
|
||||
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
@@ -5924,7 +5892,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. Requires limit. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
|
||||
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -5976,7 +5944,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
@@ -6009,7 +5977,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
|
||||
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
@@ -6023,7 +5991,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. Requires limit. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
|
||||
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -6969,7 +6937,6 @@ export enum Querybuildertypesv5RequestTypeDTO {
|
||||
raw_stream = 'raw_stream',
|
||||
trace = 'trace',
|
||||
heatmap = 'heatmap',
|
||||
distribution = 'distribution',
|
||||
}
|
||||
/**
|
||||
* The query kind.
|
||||
@@ -7097,11 +7064,6 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
* @description The unique name of the formula, such as F1, used by selectedQueryName and by panels. Referencing a formula from another formula is not reliable.
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of ordered series or rows to skip before the page starts. Requires limit.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* @type array,null
|
||||
* @description The sort order of the formula result.
|
||||
@@ -7184,7 +7146,7 @@ export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of raw or trace rows, or of ordered series or rows for time_series and scalar requests, to skip before the page starts. Requires limit.
|
||||
* @description The number of raw results to skip before returning. Aggregation requests ignore it.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -11011,7 +10973,7 @@ export interface Querybuildertypesv5TimeSeriesValueDTO {
|
||||
value?: number;
|
||||
/**
|
||||
* @type array
|
||||
* @description The per-bucket counts for heatmap results, in the order of meta.buckets plus a trailing overflow count.
|
||||
* @description The per-band counts for heatmap results, in the order of meta.buckets plus a trailing overflow count.
|
||||
*/
|
||||
values?: number[];
|
||||
}
|
||||
@@ -11632,7 +11594,7 @@ export interface PromotetypesPostableIndexMaterializedPathsDTO {
|
||||
export interface Querybuildertypesv5AggregationMetaDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @description The ascending upper bounds of the value buckets, set for heatmap and distribution results; each point then carries one count per bound plus a trailing overflow count in values. The first bucket starts at -Inf when its bound is at or under zero, else at zero.
|
||||
* @description The ascending upper bounds of the value bands, set only for heatmap results; each point then carries one count per bound plus a trailing overflow count in values.
|
||||
*/
|
||||
buckets?: number[];
|
||||
/**
|
||||
@@ -11753,91 +11715,6 @@ export interface Querybuildertypesv5ColumnDescriptorDTO {
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One count per bound in meta.buckets plus a trailing overflow count, summed over every step of the requested time range.
|
||||
*/
|
||||
counts: number[] | null;
|
||||
/**
|
||||
* @type array
|
||||
* @description The group-by field values that identify this distribution.
|
||||
*/
|
||||
labels?: Querybuildertypesv5LabelDTO[];
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionPercentilesDTO {
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
* @description The 50th percentile.
|
||||
*/
|
||||
p50: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
* @description The 75th percentile.
|
||||
*/
|
||||
p75: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
* @description The 90th percentile.
|
||||
*/
|
||||
p90: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
* @description The 95th percentile.
|
||||
*/
|
||||
p95: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
* @description The 99th percentile.
|
||||
*/
|
||||
p99: number;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionAggregationDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description The alias of the aggregation, or the internal name __result_N, with N its index, when no alias was given.
|
||||
*/
|
||||
alias: string;
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One distribution per group-by combination.
|
||||
*/
|
||||
distributions: Querybuildertypesv5DistributionDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The zero-based position of the aggregation in the query.
|
||||
*/
|
||||
index: number;
|
||||
/**
|
||||
* @description Display metadata for the aggregation, including the bucket upper bounds the counts are positional against.
|
||||
*/
|
||||
meta?: Querybuildertypesv5AggregationMetaDTO;
|
||||
/**
|
||||
* @description Percentiles over the counts of every distribution added together, estimated the way histogram_quantile does by interpolating linearly within the bucket each rank falls in. Omitted when nothing was counted or meta.buckets is empty.
|
||||
*/
|
||||
percentiles?: Querybuildertypesv5DistributionPercentilesDTO;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionDataDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One entry per aggregation of the query. The order is not guaranteed; match entries to aggregations by index.
|
||||
*/
|
||||
aggregations: Querybuildertypesv5DistributionAggregationDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
* @description The name of the query that produced this result.
|
||||
*/
|
||||
queryName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step interval in seconds that was used for each query, keyed by query name.
|
||||
*/
|
||||
@@ -12093,28 +11970,21 @@ export type Querybuildertypesv5QueryDataDTO =
|
||||
| (Querybuildertypesv5TimeSeriesDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
})
|
||||
| (Querybuildertypesv5ScalarDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
})
|
||||
| (Querybuildertypesv5RawDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
})
|
||||
| (Querybuildertypesv5DistributionDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
});
|
||||
@@ -12248,7 +12118,7 @@ export interface Querybuildertypesv5QueryRangeRequestDTO {
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw, or DistributionData for distribution requests.
|
||||
* Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series, ScalarData for scalar, or RawData for raw requests.
|
||||
*/
|
||||
export interface Querybuildertypesv5QueryRangeResponseDTO {
|
||||
/**
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { ConfigProvider, Select } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { UpdatedWindow } from '../dsl/types';
|
||||
import type { CreatorOption } from '../types';
|
||||
import { UPDATED_OPTIONS } from './constants';
|
||||
|
||||
import styles from './FilterZone.module.scss';
|
||||
|
||||
// The option checkbox already marks selection, so selected rows stay regular weight.
|
||||
const CREATOR_SELECT_THEME = {
|
||||
components: { Select: { optionSelectedFontWeight: 400 } },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
createdBy: string[];
|
||||
updated: UpdatedWindow;
|
||||
creatorOptions: CreatorOption[];
|
||||
onCreatedByChange: (emails: string[]) => void;
|
||||
onUpdatedChange: (window: UpdatedWindow) => void;
|
||||
// Run the staged draft — fired when a dropdown closes.
|
||||
onApply: () => void;
|
||||
// Clear all Created-by selections and run immediately.
|
||||
onClearCreatedBy: () => void;
|
||||
// Ids: `${testIdPrefix}-filter-created-by`, `-filter-updated`.
|
||||
testIdPrefix: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function FilterChips({
|
||||
createdBy,
|
||||
updated,
|
||||
creatorOptions,
|
||||
onCreatedByChange,
|
||||
onUpdatedChange,
|
||||
onApply,
|
||||
onClearCreatedBy,
|
||||
testIdPrefix,
|
||||
disabled = false,
|
||||
}: Props): JSX.Element {
|
||||
const creatorOptionsData = creatorOptions.map((o) => ({
|
||||
value: o.email,
|
||||
label: o.label,
|
||||
}));
|
||||
|
||||
const runOnClose = (open: boolean): void => {
|
||||
if (!open) {
|
||||
onApply();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.chips}>
|
||||
<ConfigProvider theme={CREATOR_SELECT_THEME}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
allowClear
|
||||
className={cx(styles.select, styles.selectWide)}
|
||||
placeholder="Created by"
|
||||
value={createdBy}
|
||||
options={creatorOptionsData}
|
||||
optionFilterProp="label"
|
||||
maxTagCount={1}
|
||||
menuItemSelectedIcon={null}
|
||||
data-testid={`${testIdPrefix}-filter-created-by`}
|
||||
disabled={disabled}
|
||||
onClear={onClearCreatedBy}
|
||||
optionRender={(option): JSX.Element => (
|
||||
<div className={styles.creatorOption}>
|
||||
<div className={styles.creatorCheck}>
|
||||
<Checkbox
|
||||
color="primary"
|
||||
value={createdBy.includes(option.value as string)}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.creatorLabel}>{option.label}</span>
|
||||
</div>
|
||||
)}
|
||||
onChange={(value): void => onCreatedByChange(value as string[])}
|
||||
onDropdownVisibleChange={runOnClose}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
<Select
|
||||
showSearch
|
||||
className={cx(styles.select, styles.selectNarrow)}
|
||||
placeholder="Updated"
|
||||
value={updated}
|
||||
options={UPDATED_OPTIONS}
|
||||
optionFilterProp="label"
|
||||
data-testid={`${testIdPrefix}-filter-updated`}
|
||||
disabled={disabled}
|
||||
onChange={(value): void => onUpdatedChange(value as UpdatedWindow)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterChips;
|
||||
@@ -1,166 +0,0 @@
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { X } from '@signozhq/icons';
|
||||
|
||||
import type { DslGrammar, SuggestionSource } from '../dsl/types';
|
||||
import SearchBar from '../SearchBar/SearchBar';
|
||||
import type {
|
||||
CreatorOption,
|
||||
FilterAppliedPayload,
|
||||
FilterAppliedType,
|
||||
} from '../types';
|
||||
import FilterChips from './FilterChips';
|
||||
import { useFilterZoneDraft } from './useFilterZoneDraft';
|
||||
|
||||
import styles from './FilterZone.module.scss';
|
||||
|
||||
interface Props {
|
||||
// The last-run query (source of truth for fetching + the dirty baseline).
|
||||
query: string;
|
||||
grammar: DslGrammar;
|
||||
// Ids: `${testIdPrefix}-search-zone`, `-list-search*`, `-filter-zone`,
|
||||
// `-filter-clear`, `-filter-created-by`, `-filter-updated`, `-query-error`.
|
||||
testIdPrefix: string;
|
||||
creatorOptions: CreatorOption[];
|
||||
source: SuggestionSource;
|
||||
// Commit (run) the draft — the only path that triggers a fetch.
|
||||
onQueryChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
// Rendered at the end of the search row (e.g. the New Dashboard action).
|
||||
rightSlot?: ReactNode;
|
||||
// Rendered in the filters row after the chips.
|
||||
extraFilters?: ReactNode;
|
||||
// Shows Clear even when the query is empty.
|
||||
hasExtraFilters?: boolean;
|
||||
// Called by Clear after it empties the query.
|
||||
onClearAll?: () => void;
|
||||
// Shown under the search row.
|
||||
error?: string;
|
||||
// Wraps the search box and the filter controls (e.g. in an authz tooltip).
|
||||
wrapControl?: (node: ReactElement) => ReactElement;
|
||||
onSubmitted?: (hasQuery: boolean) => void;
|
||||
onFilterApplied?: (
|
||||
filterType: FilterAppliedType,
|
||||
payload: FilterAppliedPayload,
|
||||
) => void;
|
||||
onCleared?: () => void;
|
||||
/** Filtering needs `list`: the box and chips edit a query that cannot be run. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const identity = (node: ReactElement): ReactElement => node;
|
||||
|
||||
// The filter command zone: a draft DSL query box (run with Cmd/Ctrl+Enter or the
|
||||
// Run button; a dirty dot signals unrun changes) plus Created-by / Updated chips.
|
||||
function FilterZone({
|
||||
query,
|
||||
grammar,
|
||||
testIdPrefix,
|
||||
creatorOptions,
|
||||
source,
|
||||
onQueryChange,
|
||||
placeholder,
|
||||
rightSlot,
|
||||
extraFilters,
|
||||
hasExtraFilters = false,
|
||||
onClearAll,
|
||||
error,
|
||||
wrapControl = identity,
|
||||
onSubmitted,
|
||||
onFilterApplied,
|
||||
onCleared,
|
||||
disabled = false,
|
||||
}: Props): JSX.Element {
|
||||
const {
|
||||
draft,
|
||||
setDraft,
|
||||
reflected,
|
||||
dirty,
|
||||
isEmpty,
|
||||
run,
|
||||
handleCreatedByChange,
|
||||
handleUpdatedChange,
|
||||
handleClearCreatedBy,
|
||||
handleClear,
|
||||
} = useFilterZoneDraft({
|
||||
query,
|
||||
onQueryChange,
|
||||
onFilterApplied,
|
||||
onCleared,
|
||||
onClearAll,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.filterZone}>
|
||||
<div className={styles.searchRow}>
|
||||
{wrapControl(
|
||||
<div
|
||||
className={styles.searchInput}
|
||||
data-testid={`${testIdPrefix}-search-zone`}
|
||||
>
|
||||
<SearchBar
|
||||
value={draft}
|
||||
grammar={grammar}
|
||||
testIdPrefix={`${testIdPrefix}-list-search`}
|
||||
placeholder={placeholder}
|
||||
source={source}
|
||||
dirty={dirty}
|
||||
disabled={disabled}
|
||||
onChange={setDraft}
|
||||
onSubmit={run}
|
||||
onSubmitted={onSubmitted}
|
||||
/>
|
||||
</div>,
|
||||
)}
|
||||
{rightSlot}
|
||||
</div>
|
||||
{error && (
|
||||
<Typography.Text
|
||||
className={styles.error}
|
||||
testId={`${testIdPrefix}-query-error`}
|
||||
>
|
||||
{error}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<div className={styles.filtersRow}>
|
||||
<Typography.Text className={styles.filtersLabel}>Filters</Typography.Text>
|
||||
{wrapControl(
|
||||
<div
|
||||
className={styles.filterControls}
|
||||
data-testid={`${testIdPrefix}-filter-zone`}
|
||||
>
|
||||
<FilterChips
|
||||
createdBy={reflected.createdBy}
|
||||
updated={reflected.updated}
|
||||
creatorOptions={creatorOptions}
|
||||
testIdPrefix={testIdPrefix}
|
||||
onCreatedByChange={handleCreatedByChange}
|
||||
onUpdatedChange={handleUpdatedChange}
|
||||
onApply={run}
|
||||
onClearCreatedBy={handleClearCreatedBy}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{extraFilters}
|
||||
{(!isEmpty || hasExtraFilters) && (
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<X size={12} />}
|
||||
disabled={disabled}
|
||||
onClick={handleClear}
|
||||
testId={`${testIdPrefix}-filter-clear`}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterZone;
|
||||
@@ -1,135 +0,0 @@
|
||||
import { act, renderHook } from 'tests/test-utils';
|
||||
|
||||
import { useFilterZoneDraft } from '../useFilterZoneDraft';
|
||||
|
||||
const BASE_QUERY = "name = 'x'";
|
||||
|
||||
type Args = Required<Parameters<typeof useFilterZoneDraft>[0]>;
|
||||
|
||||
function setup(query = BASE_QUERY): {
|
||||
result: { current: ReturnType<typeof useFilterZoneDraft> };
|
||||
rerender: (props: { query: string }) => void;
|
||||
onQueryChange: jest.MockedFunction<Args['onQueryChange']>;
|
||||
onFilterApplied: jest.MockedFunction<Args['onFilterApplied']>;
|
||||
onCleared: jest.MockedFunction<Args['onCleared']>;
|
||||
onClearAll: jest.MockedFunction<Args['onClearAll']>;
|
||||
} {
|
||||
const onQueryChange = jest.fn() as jest.MockedFunction<Args['onQueryChange']>;
|
||||
const onFilterApplied = jest.fn() as jest.MockedFunction<
|
||||
Args['onFilterApplied']
|
||||
>;
|
||||
const onCleared = jest.fn() as jest.MockedFunction<Args['onCleared']>;
|
||||
const onClearAll = jest.fn() as jest.MockedFunction<Args['onClearAll']>;
|
||||
const { result, rerender } = renderHook(
|
||||
({ query: q }: { query: string }) =>
|
||||
useFilterZoneDraft({
|
||||
query: q,
|
||||
onQueryChange,
|
||||
onFilterApplied,
|
||||
onCleared,
|
||||
onClearAll,
|
||||
}),
|
||||
{ initialProps: { query } },
|
||||
);
|
||||
return {
|
||||
result,
|
||||
rerender,
|
||||
onQueryChange,
|
||||
onFilterApplied,
|
||||
onCleared,
|
||||
onClearAll,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useFilterZoneDraft', () => {
|
||||
it('edits a draft without running it until run is called', () => {
|
||||
const { result, onQueryChange } = setup();
|
||||
|
||||
act(() => result.current.setDraft("name = 'y' "));
|
||||
|
||||
expect(result.current.dirty).toBe(true);
|
||||
expect(onQueryChange).not.toHaveBeenCalled();
|
||||
|
||||
act(() => result.current.run());
|
||||
|
||||
expect(onQueryChange).toHaveBeenCalledWith("name = 'y'");
|
||||
});
|
||||
|
||||
it('does not run a draft that matches the last-run query', () => {
|
||||
const { result, onQueryChange } = setup();
|
||||
|
||||
act(() => result.current.run());
|
||||
|
||||
expect(result.current.dirty).toBe(false);
|
||||
expect(onQueryChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resyncs the draft when the query changes from outside', () => {
|
||||
const { result, rerender } = setup();
|
||||
|
||||
act(() => result.current.setDraft("name = 'typing'"));
|
||||
rerender({ query: "name = 'from-view'" });
|
||||
|
||||
expect(result.current.draft).toBe("name = 'from-view'");
|
||||
expect(result.current.dirty).toBe(false);
|
||||
});
|
||||
|
||||
it('stages a created-by pick into the draft without running it', () => {
|
||||
const { result, onQueryChange, onFilterApplied } = setup();
|
||||
|
||||
act(() => result.current.handleCreatedByChange(['a@x.io', 'b@x.io']));
|
||||
|
||||
expect(result.current.draft).toBe(
|
||||
"name = 'x' AND created_by IN ['a@x.io', 'b@x.io']",
|
||||
);
|
||||
expect(result.current.reflected.createdBy).toStrictEqual([
|
||||
'a@x.io',
|
||||
'b@x.io',
|
||||
]);
|
||||
expect(onFilterApplied).toHaveBeenCalledWith('createdBy', { valueCount: 2 });
|
||||
expect(onQueryChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs an updated-window pick immediately', () => {
|
||||
const { result, onQueryChange, onFilterApplied } = setup();
|
||||
|
||||
act(() => result.current.handleUpdatedChange('7d'));
|
||||
|
||||
expect(onFilterApplied).toHaveBeenCalledWith('updatedWindow', {});
|
||||
expect(onQueryChange).toHaveBeenCalledTimes(1);
|
||||
expect(onQueryChange.mock.calls[0][0]).toContain("name = 'x' AND updated_at");
|
||||
expect(result.current.reflected.updated).toBe('7d');
|
||||
});
|
||||
|
||||
it('drops the created-by clause and runs when created-by is cleared', () => {
|
||||
const { result, onQueryChange } = setup(
|
||||
"created_by = 'a@x.io' AND name = 'x'",
|
||||
);
|
||||
|
||||
act(() => result.current.handleClearCreatedBy());
|
||||
|
||||
expect(result.current.draft).toBe("name = 'x'");
|
||||
expect(onQueryChange).toHaveBeenCalledWith("name = 'x'");
|
||||
});
|
||||
|
||||
it('empties the draft and runs the empty query on clear', () => {
|
||||
const { result, onQueryChange, onCleared, onClearAll } = setup();
|
||||
|
||||
act(() => result.current.handleClear());
|
||||
|
||||
expect(result.current.draft).toBe('');
|
||||
expect(result.current.isEmpty).toBe(true);
|
||||
expect(onCleared).toHaveBeenCalledTimes(1);
|
||||
expect(onQueryChange).toHaveBeenCalledWith('');
|
||||
expect(onClearAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips re-running an already-empty query on clear', () => {
|
||||
const { result, onQueryChange, onClearAll } = setup('');
|
||||
|
||||
act(() => result.current.handleClear());
|
||||
|
||||
expect(onQueryChange).not.toHaveBeenCalled();
|
||||
expect(onClearAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { UpdatedWindow } from '../dsl/types';
|
||||
|
||||
const UPDATED_LABELS: Record<UpdatedWindow, string> = {
|
||||
any: 'Any time',
|
||||
today: 'Today',
|
||||
'7d': 'Last 7 days',
|
||||
'30d': 'Last 30 days',
|
||||
};
|
||||
|
||||
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
|
||||
|
||||
export const UPDATED_OPTIONS = UPDATED_WINDOWS.map((w) => ({
|
||||
value: w,
|
||||
label: UPDATED_LABELS[w],
|
||||
}));
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
createdByClause,
|
||||
parseReflectedClauses,
|
||||
type ReflectedClauses,
|
||||
spliceClause,
|
||||
updatedClause,
|
||||
} from '../dsl/filterQuery';
|
||||
import type { UpdatedWindow } from '../dsl/types';
|
||||
import type { FilterAppliedPayload, FilterAppliedType } from '../types';
|
||||
|
||||
interface Args {
|
||||
query: string;
|
||||
onQueryChange: (value: string) => void;
|
||||
onFilterApplied?: (
|
||||
filterType: FilterAppliedType,
|
||||
payload: FilterAppliedPayload,
|
||||
) => void;
|
||||
onCleared?: () => void;
|
||||
onClearAll?: () => void;
|
||||
}
|
||||
|
||||
interface FilterZoneDraft {
|
||||
draft: string;
|
||||
setDraft: (value: string) => void;
|
||||
reflected: ReflectedClauses;
|
||||
dirty: boolean;
|
||||
isEmpty: boolean;
|
||||
run: () => void;
|
||||
handleCreatedByChange: (emails: string[]) => void;
|
||||
handleUpdatedChange: (window: UpdatedWindow) => void;
|
||||
handleClearCreatedBy: () => void;
|
||||
handleClear: () => void;
|
||||
}
|
||||
|
||||
// The query box is a DRAFT: typing and the Created-by / Updated dropdowns all edit
|
||||
// the draft (dropdowns splice their clause in); nothing fetches until the draft is
|
||||
// run. The draft re-syncs when the last-run query changes externally (view
|
||||
// select, back/forward).
|
||||
export function useFilterZoneDraft({
|
||||
query,
|
||||
onQueryChange,
|
||||
onFilterApplied,
|
||||
onCleared,
|
||||
onClearAll,
|
||||
}: Args): FilterZoneDraft {
|
||||
const [draft, setDraft] = useState(query);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(query);
|
||||
}, [query]);
|
||||
|
||||
const reflected = useMemo(() => parseReflectedClauses(draft), [draft]);
|
||||
const dirty = draft.trim() !== query.trim();
|
||||
const isEmpty = !draft.trim();
|
||||
|
||||
const commit = useCallback(
|
||||
(next: string): void => {
|
||||
const trimmed = next.trim();
|
||||
if (trimmed !== query) {
|
||||
onQueryChange(trimmed);
|
||||
}
|
||||
},
|
||||
[query, onQueryChange],
|
||||
);
|
||||
|
||||
const run = useCallback((): void => commit(draft), [commit, draft]);
|
||||
|
||||
// Created-by (multi-select) only STAGES its clause into the draft; the query runs
|
||||
// when the dropdown closes (FilterChips fires onApply), not on each pick.
|
||||
const handleCreatedByChange = useCallback(
|
||||
(emails: string[]): void => {
|
||||
onFilterApplied?.('createdBy', { valueCount: emails.length });
|
||||
setDraft((d) => spliceClause(d, 'created_by', createdByClause(emails)));
|
||||
},
|
||||
[onFilterApplied],
|
||||
);
|
||||
|
||||
// Updated (single-select) is a definitive single choice, so it splices AND runs
|
||||
// immediately (with the fresh value, avoiding the async draft-state lag).
|
||||
const handleUpdatedChange = useCallback(
|
||||
(window: UpdatedWindow): void => {
|
||||
onFilterApplied?.('updatedWindow', {});
|
||||
const next = spliceClause(draft, 'updated_at', updatedClause(window));
|
||||
setDraft(next);
|
||||
commit(next);
|
||||
},
|
||||
[draft, commit, onFilterApplied],
|
||||
);
|
||||
|
||||
// Clear-all on the Created-by select removes its clause and runs immediately.
|
||||
const handleClearCreatedBy = useCallback((): void => {
|
||||
const next = spliceClause(draft, 'created_by', null);
|
||||
setDraft(next);
|
||||
commit(next);
|
||||
}, [draft, commit]);
|
||||
|
||||
// Clear resets the draft and runs immediately (empty query).
|
||||
const handleClear = useCallback((): void => {
|
||||
onCleared?.();
|
||||
setDraft('');
|
||||
if (query !== '') {
|
||||
onQueryChange('');
|
||||
}
|
||||
onClearAll?.();
|
||||
}, [query, onQueryChange, onCleared, onClearAll]);
|
||||
|
||||
return {
|
||||
draft,
|
||||
setDraft,
|
||||
reflected,
|
||||
dirty,
|
||||
isEmpty,
|
||||
run,
|
||||
handleCreatedByChange,
|
||||
handleUpdatedChange,
|
||||
handleClearCreatedBy,
|
||||
handleClear,
|
||||
};
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { EditorView } from '@uiw/react-codemirror';
|
||||
|
||||
import type { SuggestionSource } from '../dsl/types';
|
||||
|
||||
export const EMPTY_SOURCE: SuggestionSource = {
|
||||
dynamicKeys: [],
|
||||
valuesByKey: {},
|
||||
creatorEmails: [],
|
||||
};
|
||||
|
||||
// Token colours come from `.cm-dsl-*` classes (SearchBar.module.scss); this only
|
||||
// handles layout — transparent background, monospace, single line.
|
||||
export const editorTheme = EditorView.theme({
|
||||
'&': { fontSize: '13px', backgroundColor: 'transparent' },
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
'.cm-content': {
|
||||
// Leave room for the overlaid lead icon (left) and Run button (right).
|
||||
padding: '8px 120px 8px 30px',
|
||||
fontFamily: "'Space Mono', monospace",
|
||||
color: 'var(--l1-foreground)',
|
||||
caretColor: 'var(--l1-foreground)',
|
||||
},
|
||||
'.cm-line': { padding: '0' },
|
||||
// Whole pixels: at 19.5px the placeholder line rounds 1px taller than text.
|
||||
'.cm-scroller': { fontFamily: 'inherit', lineHeight: '20px' },
|
||||
'.cm-placeholder': { color: 'var(--l3-foreground)' },
|
||||
});
|
||||
|
||||
export const BASIC_SETUP = {
|
||||
lineNumbers: false,
|
||||
foldGutter: false,
|
||||
highlightActiveLine: false,
|
||||
highlightActiveLineGutter: false,
|
||||
highlightSelectionMatches: false,
|
||||
bracketMatching: false,
|
||||
closeBrackets: false,
|
||||
autocompletion: false,
|
||||
// Use the browser's native caret so it blinks (a CM-drawn cursor needs extra
|
||||
// styling and doesn't blink by default).
|
||||
drawSelection: false,
|
||||
} as const;
|
||||
@@ -1,69 +0,0 @@
|
||||
import { RULES_DSL_GRAMMAR } from 'container/ListAlertRules/list.config';
|
||||
|
||||
import { getSuggestions } from './dslSuggestions';
|
||||
import type { SuggestionSource } from './types';
|
||||
|
||||
const source: SuggestionSource = {
|
||||
reservedKeys: [
|
||||
'alert_type',
|
||||
'created_at',
|
||||
'created_by',
|
||||
'labels.<key>',
|
||||
'name',
|
||||
'rule_type',
|
||||
'severity',
|
||||
'updated_at',
|
||||
'updated_by',
|
||||
],
|
||||
dynamicKeys: ['labels.team', 'labels.env'],
|
||||
valuesByKey: { 'labels.team': ['infra'], 'labels.severity': ['critical'] },
|
||||
creatorEmails: ['alice@x.io'],
|
||||
currentUserEmail: 'me@x.io',
|
||||
};
|
||||
|
||||
const suggest = (q: string): ReturnType<typeof getSuggestions>['items'] =>
|
||||
getSuggestions(q, q.length, source, RULES_DSL_GRAMMAR).items;
|
||||
const labels = (q: string): string[] => suggest(q).map((i) => i.label);
|
||||
|
||||
describe('getSuggestions — rules grammar', () => {
|
||||
it('orders reserved keys, hides labels.<key> and appends label keys', () => {
|
||||
expect(labels('')).toStrictEqual([
|
||||
'name',
|
||||
'severity',
|
||||
'alert_type',
|
||||
'rule_type',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'labels.team',
|
||||
'labels.env',
|
||||
]);
|
||||
expect(suggest('labels.t').map((i) => i.detail)).toStrictEqual(['label']);
|
||||
});
|
||||
|
||||
it('restricts enum keys to = != IN NOT IN and suggests their values', () => {
|
||||
expect(labels('rule_type ')).toStrictEqual([
|
||||
'rule_type =',
|
||||
'rule_type !=',
|
||||
'rule_type IN',
|
||||
'rule_type NOT IN',
|
||||
]);
|
||||
expect(labels('rule_type = ')).toStrictEqual([
|
||||
'threshold_rule',
|
||||
'promql_rule',
|
||||
'anomaly_rule',
|
||||
]);
|
||||
expect(labels("alert_type = 'LOGS")).toStrictEqual(['LOGS_BASED_ALERT']);
|
||||
});
|
||||
|
||||
it('gives severity and labels.* the label operators and values', () => {
|
||||
expect(labels('severity ')).toContain('severity EXISTS');
|
||||
expect(labels('severity = ')).toStrictEqual(['critical']);
|
||||
expect(labels('labels.team = ')).toStrictEqual(['infra']);
|
||||
});
|
||||
|
||||
it('suggests user emails for updated_by', () => {
|
||||
expect(labels('updated_by = ')).toStrictEqual(['me@x.io (me)', 'alice@x.io']);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { CaretContext } from './dslTokenizer';
|
||||
|
||||
// `dynamic` keys (tags, labels) take string ops plus EXISTS / NOT EXISTS.
|
||||
export type DslFieldType = 'string' | 'timestamp' | 'bool' | 'enum' | 'dynamic';
|
||||
|
||||
export interface DslField {
|
||||
key: string;
|
||||
type: DslFieldType;
|
||||
values?: readonly string[];
|
||||
}
|
||||
|
||||
// A list-filter DSL grammar, mirroring a backend `list_filter.go` allow-list.
|
||||
export interface DslGrammar {
|
||||
// Reserved fields in suggestion order. Any key not listed here is dynamic.
|
||||
fields: readonly DslField[];
|
||||
// `detail` labels dynamic keys in the popup. A reserved dynamic key (e.g.
|
||||
// rules' `severity`) aliases `${prefix}${key}` for value lookups.
|
||||
dynamicKey: { detail: string; prefix: string };
|
||||
// Advertised by the API but not filterable (e.g. `labels.<key>`).
|
||||
hiddenKeys?: readonly string[];
|
||||
}
|
||||
|
||||
export type ClauseValue = string | boolean | readonly string[];
|
||||
|
||||
export type ClauseBuilder<K extends string> = (
|
||||
key: K,
|
||||
op: string,
|
||||
value: ClauseValue,
|
||||
) => string;
|
||||
|
||||
export interface SuggestionSource {
|
||||
// Reserved keys from the list response, falling back to the grammar's.
|
||||
reservedKeys?: string[];
|
||||
// Dynamic keys exactly as typed (e.g. `team`, `labels.team`).
|
||||
dynamicKeys: string[];
|
||||
// Known values per dynamic key (lower-cased full key -> values).
|
||||
valuesByKey: Record<string, string[]>;
|
||||
// Values for `created_by` / `updated_by`.
|
||||
creatorEmails: string[];
|
||||
currentUserEmail?: string;
|
||||
}
|
||||
|
||||
export interface Suggestion {
|
||||
label: string;
|
||||
insertText: string;
|
||||
kind: 'key' | 'operator' | 'value' | 'connector';
|
||||
// Right-aligned hint in the popup — e.g. 'field' vs 'tag' so reserved columns
|
||||
// are distinguishable from dynamic keys.
|
||||
detail?: string;
|
||||
// Chars to move the caret back from the end of `insertText` after applying.
|
||||
// Used to land the caret inside a `[...]` list so multi-select can continue.
|
||||
caretOffset?: number;
|
||||
}
|
||||
|
||||
export interface SuggestionsResult {
|
||||
items: Suggestion[];
|
||||
ctx: CaretContext;
|
||||
}
|
||||
|
||||
// Relative "updated within" windows offered by the Updated filter dropdown.
|
||||
export type UpdatedWindow = 'any' | 'today' | '7d' | '30d';
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface CreatorOption {
|
||||
email: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type FilterAppliedType = 'createdBy' | 'updatedWindow';
|
||||
|
||||
// Analytics payload for a chip change (`valueCount` for Created by).
|
||||
export type FilterAppliedPayload = Record<string, unknown>;
|
||||
@@ -1,46 +0,0 @@
|
||||
.commandHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.headingBlock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dirtyDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--warning-background);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.countPill {
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-lg);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 28px;
|
||||
letter-spacing: -0.09px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Bookmark, Plus, Search } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
|
||||
import ViewNamePopover from 'components/ViewNamePopover/ViewNamePopover';
|
||||
|
||||
import type { BuiltinListView, ViewSaveMode } from '../types';
|
||||
import ViewsRailDirtyPanel from './ViewsRailDirtyPanel';
|
||||
import ViewsRailItem from './ViewsRailItem';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
interface Props {
|
||||
activeViewId: string;
|
||||
builtinViews: readonly BuiltinListView[];
|
||||
customViews: { id: string; name: string }[];
|
||||
customViewsLoading: boolean;
|
||||
isCustomActive: boolean;
|
||||
isModified: boolean;
|
||||
collapsed?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onSave: (name: string) => void;
|
||||
onSaveChanges: () => void;
|
||||
onReset: () => void;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onRename: (id: string, name: string) => void;
|
||||
/** Prefixes the rail's test ids, e.g. `<prefix>-view-<id>`. */
|
||||
testIdPrefix: string;
|
||||
saveTestIdPrefix?: string;
|
||||
renameTestIdPrefix?: string;
|
||||
customSectionLabel?: string;
|
||||
deleteDescription?: string;
|
||||
/** Wraps the rail body, e.g. in an authz guard. */
|
||||
guard?: (content: ReactElement) => ReactElement;
|
||||
onViewSaved?: (mode: ViewSaveMode) => void;
|
||||
onViewRenamed?: () => void;
|
||||
onViewDeleted?: () => void;
|
||||
}
|
||||
|
||||
// Purely presentational — active view, dirty state, and handlers come from
|
||||
// `useListViews`.
|
||||
function ViewsRail({
|
||||
activeViewId,
|
||||
builtinViews,
|
||||
customViews,
|
||||
customViewsLoading,
|
||||
isCustomActive,
|
||||
isModified,
|
||||
collapsed = false,
|
||||
onSelect,
|
||||
onSave,
|
||||
onSaveChanges,
|
||||
onReset,
|
||||
onDelete,
|
||||
onRename,
|
||||
testIdPrefix,
|
||||
saveTestIdPrefix = 'save-view',
|
||||
renameTestIdPrefix = 'rename-view',
|
||||
customSectionLabel = 'My views',
|
||||
deleteDescription = 'This removes the saved view.',
|
||||
guard,
|
||||
onViewSaved,
|
||||
onViewRenamed,
|
||||
onViewDeleted,
|
||||
}: Props): JSX.Element {
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const { contextHolder, confirmDelete } = useDeleteConfirm();
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const matchesQuery = (label: string): boolean =>
|
||||
!q || label.toLowerCase().includes(q);
|
||||
|
||||
const personal = builtinViews.filter(
|
||||
(v) => v.section === 'personal' && matchesQuery(v.label),
|
||||
);
|
||||
const system = builtinViews.filter(
|
||||
(v) => v.section === 'system' && matchesQuery(v.label),
|
||||
);
|
||||
const custom = customViews.filter((v) => matchesQuery(v.name));
|
||||
const noMatches =
|
||||
!!q && personal.length === 0 && system.length === 0 && custom.length === 0;
|
||||
|
||||
const onConfirmDelete = useCallback(
|
||||
(id: string, label: string): void => {
|
||||
confirmDelete({
|
||||
title: (
|
||||
<Typography.Title level={5}>
|
||||
Delete the{' '}
|
||||
<Typography.Text className={styles.deleteName}>{label}</Typography.Text>{' '}
|
||||
view?
|
||||
</Typography.Title>
|
||||
),
|
||||
content: deleteDescription,
|
||||
// Return the delete promise so the modal's Delete button stays loading
|
||||
// until the backend call settles, then closes — matching dashboard delete.
|
||||
onConfirm: (): Promise<void> => {
|
||||
onViewDeleted?.();
|
||||
return onDelete(id);
|
||||
},
|
||||
});
|
||||
},
|
||||
[confirmDelete, deleteDescription, onDelete, onViewDeleted],
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
(id: string, name: string): void => {
|
||||
onViewRenamed?.();
|
||||
onRename(id, name);
|
||||
},
|
||||
[onRename, onViewRenamed],
|
||||
);
|
||||
|
||||
const handleSaveAsView = (name: string): void => {
|
||||
onViewSaved?.('new');
|
||||
onSave(name);
|
||||
};
|
||||
|
||||
const handleSaveViewChanges = (): void => {
|
||||
onViewSaved?.('update');
|
||||
onSaveChanges();
|
||||
};
|
||||
|
||||
const renderBuiltin = (v: BuiltinListView): JSX.Element => (
|
||||
<ViewsRailItem
|
||||
key={v.id}
|
||||
id={v.id}
|
||||
label={v.label}
|
||||
icon={v.icon}
|
||||
active={v.id === activeViewId}
|
||||
isModified={isModified}
|
||||
testIdPrefix={testIdPrefix}
|
||||
renameTestIdPrefix={renameTestIdPrefix}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className={styles.header}>
|
||||
<h4 className={styles.headerTitle}>Views</h4>
|
||||
<ViewNamePopover
|
||||
open={saveOpen}
|
||||
onOpenChange={setSaveOpen}
|
||||
onSubmit={handleSaveAsView}
|
||||
title="Save as view"
|
||||
confirmLabel="Save view"
|
||||
testIdPrefix={saveTestIdPrefix}
|
||||
tooltip="Save current filters as a view"
|
||||
trigger={
|
||||
<Button
|
||||
aria-label="Save current filters as a view"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
testId={`${testIdPrefix}-view-save-trigger`}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
value={query}
|
||||
placeholder="Filter views by name"
|
||||
prefix={<Search size={12} />}
|
||||
testId={`${testIdPrefix}-view-search`}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setQuery(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.scroll}>
|
||||
{personal.length > 0 && (
|
||||
<>
|
||||
<div className={styles.groupLabel}>Personal</div>
|
||||
{personal.map(renderBuiltin)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{system.length > 0 && (
|
||||
<>
|
||||
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
|
||||
System
|
||||
</div>
|
||||
{system.map(renderBuiltin)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(!q || custom.length > 0) && (
|
||||
<>
|
||||
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
|
||||
{customSectionLabel}
|
||||
<Typography.Text className={styles.groupCount}>
|
||||
{customViews.length}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{customViewsLoading ? (
|
||||
<div className={styles.empty}>Loading views…</div>
|
||||
) : customViews.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
No saved views yet. Filter the list, then save it as a view.
|
||||
</div>
|
||||
) : (
|
||||
custom.map((v) => (
|
||||
<ViewsRailItem
|
||||
key={v.id}
|
||||
id={v.id}
|
||||
label={v.name}
|
||||
icon={Bookmark}
|
||||
active={v.id === activeViewId}
|
||||
isModified={isModified}
|
||||
testIdPrefix={testIdPrefix}
|
||||
renameTestIdPrefix={renameTestIdPrefix}
|
||||
onSelect={onSelect}
|
||||
onRename={handleRename}
|
||||
onDelete={onConfirmDelete}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{noMatches && (
|
||||
<div className={styles.searchEmpty}>
|
||||
No views match “{query}”
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isModified && (
|
||||
<ViewsRailDirtyPanel
|
||||
isCustomActive={isCustomActive}
|
||||
testIdPrefix={testIdPrefix}
|
||||
onSaveChanges={handleSaveViewChanges}
|
||||
onSaveAs={(): void => setSaveOpen(true)}
|
||||
onReset={onReset}
|
||||
/>
|
||||
)}
|
||||
{contextHolder}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className={cx(styles.rail, { [styles.collapsed]: collapsed })}>
|
||||
{guard ? guard(content) : content}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewsRail;
|
||||
@@ -1,87 +0,0 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
interface Props {
|
||||
isCustomActive: boolean;
|
||||
testIdPrefix: string;
|
||||
onSaveChanges: () => void;
|
||||
onSaveAs: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
function ViewsRailDirtyPanel({
|
||||
isCustomActive,
|
||||
testIdPrefix,
|
||||
onSaveChanges,
|
||||
onSaveAs,
|
||||
onReset,
|
||||
}: Props): JSX.Element {
|
||||
if (isCustomActive) {
|
||||
return (
|
||||
<div className={styles.dirtyPanel}>
|
||||
<div className={styles.dirtyTitle}>Unsaved changes</div>
|
||||
<div className={styles.dirtyActions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={onSaveChanges}
|
||||
testId={`${testIdPrefix}-view-save-changes`}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onSaveAs}
|
||||
testId={`${testIdPrefix}-view-save-as`}
|
||||
>
|
||||
Save as…
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
testId={`${testIdPrefix}-view-reset`}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(styles.dirtyPanel, styles.dirtyPanelDefault)}>
|
||||
<div className={styles.dirtyTitle}>Filters active</div>
|
||||
<div className={styles.dirtyActions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
prefix={<Plus size={12} />}
|
||||
onClick={onSaveAs}
|
||||
testId={`${testIdPrefix}-view-save-as-new`}
|
||||
>
|
||||
Save as new view
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
testId={`${testIdPrefix}-view-reset`}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewsRailDirtyPanel;
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { PenLine, Trash2 } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import ViewNamePopover from 'components/ViewNamePopover/ViewNamePopover';
|
||||
|
||||
import type { ListViewIcon } from '../types';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ListViewIcon;
|
||||
active: boolean;
|
||||
isModified: boolean;
|
||||
testIdPrefix: string;
|
||||
renameTestIdPrefix: string;
|
||||
onSelect: (id: string) => void;
|
||||
/** Present only for custom (editable) views. */
|
||||
onRename?: (id: string, name: string) => void;
|
||||
onDelete?: (id: string, label: string) => void;
|
||||
}
|
||||
|
||||
function ViewsRailItem({
|
||||
id,
|
||||
label,
|
||||
icon: Icon,
|
||||
active,
|
||||
isModified,
|
||||
testIdPrefix,
|
||||
renameTestIdPrefix,
|
||||
onSelect,
|
||||
onRename,
|
||||
onDelete,
|
||||
}: Props): JSX.Element {
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.row, { [styles.rowActive]: active })}>
|
||||
<Button
|
||||
size="md"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
width="100%"
|
||||
onClick={(): void => onSelect(id)}
|
||||
testId={`${testIdPrefix}-view-${id}`}
|
||||
prefix={<Icon size={16} className={styles.itemIcon} />}
|
||||
suffix={
|
||||
active && isModified ? (
|
||||
<div className={styles.dirtyDot} title="Unsaved changes" />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
{onRename && onDelete && (
|
||||
<div className={styles.itemActions}>
|
||||
<ViewNamePopover
|
||||
open={renaming}
|
||||
onOpenChange={setRenaming}
|
||||
onSubmit={(name): void => onRename(id, name)}
|
||||
title="Rename view"
|
||||
confirmLabel="Rename"
|
||||
initialName={label}
|
||||
testIdPrefix={renameTestIdPrefix}
|
||||
tooltip="Rename view"
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Rename view"
|
||||
testId={`${testIdPrefix}-view-rename-${id}`}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
<PenLine size={12} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Tooltip title="Delete view">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Delete view"
|
||||
testId={`${testIdPrefix}-view-delete-${id}`}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onDelete(id, label);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewsRailItem;
|
||||
@@ -1,151 +0,0 @@
|
||||
import { Bookmark, Pin } from '@signozhq/icons';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import type { BuiltinListView } from '../../types';
|
||||
import ViewsRail from '../ViewsRail';
|
||||
|
||||
const PREFIX = 'things';
|
||||
|
||||
const BUILTIN_VIEWS: BuiltinListView[] = [
|
||||
{ id: 'mine', label: 'My things', icon: Pin, section: 'personal' },
|
||||
{ id: 'all', label: 'All things', icon: Bookmark, section: 'system' },
|
||||
];
|
||||
|
||||
const CUSTOM_VIEWS = [
|
||||
{ id: 'view-1', name: 'Prod things' },
|
||||
{ id: 'view-2', name: 'Staging things' },
|
||||
];
|
||||
|
||||
type Props = React.ComponentProps<typeof ViewsRail>;
|
||||
|
||||
function renderRail(props?: Partial<Props>): Props {
|
||||
const allProps: Props = {
|
||||
activeViewId: 'all',
|
||||
builtinViews: BUILTIN_VIEWS,
|
||||
customViews: CUSTOM_VIEWS,
|
||||
customViewsLoading: false,
|
||||
isCustomActive: false,
|
||||
isModified: false,
|
||||
onSelect: jest.fn(),
|
||||
onSave: jest.fn(),
|
||||
onSaveChanges: jest.fn(),
|
||||
onReset: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
onRename: jest.fn(),
|
||||
testIdPrefix: PREFIX,
|
||||
...props,
|
||||
};
|
||||
render(<ViewsRail {...allProps} />);
|
||||
return allProps;
|
||||
}
|
||||
|
||||
describe('ViewsRail', () => {
|
||||
it('groups built-in views by section and custom views under their label', () => {
|
||||
renderRail({ customSectionLabel: 'Team views' });
|
||||
|
||||
expect(screen.getByText('Personal')).toBeInTheDocument();
|
||||
expect(screen.getByText('System')).toBeInTheDocument();
|
||||
expect(screen.getByText('Team views')).toBeInTheDocument();
|
||||
['mine', 'all', 'view-1', 'view-2'].forEach((id) =>
|
||||
expect(screen.getByTestId(`${PREFIX}-view-${id}`)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('selects a view by id', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const props = renderRail();
|
||||
|
||||
await user.click(screen.getByTestId(`${PREFIX}-view-view-2`));
|
||||
|
||||
expect(props.onSelect).toHaveBeenCalledWith('view-2');
|
||||
});
|
||||
|
||||
it('filters views by name and says when none match', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderRail();
|
||||
const search = screen.getByTestId(`${PREFIX}-view-search`);
|
||||
|
||||
await user.type(search, 'prod');
|
||||
|
||||
expect(screen.getByTestId(`${PREFIX}-view-view-1`)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(`${PREFIX}-view-view-2`)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(`${PREFIX}-view-all`)).not.toBeInTheDocument();
|
||||
|
||||
await user.clear(search);
|
||||
await user.type(search, 'nothing');
|
||||
|
||||
expect(screen.getByText(/No views match/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading and empty states for custom views', () => {
|
||||
const { unmount } = render(
|
||||
<ViewsRail
|
||||
activeViewId="all"
|
||||
builtinViews={BUILTIN_VIEWS}
|
||||
customViews={[]}
|
||||
customViewsLoading
|
||||
isCustomActive={false}
|
||||
isModified={false}
|
||||
onSelect={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
onSaveChanges={jest.fn()}
|
||||
onReset={jest.fn()}
|
||||
onDelete={jest.fn()}
|
||||
onRename={jest.fn()}
|
||||
testIdPrefix={PREFIX}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Loading views…')).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
renderRail({ customViews: [] });
|
||||
expect(screen.getByText(/No saved views yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the unsaved-changes panel while filters match the view', () => {
|
||||
renderRail();
|
||||
|
||||
expect(screen.queryByTestId(`${PREFIX}-view-reset`)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers save, save as and reset for a modified custom view', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onViewSaved = jest.fn();
|
||||
const props = renderRail({
|
||||
activeViewId: 'view-1',
|
||||
isCustomActive: true,
|
||||
isModified: true,
|
||||
onViewSaved,
|
||||
});
|
||||
|
||||
await user.click(screen.getByTestId(`${PREFIX}-view-save-changes`));
|
||||
expect(onViewSaved).toHaveBeenCalledWith('update');
|
||||
expect(props.onSaveChanges).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(screen.getByTestId(`${PREFIX}-view-save-as`)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId(`${PREFIX}-view-reset`));
|
||||
expect(props.onReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('offers only save as new for a modified built-in view', () => {
|
||||
renderRail({ isModified: true });
|
||||
|
||||
expect(screen.getByTestId(`${PREFIX}-view-save-as-new`)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId(`${PREFIX}-view-save-changes`),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('wraps its body in the guard it is given', () => {
|
||||
renderRail({
|
||||
guard: (content): JSX.Element => (
|
||||
<div data-testid="rail-guard">{content}</div>
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('rail-guard')).toContainElement(
|
||||
screen.getByTestId(`${PREFIX}-view-all`),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { Pin } from '@signozhq/icons';
|
||||
|
||||
// All @signozhq icons share this component type.
|
||||
export type ListViewIcon = typeof Pin;
|
||||
|
||||
export interface BuiltinListView {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ListViewIcon;
|
||||
section: 'personal' | 'system';
|
||||
}
|
||||
|
||||
export interface EmptyStateCopy {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// `clientFilter` narrows the rows the server already returned.
|
||||
export interface BuiltinViewConfig<
|
||||
F,
|
||||
Ctx = undefined,
|
||||
Row = never,
|
||||
> extends BuiltinListView {
|
||||
filter: (current: F, ctx: Ctx) => F;
|
||||
clientFilter?: (rows: Row[], ctx: Ctx) => Row[];
|
||||
emptyState?: EmptyStateCopy;
|
||||
}
|
||||
|
||||
export interface SavedListView<F> {
|
||||
id: string;
|
||||
name: string;
|
||||
filter: F;
|
||||
}
|
||||
|
||||
export interface SavedListViewsApi<F> {
|
||||
views: SavedListView<F>[];
|
||||
isLoading: boolean;
|
||||
createView: (name: string, filter: F) => Promise<SavedListView<F> | null>;
|
||||
updateView: (id: string, name: string, filter: F) => void;
|
||||
deleteView: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export type ViewSaveMode = 'new' | 'update';
|
||||
@@ -1,26 +0,0 @@
|
||||
.savePopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 280px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.saveTitle {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.saveLabel {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.saveActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -45,7 +45,6 @@ export enum LOCALSTORAGE {
|
||||
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
|
||||
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
|
||||
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
|
||||
ALERT_RULES_LIST_VIEWS = 'ALERT_RULES_LIST_VIEWS',
|
||||
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
|
||||
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
|
||||
}
|
||||
|
||||
@@ -2,12 +2,7 @@ import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/s
|
||||
|
||||
import { toChannelConfig, toPostableChannel } from './channelConfig';
|
||||
import { toChannelFormState } from './channelFormValues';
|
||||
import {
|
||||
ChannelFormValues,
|
||||
ChannelKind,
|
||||
JiraDeployment,
|
||||
WebhookAuth,
|
||||
} from './types';
|
||||
import { ChannelFormValues, ChannelKind, WebhookAuth } from './types';
|
||||
|
||||
const slackValues: ChannelFormValues = {
|
||||
name: 'prod alerts',
|
||||
@@ -150,11 +145,11 @@ describe('jira reopen duration', () => {
|
||||
])('sends %s as %s, the only spelling the api accepts back', (typed, sent) => {
|
||||
expect(
|
||||
toChannelConfig(ChannelKind.jira, {
|
||||
deployment: JiraDeployment.cloud,
|
||||
site: 'https://acme.atlassian.net',
|
||||
project: 'OPS',
|
||||
issueType: 'Task',
|
||||
auth: { email: 'a@b.c', apiToken: 'tok' },
|
||||
email: 'a@b.c',
|
||||
apiToken: 'tok',
|
||||
reopenDuration: typed,
|
||||
}).spec,
|
||||
).toMatchObject({ reopenDuration: sent });
|
||||
@@ -166,53 +161,3 @@ describe('jira reopen duration', () => {
|
||||
).toMatchObject({ reopenDuration: 'tomorrow' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('jira deployment', () => {
|
||||
const base = {
|
||||
site: 'https://jira.acme.com',
|
||||
project: 'OPS',
|
||||
issueType: 'Task',
|
||||
};
|
||||
|
||||
it('sends cloud with email and api token under auth, never the pat', () => {
|
||||
expect(
|
||||
toChannelConfig(ChannelKind.jira, {
|
||||
...base,
|
||||
deployment: JiraDeployment.cloud,
|
||||
auth: { email: 'a@b.c', apiToken: 'tok', pat: 'stale-pat' },
|
||||
}).spec,
|
||||
).toStrictEqual({
|
||||
...base,
|
||||
deployment: 'cloud',
|
||||
auth: { email: 'a@b.c', apiToken: 'tok' },
|
||||
});
|
||||
});
|
||||
|
||||
it('sends selfhosted with the pat under auth, never email or api token', () => {
|
||||
expect(
|
||||
toChannelConfig(ChannelKind.jira, {
|
||||
...base,
|
||||
deployment: JiraDeployment.selfhosted,
|
||||
auth: { email: 'stale@b.c', apiToken: 'stale', pat: 'pat-1' },
|
||||
}).spec,
|
||||
).toStrictEqual({
|
||||
...base,
|
||||
deployment: 'selfhosted',
|
||||
auth: { pat: 'pat-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('drops blank credentials inside auth', () => {
|
||||
expect(
|
||||
toChannelConfig(ChannelKind.jira, {
|
||||
...base,
|
||||
deployment: JiraDeployment.cloud,
|
||||
auth: { email: 'a@b.c', apiToken: '' },
|
||||
}).spec,
|
||||
).toStrictEqual({
|
||||
...base,
|
||||
deployment: 'cloud',
|
||||
auth: { email: 'a@b.c' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,8 +22,6 @@ import {
|
||||
ChannelFormValues,
|
||||
ChannelKind,
|
||||
ChannelSpecFormValues,
|
||||
JiraDeployment,
|
||||
KeysOfUnion,
|
||||
WebhookAuth,
|
||||
} from './types';
|
||||
import {
|
||||
@@ -97,11 +95,11 @@ const SPEC_FIELDS = {
|
||||
'sendResolved',
|
||||
] as const satisfies readonly (keyof AlertmanagertypesChannelGoogleChatConfigDTO)[],
|
||||
[ChannelKind.jira]: [
|
||||
'deployment',
|
||||
'site',
|
||||
'project',
|
||||
'issueType',
|
||||
'auth',
|
||||
'email',
|
||||
'apiToken',
|
||||
'summary',
|
||||
'description',
|
||||
'priority',
|
||||
@@ -147,7 +145,7 @@ const SPEC_FIELDS = {
|
||||
* instead, and the error names the missing field.
|
||||
*/
|
||||
type MissingFrom<D, T extends readonly PropertyKey[]> = Exclude<
|
||||
KeysOfUnion<D>,
|
||||
keyof D,
|
||||
T[number]
|
||||
>;
|
||||
|
||||
@@ -232,15 +230,6 @@ export function toChannelConfig(
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === ChannelKind.jira) {
|
||||
const auth = values.auth ?? {};
|
||||
picked.auth = omitBlank(
|
||||
values.deployment === JiraDeployment.selfhosted
|
||||
? pick(auth, ['pat'])
|
||||
: pick(auth, ['email', 'apiToken']),
|
||||
);
|
||||
}
|
||||
|
||||
const spec: Record<string, unknown> = omitBlank(picked);
|
||||
|
||||
// the API answers a read with the duration prometheus would print, and
|
||||
|
||||
@@ -68,11 +68,11 @@ const SPECS: Record<string, Record<string, unknown>> = {
|
||||
sendResolved: true,
|
||||
},
|
||||
jira: {
|
||||
deployment: 'cloud',
|
||||
site: 'https://acme.atlassian.net',
|
||||
project: 'OPS',
|
||||
issueType: 'Task',
|
||||
auth: { email: 'a@b.c', apiToken: 'tok' },
|
||||
email: 'a@b.c',
|
||||
apiToken: 'tok',
|
||||
summary: 's',
|
||||
description: 'd',
|
||||
priority: 'High',
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
AlertmanagertypesChannelTelegramConfigDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { ChannelKind, ChannelSpecFormValues, JiraDeployment } from './types';
|
||||
import { ChannelKind, ChannelSpecFormValues } from './types';
|
||||
|
||||
// shared by slack and ms teams, both render the same title / description boxes
|
||||
export const SlackInitialConfig: Partial<AlertmanagertypesChannelSlackConfigDTO> =
|
||||
@@ -58,7 +58,6 @@ export const GoogleChatInitialConfig: Partial<AlertmanagertypesChannelGoogleChat
|
||||
// wrapped in the ADF status panel + deep-links server-side.
|
||||
export const JiraInitialConfig: Partial<AlertmanagertypesChannelJiraConfigDTO> =
|
||||
{
|
||||
deployment: JiraDeployment.cloud,
|
||||
issueType: 'Task',
|
||||
summary: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
|
||||
description: `{{ range .Alerts -}}
|
||||
|
||||
@@ -1,40 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { JiraDeployment } from '../types';
|
||||
import { isValidJiraReopenDuration } from '../utils';
|
||||
import { requiredString } from './shared';
|
||||
|
||||
const required = requiredString('jira_required_fields');
|
||||
|
||||
export const jiraSchema = z
|
||||
.object({
|
||||
deployment: z.enum(JiraDeployment),
|
||||
site: required,
|
||||
auth: z
|
||||
.object({
|
||||
email: z.string().optional(),
|
||||
apiToken: z.string().optional(),
|
||||
pat: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
project: required,
|
||||
issueType: required,
|
||||
reopenDuration: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((v) => !v || isValidJiraReopenDuration(v), {
|
||||
message: 'jira_reopen_duration_invalid',
|
||||
}),
|
||||
})
|
||||
.refine(
|
||||
(values) =>
|
||||
values.deployment === JiraDeployment.selfhosted ||
|
||||
(!!values.auth?.email?.trim() && !!values.auth?.apiToken?.trim()),
|
||||
{ message: 'jira_cloud_credentials_required', path: ['auth', 'email'] },
|
||||
)
|
||||
.refine(
|
||||
(values) =>
|
||||
values.deployment !== JiraDeployment.selfhosted ||
|
||||
!!values.auth?.pat?.trim(),
|
||||
{ message: 'jira_pat_required', path: ['auth', 'pat'] },
|
||||
);
|
||||
export const jiraSchema = z.object({
|
||||
site: required,
|
||||
email: required,
|
||||
apiToken: required,
|
||||
project: required,
|
||||
issueType: required,
|
||||
reopenDuration: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((v) => !v || isValidJiraReopenDuration(v), {
|
||||
message: 'jira_reopen_duration_invalid',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
AlertmanagertypesChannelEmailConfigDTO,
|
||||
AlertmanagertypesChannelGoogleChatConfigDTO,
|
||||
AlertmanagertypesChannelIncidentIOConfigDTO,
|
||||
AlertmanagertypesChannelJiraAuthDTO,
|
||||
AlertmanagertypesChannelJiraConfigDTO,
|
||||
AlertmanagertypesChannelJSMOpsConfigDTO,
|
||||
AlertmanagertypesChannelKindDTO,
|
||||
@@ -12,33 +11,11 @@ import {
|
||||
AlertmanagertypesChannelSlackConfigDTO,
|
||||
AlertmanagertypesChannelTelegramConfigDTO,
|
||||
AlertmanagertypesChannelWebhookConfigDTO,
|
||||
AlertmanagertypesJiraDeploymentDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const ChannelKind = AlertmanagertypesChannelKindDTO;
|
||||
export type ChannelKind = AlertmanagertypesChannelKindDTO;
|
||||
|
||||
export const JiraDeployment = AlertmanagertypesJiraDeploymentDTO;
|
||||
export type JiraDeployment = AlertmanagertypesJiraDeploymentDTO;
|
||||
|
||||
export type KeysOfUnion<T> = T extends unknown ? keyof T : never;
|
||||
|
||||
/** Every key of every member, optional, so a form can hold any member's fields. */
|
||||
export type FlattenUnion<T> = {
|
||||
[K in KeysOfUnion<T>]?: T extends unknown
|
||||
? K extends keyof T
|
||||
? T[K]
|
||||
: never
|
||||
: never;
|
||||
};
|
||||
|
||||
export type JiraFormSpec = Omit<
|
||||
AlertmanagertypesChannelJiraConfigDTO,
|
||||
'auth'
|
||||
> & {
|
||||
auth?: FlattenUnion<AlertmanagertypesChannelJiraAuthDTO>;
|
||||
};
|
||||
|
||||
/** Merged so switching kind keeps the fields the kinds share. */
|
||||
export type ChannelSpecFormValues = Partial<
|
||||
AlertmanagertypesChannelSlackConfigDTO &
|
||||
@@ -48,7 +25,7 @@ export type ChannelSpecFormValues = Partial<
|
||||
AlertmanagertypesChannelOpsgenieConfigDTO &
|
||||
AlertmanagertypesChannelMSTeamsConfigDTO &
|
||||
AlertmanagertypesChannelGoogleChatConfigDTO &
|
||||
JiraFormSpec &
|
||||
AlertmanagertypesChannelJiraConfigDTO &
|
||||
AlertmanagertypesChannelJSMOpsConfigDTO &
|
||||
AlertmanagertypesChannelIncidentIOConfigDTO &
|
||||
AlertmanagertypesChannelTelegramConfigDTO
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
import {
|
||||
ChannelFormValues,
|
||||
ChannelKind,
|
||||
JiraDeployment,
|
||||
WebhookAuth,
|
||||
} from './types';
|
||||
import { ChannelFormValues, ChannelKind, WebhookAuth } from './types';
|
||||
import { validateChannel } from './validation';
|
||||
|
||||
const t = ((key: string) => key) as TFunction;
|
||||
@@ -22,50 +17,14 @@ describe('validateChannel', () => {
|
||||
'a jira site that is not on atlassian.net',
|
||||
ChannelKind.jira,
|
||||
{
|
||||
deployment: JiraDeployment.cloud,
|
||||
site: 'https://jira-proxy.acme.com/',
|
||||
auth: { email: 'a@acme.com', apiToken: 't' },
|
||||
email: 'a@acme.com',
|
||||
apiToken: 't',
|
||||
project: 'OPS',
|
||||
issueType: 'Bug',
|
||||
},
|
||||
null,
|
||||
],
|
||||
[
|
||||
'a self-hosted jira site with a personal access token',
|
||||
ChannelKind.jira,
|
||||
{
|
||||
deployment: JiraDeployment.selfhosted,
|
||||
site: 'https://jira.acme.com',
|
||||
auth: { pat: 'pat-1' },
|
||||
project: 'OPS',
|
||||
issueType: 'Task',
|
||||
},
|
||||
null,
|
||||
],
|
||||
[
|
||||
'a self-hosted jira site without a personal access token',
|
||||
ChannelKind.jira,
|
||||
{
|
||||
deployment: JiraDeployment.selfhosted,
|
||||
site: 'https://jira.acme.com',
|
||||
auth: { email: 'a@acme.com', apiToken: 't' },
|
||||
project: 'OPS',
|
||||
issueType: 'Task',
|
||||
},
|
||||
'jira_pat_required',
|
||||
],
|
||||
[
|
||||
'a jira cloud site without an api token',
|
||||
ChannelKind.jira,
|
||||
{
|
||||
deployment: JiraDeployment.cloud,
|
||||
site: 'https://acme.atlassian.net',
|
||||
auth: { email: 'a@acme.com' },
|
||||
project: 'OPS',
|
||||
issueType: 'Task',
|
||||
},
|
||||
'jira_cloud_credentials_required',
|
||||
],
|
||||
[
|
||||
'any incident.io url',
|
||||
ChannelKind.incidentio,
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.deployment {
|
||||
--radio-group-display: flex;
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Controller, useFormContext, useWatch } from 'react-hook-form';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { RadioGroup } from '@signozhq/ui/radio-group';
|
||||
import { Input as AntdInput, Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import {
|
||||
ChannelSpecFormValues,
|
||||
JiraDeployment,
|
||||
} from '../../CreateAlertChannels/types';
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import FormCard from '../FormCard';
|
||||
import FormField from '../FormField';
|
||||
|
||||
import styles from './Jira.module.scss';
|
||||
|
||||
const { TextArea } = AntdInput;
|
||||
|
||||
function JiraSettings(): JSX.Element {
|
||||
@@ -23,8 +17,6 @@ function JiraSettings(): JSX.Element {
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useFormContext<ChannelSpecFormValues>();
|
||||
const deployment = useWatch({ control, name: 'deployment' });
|
||||
const selfHosted = deployment === JiraDeployment.selfhosted;
|
||||
|
||||
const advanced = (
|
||||
<>
|
||||
@@ -107,132 +99,62 @@ function JiraSettings(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<FormCard title={t('section_connection')}>
|
||||
<FormField label={t('field_jira_deployment')}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="deployment"
|
||||
render={({ field }): JSX.Element => (
|
||||
<div className={styles.deployment}>
|
||||
<RadioGroup
|
||||
color="primary"
|
||||
aria-label={t('field_jira_deployment')}
|
||||
value={deployment}
|
||||
onChange={field.onChange}
|
||||
testId="jira-deployment-radio"
|
||||
items={[
|
||||
{
|
||||
value: JiraDeployment.cloud,
|
||||
label: t('option_jira_deployment_cloud'),
|
||||
},
|
||||
{
|
||||
value: JiraDeployment.selfhosted,
|
||||
label: t('option_jira_deployment_selfhosted'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{selfHosted ? (
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
size="sm"
|
||||
testId="jira-selfhosted-tip"
|
||||
style={{ display: 'block', marginBottom: 16 }}
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
size="sm"
|
||||
testId="jira-service-account-tip"
|
||||
style={{ display: 'block', marginBottom: 16 }}
|
||||
>
|
||||
{t('jira_service_account_tip')}{' '}
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/alerts-management/notification-channel/jira/#use-a-service-account-recommended"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('jira_selfhosted_tip')}{' '}
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/alerts-management/notification-channel/jira/#self-hosted-jira"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('jira_selfhosted_tip_link')}
|
||||
</Typography.Link>
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
size="sm"
|
||||
testId="jira-service-account-tip"
|
||||
style={{ display: 'block', marginBottom: 16 }}
|
||||
>
|
||||
{t('jira_service_account_tip')}{' '}
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/alerts-management/notification-channel/jira/#use-a-service-account-recommended"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('jira_service_account_tip_link')}
|
||||
</Typography.Link>
|
||||
</Typography.Text>
|
||||
)}
|
||||
{t('jira_service_account_tip_link')}
|
||||
</Typography.Link>
|
||||
</Typography.Text>
|
||||
|
||||
<FormField
|
||||
label={t('field_jira_site')}
|
||||
tooltip={t(
|
||||
selfHosted ? 'tooltip_jira_site_selfhosted' : 'tooltip_jira_site',
|
||||
)}
|
||||
tooltip={t('tooltip_jira_site')}
|
||||
error={errors.site?.message}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
{...register('site')}
|
||||
aria-required
|
||||
placeholder={
|
||||
selfHosted
|
||||
? 'https://jira.your-company.com'
|
||||
: 'https://your-domain.atlassian.net'
|
||||
}
|
||||
placeholder="https://your-domain.atlassian.net"
|
||||
data-testid="jira-site-textbox"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{selfHosted ? (
|
||||
<FormField
|
||||
label={t('field_jira_pat')}
|
||||
help={t('help_jira_pat')}
|
||||
error={errors.auth?.pat?.message}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
{...register('auth.pat')}
|
||||
aria-required
|
||||
type="password"
|
||||
data-testid="jira-pat-textbox"
|
||||
/>
|
||||
</FormField>
|
||||
) : (
|
||||
<>
|
||||
<FormField
|
||||
label={t('field_jira_email')}
|
||||
help={t('help_jira_email')}
|
||||
error={errors.auth?.email?.message}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
{...register('auth.email')}
|
||||
aria-required
|
||||
data-testid="jira-email-textbox"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('field_jira_email')}
|
||||
help={t('help_jira_email')}
|
||||
error={errors.email?.message}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
{...register('email')}
|
||||
aria-required
|
||||
data-testid="jira-email-textbox"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t('field_jira_api_token')}
|
||||
help={t('help_jira_api_token')}
|
||||
error={errors.auth?.apiToken?.message}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
{...register('auth.apiToken')}
|
||||
aria-required
|
||||
type="password"
|
||||
data-testid="jira-api-token-textbox"
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
<FormField
|
||||
label={t('field_jira_api_token')}
|
||||
help={t('help_jira_api_token')}
|
||||
error={errors.apiToken?.message}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
{...register('apiToken')}
|
||||
aria-required
|
||||
type="password"
|
||||
data-testid="jira-api-token-textbox"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t('field_jira_project')}
|
||||
@@ -281,9 +203,7 @@ function JiraSettings(): JSX.Element {
|
||||
|
||||
<FormField
|
||||
label={t('field_jira_description')}
|
||||
help={t(
|
||||
selfHosted ? 'help_jira_description_selfhosted' : 'help_jira_description',
|
||||
)}
|
||||
help={t('help_jira_description')}
|
||||
error={errors.description?.message}
|
||||
>
|
||||
<Controller
|
||||
|
||||
@@ -11,10 +11,6 @@ import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { initialQueryAIWithType } from 'constants/queryBuilder';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import {
|
||||
defaultOptionsQuery,
|
||||
URL_OPTIONS,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
@@ -119,16 +115,11 @@ function Explorer(): JSX.Element {
|
||||
|
||||
const handleChangeSelectedView = useCallback(
|
||||
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
|
||||
const panelType = explorerViewToPanelType[view];
|
||||
handleSetConfig(panelType, DataSource.TRACES);
|
||||
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
|
||||
|
||||
// Drop the left panel's url columns; each panel loads its own.
|
||||
handleExplorerTabChange(
|
||||
panelType,
|
||||
explorerViewToPanelType[view],
|
||||
querySearchParameters,
|
||||
undefined,
|
||||
undefined,
|
||||
{ [URL_OPTIONS]: defaultOptionsQuery },
|
||||
);
|
||||
},
|
||||
[handleExplorerTabChange, handleSetConfig],
|
||||
|
||||
@@ -12,15 +12,12 @@ import { QueryKey } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { getTraceLink, transformSpanRows } from './utils';
|
||||
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
@@ -40,7 +37,11 @@ import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
|
||||
|
||||
import TraceExplorerControls from '../Controls';
|
||||
import { getListViewQuery } from '../explorerUtils';
|
||||
import { PER_PAGE_OPTIONS, TIMESTAMP_FIELD } from './configs';
|
||||
import {
|
||||
defaultSelectedColumns,
|
||||
PER_PAGE_OPTIONS,
|
||||
TIMESTAMP_FIELD,
|
||||
} from './configs';
|
||||
import './ListView.styles.scss';
|
||||
|
||||
import styles from './ListView.module.scss';
|
||||
@@ -76,11 +77,6 @@ function ListView({
|
||||
loading: timeRangeUpdateLoading,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { options, config } = useOptionsMenu({
|
||||
sourcePage: SavedviewtypesSourceDTO.ai_observability,
|
||||
});
|
||||
const selectColumns = options.selectColumns;
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
@@ -92,18 +88,6 @@ function ListView({
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
// Sorted field keys: reorder keeps the cache, any field change refetches.
|
||||
const selectColumnsSignature = useMemo(
|
||||
() =>
|
||||
selectColumns
|
||||
.map(({ name, fieldContext, fieldDataType }) =>
|
||||
buildCompositeKey(name, fieldContext, fieldDataType),
|
||||
)
|
||||
.sort()
|
||||
.join(','),
|
||||
[selectColumns],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
@@ -113,7 +97,6 @@ function ListView({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
],
|
||||
[
|
||||
stagedQuery,
|
||||
@@ -122,7 +105,6 @@ function ListView({
|
||||
paginationConfig,
|
||||
maxTime,
|
||||
minTime,
|
||||
selectColumnsSignature,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -141,7 +123,7 @@ function ListView({
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationConfig,
|
||||
selectColumns,
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
@@ -174,20 +156,13 @@ function ListView({
|
||||
[queryTableDataResult],
|
||||
);
|
||||
|
||||
// TODO(ai-explorer): static columns until the preferences framework lands.
|
||||
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(
|
||||
() =>
|
||||
[
|
||||
TIMESTAMP_FIELD,
|
||||
...selectColumns.filter((field) => field.name !== TIMESTAMP_FIELD.name),
|
||||
].map((field) => getFieldColumn(field)),
|
||||
[selectColumns],
|
||||
);
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(cols: TableColumnDef<TracesTableRow>[]): void => {
|
||||
config?.addColumn?.onReorder(cols.map((column) => column.id));
|
||||
},
|
||||
[config],
|
||||
[TIMESTAMP_FIELD, ...defaultSelectedColumns].map((field) =>
|
||||
getFieldColumn(field),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const rows = useMemo(
|
||||
@@ -229,7 +204,6 @@ function ListView({
|
||||
isLoading={isFetching}
|
||||
totalCount={rows.length}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
config={config}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -237,8 +211,7 @@ function ListView({
|
||||
data={rows}
|
||||
columns={columns}
|
||||
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_LIST_COLUMNS}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
respectColumnOrder
|
||||
panelType="LIST"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import type { ComponentProps, MutableRefObject } from 'react';
|
||||
import type { QueryKey } from 'react-query';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import type { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import store from 'store';
|
||||
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
|
||||
import type {
|
||||
BuilderQuery,
|
||||
QueryRangeRequestV5,
|
||||
} from 'types/api/v5/queryRange';
|
||||
|
||||
import type TraceExplorerControls from '../../Controls';
|
||||
import type { TracesTableProps } from '../../TracesTable/TracesTable';
|
||||
import { defaultSelectedColumns } from '../configs';
|
||||
import ListView from '../ListView';
|
||||
|
||||
const mockAddedField: TelemetryFieldKey = {
|
||||
name: 'gen_ai.request.model',
|
||||
signal: 'traces',
|
||||
fieldContext: 'attribute',
|
||||
fieldDataType: 'string',
|
||||
};
|
||||
|
||||
jest.mock('../../Controls', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
config,
|
||||
}: ComponentProps<typeof TraceExplorerControls>): JSX.Element => {
|
||||
const fieldsSelector = config?.fieldsSelector;
|
||||
return (
|
||||
<button
|
||||
data-testid="add-column"
|
||||
type="button"
|
||||
onClick={(): void =>
|
||||
fieldsSelector?.onFieldsChange([...fieldsSelector.value, mockAddedField])
|
||||
}
|
||||
>
|
||||
add
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../TracesTable/TracesTable', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
columns,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
}: TracesTableProps): JSX.Element => (
|
||||
<div>
|
||||
<div data-testid="table-columns">
|
||||
{columns.map((column) => String(column.header)).join(',')}
|
||||
</div>
|
||||
<button
|
||||
data-testid="reverse-columns"
|
||||
type="button"
|
||||
onClick={(): void => onColumnOrderChange?.([...columns].reverse())}
|
||||
>
|
||||
reverse
|
||||
</button>
|
||||
<button
|
||||
data-testid="remove-status-code"
|
||||
type="button"
|
||||
onClick={(): void => onColumnRemove?.('span:response_status_code')}
|
||||
>
|
||||
remove
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
|
||||
const LIST_VIEW_ROUTE = `${ROUTES.AI_OBSERVABILITY_EXPLORER}?${
|
||||
QueryParams.panelTypes
|
||||
}=${encodeURIComponent(JSON.stringify(PANEL_TYPES.LIST))}`;
|
||||
|
||||
const DEFAULT_NAMES = defaultSelectedColumns.map((field) => field.name);
|
||||
|
||||
let requestedFields: string[][] = [];
|
||||
|
||||
const shownColumns = (): string[] =>
|
||||
(screen.getByTestId('table-columns').textContent ?? '').split(',');
|
||||
|
||||
const renderListView = (
|
||||
queryKeyRef?: MutableRefObject<QueryKey | undefined>,
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<ListView
|
||||
isFilterApplied={false}
|
||||
setWarning={jest.fn()}
|
||||
setIsLoadingQueries={jest.fn()}
|
||||
queryKeyRef={queryKeyRef}
|
||||
/>,
|
||||
{},
|
||||
{
|
||||
initialRoute: LIST_VIEW_ROUTE,
|
||||
queryBuilderOverrides: {
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
stagedQuery: initialQueryAIWithType,
|
||||
currentQuery: initialQueryAIWithType,
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
describe('ListView columns', () => {
|
||||
beforeAll(() => {
|
||||
// The list waits until the global time range has loaded.
|
||||
const { minTime, maxTime, selectedTime } = store.getState().globalTime;
|
||||
store.dispatch({
|
||||
type: UPDATE_TIME_INTERVAL,
|
||||
payload: { minTime, maxTime, selectedTime },
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
requestedFields = [];
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
|
||||
const body = (await req.json()) as QueryRangeRequestV5;
|
||||
const spec = body.compositeQuery.queries[0].spec as BuilderQuery;
|
||||
requestedFields.push((spec.selectFields ?? []).map((field) => field.name));
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: { type: 'raw', data: { results: [] } } }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('refetches with the columns added or removed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderListView();
|
||||
await waitFor(() => expect(requestedFields).toStrictEqual([DEFAULT_NAMES]));
|
||||
|
||||
await user.click(screen.getByTestId('add-column'));
|
||||
|
||||
await waitFor(() => expect(requestedFields).toHaveLength(2));
|
||||
expect(requestedFields[1]).toStrictEqual([
|
||||
...DEFAULT_NAMES,
|
||||
mockAddedField.name,
|
||||
]);
|
||||
|
||||
await user.click(screen.getByTestId('remove-status-code'));
|
||||
|
||||
await waitFor(() => expect(requestedFields).toHaveLength(3));
|
||||
expect(requestedFields[2]).toStrictEqual([
|
||||
...DEFAULT_NAMES.filter((name) => name !== 'response_status_code'),
|
||||
mockAddedField.name,
|
||||
]);
|
||||
});
|
||||
|
||||
it('reorders the columns without refetching', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const queryKeyRef: MutableRefObject<QueryKey | undefined> = {
|
||||
current: undefined,
|
||||
};
|
||||
renderListView(queryKeyRef);
|
||||
await waitFor(() => expect(requestedFields).toHaveLength(1));
|
||||
const initialQueryKey = queryKeyRef.current;
|
||||
|
||||
await user.click(screen.getByTestId('reverse-columns'));
|
||||
|
||||
expect(shownColumns()).toStrictEqual([
|
||||
'timestamp',
|
||||
...[...DEFAULT_NAMES].reverse(),
|
||||
]);
|
||||
expect(queryKeyRef.current).toStrictEqual(initialQueryKey);
|
||||
expect(requestedFields).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('restores the stored list columns', async () => {
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS,
|
||||
JSON.stringify({ [PANEL_TYPES.LIST]: [mockAddedField] }),
|
||||
);
|
||||
|
||||
renderListView();
|
||||
|
||||
expect(shownColumns()).toStrictEqual(['timestamp', mockAddedField.name]);
|
||||
await waitFor(() =>
|
||||
expect(requestedFields).toStrictEqual([[mockAddedField.name]]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Dispatch, SetStateAction, useCallback } from 'react';
|
||||
import { useAppNavigate } from 'lib/router/useAppNavigate';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import type { ColumnSizingState } from '@tanstack/react-table';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import type {
|
||||
CellTypographySize,
|
||||
@@ -31,6 +32,8 @@ export type TracesTableProps = {
|
||||
isFilterApplied: boolean;
|
||||
onColumnOrderChange?: (cols: TableColumnDef<TracesTableRow>[]) => void;
|
||||
onColumnRemove?: (columnId: string) => void;
|
||||
columnSizing?: ColumnSizingState;
|
||||
onColumnSizingChange?: Dispatch<SetStateAction<ColumnSizingState>>;
|
||||
cellTypographySize?: CellTypographySize;
|
||||
};
|
||||
|
||||
@@ -48,6 +51,8 @@ function TracesTable({
|
||||
isFilterApplied,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
columnSizing,
|
||||
onColumnSizingChange,
|
||||
cellTypographySize = 'medium',
|
||||
}: TracesTableProps): JSX.Element {
|
||||
const navigate = useAppNavigate();
|
||||
@@ -98,6 +103,8 @@ function TracesTable({
|
||||
cellTypographySize={cellTypographySize}
|
||||
onColumnOrderChange={onColumnOrderChange}
|
||||
onColumnRemove={onColumnRemove}
|
||||
columnSizing={columnSizing}
|
||||
onColumnSizingChange={onColumnSizingChange}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
getRowTestId={(row): string => `traces-table-row-${row.id}`}
|
||||
@@ -113,6 +120,8 @@ TracesTable.defaultProps = {
|
||||
respectColumnOrder: false,
|
||||
onColumnOrderChange: undefined,
|
||||
onColumnRemove: undefined,
|
||||
columnSizing: undefined,
|
||||
onColumnSizingChange: undefined,
|
||||
cellTypographySize: 'medium',
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,12 @@ import { QueryKey } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import type { ColumnSizingState } from '@tanstack/react-table';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
setColumnSizing,
|
||||
useColumnSizing,
|
||||
} from 'components/TanStackTableView/useColumnStore';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
@@ -87,6 +92,22 @@ function TracesView({
|
||||
[config],
|
||||
);
|
||||
|
||||
// The column store keeps widths only; the picked columns live in the slice.
|
||||
const columnSizing = useColumnSizing(
|
||||
LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS,
|
||||
);
|
||||
const handleColumnSizingChange = useCallback<
|
||||
Dispatch<SetStateAction<ColumnSizingState>>
|
||||
>(
|
||||
(updater) => {
|
||||
setColumnSizing(
|
||||
LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS,
|
||||
typeof updater === 'function' ? updater(columnSizing) : updater,
|
||||
);
|
||||
},
|
||||
[columnSizing],
|
||||
);
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
@@ -222,9 +243,10 @@ function TracesView({
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={tableColumns}
|
||||
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
columnSizing={columnSizing}
|
||||
onColumnSizingChange={handleColumnSizingChange}
|
||||
panelType="TRACE"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -14,15 +14,14 @@ jest.mock('../../TracesTable/TracesTable', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
columns,
|
||||
columnStorageKey,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
onColumnSizingChange,
|
||||
}: TracesTableProps): JSX.Element => (
|
||||
<div>
|
||||
<div data-testid="table-columns">
|
||||
{columns.map((column) => String(column.header)).join(',')}
|
||||
</div>
|
||||
<div data-testid="column-storage-key">{columnStorageKey}</div>
|
||||
<button
|
||||
data-testid="reverse-columns"
|
||||
type="button"
|
||||
@@ -37,6 +36,13 @@ jest.mock('../../TracesTable/TracesTable', () => ({
|
||||
>
|
||||
remove
|
||||
</button>
|
||||
<button
|
||||
data-testid="resize-trace-id"
|
||||
type="button"
|
||||
onClick={(): void => onColumnSizingChange?.({ trace_id: 240 })}
|
||||
>
|
||||
resize
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -162,11 +168,15 @@ describe('TracesView columns', () => {
|
||||
expect(shownColumns()).toStrictEqual(['trace_id']);
|
||||
});
|
||||
|
||||
it('keeps column widths under the trace view key', () => {
|
||||
it('keeps column widths in the column store', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTracesView();
|
||||
|
||||
expect(screen.getByTestId('column-storage-key')).toHaveTextContent(
|
||||
SIZING_KEY,
|
||||
);
|
||||
await user.click(screen.getByTestId('resize-trace-id'));
|
||||
|
||||
expect(
|
||||
useColumnStore.getState().tables[SIZING_KEY].columnSizing,
|
||||
).toStrictEqual({ trace_id: 240 });
|
||||
expect(localStorage.getItem(PANEL_COLUMNS_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
defaultOptionsQuery,
|
||||
URL_OPTIONS,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import {
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
} from 'container/SavedViews/__tests__/savedViewsTestUtils';
|
||||
import { act, screen, userEvent } from 'tests/test-utils';
|
||||
import { renderWithExplorerProviders } from 'container/SavedViews/__tests__/savedViewsTestUtils';
|
||||
import { act, screen } from 'tests/test-utils';
|
||||
|
||||
import Explorer from '../Explorer';
|
||||
|
||||
@@ -22,10 +14,6 @@ jest.mock('../TableView/TableView', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="ai-table-view" />,
|
||||
}));
|
||||
jest.mock('../TracesView/TracesView', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="ai-trace-view" />,
|
||||
}));
|
||||
|
||||
const explorerUrlWithPanel = (panelType: PANEL_TYPES): string =>
|
||||
`${ROUTES.AI_OBSERVABILITY_EXPLORER}?${QueryParams.panelTypes}=${encodeURIComponent(
|
||||
@@ -33,10 +21,6 @@ const explorerUrlWithPanel = (panelType: PANEL_TYPES): string =>
|
||||
)}`;
|
||||
|
||||
describe('AI observability Explorer', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('shows the view of the panel type in the url', () => {
|
||||
renderWithExplorerProviders(
|
||||
<Explorer />,
|
||||
@@ -59,27 +43,4 @@ describe('AI observability Explorer', () => {
|
||||
expect(screen.getByTestId('ai-table-view')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('ai-list-view')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears the url columns of the panel being left', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const traceColumns = [{ name: 'span_count' }];
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS,
|
||||
JSON.stringify({ [PANEL_TYPES.TRACE]: traceColumns }),
|
||||
);
|
||||
const { history } = renderWithExplorerProviders(
|
||||
<Explorer />,
|
||||
`${explorerUrlWithPanel(PANEL_TYPES.TRACE)}&${URL_OPTIONS}=${encodeURIComponent(
|
||||
JSON.stringify({ ...defaultOptionsQuery, selectColumns: traceColumns }),
|
||||
)}`,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('search-view'));
|
||||
|
||||
expect(screen.getByTestId('ai-list-view')).toBeInTheDocument();
|
||||
expect(JSON.parse(urlParam(history, URL_OPTIONS) ?? '')).toStrictEqual({
|
||||
...defaultOptionsQuery,
|
||||
selectColumns: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,9 +172,6 @@
|
||||
}
|
||||
|
||||
.infoLinkContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ArrowRight, CirclePlay } from '@signozhq/icons';
|
||||
import { Flex } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
@@ -20,8 +21,7 @@ function InfoLinkText({
|
||||
onClick,
|
||||
}: InfoLinkTextProps): JSX.Element {
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
<Flex
|
||||
onClick={(): void => {
|
||||
onClick();
|
||||
openInNewTab(link);
|
||||
@@ -33,7 +33,7 @@ function InfoLinkText({
|
||||
{rightIconVisible && (
|
||||
<ArrowRight size={16} style={{ transform: 'rotate(315deg)' }} />
|
||||
)}
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,46 @@
|
||||
.layout {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--l1-background);
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.headerZone {
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
gap: 14px;
|
||||
padding: 20px 24px;
|
||||
background: var(--l2-background);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
padding: 0 var(--spacing-8);
|
||||
}
|
||||
|
||||
.refreshRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filtersRow {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0 var(--spacing-8);
|
||||
|
||||
--combobox-trigger-height: 2rem;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
min-width: 300px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
@@ -61,6 +77,10 @@
|
||||
--badge-cursor: pointer;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.actionsColumn {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
alertRulesFixture,
|
||||
listRulesV3Response,
|
||||
} from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { getCurrentNuqsSearchParams } from 'tests/nuqs-helpers';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
import { screen } from 'tests/test-utils';
|
||||
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
@@ -21,11 +17,8 @@ describe('ListAlertRules — empty states', () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(listRulesV3Response([], req.url.searchParams)),
|
||||
),
|
||||
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [], status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -49,14 +42,14 @@ describe('ListAlertRules — empty states', () => {
|
||||
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
rest.get('http://localhost/api/v2/rules', (_, res, ctx) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return res(ctx.status(500), ctx.json({ status: 'error' }));
|
||||
}
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(listRulesV3Response(alertRulesFixture, req.url.searchParams)),
|
||||
ctx.json({ data: alertRulesFixture, status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -71,29 +64,18 @@ describe('ListAlertRules — empty states', () => {
|
||||
expect(rule).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders NoResultsEmptyState when the query matches nothing; Clear Search resets it', async () => {
|
||||
it('renders NoResultsEmptyState when search yields no match; Clear Search resets', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
listRulesV3Response(
|
||||
req.url.searchParams.has('query') ? [] : alertRulesFixture,
|
||||
req.url.searchParams,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
renderListAlertRules();
|
||||
|
||||
renderListAlertRules({
|
||||
initialRoute: `/?query=${encodeURIComponent("name = 'missing'")}`,
|
||||
});
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
const searchInput = screen.getByTestId('list-alerts-search-input');
|
||||
await user.clear(searchInput);
|
||||
await user.type(searchInput, 'totally-not-found');
|
||||
|
||||
await screen.findByTestId('no-results-empty-state');
|
||||
expect(screen.queryByText('No Alert rules yet.')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
|
||||
'No matching alert rules',
|
||||
);
|
||||
@@ -103,28 +85,7 @@ describe('ListAlertRules — empty states', () => {
|
||||
|
||||
await user.click(screen.getByTestId('no-results-clear-button'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('High CPU Alert'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(getCurrentNuqsSearchParams().has('query')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders NoResultsEmptyState when the states filter matches nothing; Clear Search resets it', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
|
||||
renderListAlertRules({ initialRoute: '/?states=nodata' });
|
||||
|
||||
await screen.findByTestId('no-results-empty-state');
|
||||
|
||||
await user.click(screen.getByTestId('no-results-clear-button'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('High CPU Alert'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(getCurrentNuqsSearchParams().has('states')).toBe(false);
|
||||
});
|
||||
const rule = await screen.findByText('High CPU Alert');
|
||||
expect(rule).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,18 +101,17 @@ describe('ListAlertRules — list rendering', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders header controls (search, states, columns, new alert)', async () => {
|
||||
it('renders header controls (search, columns, new alert)', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('alert-row-rule-1-name')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('alert-rules-list-search')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('list-alerts-search-input')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('alert-rules-list-search-submit'),
|
||||
screen.getByPlaceholderText('Search by Alert Name, Severity and Labels'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('alert-rules-filter-states')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('alert-columns-button')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('list-alerts-new-alert-button'),
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
alertRulesPaginationFixture,
|
||||
listRulesV3Response,
|
||||
} from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { alertRulesPaginationFixture } from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
@@ -11,38 +8,31 @@ import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
describe('ListAlertRules — pagination', () => {
|
||||
let requests: URLSearchParams[];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
requests = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
requests.push(req.url.searchParams);
|
||||
return res(
|
||||
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
listRulesV3Response(alertRulesPaginationFixture, req.url.searchParams),
|
||||
),
|
||||
);
|
||||
}),
|
||||
ctx.json({ data: alertRulesPaginationFixture, status: 'success' }),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('requests the first page with the default limit and renders it', async () => {
|
||||
it('shows first 10 rows on page 1 (default limit)', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('Pag Rule 0');
|
||||
|
||||
expect(requests[0].get('limit')).toBe('10');
|
||||
expect(requests[0].get('offset')).toBe('0');
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
expect(screen.getByText(`Pag Rule ${i}`)).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.queryByText('Pag Rule 10')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Pag Rule 14')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the total count from the API response', async () => {
|
||||
it('shows total count when showTotalCount is enabled', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('Pag Rule 0');
|
||||
@@ -52,13 +42,14 @@ describe('ListAlertRules — pagination', () => {
|
||||
expect(totalCount.textContent).toContain('of 15');
|
||||
});
|
||||
|
||||
it('navigates to page 2 by requesting the next offset', async () => {
|
||||
it('navigates to page 2 and shows remaining rows', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('Pag Rule 0');
|
||||
|
||||
await user.click(screen.getByLabelText('Go to next page'));
|
||||
const nextBtn = screen.getByLabelText('Go to next page');
|
||||
await user.click(nextBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Pag Rule 10')).toBeInTheDocument();
|
||||
@@ -66,20 +57,8 @@ describe('ListAlertRules — pagination', () => {
|
||||
expect(screen.queryByText('Pag Rule 0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const lastRequest = requests[requests.length - 1];
|
||||
expect(lastRequest.get('limit')).toBe('10');
|
||||
expect(lastRequest.get('offset')).toBe('10');
|
||||
await waitFor(() => {
|
||||
expect(getCurrentNuqsQueryString()).toContain('page=2');
|
||||
});
|
||||
});
|
||||
|
||||
it('requests the page from the URL on load', async () => {
|
||||
renderListAlertRules({ initialRoute: '/?page=2' });
|
||||
|
||||
await screen.findByText('Pag Rule 10');
|
||||
|
||||
expect(requests[0].get('offset')).toBe('10');
|
||||
expect(screen.queryByText('Pag Rule 0')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ describe('ListAlertRules — row click navigation', () => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('clicking a row calls safeNavigate to alerts/overview with ruleId', async () => {
|
||||
it('clicking a row calls safeNavigate to alerts/overview with composite query + ruleId', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
@@ -26,7 +26,8 @@ describe('ListAlertRules — row click navigation', () => {
|
||||
const [url] = safeNavigateMock.mock.calls[0];
|
||||
expect(url).toContain('/alerts/overview?');
|
||||
expect(url).toContain('ruleId=rule-1');
|
||||
expect(url).not.toContain('compositeQuery=');
|
||||
expect(url).toContain('panelTypes=graph');
|
||||
expect(url).toContain('compositeQuery=');
|
||||
});
|
||||
|
||||
it('ctrl+click on a row navigates with newTab option', async () => {
|
||||
|
||||
@@ -1,217 +1,99 @@
|
||||
import {
|
||||
alertRulesFixture,
|
||||
listRulesV3Response,
|
||||
} from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import {
|
||||
getCurrentNuqsQueryString,
|
||||
getCurrentNuqsSearchParams,
|
||||
} from 'tests/nuqs-helpers';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
|
||||
|
||||
import {
|
||||
getSearchDraft,
|
||||
renderListAlertRules,
|
||||
runSearchQuery,
|
||||
setSearchDraft,
|
||||
} from './_helpers';
|
||||
import { renderListAlertRules } from './_helpers';
|
||||
|
||||
const QUERY = "name CONTAINS 'CPU'";
|
||||
|
||||
function lastRequest(requests: URLSearchParams[]): URLSearchParams {
|
||||
return requests[requests.length - 1];
|
||||
function getSearchInput(): HTMLInputElement {
|
||||
return screen.getByTestId('list-alerts-search-input') as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe('ListAlertRules — search', () => {
|
||||
let requests: URLSearchParams[];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
requests = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
requests.push(req.url.searchParams);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(listRulesV3Response(alertRulesFixture, req.url.searchParams)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the draft query only when Run is clicked', async () => {
|
||||
it('filters rows by alert name with debounce', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
setSearchDraft(QUERY);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('alert-rules-list-search-dirty'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(requests.some((r) => r.has('query'))).toBe(false);
|
||||
expect(getCurrentNuqsSearchParams().has('query')).toBe(false);
|
||||
|
||||
await user.click(screen.getByTestId('alert-rules-list-search-submit'));
|
||||
await user.clear(getSearchInput());
|
||||
await user.type(getSearchInput(), 'CPU');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastRequest(requests).get('query')).toBe(QUERY);
|
||||
expect(screen.getByText('High CPU Alert')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Memory Pending Alert')).not.toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(getCurrentNuqsSearchParams().get('query')).toBe(QUERY);
|
||||
});
|
||||
expect(
|
||||
screen.queryByTestId('alert-rules-list-search-dirty'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('runs the draft on Mod-Enter', async () => {
|
||||
it('filters rows by label values (severity)', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
setSearchDraft(QUERY);
|
||||
const content = screen
|
||||
.getByTestId('alert-rules-list-search')
|
||||
.querySelector<HTMLElement>('.cm-content');
|
||||
if (!content) {
|
||||
throw new Error('Editor content not found');
|
||||
}
|
||||
content.focus();
|
||||
await user.keyboard('{Control>}{Enter}{/Control}');
|
||||
await user.clear(getSearchInput());
|
||||
await user.type(getSearchInput(), 'warning');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getCurrentNuqsSearchParams().get('query')).toBe(QUERY);
|
||||
expect(screen.getByText('Memory Pending Alert')).toBeInTheDocument();
|
||||
expect(screen.queryByText('High CPU Alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('trims the submitted query', async () => {
|
||||
it('restores all rows when search is cleared', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await runSearchQuery(user, ` ${QUERY} `);
|
||||
await user.clear(getSearchInput());
|
||||
await user.type(getSearchInput(), 'CPU');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getCurrentNuqsSearchParams().get('query')).toBe(QUERY);
|
||||
expect(screen.queryByText('Memory Pending Alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(getSearchInput());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High CPU Alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Pending Alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Healthy Alert')).toBeInTheDocument();
|
||||
});
|
||||
expect(lastRequest(requests).get('query')).toBe(QUERY);
|
||||
});
|
||||
|
||||
it('loads the query from the URL into the editor and the request', async () => {
|
||||
renderListAlertRules({
|
||||
initialRoute: `/?query=${encodeURIComponent(QUERY)}`,
|
||||
});
|
||||
it('shows no-results state when no match', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
expect(getSearchDraft()).toBe(QUERY);
|
||||
expect(requests[0].get('query')).toBe(QUERY);
|
||||
await user.clear(getSearchInput());
|
||||
await user.type(getSearchInput(), 'zzzzzz-no-match');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('no-results-empty-state')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
|
||||
'No matching alert rules',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('resets page to 1 when a query is run', async () => {
|
||||
it('resets page to 1 when search debounce fires', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules({ initialRoute: '/?page=2' });
|
||||
|
||||
await screen.findByTestId('alert-rules-list-search');
|
||||
await runSearchQuery(user, QUERY);
|
||||
// Page 2 of the 4-rule fixture has no rows; we only need the search input
|
||||
// to be mounted, which happens before data is fetched.
|
||||
const input = await screen.findByTestId('list-alerts-search-input');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'CPU');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getCurrentNuqsSearchParams().get('query')).toBe(QUERY);
|
||||
});
|
||||
expect(getCurrentNuqsQueryString()).not.toContain('page=2');
|
||||
await waitFor(() => {
|
||||
expect(lastRequest(requests).get('offset')).toBe('0');
|
||||
expect(getCurrentNuqsQueryString()).not.toContain('page=2');
|
||||
});
|
||||
});
|
||||
|
||||
it('sends each selected state as a repeated states= param', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await user.click(screen.getByTestId('alert-rules-filter-states'));
|
||||
await user.click(await screen.findByRole('option', { name: 'Firing' }));
|
||||
await user.click(await screen.findByRole('option', { name: 'Disabled' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastRequest(requests).getAll('states')).toStrictEqual([
|
||||
'firing',
|
||||
'disabled',
|
||||
]);
|
||||
});
|
||||
expect(lastRequest(requests).has('states[]')).toBe(false);
|
||||
await waitFor(() => {
|
||||
expect(getCurrentNuqsSearchParams().get('states')).toBe('firing,disabled');
|
||||
});
|
||||
});
|
||||
|
||||
it('Clear empties the query and the states', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderListAlertRules({
|
||||
initialRoute: `/?query=${encodeURIComponent(QUERY)}&states=firing`,
|
||||
});
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
expect(lastRequest(requests).get('query')).toBe(QUERY);
|
||||
|
||||
await user.click(screen.getByTestId('alert-rules-filter-clear'));
|
||||
|
||||
await waitFor(() => {
|
||||
const params = getCurrentNuqsSearchParams();
|
||||
expect(params.has('query')).toBe(false);
|
||||
expect(params.has('states')).toBe(false);
|
||||
});
|
||||
await waitFor(() => {
|
||||
const last = lastRequest(requests);
|
||||
expect(last.has('query')).toBe(false);
|
||||
expect(last.has('states')).toBe(false);
|
||||
});
|
||||
expect(getSearchDraft()).toBe('');
|
||||
expect(
|
||||
screen.queryByTestId('alert-rules-filter-clear'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(['rule_list_filter_invalid', 'rule_list_invalid'])(
|
||||
'shows the backend message and no-results state for %s',
|
||||
async (code) => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
if (req.url.searchParams.has('query')) {
|
||||
return res(
|
||||
ctx.status(400),
|
||||
ctx.json({ error: { code, message: 'unknown field "nme"' } }),
|
||||
);
|
||||
}
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(listRulesV3Response(alertRulesFixture, req.url.searchParams)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await runSearchQuery(user, "nme = 'x'");
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('alert-rules-query-error'),
|
||||
).resolves.toHaveTextContent('unknown field "nme"');
|
||||
const empty = screen.getByTestId('no-results-empty-state');
|
||||
expect(within(empty).getByTestId('no-results-subtitle')).toHaveTextContent(
|
||||
'The filter query is invalid. Fix it or clear the search.',
|
||||
);
|
||||
expect(screen.queryByTestId('error-empty-state')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('High CPU Alert')).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import {
|
||||
RuletypesListOrderDTO,
|
||||
RuletypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
|
||||
import type { AlertRule, RuleListFilter } from '../types';
|
||||
import type { AlertRule } from '../types';
|
||||
import {
|
||||
ALERT_ACTIONS,
|
||||
alertActionLogEvent,
|
||||
DEFAULT_RULE_LIST_FILTER,
|
||||
isDefaultRuleListFilter,
|
||||
isSameRuleListFilter,
|
||||
toListSort,
|
||||
toOrderBy,
|
||||
filterRulesByFilters,
|
||||
getAlertSortValue,
|
||||
sortRules,
|
||||
} from '../utils';
|
||||
|
||||
const baseRule = {
|
||||
@@ -32,116 +27,159 @@ const makeRule = (overrides: Partial<AlertRule>): AlertRule => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('toListSort', () => {
|
||||
it('returns the default sort when orderBy is null', () => {
|
||||
expect(toListSort(null)).toStrictEqual({
|
||||
sort: RuletypesListSortDTO.updated_at,
|
||||
order: RuletypesListOrderDTO.desc,
|
||||
});
|
||||
describe('getAlertSortValue', () => {
|
||||
it('returns state for "state"', () => {
|
||||
expect(
|
||||
getAlertSortValue(
|
||||
makeRule({ state: RuletypesAlertStateDTO.firing }),
|
||||
'state',
|
||||
),
|
||||
).toBe('firing');
|
||||
});
|
||||
|
||||
it('returns the default sort for an unknown column', () => {
|
||||
expect(toListSort({ columnName: 'labels', order: 'asc' })).toStrictEqual({
|
||||
sort: RuletypesListSortDTO.updated_at,
|
||||
order: RuletypesListOrderDTO.desc,
|
||||
});
|
||||
it('returns alert name for "name"', () => {
|
||||
expect(getAlertSortValue(makeRule({ alert: 'My Rule' }), 'name')).toBe(
|
||||
'My Rule',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['state', RuletypesListSortDTO.state],
|
||||
['name', RuletypesListSortDTO.name],
|
||||
['severity', RuletypesListSortDTO.severity],
|
||||
['createdAt', RuletypesListSortDTO.created_at],
|
||||
['updatedAt', RuletypesListSortDTO.updated_at],
|
||||
])('maps column "%s" to sort "%s"', (columnName, sort) => {
|
||||
expect(toListSort({ columnName, order: 'asc' })).toStrictEqual({
|
||||
sort,
|
||||
order: RuletypesListOrderDTO.asc,
|
||||
});
|
||||
it('returns severity label for "severity"', () => {
|
||||
expect(
|
||||
getAlertSortValue(
|
||||
makeRule({ labels: { severity: 'critical' } }),
|
||||
'severity',
|
||||
),
|
||||
).toBe('critical');
|
||||
});
|
||||
|
||||
it('maps desc order', () => {
|
||||
expect(toListSort({ columnName: 'name', order: 'desc' })).toStrictEqual({
|
||||
sort: RuletypesListSortDTO.name,
|
||||
order: RuletypesListOrderDTO.desc,
|
||||
});
|
||||
it('returns createdAt as ms', () => {
|
||||
const rule = makeRule({ createdAt: '2023-10-15T10:00:00Z' });
|
||||
const result = getAlertSortValue(rule, 'createdAt');
|
||||
expect(result).toBe(new Date('2023-10-15T10:00:00Z').getTime());
|
||||
});
|
||||
|
||||
it('returns updatedAt as ms', () => {
|
||||
const rule = makeRule({ updatedAt: '2023-10-19T10:00:00Z' });
|
||||
const result = getAlertSortValue(rule, 'updatedAt');
|
||||
expect(result).toBe(new Date('2023-10-19T10:00:00Z').getTime());
|
||||
});
|
||||
|
||||
it('returns 0 when createdAt missing', () => {
|
||||
expect(
|
||||
getAlertSortValue(makeRule({ createdAt: undefined }), 'createdAt'),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('returns empty for unknown column', () => {
|
||||
expect(getAlertSortValue(baseRule, 'xxx')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty for missing fields', () => {
|
||||
expect(
|
||||
getAlertSortValue(
|
||||
makeRule({ state: undefined, labels: undefined }),
|
||||
'state',
|
||||
),
|
||||
).toBe('');
|
||||
expect(
|
||||
getAlertSortValue(
|
||||
makeRule({ state: undefined, labels: undefined }),
|
||||
'severity',
|
||||
),
|
||||
).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toOrderBy', () => {
|
||||
it('returns null for the default sort', () => {
|
||||
expect(
|
||||
toOrderBy(RuletypesListSortDTO.updated_at, RuletypesListOrderDTO.desc),
|
||||
).toBeNull();
|
||||
describe('sortRules', () => {
|
||||
const r1 = makeRule({ id: '1', alert: 'A' });
|
||||
const r2 = makeRule({ id: '2', alert: 'B' });
|
||||
const r3 = makeRule({ id: '3', alert: 'C' });
|
||||
|
||||
it('sorts ascending by name', () => {
|
||||
const order: SortState = { columnName: 'name', order: 'asc' };
|
||||
const result = sortRules([r3, r1, r2], order);
|
||||
expect(result.map((r) => r.alert)).toStrictEqual(['A', 'B', 'C']);
|
||||
});
|
||||
|
||||
it('returns the column for a non-default sort', () => {
|
||||
expect(
|
||||
toOrderBy(RuletypesListSortDTO.created_at, RuletypesListOrderDTO.asc),
|
||||
).toStrictEqual<SortState>({ columnName: 'createdAt', order: 'asc' });
|
||||
expect(
|
||||
toOrderBy(RuletypesListSortDTO.updated_at, RuletypesListOrderDTO.asc),
|
||||
).toStrictEqual<SortState>({ columnName: 'updatedAt', order: 'asc' });
|
||||
it('sorts descending by name', () => {
|
||||
const order: SortState = { columnName: 'name', order: 'desc' };
|
||||
const result = sortRules([r1, r2, r3], order);
|
||||
expect(result.map((r) => r.alert)).toStrictEqual(['C', 'B', 'A']);
|
||||
});
|
||||
|
||||
it('round-trips with toListSort', () => {
|
||||
const orderBy: SortState = { columnName: 'severity', order: 'desc' };
|
||||
const { sort, order } = toListSort(orderBy);
|
||||
expect(toOrderBy(sort, order)).toStrictEqual(orderBy);
|
||||
it('returns unsorted when orderBy is null', () => {
|
||||
const result = sortRules([r3, r1, r2], null);
|
||||
expect(result.map((r) => r.alert)).toStrictEqual(['C', 'A', 'B']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSameRuleListFilter', () => {
|
||||
const filter: RuleListFilter = {
|
||||
query: "severity = 'critical'",
|
||||
states: ['firing', 'pending'],
|
||||
sort: RuletypesListSortDTO.name,
|
||||
order: RuletypesListOrderDTO.asc,
|
||||
};
|
||||
describe('filterRulesByFilters', () => {
|
||||
const r1 = makeRule({
|
||||
id: '1',
|
||||
alert: 'R1',
|
||||
state: RuletypesAlertStateDTO.firing,
|
||||
labels: { severity: 'critical' },
|
||||
});
|
||||
const r2 = makeRule({
|
||||
id: '2',
|
||||
alert: 'R2',
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
labels: { severity: 'warning' },
|
||||
});
|
||||
const r3 = makeRule({
|
||||
id: '3',
|
||||
alert: 'R3',
|
||||
state: RuletypesAlertStateDTO.firing,
|
||||
labels: { severity: 'warning' },
|
||||
});
|
||||
const rules = [r1, r2, r3];
|
||||
|
||||
it('ignores surrounding whitespace in the query', () => {
|
||||
expect(
|
||||
isSameRuleListFilter(filter, { ...filter, query: ` ${filter.query} ` }),
|
||||
).toBe(true);
|
||||
it('returns input when filters empty', () => {
|
||||
expect(filterRulesByFilters(rules, [])).toStrictEqual(rules);
|
||||
});
|
||||
|
||||
it('ignores states order', () => {
|
||||
expect(
|
||||
isSameRuleListFilter(filter, { ...filter, states: ['pending', 'firing'] }),
|
||||
).toBe(true);
|
||||
it('filters by state', () => {
|
||||
const result = filterRulesByFilters(rules, ['state:firing']);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['1', '3']);
|
||||
});
|
||||
|
||||
it.each<[string, Partial<RuleListFilter>]>([
|
||||
['query', { query: "severity = 'warning'" }],
|
||||
['states', { states: ['firing'] }],
|
||||
['sort', { sort: RuletypesListSortDTO.state }],
|
||||
['order', { order: RuletypesListOrderDTO.desc }],
|
||||
])('detects a different %s', (_, change) => {
|
||||
expect(isSameRuleListFilter(filter, { ...filter, ...change })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDefaultRuleListFilter', () => {
|
||||
it('is true for DEFAULT_RULE_LIST_FILTER', () => {
|
||||
expect(isDefaultRuleListFilter(DEFAULT_RULE_LIST_FILTER)).toBe(true);
|
||||
it('filters by severity', () => {
|
||||
const result = filterRulesByFilters(rules, ['severity:warning']);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['2', '3']);
|
||||
});
|
||||
|
||||
it('is true for a whitespace-only query', () => {
|
||||
expect(
|
||||
isDefaultRuleListFilter({ ...DEFAULT_RULE_LIST_FILTER, query: ' ' }),
|
||||
).toBe(true);
|
||||
it('combines state AND severity', () => {
|
||||
const result = filterRulesByFilters(rules, [
|
||||
'state:firing',
|
||||
'severity:warning',
|
||||
]);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['3']);
|
||||
});
|
||||
|
||||
it('is false once any field differs', () => {
|
||||
expect(
|
||||
isDefaultRuleListFilter({ ...DEFAULT_RULE_LIST_FILTER, states: ['firing'] }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isDefaultRuleListFilter({
|
||||
...DEFAULT_RULE_LIST_FILTER,
|
||||
sort: RuletypesListSortDTO.name,
|
||||
}),
|
||||
).toBe(false);
|
||||
it('OR within same key (state)', () => {
|
||||
const result = filterRulesByFilters(rules, [
|
||||
'state:firing',
|
||||
'state:inactive',
|
||||
]);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['1', '2', '3']);
|
||||
});
|
||||
|
||||
it('matches values case-insensitively', () => {
|
||||
const result = filterRulesByFilters(rules, ['state:FIRING']);
|
||||
expect(result.map((r) => r.id)).toStrictEqual(['1', '3']);
|
||||
});
|
||||
|
||||
it('ignores prefixes with wrong case (state: is required lowercase)', () => {
|
||||
const result = filterRulesByFilters(rules, ['STATE:FIRING']);
|
||||
expect(result).toStrictEqual(rules);
|
||||
});
|
||||
|
||||
it('returns empty when no rule matches', () => {
|
||||
expect(filterRulesByFilters(rules, ['state:nonexistent'])).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('ignores unknown prefix', () => {
|
||||
expect(filterRulesByFilters(rules, ['foo:bar'])).toStrictEqual(rules);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,567 +0,0 @@
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { useAlertRuleViewsStore } from 'container/ListAlertRules/useAlertRuleViewsStore';
|
||||
import {
|
||||
alertRulesFixture,
|
||||
listRulesV3Response,
|
||||
ruleViewsFixture,
|
||||
} from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { getCurrentNuqsSearchParams } from 'tests/nuqs-helpers';
|
||||
import {
|
||||
getAppContextMock,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import { renderListAlertRules, runSearchQuery } from './_helpers';
|
||||
|
||||
// jsdom has no layout, and Floating UI's getComputedStyle walk over every
|
||||
// popover ancestor costs ~1 s per open here, enough to time out under load.
|
||||
jest.mock('@floating-ui/dom', () => ({
|
||||
...jest.requireActual('@floating-ui/dom'),
|
||||
autoUpdate: (): (() => void) => (): void => {},
|
||||
computePosition: (
|
||||
_reference: unknown,
|
||||
_floating: unknown,
|
||||
{ placement = 'bottom', strategy = 'absolute' } = {},
|
||||
): Promise<unknown> =>
|
||||
Promise.resolve({ x: 0, y: 0, placement, strategy, middlewareData: {} }),
|
||||
}));
|
||||
|
||||
const VIEW_1_ORDER_BY = JSON.stringify({ columnName: 'name', order: 'asc' });
|
||||
const VIEW_1_QUERY = "severity = 'critical'";
|
||||
|
||||
type Body = Record<string, unknown>;
|
||||
type User = ReturnType<typeof userEvent.setup>;
|
||||
|
||||
function captureRulesRequests(): URLSearchParams[] {
|
||||
const requests: URLSearchParams[] = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
requests.push(req.url.searchParams);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(listRulesV3Response(alertRulesFixture, req.url.searchParams)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
return requests;
|
||||
}
|
||||
|
||||
function captureViewWrites(): {
|
||||
posts: Body[];
|
||||
puts: { id: string; body: Body }[];
|
||||
deletes: string[];
|
||||
} {
|
||||
const captured = {
|
||||
posts: [] as Body[],
|
||||
puts: [] as { id: string; body: Body }[],
|
||||
deletes: [] as string[],
|
||||
};
|
||||
server.use(
|
||||
rest.post('http://localhost/api/v2/rule_views', async (req, res, ctx) => {
|
||||
const body = (await req.json()) as Body;
|
||||
captured.posts.push(body);
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json({
|
||||
data: { ...body, id: 'new-view-id', orgId: 'org-1' },
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
}),
|
||||
rest.put('http://localhost/api/v2/rule_views/:id', async (req, res, ctx) => {
|
||||
const body = (await req.json()) as Body;
|
||||
const id = req.params.id as string;
|
||||
captured.puts.push({ id, body });
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: { ...body, id, orgId: 'org-1' }, status: 'success' }),
|
||||
);
|
||||
}),
|
||||
rest.delete('http://localhost/api/v2/rule_views/:id', (req, res, ctx) => {
|
||||
captured.deletes.push(req.params.id as string);
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
);
|
||||
return captured;
|
||||
}
|
||||
|
||||
function lastRequest(requests: URLSearchParams[]): URLSearchParams {
|
||||
return requests[requests.length - 1];
|
||||
}
|
||||
|
||||
function urlParam(key: string): string | null {
|
||||
return getCurrentNuqsSearchParams().get(key);
|
||||
}
|
||||
|
||||
// The linked-view restore writes the URL a tick after the views render.
|
||||
function urlSettled(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function viewItem(id: string): HTMLElement {
|
||||
return screen.getByTestId(`alert-rules-view-${id}`);
|
||||
}
|
||||
|
||||
function activeHeading(): HTMLElement {
|
||||
return screen.getByTestId('alert-rules-list-heading');
|
||||
}
|
||||
|
||||
async function selectView(user: User, id: string): Promise<void> {
|
||||
await user.click(await screen.findByTestId(`alert-rules-view-${id}`));
|
||||
await waitFor(() => expect(urlParam('view')).toBe(id));
|
||||
}
|
||||
|
||||
// A role query walks the whole table; the antd confirm's button is found by text.
|
||||
function findConfirmDelete(): Promise<HTMLElement> {
|
||||
return screen.findByText('Delete', {
|
||||
selector: '.ant-modal-confirm button span',
|
||||
});
|
||||
}
|
||||
|
||||
describe('ListAlertRules — saved views rail', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
|
||||
localStorage.removeItem(LOCALSTORAGE.ALERT_RULES_LIST_VIEWS);
|
||||
useAlertRuleViewsStore.setState({ railCollapsed: false });
|
||||
});
|
||||
|
||||
describe('builtin views', () => {
|
||||
it('renders the builtin views with All active by default', async () => {
|
||||
renderListAlertRules();
|
||||
|
||||
await screen.findByText('High CPU Alert');
|
||||
expect(viewItem('mine')).toHaveTextContent('My alert rules');
|
||||
expect(viewItem('all')).toHaveTextContent('All alert rules');
|
||||
expect(viewItem('firing')).toHaveTextContent('Firing');
|
||||
expect(viewItem('disabled')).toHaveTextContent('Disabled');
|
||||
expect(activeHeading()).toHaveTextContent('All alert rules');
|
||||
expect(
|
||||
screen.queryByTestId('alert-rules-view-save-as-new'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('alert-rules-view-reset'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['firing', 'Firing'],
|
||||
['disabled', 'Disabled'],
|
||||
])('%s sends states=%s', async (id, label) => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
const requests = captureRulesRequests();
|
||||
renderListAlertRules();
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await selectView(user, id);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastRequest(requests).getAll('states')).toStrictEqual([id]);
|
||||
});
|
||||
expect(lastRequest(requests).has('query')).toBe(false);
|
||||
expect(urlParam('states')).toBe(id);
|
||||
expect(activeHeading()).toHaveTextContent(label);
|
||||
expect(
|
||||
screen.queryByTestId('alert-rules-view-save-as-new'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('My alert rules filters by the current user as creator', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
const requests = captureRulesRequests();
|
||||
const { email } = getAppContextMock('ADMIN').user;
|
||||
renderListAlertRules();
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await selectView(user, 'mine');
|
||||
|
||||
const expected = `created_by = '${email}'`;
|
||||
await waitFor(() => {
|
||||
expect(lastRequest(requests).get('query')).toBe(expected);
|
||||
});
|
||||
expect(lastRequest(requests).has('states')).toBe(false);
|
||||
expect(urlParam('query')).toBe(expected);
|
||||
expect(activeHeading()).toHaveTextContent('My alert rules');
|
||||
});
|
||||
|
||||
it('editing a builtin view filter offers save as new view, which POSTs', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
const captured = captureViewWrites();
|
||||
renderListAlertRules();
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await runSearchQuery(user, 'foo');
|
||||
|
||||
await user.click(await screen.findByTestId('alert-rules-view-save-as-new'));
|
||||
expect(
|
||||
screen.queryByTestId('alert-rules-view-save-changes'),
|
||||
).not.toBeInTheDocument();
|
||||
await user.type(
|
||||
await screen.findByTestId('save-alert-rule-view-name'),
|
||||
'Foo alerts',
|
||||
);
|
||||
await user.click(screen.getByTestId('save-alert-rule-view-confirm'));
|
||||
|
||||
await waitFor(() => expect(captured.posts).toHaveLength(1));
|
||||
expect(captured.posts[0]).toStrictEqual({
|
||||
name: 'Foo alerts',
|
||||
data: {
|
||||
version: 'v1',
|
||||
query: 'foo',
|
||||
states: [],
|
||||
sort: 'updated_at',
|
||||
order: 'desc',
|
||||
},
|
||||
});
|
||||
await waitFor(() => expect(urlParam('view')).toBe('new-view-id'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('saved views', () => {
|
||||
it('lists saved views and applies the selected one', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
const requests = captureRulesRequests();
|
||||
renderListAlertRules({ initialRoute: '/?page=2' });
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('alert-rules-view-view-1'),
|
||||
).resolves.toHaveTextContent('Critical firing');
|
||||
expect(viewItem('view-2')).toHaveTextContent('Infra team');
|
||||
|
||||
await selectView(user, 'view-1');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(urlParam('query')).toBe(VIEW_1_QUERY);
|
||||
expect(urlParam('states')).toBe('firing');
|
||||
expect(urlParam('orderBy')).toBe(VIEW_1_ORDER_BY);
|
||||
});
|
||||
expect(urlParam('page')).not.toBe('2');
|
||||
await waitFor(() => {
|
||||
const last = lastRequest(requests);
|
||||
expect(last.get('query')).toBe(VIEW_1_QUERY);
|
||||
expect(last.getAll('states')).toStrictEqual(['firing']);
|
||||
expect(last.get('sort')).toBe('name');
|
||||
expect(last.get('order')).toBe('asc');
|
||||
expect(last.get('offset')).toBe('0');
|
||||
});
|
||||
expect(activeHeading()).toHaveTextContent('Critical firing');
|
||||
expect(
|
||||
screen.queryByTestId('alert-rules-view-save-changes'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the unsaved-changes panel after editing an applied view filter', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
renderListAlertRules();
|
||||
await selectView(user, 'view-1');
|
||||
|
||||
await runSearchQuery(user, 'foo');
|
||||
|
||||
await screen.findByTestId('alert-rules-view-save-changes');
|
||||
expect(screen.getByTestId('alert-rules-view-save-as')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('alert-rules-view-reset')).toBeInTheDocument();
|
||||
expect(
|
||||
within(viewItem('view-1')).getByTitle('Unsaved changes'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Save PUTs the full current filter and the unchanged name', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
const captured = captureViewWrites();
|
||||
renderListAlertRules();
|
||||
await selectView(user, 'view-1');
|
||||
await runSearchQuery(user, 'foo');
|
||||
|
||||
await user.click(await screen.findByTestId('alert-rules-view-save-changes'));
|
||||
|
||||
await waitFor(() => expect(captured.puts).toHaveLength(1));
|
||||
expect(captured.puts[0]).toStrictEqual({
|
||||
id: 'view-1',
|
||||
body: {
|
||||
name: 'Critical firing',
|
||||
data: {
|
||||
version: 'v1',
|
||||
query: 'foo',
|
||||
states: ['firing'],
|
||||
sort: 'name',
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByTestId('alert-rules-view-save-changes'),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('Save as POSTs a new view and activates it', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
const captured = captureViewWrites();
|
||||
renderListAlertRules();
|
||||
await selectView(user, 'view-1');
|
||||
await runSearchQuery(user, 'foo');
|
||||
|
||||
await user.click(await screen.findByTestId('alert-rules-view-save-as'));
|
||||
await user.type(
|
||||
await screen.findByTestId('save-alert-rule-view-name'),
|
||||
'My view',
|
||||
);
|
||||
await user.click(screen.getByTestId('save-alert-rule-view-confirm'));
|
||||
|
||||
await waitFor(() => expect(captured.posts).toHaveLength(1));
|
||||
expect(captured.posts[0]).toStrictEqual({
|
||||
name: 'My view',
|
||||
data: {
|
||||
version: 'v1',
|
||||
query: 'foo',
|
||||
states: ['firing'],
|
||||
sort: 'name',
|
||||
order: 'asc',
|
||||
},
|
||||
});
|
||||
await waitFor(() => expect(urlParam('view')).toBe('new-view-id'));
|
||||
expect(captured.puts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('caps a view name at 64 characters', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
const captured = captureViewWrites();
|
||||
renderListAlertRules();
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
await user.click(screen.getByLabelText('Save current filters as a view'));
|
||||
const input = await screen.findByTestId('save-alert-rule-view-name');
|
||||
await user.type(input, 'x'.repeat(70));
|
||||
|
||||
expect(input).toHaveValue('x'.repeat(64));
|
||||
await user.click(screen.getByTestId('save-alert-rule-view-confirm'));
|
||||
|
||||
await waitFor(() => expect(captured.posts).toHaveLength(1));
|
||||
expect(captured.posts[0]).toMatchObject({ name: 'x'.repeat(64) });
|
||||
});
|
||||
|
||||
it('Reset restores the active view filter', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
renderListAlertRules();
|
||||
await selectView(user, 'view-1');
|
||||
await runSearchQuery(user, 'foo');
|
||||
await waitFor(() => expect(urlParam('query')).toBe('foo'));
|
||||
|
||||
await user.click(await screen.findByTestId('alert-rules-view-reset'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(urlParam('query')).toBe(VIEW_1_QUERY);
|
||||
});
|
||||
expect(urlParam('states')).toBe('firing');
|
||||
expect(urlParam('orderBy')).toBe(VIEW_1_ORDER_BY);
|
||||
expect(urlParam('view')).toBe('view-1');
|
||||
expect(
|
||||
screen.queryByTestId('alert-rules-view-reset'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('rename PUTs the new name and keeps the stored filter', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
const captured = captureViewWrites();
|
||||
renderListAlertRules();
|
||||
await selectView(user, 'view-2');
|
||||
await runSearchQuery(user, 'foo');
|
||||
await screen.findByTestId('alert-rules-view-save-changes');
|
||||
|
||||
const row = viewItem('view-1').parentElement as HTMLElement;
|
||||
await user.click(within(row).getByLabelText('Rename view'));
|
||||
const input = await screen.findByTestId('rename-alert-rule-view-name');
|
||||
expect(input).toHaveValue('Critical firing');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Renamed');
|
||||
await user.click(screen.getByTestId('rename-alert-rule-view-confirm'));
|
||||
|
||||
await waitFor(() => expect(captured.puts).toHaveLength(1));
|
||||
expect(captured.puts[0]).toStrictEqual({
|
||||
id: 'view-1',
|
||||
body: {
|
||||
name: 'Renamed',
|
||||
data: {
|
||||
version: 'v1',
|
||||
query: VIEW_1_QUERY,
|
||||
states: ['firing'],
|
||||
sort: 'name',
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
});
|
||||
await waitFor(() => expect(viewItem('view-1')).toHaveTextContent('Renamed'));
|
||||
});
|
||||
|
||||
it('delete asks for confirmation and sends DELETE', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
const captured = captureViewWrites();
|
||||
renderListAlertRules();
|
||||
await selectView(user, 'view-1');
|
||||
|
||||
await user.click(screen.getByTestId('alert-rules-view-delete-view-2'));
|
||||
|
||||
await expect(
|
||||
screen.findByText(/Views are shared across your organization/),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(captured.deletes).toHaveLength(0);
|
||||
await user.click(await findConfirmDelete());
|
||||
|
||||
await waitFor(() => expect(captured.deletes).toStrictEqual(['view-2']));
|
||||
expect(urlParam('view')).toBe('view-1');
|
||||
});
|
||||
|
||||
it('deleting the active view falls back to All', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
const captured = captureViewWrites();
|
||||
renderListAlertRules();
|
||||
await selectView(user, 'view-1');
|
||||
await waitFor(() => expect(urlParam('query')).toBe(VIEW_1_QUERY));
|
||||
|
||||
await user.click(screen.getByTestId('alert-rules-view-delete-view-1'));
|
||||
await user.click(await findConfirmDelete());
|
||||
|
||||
await waitFor(() => expect(captured.deletes).toStrictEqual(['view-1']));
|
||||
await waitFor(() => {
|
||||
expect(urlParam('view')).toBeNull();
|
||||
expect(urlParam('query')).toBeNull();
|
||||
expect(urlParam('states')).toBeNull();
|
||||
});
|
||||
expect(activeHeading()).toHaveTextContent('All alert rules');
|
||||
});
|
||||
|
||||
it('All alert rules clears the view and its filters', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
renderListAlertRules();
|
||||
await selectView(user, 'view-1');
|
||||
await waitFor(() => expect(urlParam('query')).not.toBeNull());
|
||||
|
||||
await user.click(viewItem('all'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(urlParam('view')).toBeNull();
|
||||
expect(urlParam('query')).toBeNull();
|
||||
expect(urlParam('states')).toBeNull();
|
||||
});
|
||||
expect(activeHeading()).toHaveTextContent('All alert rules');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared links', () => {
|
||||
it('applies the linked view filter when the URL carries only view=', async () => {
|
||||
renderListAlertRules({ initialRoute: '/?view=view-1' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(urlParam('query')).toBe(VIEW_1_QUERY);
|
||||
expect(urlParam('states')).toBe('firing');
|
||||
expect(urlParam('orderBy')).toBe(VIEW_1_ORDER_BY);
|
||||
});
|
||||
expect(urlParam('view')).toBe('view-1');
|
||||
expect(activeHeading()).toHaveTextContent('Critical firing');
|
||||
expect(
|
||||
screen.queryByTestId('alert-rules-view-save-changes'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps explicit filter params from the link and marks the view modified', async () => {
|
||||
renderListAlertRules({ initialRoute: '/?view=view-1&query=foo' });
|
||||
|
||||
await screen.findByTestId('alert-rules-view-save-changes');
|
||||
expect(urlParam('query')).toBe('foo');
|
||||
expect(urlParam('states')).toBeNull();
|
||||
expect(activeHeading()).toHaveTextContent('Critical firing');
|
||||
});
|
||||
|
||||
it('marks a cleared view filter in the URL', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
renderListAlertRules();
|
||||
await selectView(user, 'view-2');
|
||||
|
||||
await user.click(screen.getByTestId('alert-rules-filter-clear'));
|
||||
|
||||
await waitFor(() => expect(urlParam('query')).toBe(''));
|
||||
expect(urlParam('view')).toBe('view-2');
|
||||
});
|
||||
|
||||
it('keeps a cleared view filter cleared on reload', async () => {
|
||||
const requests = captureRulesRequests();
|
||||
renderListAlertRules({ initialRoute: '/?view=view-1&query=' });
|
||||
|
||||
await screen.findByTestId('alert-rules-view-save-changes');
|
||||
await urlSettled();
|
||||
expect(urlParam('query')).toBe('');
|
||||
expect(urlParam('states')).toBeNull();
|
||||
expect(lastRequest(requests).get('query') ?? '').toBe('');
|
||||
});
|
||||
|
||||
it('keeps a query run while the linked views are still loading', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/rule_views', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.delay(300),
|
||||
ctx.status(200),
|
||||
ctx.json({ data: { views: ruleViewsFixture }, status: 'success' }),
|
||||
),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
renderListAlertRules({ initialRoute: '/?view=view-1' });
|
||||
|
||||
await runSearchQuery(user, 'foo');
|
||||
await screen.findByTestId(
|
||||
'alert-rules-view-save-changes',
|
||||
{},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await urlSettled();
|
||||
expect(urlParam('query')).toBe('foo');
|
||||
expect(urlParam('states')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rail chrome', () => {
|
||||
it('filters views by name', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
renderListAlertRules();
|
||||
await screen.findByTestId('alert-rules-view-view-2');
|
||||
|
||||
const search = screen.getByTestId('alert-rules-view-search');
|
||||
await user.type(search, 'infra');
|
||||
|
||||
expect(viewItem('view-2')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('alert-rules-view-view-1'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('alert-rules-view-all')).not.toBeInTheDocument();
|
||||
|
||||
await user.clear(search);
|
||||
await user.type(search, 'zzz');
|
||||
|
||||
expect(screen.getByText(/No views match/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses the rail from the status bar and persists it', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
renderListAlertRules();
|
||||
await screen.findByText('High CPU Alert');
|
||||
|
||||
const toggle = screen.getByTestId('alert-rules-rail-toggle');
|
||||
expect(toggle).toHaveTextContent('Collapse');
|
||||
|
||||
await user.click(toggle);
|
||||
|
||||
expect(toggle).toHaveTextContent('Expand');
|
||||
const stored = JSON.parse(
|
||||
localStorage.getItem(LOCALSTORAGE.ALERT_RULES_LIST_VIEWS) ?? '{}',
|
||||
);
|
||||
expect(stored.state.railCollapsed).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,6 @@
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { act, render, RenderResult, screen } from '@testing-library/react';
|
||||
import type userEvent from '@testing-library/user-event';
|
||||
import { EditorView } from '@uiw/react-codemirror';
|
||||
import { render, RenderResult, screen } from '@testing-library/react';
|
||||
import ListAlertRules from 'container/ListAlertRules';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
@@ -12,32 +9,6 @@ import { onNuqsUrlUpdate, resetNuqsState } from 'tests/nuqs-helpers';
|
||||
import { TestRouter } from 'tests/router';
|
||||
import { getAppContextMock } from 'tests/test-utils';
|
||||
|
||||
// NuqsTestingAdapter resets its update queue on every render, not only on mount,
|
||||
// dropping writes queued just before it re-renders.
|
||||
function UrlAdapter({
|
||||
searchParams,
|
||||
children,
|
||||
}: {
|
||||
searchParams: string;
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
return (
|
||||
<NuqsTestingAdapter
|
||||
searchParams={searchParams}
|
||||
onUrlUpdate={onNuqsUrlUpdate}
|
||||
rateLimitFactor={0}
|
||||
resetUrlUpdateQueueOnMount={!mounted}
|
||||
hasMemory
|
||||
>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
);
|
||||
}
|
||||
|
||||
interface RenderOptions {
|
||||
role?: string;
|
||||
initialRoute?: string;
|
||||
@@ -62,7 +33,12 @@ export function renderListAlertRules(
|
||||
|
||||
return render(
|
||||
<TestRouter initialRoute={initialRoute}>
|
||||
<UrlAdapter searchParams={initialSearch}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={initialSearch}
|
||||
onUrlUpdate={onNuqsUrlUpdate}
|
||||
rateLimitFactor={0}
|
||||
hasMemory
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppContext.Provider value={getAppContextMock(role)}>
|
||||
<TimezoneProvider>
|
||||
@@ -74,7 +50,7 @@ export function renderListAlertRules(
|
||||
</TimezoneProvider>
|
||||
</AppContext.Provider>
|
||||
</QueryClientProvider>
|
||||
</UrlAdapter>
|
||||
</NuqsTestingAdapter>
|
||||
</TestRouter>,
|
||||
);
|
||||
}
|
||||
@@ -87,31 +63,3 @@ export async function findAlertRow(alertName: string): Promise<HTMLElement> {
|
||||
}
|
||||
return row as HTMLElement;
|
||||
}
|
||||
|
||||
type User = ReturnType<typeof userEvent.setup>;
|
||||
|
||||
export function setSearchDraft(value: string): void {
|
||||
const view = EditorView.findFromDOM(
|
||||
screen.getByTestId('alert-rules-list-search'),
|
||||
);
|
||||
if (!view) {
|
||||
throw new Error('Search editor not found');
|
||||
}
|
||||
act(() => {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getSearchDraft(): string {
|
||||
const view = EditorView.findFromDOM(
|
||||
screen.getByTestId('alert-rules-list-search'),
|
||||
);
|
||||
return view?.state.doc.toString() ?? '';
|
||||
}
|
||||
|
||||
export async function runSearchQuery(user: User, value: string): Promise<void> {
|
||||
setSearchDraft(value);
|
||||
await user.click(screen.getByTestId('alert-rules-list-search-submit'));
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { toast } from '@signozhq/ui/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
deleteRuleByID,
|
||||
invalidateListRulesV3,
|
||||
invalidateListRules,
|
||||
patchRuleByID,
|
||||
cloneRuleByID,
|
||||
} from 'api/generated/services/rules';
|
||||
@@ -22,7 +22,7 @@ import { ALERT_ACTIONS, alertActionLogEvent } from '../utils';
|
||||
|
||||
interface ActionsMenuProps {
|
||||
rule: AlertRule;
|
||||
onEdit: (rule: Pick<AlertRule, 'id'>, options?: { newTab?: boolean }) => void;
|
||||
onEdit: (rule: AlertRule, options?: { newTab?: boolean }) => void;
|
||||
}
|
||||
|
||||
function ActionsMenu({ rule, onEdit }: ActionsMenuProps): JSX.Element {
|
||||
@@ -34,9 +34,7 @@ function ActionsMenu({ rule, onEdit }: ActionsMenuProps): JSX.Element {
|
||||
toast.promise(
|
||||
patchRuleByID({ id: rule.id ?? '' }, {
|
||||
disabled: newDisabled,
|
||||
} as RuletypesPostableRuleDTO).then(() =>
|
||||
invalidateListRulesV3(queryClient),
|
||||
),
|
||||
} as RuletypesPostableRuleDTO).then(() => invalidateListRules(queryClient)),
|
||||
{
|
||||
loading: newDisabled ? 'Disabling alert...' : 'Enabling alert...',
|
||||
success: newDisabled ? 'Alert disabled' : 'Alert enabled',
|
||||
@@ -67,10 +65,10 @@ function ActionsMenu({ rule, onEdit }: ActionsMenuProps): JSX.Element {
|
||||
cloneRuleByID({
|
||||
id: rule.id,
|
||||
}).then(async (response) => {
|
||||
await invalidateListRulesV3(queryClient);
|
||||
await invalidateListRules(queryClient);
|
||||
const newRule = response.data;
|
||||
if (newRule) {
|
||||
onEdit(newRule);
|
||||
onEdit(newRule as AlertRule);
|
||||
}
|
||||
return newRule;
|
||||
}),
|
||||
@@ -92,7 +90,7 @@ function ActionsMenu({ rule, onEdit }: ActionsMenuProps): JSX.Element {
|
||||
alertActionLogEvent(ALERT_ACTIONS.DELETE, rule);
|
||||
toast.promise(
|
||||
deleteRuleByID({ id: rule.id ?? '' }).then(() =>
|
||||
invalidateListRulesV3(queryClient),
|
||||
invalidateListRules(queryClient),
|
||||
),
|
||||
{
|
||||
loading: 'Deleting alert...',
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import FilterZone from 'components/ListFilter/FilterZone/FilterZone';
|
||||
import type { SuggestionSource } from 'components/ListFilter/dsl/types';
|
||||
import type { CreatorOption } from 'components/ListFilter/types';
|
||||
|
||||
import { RULES_DSL_GRAMMAR } from '../list.config';
|
||||
import RuleStatesFilter from './RuleStatesFilter';
|
||||
|
||||
interface Props {
|
||||
query: string;
|
||||
states: string[];
|
||||
creatorOptions: CreatorOption[];
|
||||
source: SuggestionSource;
|
||||
error: string | null;
|
||||
onQueryChange: (query: string) => void;
|
||||
onStatesChange: (states: string[]) => void;
|
||||
}
|
||||
|
||||
function AlertRulesFilterZone({
|
||||
query,
|
||||
states,
|
||||
creatorOptions,
|
||||
source,
|
||||
error,
|
||||
onQueryChange,
|
||||
onStatesChange,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<FilterZone
|
||||
query={query}
|
||||
grammar={RULES_DSL_GRAMMAR}
|
||||
testIdPrefix="alert-rules"
|
||||
placeholder="DSL Filter — e.g. name CONTAINS 'cpu' AND labels.team = 'infra'"
|
||||
creatorOptions={creatorOptions}
|
||||
source={source}
|
||||
onQueryChange={onQueryChange}
|
||||
extraFilters={<RuleStatesFilter states={states} onChange={onStatesChange} />}
|
||||
hasExtraFilters={states.length > 0}
|
||||
onClearAll={(): void => onStatesChange([])}
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default AlertRulesFilterZone;
|
||||
@@ -1,5 +0,0 @@
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ColumnSelector } from 'components/TanStackTableView';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
|
||||
import type { AlertRule } from '../types';
|
||||
|
||||
import styles from './AlertRulesHeaderActions.module.scss';
|
||||
|
||||
interface Props {
|
||||
columns: TableColumnDef<AlertRule>[];
|
||||
canAddAlert: boolean;
|
||||
onNewAlert: (e: MouseEvent) => void;
|
||||
}
|
||||
|
||||
function AlertRulesHeaderActions({
|
||||
columns,
|
||||
canAddAlert,
|
||||
onNewAlert,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
<ColumnSelector
|
||||
columns={columns}
|
||||
storageKey="alert-rules-columns"
|
||||
testId="alert-columns-button"
|
||||
/>
|
||||
{canAddAlert && (
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={onNewAlert}
|
||||
color="primary"
|
||||
testId="list-alerts-new-alert-button"
|
||||
>
|
||||
New Alert
|
||||
</Button>
|
||||
)}
|
||||
<TextToolTip
|
||||
text="More details on how to create alerts"
|
||||
url="https://signoz.io/docs/alerts/?utm_source=product&utm_medium=list-alerts"
|
||||
urlText="Learn More"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AlertRulesHeaderActions;
|
||||
@@ -1,4 +0,0 @@
|
||||
.statesSelect {
|
||||
width: 240px;
|
||||
flex: none;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { ComboboxSimple, type ComboboxSimpleItem } from '@signozhq/ui/combobox';
|
||||
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import styles from './RuleStatesFilter.module.scss';
|
||||
|
||||
const STATE_ITEMS: ComboboxSimpleItem[] = [
|
||||
{ value: RuletypesAlertStateDTO.firing, label: 'Firing' },
|
||||
{ value: RuletypesAlertStateDTO.pending, label: 'Pending' },
|
||||
{ value: RuletypesAlertStateDTO.recovering, label: 'Recovering' },
|
||||
{ value: RuletypesAlertStateDTO.inactive, label: 'OK' },
|
||||
{ value: RuletypesAlertStateDTO.nodata, label: 'No data' },
|
||||
{ value: RuletypesAlertStateDTO.disabled, label: 'Disabled' },
|
||||
];
|
||||
|
||||
interface RuleStatesFilterProps {
|
||||
states: string[];
|
||||
onChange: (states: string[]) => void;
|
||||
}
|
||||
|
||||
function RuleStatesFilter({
|
||||
states,
|
||||
onChange,
|
||||
}: RuleStatesFilterProps): JSX.Element {
|
||||
const handleChange = useCallback(
|
||||
(values: unknown): void => {
|
||||
if (Array.isArray(values)) {
|
||||
onChange(values as string[]);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<ComboboxSimple
|
||||
className={styles.statesSelect}
|
||||
multiple
|
||||
value={states}
|
||||
onChange={handleChange}
|
||||
placeholder="Filter by state"
|
||||
inputPlaceholder="Select one or more"
|
||||
items={STATE_ITEMS}
|
||||
maxDisplayedPills={2}
|
||||
testId="alert-rules-filter-states"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default RuleStatesFilter;
|
||||
@@ -1,34 +1,24 @@
|
||||
import {
|
||||
Options,
|
||||
parseAsArrayOf,
|
||||
parseAsString,
|
||||
useQueryState,
|
||||
UseQueryStateReturn,
|
||||
} from 'nuqs';
|
||||
import { Options, useQueryState, UseQueryStateReturn } from 'nuqs';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
|
||||
const defaultNuqsOptions: Options = {
|
||||
history: 'push',
|
||||
};
|
||||
|
||||
export const ALERT_RULES_PARAMS = {
|
||||
QUERY: 'query',
|
||||
STATES: 'states',
|
||||
VIEW: 'view',
|
||||
SEARCH: 'search',
|
||||
PAGE: 'page',
|
||||
RULE_TYPE: 'ruleType',
|
||||
FILTERS: 'alertRulesFilters',
|
||||
} as const;
|
||||
|
||||
export const useAlertRulesQuery = (): UseQueryStateReturn<string, string> =>
|
||||
useQueryState(
|
||||
ALERT_RULES_PARAMS.QUERY,
|
||||
parseAsString.withDefault('').withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
export const useAlertRulesStates = (): UseQueryStateReturn<
|
||||
export const useAlertRulesFilters = (): UseQueryStateReturn<
|
||||
string[],
|
||||
string[]
|
||||
> =>
|
||||
useQueryState(
|
||||
ALERT_RULES_PARAMS.STATES,
|
||||
parseAsArrayOf(parseAsString).withDefault([]).withOptions(defaultNuqsOptions),
|
||||
ALERT_RULES_PARAMS.FILTERS,
|
||||
parseAsJsonNoValidate<string[]>()
|
||||
.withDefault([])
|
||||
.withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Plus, Search } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
|
||||
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
|
||||
import ListCommandHeader from 'components/ListViews/ListCommandHeader/ListCommandHeader';
|
||||
import ListStatusBar from 'components/ListViews/ListStatusBar/ListStatusBar';
|
||||
import ViewsRail from 'components/ListViews/ViewsRail/ViewsRail';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import TanStackTable, { ColumnSelector } from 'components/TanStackTableView';
|
||||
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
|
||||
import { useTableParams } from 'components/TanStackTableView/useTableParams';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useUrlSearchState } from 'hooks/useUrlSearchState';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
|
||||
import { AlertsEmptyState } from './AlertsEmptyState/AlertsEmptyState';
|
||||
import { ActionsMenu } from './components';
|
||||
import AlertRulesFilterZone from './components/AlertRulesFilterZone';
|
||||
import AlertRulesHeaderActions from './components/AlertRulesHeaderActions';
|
||||
import { ALERT_RULES_PARAMS, useAlertRulesFilters } from './hooks';
|
||||
import styles from './ListAlertRules.module.scss';
|
||||
import { getAlertRuleColumns } from './table.config';
|
||||
import type { AlertRule } from './types';
|
||||
import { useAlertRulesStripInfo } from './useAlertRulesStripInfo';
|
||||
import { useAlertRulesData } from './useAlertRulesData';
|
||||
import { useAlertRulesFilterSource } from './useAlertRulesFilterSource';
|
||||
import { useAlertRulesHandlers } from './useAlertRulesHandlers';
|
||||
import { useAlertRulesListFilter } from './useAlertRulesListFilter';
|
||||
import { useAlertRuleViews } from './useAlertRuleViews';
|
||||
import { useAlertRuleViewsStore } from './useAlertRuleViewsStore';
|
||||
import { DEFAULT_RULE_VIEW, RULE_BUILTIN_VIEWS } from './list.config';
|
||||
|
||||
const QUERY_PARAMS_CONFIG = {
|
||||
orderBy: 'orderBy',
|
||||
@@ -43,13 +40,14 @@ function ListAlertRules(): JSX.Element {
|
||||
user.role,
|
||||
);
|
||||
|
||||
const [filterValues, setFilterValues] = useAlertRulesFilters();
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
|
||||
rowHeight: 46,
|
||||
});
|
||||
|
||||
const { orderBy, page, limit, setLimit, setPage, setOrderBy } = useTableParams(
|
||||
const { orderBy, page, limit, setLimit, setPage } = useTableParams(
|
||||
QUERY_PARAMS_CONFIG,
|
||||
{
|
||||
page: DEFAULT_PAGE,
|
||||
@@ -60,63 +58,39 @@ function ListAlertRules(): JSX.Element {
|
||||
},
|
||||
);
|
||||
|
||||
const { filter, applyFilter, submitQuery, changeStates } =
|
||||
useAlertRulesListFilter({ orderBy, setOrderBy, setPage });
|
||||
const resetPageOnSearch = useCallback((): void => {
|
||||
setPage(1);
|
||||
}, [setPage]);
|
||||
|
||||
const ruleViews = useAlertRuleViews({ filter, applyFilter });
|
||||
const { searchText, debouncedSearch, handleSearchChange, clearSearch } =
|
||||
useUrlSearchState(ALERT_RULES_PARAMS.SEARCH, {
|
||||
onDebouncedChange: resetPageOnSearch,
|
||||
});
|
||||
|
||||
const railCollapsed = useAlertRuleViewsStore((s) => s.railCollapsed);
|
||||
const setRailCollapsed = useAlertRuleViewsStore((s) => s.setRailCollapsed);
|
||||
const toggleRail = useCallback((): void => {
|
||||
setRailCollapsed(!railCollapsed);
|
||||
}, [setRailCollapsed, railCollapsed]);
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
query: filter.query || undefined,
|
||||
states: filter.states.length ? filter.states : undefined,
|
||||
sort: filter.sort,
|
||||
order: filter.order,
|
||||
limit,
|
||||
offset: (page - 1) * limit,
|
||||
}),
|
||||
[filter, limit, page],
|
||||
);
|
||||
|
||||
const {
|
||||
rules,
|
||||
total,
|
||||
labels,
|
||||
reservedKeywords,
|
||||
isFetching,
|
||||
isError,
|
||||
queryError,
|
||||
refetch,
|
||||
} = useAlertRulesData(listParams);
|
||||
|
||||
const { creatorOptions, source } = useAlertRulesFilterSource({
|
||||
rules,
|
||||
labels,
|
||||
reservedKeywords,
|
||||
userEmail: user.email,
|
||||
});
|
||||
const { filteredRules, isFetching, isError, allRules, refetch } =
|
||||
useAlertRulesData(orderBy, debouncedSearch, filterValues ?? []);
|
||||
|
||||
const { handleEdit, handleNewAlert, handleRowClick, handleRowClickNewTab } =
|
||||
useAlertRulesHandlers(total);
|
||||
useAlertRulesHandlers(allRules.length);
|
||||
|
||||
const { selectView } = ruleViews;
|
||||
const handleClearFilters = useCallback((): void => {
|
||||
selectView(DEFAULT_RULE_VIEW);
|
||||
}, [selectView]);
|
||||
void setFilterValues(null);
|
||||
clearSearch();
|
||||
}, [setFilterValues, clearSearch]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => getAlertRuleColumns(formatTimezoneAdjustedTimestamp),
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const paginatedRules = useMemo(() => {
|
||||
const start = (page - 1) * limit;
|
||||
return filteredRules.slice(start, start + limit);
|
||||
}, [filteredRules, page, limit]);
|
||||
|
||||
useAlertRulesStripInfo({
|
||||
shownCount: rules.length,
|
||||
totalCount: total,
|
||||
shownCount: paginatedRules.length,
|
||||
totalCount: filteredRules.length,
|
||||
});
|
||||
|
||||
const columnsWithActions = useMemo(() => {
|
||||
@@ -146,113 +120,93 @@ function ListAlertRules(): JSX.Element {
|
||||
];
|
||||
}, [action, columns, handleEdit]);
|
||||
|
||||
const hasActiveFilters = filter.query.length > 0 || filter.states.length > 0;
|
||||
const hasActiveFilters =
|
||||
searchText.length > 0 || (filterValues ?? []).length > 0;
|
||||
const isEmptyDueToFilters =
|
||||
!isFetching && total === 0 && (hasActiveFilters || !!queryError);
|
||||
const isEmptyNoRules =
|
||||
!isFetching && !isError && !queryError && !hasActiveFilters && total === 0;
|
||||
|
||||
const activeLabel =
|
||||
ruleViews.activeView?.name ??
|
||||
RULE_BUILTIN_VIEWS.find((v) => v.id === ruleViews.activeViewId)?.label ??
|
||||
'Alert rules';
|
||||
!isFetching &&
|
||||
filteredRules.length === 0 &&
|
||||
hasActiveFilters &&
|
||||
allRules.length > 0;
|
||||
const isEmptyNoRules = !isFetching && !isError && allRules.length === 0;
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<ViewsRail
|
||||
activeViewId={ruleViews.activeViewId}
|
||||
builtinViews={RULE_BUILTIN_VIEWS}
|
||||
customViews={ruleViews.customViews}
|
||||
customViewsLoading={ruleViews.customViewsLoading}
|
||||
isCustomActive={ruleViews.isCustomActive}
|
||||
isModified={ruleViews.isModified}
|
||||
collapsed={railCollapsed}
|
||||
onSelect={ruleViews.selectView}
|
||||
onSave={ruleViews.saveView}
|
||||
onSaveChanges={ruleViews.saveActiveView}
|
||||
onReset={ruleViews.resetView}
|
||||
onDelete={ruleViews.removeView}
|
||||
onRename={ruleViews.renameView}
|
||||
testIdPrefix="alert-rules"
|
||||
saveTestIdPrefix="save-alert-rule-view"
|
||||
renameTestIdPrefix="rename-alert-rule-view"
|
||||
deleteDescription="Views are shared across your organization, so this removes it for everyone. Alert rules are not affected."
|
||||
/>
|
||||
<div className={styles.main}>
|
||||
{!isEmptyNoRules && (
|
||||
<div className={styles.headerZone}>
|
||||
<ListCommandHeader
|
||||
label={activeLabel}
|
||||
count={total}
|
||||
isModified={ruleViews.isModified}
|
||||
testIdPrefix="alert-rules"
|
||||
action={
|
||||
<AlertRulesHeaderActions
|
||||
columns={columns}
|
||||
canAddAlert={addNewAlert}
|
||||
onNewAlert={handleNewAlert}
|
||||
/>
|
||||
}
|
||||
<div className={styles.container}>
|
||||
{!isEmptyNoRules && (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.refreshRow}>
|
||||
<ColumnSelector
|
||||
columns={columns}
|
||||
storageKey="alert-rules-columns"
|
||||
testId="alert-columns-button"
|
||||
/>
|
||||
<AlertRulesFilterZone
|
||||
query={filter.query}
|
||||
states={filter.states}
|
||||
creatorOptions={creatorOptions}
|
||||
source={source}
|
||||
error={queryError}
|
||||
onQueryChange={submitQuery}
|
||||
onStatesChange={changeStates}
|
||||
{addNewAlert && (
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={handleNewAlert}
|
||||
color="primary"
|
||||
testId="list-alerts-new-alert-button"
|
||||
>
|
||||
New Alert
|
||||
</Button>
|
||||
)}
|
||||
<TextToolTip
|
||||
text="More details on how to create alerts"
|
||||
url="https://signoz.io/docs/alerts/?utm_source=product&utm_medium=list-alerts"
|
||||
urlText="Learn More"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={containerRef} className={styles.tableContainer}>
|
||||
{isError ? (
|
||||
<ErrorEmptyState title="Failed to load alert rules" onRefresh={refetch} />
|
||||
) : isEmptyDueToFilters ? (
|
||||
<NoResultsEmptyState
|
||||
title="No matching alert rules"
|
||||
subtitle={
|
||||
queryError
|
||||
? 'The filter query is invalid. Fix it or clear the search.'
|
||||
: 'No alert rules match your search. Try adjusting your search criteria.'
|
||||
}
|
||||
onClear={handleClearFilters}
|
||||
clearButtonText="Clear Search"
|
||||
/>
|
||||
) : isEmptyNoRules ? (
|
||||
<AlertsEmptyState onRefresh={refetch} />
|
||||
) : (
|
||||
<TanStackTable<AlertRule>
|
||||
data={rules}
|
||||
columns={columnsWithActions}
|
||||
isLoading={isFetching}
|
||||
getRowKey={(row): string => row.id ?? ''}
|
||||
getItemKey={(row): string => row.id ?? ''}
|
||||
columnStorageKey="alert-rules-columns"
|
||||
enableQueryParams={QUERY_PARAMS_CONFIG}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
pagination={{
|
||||
total,
|
||||
calculatedPageSize,
|
||||
onLimitChange: setLimit,
|
||||
showTotalCount: true,
|
||||
}}
|
||||
paginationClassname={styles.paginationContainer}
|
||||
enableAlternatingRowColors
|
||||
plainTextCellLineClamp={2}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ListStatusBar
|
||||
collapsed={railCollapsed}
|
||||
onToggleCollapse={toggleRail}
|
||||
count={rules.length}
|
||||
total={total}
|
||||
noun="alert rules"
|
||||
testId="alert-rules-rail-toggle"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isEmptyNoRules && (
|
||||
<div className={styles.filtersRow}>
|
||||
<Input
|
||||
className={styles.searchInput}
|
||||
placeholder="Search by Alert Name, Severity and Labels"
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
suffix={<Search size={14} className={styles.searchIcon} />}
|
||||
testId="list-alerts-search-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={containerRef} className={styles.tableContainer}>
|
||||
{isError ? (
|
||||
<ErrorEmptyState title="Failed to load alert rules" onRefresh={refetch} />
|
||||
) : isEmptyDueToFilters ? (
|
||||
<NoResultsEmptyState
|
||||
title="No matching alert rules"
|
||||
subtitle="No alert rules match your search. Try adjusting your search criteria."
|
||||
onClear={handleClearFilters}
|
||||
clearButtonText="Clear Search"
|
||||
/>
|
||||
) : isEmptyNoRules ? (
|
||||
<AlertsEmptyState onRefresh={refetch} />
|
||||
) : (
|
||||
<TanStackTable<AlertRule>
|
||||
data={paginatedRules}
|
||||
columns={columnsWithActions}
|
||||
isLoading={isFetching}
|
||||
getRowKey={(row): string => row.id ?? ''}
|
||||
getItemKey={(row): string => row.id ?? ''}
|
||||
columnStorageKey="alert-rules-columns"
|
||||
enableQueryParams={QUERY_PARAMS_CONFIG}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
pagination={{
|
||||
total: filteredRules.length,
|
||||
calculatedPageSize,
|
||||
onLimitChange: setLimit,
|
||||
showTotalCount: true,
|
||||
}}
|
||||
paginationClassname={styles.paginationContainer}
|
||||
enableAlternatingRowColors
|
||||
plainTextCellLineClamp={2}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { BellOff, Flame, Layers, User } from '@signozhq/icons';
|
||||
import { clause } from 'components/ListFilter/dsl/filterQuery';
|
||||
import type {
|
||||
ClauseBuilder,
|
||||
DslGrammar,
|
||||
} from 'components/ListFilter/dsl/types';
|
||||
import type { BuiltinViewConfig } from 'components/ListViews/types';
|
||||
|
||||
import type { RuleListFilter, RuleViewCtx } from './types';
|
||||
|
||||
// Mirrors pkg/types/ruletypes/list_filter.go. Label keys go under `labels.`.
|
||||
export const RULES_DSL_GRAMMAR = {
|
||||
fields: [
|
||||
{ key: 'name', type: 'string' },
|
||||
// Aliases labels.severity, so it takes the label operators.
|
||||
{ key: 'severity', type: 'dynamic' },
|
||||
{
|
||||
key: 'alert_type',
|
||||
type: 'enum',
|
||||
values: [
|
||||
'METRIC_BASED_ALERT',
|
||||
'LOGS_BASED_ALERT',
|
||||
'TRACES_BASED_ALERT',
|
||||
'EXCEPTIONS_BASED_ALERT',
|
||||
'AI_TRACES_BASED_ALERT',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'rule_type',
|
||||
type: 'enum',
|
||||
values: ['threshold_rule', 'promql_rule', 'anomaly_rule'],
|
||||
},
|
||||
{ key: 'created_by', type: 'string' },
|
||||
{ key: 'updated_by', type: 'string' },
|
||||
{ key: 'created_at', type: 'timestamp' },
|
||||
{ key: 'updated_at', type: 'timestamp' },
|
||||
],
|
||||
dynamicKey: { detail: 'label', prefix: 'labels.' },
|
||||
hiddenKeys: ['labels.<key>'],
|
||||
} as const satisfies DslGrammar;
|
||||
|
||||
const where: ClauseBuilder<
|
||||
(typeof RULES_DSL_GRAMMAR)['fields'][number]['key']
|
||||
> = clause;
|
||||
|
||||
type RuleViewConfig = BuiltinViewConfig<RuleListFilter, RuleViewCtx>;
|
||||
|
||||
// Built-in views replace query and states; the current sort/order is kept.
|
||||
const views = [
|
||||
{
|
||||
id: 'mine',
|
||||
label: 'My alert rules',
|
||||
icon: User,
|
||||
section: 'personal',
|
||||
filter: (current, { userEmail }): RuleListFilter => ({
|
||||
...current,
|
||||
query: userEmail ? where('created_by', '=', userEmail) : '',
|
||||
states: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'all',
|
||||
label: 'All alert rules',
|
||||
icon: Layers,
|
||||
section: 'system',
|
||||
filter: (current): RuleListFilter => ({ ...current, query: '', states: [] }),
|
||||
},
|
||||
{
|
||||
id: 'firing',
|
||||
label: 'Firing',
|
||||
icon: Flame,
|
||||
section: 'system',
|
||||
filter: (current): RuleListFilter => ({
|
||||
...current,
|
||||
query: '',
|
||||
states: [RuletypesAlertStateDTO.firing],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'disabled',
|
||||
label: 'Disabled',
|
||||
icon: BellOff,
|
||||
section: 'system',
|
||||
filter: (current): RuleListFilter => ({
|
||||
...current,
|
||||
query: '',
|
||||
states: [RuletypesAlertStateDTO.disabled],
|
||||
}),
|
||||
},
|
||||
] as const satisfies readonly RuleViewConfig[];
|
||||
|
||||
export type RuleViewId = (typeof views)[number]['id'];
|
||||
|
||||
export const RULE_BUILTIN_VIEWS: readonly RuleViewConfig[] = views;
|
||||
|
||||
export const DEFAULT_RULE_VIEW: RuleViewId = 'all';
|
||||
@@ -1,22 +1,3 @@
|
||||
import type {
|
||||
RuletypesListableRuleDTO,
|
||||
RuletypesListOrderDTO,
|
||||
RuletypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { SavedListView } from 'components/ListViews/types';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type AlertRule = RuletypesListableRuleDTO;
|
||||
|
||||
// The v3 list filter a saved view snapshots; pagination is deliberately excluded.
|
||||
export interface RuleListFilter {
|
||||
query: string;
|
||||
states: string[];
|
||||
sort: RuletypesListSortDTO;
|
||||
order: RuletypesListOrderDTO;
|
||||
}
|
||||
|
||||
export type RuleView = SavedListView<RuleListFilter>;
|
||||
|
||||
export interface RuleViewCtx {
|
||||
userEmail: string;
|
||||
}
|
||||
export type AlertRule = RuletypesRuleDTO;
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import type { UseListViewsResult } from 'hooks/listViews/types';
|
||||
import { useListViews } from 'hooks/listViews/useListViews';
|
||||
|
||||
import { ALERT_RULES_PARAMS } from './hooks';
|
||||
import { DEFAULT_RULE_VIEW, RULE_BUILTIN_VIEWS } from './list.config';
|
||||
import type { RuleListFilter, RuleViewCtx } from './types';
|
||||
import { useRuleViewCtx } from './useRuleViewCtx';
|
||||
import { useRuleViews } from './useRuleViews';
|
||||
import { isDefaultRuleListFilter, isSameRuleListFilter } from './utils';
|
||||
|
||||
interface UseAlertRuleViewsArgs {
|
||||
filter: RuleListFilter;
|
||||
applyFilter: (next: RuleListFilter) => void;
|
||||
}
|
||||
|
||||
export interface UseAlertRuleViewsResult extends UseListViewsResult<RuleListFilter> {
|
||||
customViewsLoading: boolean;
|
||||
}
|
||||
|
||||
// A link carrying only `view=<custom id>` gets that view's filter applied once
|
||||
// the views load; a link carrying its own filter params keeps them. Clearing a
|
||||
// custom view's filter writes an empty `query=` so a reload keeps it cleared.
|
||||
export function useAlertRuleViews({
|
||||
filter,
|
||||
applyFilter,
|
||||
}: UseAlertRuleViewsArgs): UseAlertRuleViewsResult {
|
||||
const api = useRuleViews();
|
||||
|
||||
const ctx = useRuleViewCtx();
|
||||
|
||||
const views = useListViews<RuleListFilter, RuleViewCtx>({
|
||||
filter,
|
||||
applyFilter,
|
||||
isEqual: isSameRuleListFilter,
|
||||
builtinViews: RULE_BUILTIN_VIEWS,
|
||||
ctx,
|
||||
defaultViewId: DEFAULT_RULE_VIEW,
|
||||
api,
|
||||
urlParam: ALERT_RULES_PARAMS.VIEW,
|
||||
});
|
||||
|
||||
const { activeView } = views;
|
||||
const [rawQuery, setRawQuery] = useQueryState(ALERT_RULES_PARAMS.QUERY);
|
||||
const startedFromViewLink = useRef(
|
||||
isDefaultRuleListFilter(filter) && rawQuery === null,
|
||||
);
|
||||
const hasResolvedLinkedView = useRef(false);
|
||||
|
||||
// Declared before the restore so it skips the render that applies a linked view.
|
||||
const clearedCustomView =
|
||||
!!activeView && rawQuery === null && isDefaultRuleListFilter(filter);
|
||||
useEffect(() => {
|
||||
if (hasResolvedLinkedView.current && clearedCustomView) {
|
||||
void setRawQuery('', { clearOnDefault: false, history: 'replace' });
|
||||
}
|
||||
}, [clearedCustomView, setRawQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasResolvedLinkedView.current || api.isLoading) {
|
||||
return;
|
||||
}
|
||||
hasResolvedLinkedView.current = true;
|
||||
if (
|
||||
activeView &&
|
||||
startedFromViewLink.current &&
|
||||
isDefaultRuleListFilter(filter)
|
||||
) {
|
||||
applyFilter(activeView.filter);
|
||||
}
|
||||
}, [api.isLoading, activeView, applyFilter, filter]);
|
||||
|
||||
return { ...views, customViewsLoading: api.isLoading };
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
interface AlertRuleViewsState {
|
||||
railCollapsed: boolean;
|
||||
setRailCollapsed: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
export const useAlertRuleViewsStore = create<AlertRuleViewsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
railCollapsed: false,
|
||||
setRailCollapsed: (collapsed): void => {
|
||||
set({ railCollapsed: collapsed });
|
||||
},
|
||||
}),
|
||||
{ name: LOCALSTORAGE.ALERT_RULES_LIST_VIEWS },
|
||||
),
|
||||
);
|
||||
@@ -1,92 +1,55 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useListRulesV3 } from 'api/generated/services/rules';
|
||||
import type {
|
||||
ListRulesV3200,
|
||||
ListRulesV3Params,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesLabelPairDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AxiosError } from 'axios';
|
||||
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
|
||||
import { useListRules } from 'api/generated/services/rules';
|
||||
import { searchByLabels } from 'components/Alerts/utils';
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
import { isUndefined } from 'lodash-es';
|
||||
|
||||
import type { AlertRule } from './types';
|
||||
import { RULE_LIST_INVALID_ERROR_CODES } from './utils';
|
||||
import { filterRulesByFilters, sortRules } from './utils';
|
||||
|
||||
interface UseAlertRulesDataReturn {
|
||||
rules: AlertRule[];
|
||||
total: number;
|
||||
/** Label pairs across the org's rules, for DSL autocomplete. */
|
||||
labels: RuletypesLabelPairDTO[];
|
||||
reservedKeywords: string[] | undefined;
|
||||
allRules: AlertRule[];
|
||||
filteredRules: AlertRule[];
|
||||
isFetching: boolean;
|
||||
/** A failure other than an invalid filter query. */
|
||||
isError: boolean;
|
||||
/** The backend's message when the filter query is invalid. */
|
||||
queryError: string | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useAlertRulesData(
|
||||
params: ListRulesV3Params,
|
||||
orderBy: SortState | null,
|
||||
searchText = '',
|
||||
filters: string[] = [],
|
||||
): UseAlertRulesDataReturn {
|
||||
const hasLoggedEvent = useRef(false);
|
||||
|
||||
const rulesResponse = useListRulesV3(params, {
|
||||
query: {
|
||||
keepPreviousData: true,
|
||||
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
|
||||
queryFn: ({ signal }) =>
|
||||
GeneratedAPIInstance<ListRulesV3200>({
|
||||
url: '/api/v3/rules',
|
||||
method: 'GET',
|
||||
params,
|
||||
paramsSerializer: { indexes: null },
|
||||
signal,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const rulesResponse = useListRules();
|
||||
|
||||
const rules = useMemo(
|
||||
() => rulesResponse.data?.data.rules ?? [],
|
||||
[rulesResponse.data],
|
||||
);
|
||||
const total = rulesResponse.data?.data.total ?? 0;
|
||||
const labels = useMemo(
|
||||
() => rulesResponse.data?.data.labels ?? [],
|
||||
const allRules = useMemo(
|
||||
() => rulesResponse.data?.data ?? [],
|
||||
[rulesResponse.data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasLoggedEvent.current && !isUndefined(rulesResponse.data?.data)) {
|
||||
void logEvent('Alert: List page visited', {
|
||||
number: total,
|
||||
number: allRules.length,
|
||||
});
|
||||
hasLoggedEvent.current = true;
|
||||
}
|
||||
}, [rulesResponse.data, total]);
|
||||
}, [rulesResponse.data, allRules.length]);
|
||||
|
||||
const apiError = useMemo(
|
||||
() =>
|
||||
convertToApiError(
|
||||
rulesResponse.error as AxiosError<RenderErrorResponseDTO> | null,
|
||||
),
|
||||
[rulesResponse.error],
|
||||
);
|
||||
const errorCode = apiError?.getErrorCode();
|
||||
const isQueryError =
|
||||
!!errorCode && RULE_LIST_INVALID_ERROR_CODES.includes(errorCode);
|
||||
const filteredRules = useMemo(() => {
|
||||
const filtered = filterRulesByFilters(allRules, filters);
|
||||
const searched = searchByLabels(filtered, searchText, (r) => r.alert ?? '');
|
||||
return sortRules(searched, orderBy);
|
||||
}, [allRules, filters, searchText, orderBy]);
|
||||
|
||||
return {
|
||||
rules: isQueryError ? [] : rules,
|
||||
total: isQueryError ? 0 : total,
|
||||
labels,
|
||||
reservedKeywords: rulesResponse.data?.data.reservedKeywords,
|
||||
allRules,
|
||||
filteredRules,
|
||||
isFetching: rulesResponse.isFetching,
|
||||
isError: rulesResponse.isError && !isQueryError,
|
||||
queryError: isQueryError ? (apiError?.getErrorMessage() ?? null) : null,
|
||||
isError: rulesResponse.isError,
|
||||
refetch: rulesResponse.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { RuletypesLabelPairDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { SuggestionSource } from 'components/ListFilter/dsl/types';
|
||||
import type { CreatorOption } from 'components/ListFilter/types';
|
||||
import { useCreatorOptions } from 'components/ListFilter/useCreatorOptions';
|
||||
|
||||
import type { AlertRule } from './types';
|
||||
|
||||
const LABEL_PREFIX = 'labels.';
|
||||
|
||||
interface UseAlertRulesFilterSourceArgs {
|
||||
rules: AlertRule[];
|
||||
labels: RuletypesLabelPairDTO[];
|
||||
reservedKeywords: string[] | undefined;
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
interface UseAlertRulesFilterSourceResult {
|
||||
creatorOptions: CreatorOption[];
|
||||
source: SuggestionSource;
|
||||
}
|
||||
|
||||
export function useAlertRulesFilterSource({
|
||||
rules,
|
||||
labels,
|
||||
reservedKeywords,
|
||||
userEmail,
|
||||
}: UseAlertRulesFilterSourceArgs): UseAlertRulesFilterSourceResult {
|
||||
const pageAuthorEmails = useMemo<string[]>(
|
||||
() =>
|
||||
rules.map((r) => r.createdBy).filter((email): email is string => !!email),
|
||||
[rules],
|
||||
);
|
||||
const creatorOptions = useCreatorOptions({
|
||||
currentUserEmail: userEmail,
|
||||
fallbackEmails: pageAuthorEmails,
|
||||
});
|
||||
|
||||
const source = useMemo<SuggestionSource>(() => {
|
||||
const dynamicKeys = new Set<string>();
|
||||
const valuesByKey: Record<string, string[]> = {};
|
||||
labels.forEach(({ key, value }) => {
|
||||
const fullKey = `${LABEL_PREFIX}${key}`;
|
||||
dynamicKeys.add(fullKey);
|
||||
(valuesByKey[fullKey.toLowerCase()] ??= []).push(value);
|
||||
});
|
||||
return {
|
||||
reservedKeys: reservedKeywords,
|
||||
dynamicKeys: Array.from(dynamicKeys),
|
||||
valuesByKey,
|
||||
creatorEmails: creatorOptions.map((o) => o.email),
|
||||
currentUserEmail: userEmail,
|
||||
};
|
||||
}, [labels, reservedKeywords, creatorOptions, userEmail]);
|
||||
|
||||
return { creatorOptions, source };
|
||||
}
|
||||
@@ -2,18 +2,18 @@ import { useCallback } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { sanitizeDefaultAlertQuery } from 'container/EditAlertV2/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useTableRowClick } from 'hooks/useTableRowClick';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import type { AlertRule } from './types';
|
||||
|
||||
interface UseAlertRulesHandlersReturn {
|
||||
handleEdit: (
|
||||
rule: Pick<AlertRule, 'id'>,
|
||||
options?: { newTab?: boolean },
|
||||
) => void;
|
||||
handleEdit: (rule: AlertRule, options?: { newTab?: boolean }) => void;
|
||||
handleNewAlert: (e: React.MouseEvent) => void;
|
||||
handleRowClick: (rule: AlertRule) => void;
|
||||
handleRowClickNewTab: (rule: AlertRule) => void;
|
||||
@@ -26,16 +26,30 @@ export function useAlertRulesHandlers(
|
||||
const params = useUrlQuery();
|
||||
|
||||
const getEditUrl = useCallback(
|
||||
(rule: Pick<AlertRule, 'id'>): string => {
|
||||
const editParams = new URLSearchParams(params);
|
||||
editParams.set(QueryParams.ruleId, rule.id);
|
||||
return `${ROUTES.ALERT_OVERVIEW}?${editParams.toString()}`;
|
||||
(rule: AlertRule): string => {
|
||||
const compositeQuery = sanitizeDefaultAlertQuery(
|
||||
mapQueryDataFromApi(toCompositeMetricQuery(rule.condition.compositeQuery)),
|
||||
rule.alertType,
|
||||
);
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
const panelType = rule.condition.compositeQuery.panelType;
|
||||
if (panelType) {
|
||||
params.set(QueryParams.panelTypes, panelType);
|
||||
}
|
||||
|
||||
params.set(QueryParams.ruleId, rule.id);
|
||||
|
||||
return `${ROUTES.ALERT_OVERVIEW}?${params.toString()}`;
|
||||
},
|
||||
[params],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(rule: Pick<AlertRule, 'id'>, options?: { newTab?: boolean }): void => {
|
||||
(rule: AlertRule, options?: { newTab?: boolean }): void => {
|
||||
safeNavigate(getEditUrl(rule), options);
|
||||
},
|
||||
[getEditUrl, safeNavigate],
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
|
||||
import { useAlertRulesQuery, useAlertRulesStates } from './hooks';
|
||||
import type { RuleListFilter } from './types';
|
||||
import { toListSort, toOrderBy } from './utils';
|
||||
|
||||
interface UseAlertRulesListFilterArgs {
|
||||
orderBy: SortState | null;
|
||||
setOrderBy: (orderBy: SortState | null) => void;
|
||||
setPage: (page: number) => void;
|
||||
}
|
||||
|
||||
interface UseAlertRulesListFilterResult {
|
||||
filter: RuleListFilter;
|
||||
applyFilter: (filter: RuleListFilter) => void;
|
||||
submitQuery: (query: string) => void;
|
||||
changeStates: (states: string[]) => void;
|
||||
}
|
||||
|
||||
// The v3 list filter, read from and written to the URL. Every change goes back to page 1.
|
||||
export function useAlertRulesListFilter({
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
setPage,
|
||||
}: UseAlertRulesListFilterArgs): UseAlertRulesListFilterResult {
|
||||
const [query, setQuery] = useAlertRulesQuery();
|
||||
const [states, setStates] = useAlertRulesStates();
|
||||
|
||||
const filter = useMemo<RuleListFilter>(
|
||||
() => ({ query, states, ...toListSort(orderBy) }),
|
||||
[query, states, orderBy],
|
||||
);
|
||||
|
||||
const applyFilter = useCallback(
|
||||
(next: RuleListFilter): void => {
|
||||
void setQuery(next.query || null);
|
||||
void setStates(next.states.length ? next.states : null);
|
||||
setOrderBy(toOrderBy(next.sort, next.order));
|
||||
setPage(1);
|
||||
},
|
||||
[setQuery, setStates, setOrderBy, setPage],
|
||||
);
|
||||
|
||||
const submitQuery = useCallback(
|
||||
(next: string): void => {
|
||||
void setQuery(next || null);
|
||||
setPage(1);
|
||||
},
|
||||
[setQuery, setPage],
|
||||
);
|
||||
|
||||
const changeStates = useCallback(
|
||||
(next: string[]): void => {
|
||||
void setStates(next.length ? next : null);
|
||||
setPage(1);
|
||||
},
|
||||
[setStates, setPage],
|
||||
);
|
||||
|
||||
return { filter, applyFilter, submitQuery, changeStates };
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import type { RuleViewCtx } from './types';
|
||||
|
||||
export function useRuleViewCtx(): RuleViewCtx {
|
||||
const { user } = useAppContext();
|
||||
return useMemo(() => ({ userEmail: user.email }), [user.email]);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
getListRuleViewsQueryKey,
|
||||
invalidateListRuleViews,
|
||||
useCreateRuleView,
|
||||
useDeleteRuleView,
|
||||
useListRuleViews,
|
||||
useUpdateRuleView,
|
||||
} from 'api/generated/services/rules';
|
||||
import type {
|
||||
ListRuleViews200,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesPostableRuleViewDTO,
|
||||
RuletypesRuleViewDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { SavedListViewsApi } from 'components/ListViews/types';
|
||||
|
||||
import type { RuleListFilter, RuleView } from './types';
|
||||
import { DEFAULT_RULE_LIST_ORDER, DEFAULT_RULE_LIST_SORT } from './utils';
|
||||
|
||||
// Schema version stamped on the view's data envelope (the backend requires it).
|
||||
const VIEW_DATA_VERSION = 'v1';
|
||||
|
||||
const toRuleView = (dto: RuletypesRuleViewDTO): RuleView => ({
|
||||
id: dto.id,
|
||||
name: dto.name,
|
||||
filter: {
|
||||
query: dto.data.query ?? '',
|
||||
states: dto.data.states ?? [],
|
||||
sort: dto.data.sort ?? DEFAULT_RULE_LIST_SORT,
|
||||
order: dto.data.order ?? DEFAULT_RULE_LIST_ORDER,
|
||||
},
|
||||
});
|
||||
|
||||
const toPostable = (
|
||||
name: string,
|
||||
filter: RuleListFilter,
|
||||
): RuletypesPostableRuleViewDTO => ({
|
||||
name: name.trim(),
|
||||
data: {
|
||||
version: VIEW_DATA_VERSION,
|
||||
query: filter.query.trim(),
|
||||
states: filter.states,
|
||||
sort: filter.sort,
|
||||
order: filter.order,
|
||||
},
|
||||
});
|
||||
|
||||
const toastApiError =
|
||||
(fallback: string) =>
|
||||
(error: unknown): void => {
|
||||
const apiError = convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO>,
|
||||
);
|
||||
toast.error(apiError?.getErrorMessage() || fallback);
|
||||
};
|
||||
|
||||
/** Org-shared saved views for the alert rules list, backed by the rule views API. */
|
||||
export function useRuleViews(): SavedListViewsApi<RuleListFilter> {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading } = useListRuleViews();
|
||||
|
||||
const views = useMemo<RuleView[]>(
|
||||
() => (data?.data?.views ?? []).map(toRuleView),
|
||||
[data],
|
||||
);
|
||||
|
||||
const invalidate = useCallback((): void => {
|
||||
void invalidateListRuleViews(queryClient);
|
||||
}, [queryClient]);
|
||||
|
||||
const createMutation = useCreateRuleView({
|
||||
mutation: {
|
||||
onSuccess: invalidate,
|
||||
onError: toastApiError('Failed to save view.'),
|
||||
},
|
||||
});
|
||||
// Update returns the full view, so patch it into the cached list instead of refetching.
|
||||
const updateMutation = useUpdateRuleView({
|
||||
mutation: {
|
||||
onSuccess: (response): void => {
|
||||
const updated = response?.data;
|
||||
const key = getListRuleViewsQueryKey();
|
||||
const prev = queryClient.getQueryData<ListRuleViews200>(key);
|
||||
if (!updated || !prev) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData<ListRuleViews200>(key, {
|
||||
...prev,
|
||||
data: {
|
||||
...prev.data,
|
||||
views: (prev.data.views ?? []).map((v) =>
|
||||
v.id === updated.id ? updated : v,
|
||||
),
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: toastApiError('Failed to update view.'),
|
||||
},
|
||||
});
|
||||
const deleteMutation = useDeleteRuleView({
|
||||
mutation: {
|
||||
onSuccess: invalidate,
|
||||
onError: toastApiError('Failed to delete view.'),
|
||||
},
|
||||
});
|
||||
|
||||
const createView = useCallback(
|
||||
async (name: string, filter: RuleListFilter): Promise<RuleView | null> => {
|
||||
try {
|
||||
const res = await createMutation.mutateAsync({
|
||||
data: toPostable(name, filter),
|
||||
});
|
||||
return res?.data ? toRuleView(res.data) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[createMutation],
|
||||
);
|
||||
|
||||
const updateView = useCallback(
|
||||
(id: string, name: string, filter: RuleListFilter): void => {
|
||||
updateMutation.mutate({
|
||||
pathParams: { id },
|
||||
data: toPostable(name, filter),
|
||||
});
|
||||
},
|
||||
[updateMutation],
|
||||
);
|
||||
|
||||
const deleteView = useCallback(
|
||||
(id: string): Promise<void> =>
|
||||
deleteMutation.mutateAsync({ pathParams: { id } }).then(() => undefined),
|
||||
[deleteMutation],
|
||||
);
|
||||
|
||||
return { views, isLoading, createView, updateView, deleteView };
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
RuletypesListOrderDTO,
|
||||
RuletypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { sortByColumn } from 'components/Alerts/utils';
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
import { dataSourceForAlertType } from 'constants/alerts';
|
||||
|
||||
import type { AlertRule, RuleListFilter } from './types';
|
||||
import type { AlertRule } from './types';
|
||||
|
||||
export const ALERT_RULES_REFRESH_INTERVAL = 30_000;
|
||||
|
||||
@@ -26,7 +24,7 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
|
||||
export const alertActionLogEvent = (
|
||||
action: string,
|
||||
record: AlertRule,
|
||||
record: RuletypesRuleDTO,
|
||||
): void => {
|
||||
const actionValue = ACTION_LABELS[action] ?? action;
|
||||
void logEvent('Alert: Action', {
|
||||
@@ -37,75 +35,58 @@ export const alertActionLogEvent = (
|
||||
});
|
||||
};
|
||||
|
||||
const COLUMN_TO_SORT: Record<string, RuletypesListSortDTO> = {
|
||||
state: RuletypesListSortDTO.state,
|
||||
name: RuletypesListSortDTO.name,
|
||||
severity: RuletypesListSortDTO.severity,
|
||||
createdAt: RuletypesListSortDTO.created_at,
|
||||
updatedAt: RuletypesListSortDTO.updated_at,
|
||||
};
|
||||
export function getAlertSortValue(
|
||||
rule: AlertRule,
|
||||
columnName: string,
|
||||
): string | number {
|
||||
switch (columnName) {
|
||||
case 'state':
|
||||
return rule.state ?? '';
|
||||
case 'name':
|
||||
return rule.alert ?? '';
|
||||
case 'severity':
|
||||
return rule.labels?.severity ?? '';
|
||||
case 'createdAt':
|
||||
return rule.createdAt ? new Date(rule.createdAt).getTime() : 0;
|
||||
case 'updatedAt':
|
||||
return rule.updatedAt ? new Date(rule.updatedAt).getTime() : 0;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_RULE_LIST_SORT = RuletypesListSortDTO.updated_at;
|
||||
export const DEFAULT_RULE_LIST_ORDER = RuletypesListOrderDTO.desc;
|
||||
|
||||
// `rule_list_filter_invalid`: DSL syntax / key / operator; `rule_list_invalid`: other list params.
|
||||
export const RULE_LIST_INVALID_ERROR_CODES: readonly string[] = [
|
||||
'rule_list_filter_invalid',
|
||||
'rule_list_invalid',
|
||||
];
|
||||
|
||||
export function toListSort(
|
||||
export function sortRules(
|
||||
rules: AlertRule[],
|
||||
orderBy: SortState | null,
|
||||
): Pick<RuleListFilter, 'sort' | 'order'> {
|
||||
const sort = orderBy ? COLUMN_TO_SORT[orderBy.columnName] : undefined;
|
||||
if (!orderBy || !sort) {
|
||||
return { sort: DEFAULT_RULE_LIST_SORT, order: DEFAULT_RULE_LIST_ORDER };
|
||||
): AlertRule[] {
|
||||
return sortByColumn(rules, orderBy, getAlertSortValue);
|
||||
}
|
||||
|
||||
export function filterRulesByFilters(
|
||||
rules: AlertRule[],
|
||||
filters: string[],
|
||||
): AlertRule[] {
|
||||
if (filters.length === 0) {
|
||||
return rules;
|
||||
}
|
||||
return {
|
||||
sort,
|
||||
order:
|
||||
orderBy.order === 'asc'
|
||||
? RuletypesListOrderDTO.asc
|
||||
: RuletypesListOrderDTO.desc,
|
||||
};
|
||||
}
|
||||
|
||||
// The backend default sort maps to `null` so the table shows no sort indicator.
|
||||
export function toOrderBy(
|
||||
sort: RuletypesListSortDTO,
|
||||
order: RuletypesListOrderDTO,
|
||||
): SortState | null {
|
||||
if (sort === DEFAULT_RULE_LIST_SORT && order === DEFAULT_RULE_LIST_ORDER) {
|
||||
return null;
|
||||
}
|
||||
const columnName = Object.keys(COLUMN_TO_SORT).find(
|
||||
(key) => COLUMN_TO_SORT[key] === sort,
|
||||
);
|
||||
return columnName
|
||||
? { columnName, order: order === RuletypesListOrderDTO.asc ? 'asc' : 'desc' }
|
||||
: null;
|
||||
}
|
||||
const stateFilters = filters
|
||||
.filter((f) => f.startsWith('state:'))
|
||||
.map((f) => f.replace('state:', '').toLowerCase());
|
||||
|
||||
export function isSameRuleListFilter(
|
||||
a: RuleListFilter,
|
||||
b: RuleListFilter,
|
||||
): boolean {
|
||||
return (
|
||||
a.query.trim() === b.query.trim() &&
|
||||
a.sort === b.sort &&
|
||||
a.order === b.order &&
|
||||
a.states.length === b.states.length &&
|
||||
[...a.states].sort().join(',') === [...b.states].sort().join(',')
|
||||
);
|
||||
}
|
||||
const severityFilters = filters
|
||||
.filter((f) => f.startsWith('severity:'))
|
||||
.map((f) => f.replace('severity:', '').toLowerCase());
|
||||
|
||||
export const DEFAULT_RULE_LIST_FILTER: RuleListFilter = {
|
||||
query: '',
|
||||
states: [],
|
||||
sort: DEFAULT_RULE_LIST_SORT,
|
||||
order: DEFAULT_RULE_LIST_ORDER,
|
||||
};
|
||||
return rules.filter((rule) => {
|
||||
const state = rule.state?.toLowerCase() ?? '';
|
||||
const severity = rule.labels?.severity?.toLowerCase() ?? '';
|
||||
|
||||
export function isDefaultRuleListFilter(filter: RuleListFilter): boolean {
|
||||
return isSameRuleListFilter(filter, DEFAULT_RULE_LIST_FILTER);
|
||||
const matchesState =
|
||||
stateFilters.length === 0 || stateFilters.includes(state);
|
||||
const matchesSeverity =
|
||||
severityFilters.length === 0 || severityFilters.includes(severity);
|
||||
|
||||
return matchesState && matchesSeverity;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
OpenTraceSource,
|
||||
TracesExplorerEvents,
|
||||
} from 'container/TracesExplorer/events';
|
||||
import { getCurrentLocation } from 'lib/router/navigation';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
@@ -50,12 +44,6 @@ jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const ROW_TEST_ID = `traces-table-row-${listRows[0].data.span_id}`;
|
||||
|
||||
// The row click handler sits on each cell, so a click on the <tr> does nothing.
|
||||
const getRowCell = (row: HTMLElement): HTMLElement =>
|
||||
within(row).getAllByRole('cell')[0];
|
||||
|
||||
describe('Traces ListView - span drawer navigation', () => {
|
||||
beforeEach(() => {
|
||||
mockSuccess();
|
||||
@@ -146,63 +134,6 @@ describe('Traces ListView - span drawer navigation', () => {
|
||||
);
|
||||
expect(getCurrentLocation().pathname).toBe('/traces-explorer');
|
||||
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument();
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
TracesExplorerEvents.OpenTraceClicked,
|
||||
{
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
source: OpenTraceSource.RowIcon,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the trace page in a new tab from the drawer', async () => {
|
||||
const open = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||
const user = userEvent.setup();
|
||||
renderListView();
|
||||
|
||||
await user.click(getRowCell(await screen.findByTestId(ROW_TEST_ID)));
|
||||
expect(logEventMock).not.toHaveBeenCalledWith(
|
||||
TracesExplorerEvents.OpenTraceClicked,
|
||||
{ panelType: PANEL_TYPES.LIST, source: OpenTraceSource.Row },
|
||||
);
|
||||
await user.click(await screen.findByTestId('span-drawer-view-full-trace'));
|
||||
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/trace\/0000000000000000344ded1387b08a7e\?/),
|
||||
'_blank',
|
||||
);
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
TracesExplorerEvents.OpenTraceClicked,
|
||||
{
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
source: OpenTraceSource.SpanDrawer,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the trace page in a new tab on a Cmd-click without opening the drawer', async () => {
|
||||
const open = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||
const user = userEvent.setup();
|
||||
renderListView();
|
||||
|
||||
const cell = getRowCell(await screen.findByTestId(ROW_TEST_ID));
|
||||
await user.keyboard('{Meta>}');
|
||||
await user.click(cell);
|
||||
await user.keyboard('{/Meta}');
|
||||
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/trace\/0000000000000000344ded1387b08a7e\?/),
|
||||
'_blank',
|
||||
'noopener',
|
||||
);
|
||||
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument();
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
TracesExplorerEvents.OpenTraceClicked,
|
||||
{
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
source: OpenTraceSource.Row,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the row icon only on the hovered copy of a duplicate span', async () => {
|
||||
|
||||
@@ -146,9 +146,7 @@ function ListView({
|
||||
(field) => field.name !== TIMESTAMP_FIELD.name,
|
||||
),
|
||||
];
|
||||
const fieldColumns = fields.map((field) =>
|
||||
getFieldColumn(field, PANEL_TYPES.LIST),
|
||||
);
|
||||
const fieldColumns = fields.map((field) => getFieldColumn(field));
|
||||
return [openTraceColumn, ...fieldColumns];
|
||||
}, [options?.selectColumns, openTraceColumn]);
|
||||
|
||||
|
||||
@@ -2,12 +2,7 @@ import { MouseEvent } from 'react';
|
||||
import { CornerUpRight } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useIsRowHovered } from 'components/TanStackTableView/TanStackTableStateContext';
|
||||
import {
|
||||
logOpenTraceClicked,
|
||||
OpenTraceSource,
|
||||
} from 'container/TracesExplorer/events';
|
||||
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
@@ -28,7 +23,6 @@ function OpenTraceButton({
|
||||
const handleClick = (event: MouseEvent): void => {
|
||||
// The row click would open the drawer instead.
|
||||
event.stopPropagation();
|
||||
logOpenTraceClicked(PANEL_TYPES.LIST, OpenTraceSource.RowIcon);
|
||||
openInNewTab(getTraceLink(row));
|
||||
};
|
||||
|
||||
|
||||
@@ -4,12 +4,7 @@ import { ButtonGroup } from '@signozhq/ui/button-group';
|
||||
import { ChevronDown, ChevronUp, CornerUpRight } from '@signozhq/icons';
|
||||
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
|
||||
import { HeaderAction } from 'components/DetailsPanel';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { UseRowNavigationResult } from 'components/TanStackTableView/useRowNavigation';
|
||||
import {
|
||||
logOpenTraceClicked,
|
||||
OpenTraceSource,
|
||||
} from 'container/TracesExplorer/events';
|
||||
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { SpanDetailVariant } from 'pages/TraceDetailsV3/SpanDetailsPanel/constants';
|
||||
@@ -53,10 +48,7 @@ function SpanDrawer({
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<CornerUpRight />}
|
||||
onClick={(): void => {
|
||||
logOpenTraceClicked(PANEL_TYPES.LIST, OpenTraceSource.SpanDrawer);
|
||||
openInNewTab(getTraceLink(row));
|
||||
}}
|
||||
onClick={(): void => openInNewTab(getTraceLink(row))}
|
||||
testId="span-drawer-view-full-trace"
|
||||
>
|
||||
View full trace
|
||||
|
||||
@@ -4,12 +4,7 @@ import { Minus } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
logOpenTraceClicked,
|
||||
OpenTraceSource,
|
||||
} from 'container/TracesExplorer/events';
|
||||
import { getMs } from 'utils/timeUtils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
@@ -26,10 +21,9 @@ import styles from './FieldCell.module.scss';
|
||||
type FieldCellProps = {
|
||||
name: string;
|
||||
value: unknown;
|
||||
panelType: PANEL_TYPES;
|
||||
};
|
||||
|
||||
function FieldCell({ name, value, panelType }: FieldCellProps): JSX.Element {
|
||||
function FieldCell({ name, value }: FieldCellProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
if (TIMESTAMP_FIELD_NAMES.has(name)) {
|
||||
@@ -60,15 +54,7 @@ function FieldCell({ name, value, panelType }: FieldCellProps): JSX.Element {
|
||||
<AppLink
|
||||
to={buildRoutePath(ROUTES.TRACE_DETAIL, { id: text })}
|
||||
data-testid="trace-id"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
logOpenTraceClicked(panelType, OpenTraceSource.TraceId);
|
||||
}}
|
||||
onAuxClick={(e): void => {
|
||||
if (e.button === 1) {
|
||||
logOpenTraceClicked(panelType, OpenTraceSource.TraceId);
|
||||
}
|
||||
}}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
{text}
|
||||
</AppLink>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useRef } from 'react';
|
||||
import { useAppNavigate } from 'lib/router/useAppNavigate';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type {
|
||||
CellTypographySize,
|
||||
TableColumnDef,
|
||||
@@ -11,10 +10,6 @@ import { useRecoverFromEmptyPage } from 'components/TanStackTableView/useRecover
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
|
||||
import {
|
||||
logOpenTraceClicked,
|
||||
OpenTraceSource,
|
||||
} from 'container/TracesExplorer/events';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource, PanelTypeKeys } from 'types/common/queryBuilder';
|
||||
import { noop } from 'lodash-es';
|
||||
@@ -106,18 +101,16 @@ function TracesTable({
|
||||
onRowClick(row);
|
||||
return;
|
||||
}
|
||||
logOpenTraceClicked(PANEL_TYPES[panelType], OpenTraceSource.Row);
|
||||
navigate(getRowHref(row));
|
||||
},
|
||||
[onRowClick, navigate, getRowHref, panelType],
|
||||
[onRowClick, navigate, getRowHref],
|
||||
);
|
||||
|
||||
const handleRowClickNewTab = useCallback(
|
||||
(row: TracesTableRow): void => {
|
||||
logOpenTraceClicked(PANEL_TYPES[panelType], OpenTraceSource.Row);
|
||||
window.open(getAbsoluteUrl(getRowHref(row)), '_blank', 'noopener');
|
||||
},
|
||||
[getRowHref, panelType],
|
||||
[getRowHref],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,6 @@ import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import { TIMESTAMP_FIELD_NAMES } from './constants';
|
||||
import FieldCell from './FieldCell';
|
||||
@@ -11,7 +10,6 @@ export type TracesTableRow = { id: string } & Record<string, unknown>;
|
||||
|
||||
export function getFieldColumn(
|
||||
field: TelemetryFieldKey,
|
||||
panelType: PANEL_TYPES,
|
||||
): TableColumnDef<TracesTableRow> {
|
||||
const { name, fieldContext, fieldDataType } = field;
|
||||
const isTimestamp = TIMESTAMP_FIELD_NAMES.has(name);
|
||||
@@ -27,8 +25,6 @@ export function getFieldColumn(
|
||||
enableRemove: !isTimestamp,
|
||||
canBeHidden: !isTimestamp,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<FieldCell name={name} value={value} panelType={panelType} />
|
||||
),
|
||||
cell: ({ value }): JSX.Element => <FieldCell name={name} value={value} />,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
OpenTraceSource,
|
||||
TracesExplorerEvents,
|
||||
} from 'container/TracesExplorer/events';
|
||||
import { getCurrentLocation } from 'lib/router/navigation';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
groupedResponse,
|
||||
groupedRows,
|
||||
QUERY_RANGE_URL,
|
||||
renderTracesView,
|
||||
} from './testUtils';
|
||||
|
||||
const TRACE_ID = '0000000000000000344ded1387b08a7e';
|
||||
const ROW_TEST_ID = `traces-table-row-${TRACE_ID}`;
|
||||
|
||||
describe('TracesView - open trace clicked', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(groupedResponse(groupedRows))),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('logs a row click that opens the trace page', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTracesView();
|
||||
|
||||
const row = await screen.findByTestId(ROW_TEST_ID);
|
||||
// The row click handler sits on each cell, so a click on the <tr> does nothing.
|
||||
await user.click(within(row).getAllByRole('cell')[0]);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getCurrentLocation().pathname).toBe(`/trace/${TRACE_ID}`),
|
||||
);
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
TracesExplorerEvents.OpenTraceClicked,
|
||||
{
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
source: OpenTraceSource.Row,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('logs a trace_id link click', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTracesView();
|
||||
|
||||
const row = await screen.findByTestId(ROW_TEST_ID);
|
||||
await user.click(within(row).getByTestId('trace-id'));
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
TracesExplorerEvents.OpenTraceClicked,
|
||||
{
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
source: OpenTraceSource.TraceId,
|
||||
},
|
||||
);
|
||||
expect(logEventMock).not.toHaveBeenCalledWith(
|
||||
TracesExplorerEvents.OpenTraceClicked,
|
||||
{ panelType: PANEL_TYPES.TRACE, source: OpenTraceSource.Row },
|
||||
);
|
||||
});
|
||||
|
||||
it('logs a middle click on the trace_id link', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTracesView();
|
||||
|
||||
const row = await screen.findByTestId(ROW_TEST_ID);
|
||||
await user.pointer({
|
||||
keys: '[MouseMiddle]',
|
||||
target: within(row).getByTestId('trace-id'),
|
||||
});
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
TracesExplorerEvents.OpenTraceClicked,
|
||||
{
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
source: OpenTraceSource.TraceId,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import {
|
||||
getFieldColumn,
|
||||
@@ -16,7 +15,7 @@ const TRACE_FIELDS = [
|
||||
|
||||
export const columns: TableColumnDef<TracesTableRow>[] = TRACE_FIELDS.map(
|
||||
(field) => ({
|
||||
...getFieldColumn(field, PANEL_TYPES.TRACE),
|
||||
...getFieldColumn(field),
|
||||
enableRemove: false,
|
||||
canBeHidden: false,
|
||||
}),
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
export enum TracesExplorerEvents {
|
||||
OpenTraceClicked = 'Traces Explorer: Open trace clicked',
|
||||
}
|
||||
|
||||
export enum OpenTraceSource {
|
||||
Row = 'row',
|
||||
RowIcon = 'row_icon',
|
||||
TraceId = 'trace_id',
|
||||
SpanDrawer = 'span_drawer',
|
||||
}
|
||||
|
||||
export function logOpenTraceClicked(
|
||||
panelType: PANEL_TYPES,
|
||||
source: OpenTraceSource,
|
||||
): void {
|
||||
void logEvent(TracesExplorerEvents.OpenTraceClicked, { panelType, source });
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { act, renderHook, waitFor } from 'tests/test-utils';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import type {
|
||||
SavedListView,
|
||||
SavedListViewsApi,
|
||||
} from 'components/ListViews/types';
|
||||
|
||||
import { useListViews } from '../useListViews';
|
||||
import type { UseListViewsResult } from '../types';
|
||||
|
||||
interface Filter {
|
||||
query: string;
|
||||
sort: string;
|
||||
}
|
||||
|
||||
const BUILTIN_VIEWS = [
|
||||
{
|
||||
id: 'all',
|
||||
filter: (current: Filter): Filter => ({ ...current, query: '' }),
|
||||
},
|
||||
{
|
||||
id: 'locked',
|
||||
filter: (current: Filter): Filter => ({ ...current, query: 'locked' }),
|
||||
},
|
||||
];
|
||||
|
||||
const isEqual = (a: Filter, b: Filter): boolean =>
|
||||
a.query.trim() === b.query.trim();
|
||||
|
||||
const CUSTOM: SavedListView<Filter> = {
|
||||
id: 'v1',
|
||||
name: 'Prod',
|
||||
filter: { query: 'env = prod', sort: 'name' },
|
||||
};
|
||||
|
||||
function makeApi(): jest.Mocked<SavedListViewsApi<Filter>> {
|
||||
return {
|
||||
views: [CUSTOM],
|
||||
isLoading: false,
|
||||
createView: jest.fn(async (name: string, filter: Filter) => ({
|
||||
id: 'new',
|
||||
name,
|
||||
filter,
|
||||
})),
|
||||
updateView: jest.fn(),
|
||||
deleteView: jest.fn(async (_id: string): Promise<void> => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
interface Harness extends UseListViewsResult<Filter> {
|
||||
filter: Filter;
|
||||
setFilter: (f: Filter) => void;
|
||||
}
|
||||
|
||||
function setup(
|
||||
api: SavedListViewsApi<Filter>,
|
||||
searchParams = '',
|
||||
initial: Filter = { query: '', sort: 'updated' },
|
||||
): { result: { current: Harness } } {
|
||||
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
);
|
||||
return renderHook(
|
||||
(): Harness => {
|
||||
const [filter, setFilter] = useState<Filter>(initial);
|
||||
const views = useListViews<Filter>({
|
||||
filter,
|
||||
applyFilter: setFilter,
|
||||
isEqual,
|
||||
builtinViews: BUILTIN_VIEWS,
|
||||
ctx: undefined,
|
||||
defaultViewId: 'all',
|
||||
api,
|
||||
});
|
||||
return { ...views, filter, setFilter };
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
}
|
||||
|
||||
describe('useListViews', () => {
|
||||
it('defaults to the default view and is clean', () => {
|
||||
const { result } = setup(makeApi());
|
||||
expect(result.current.activeViewId).toBe('all');
|
||||
expect(result.current.isCustomActive).toBe(false);
|
||||
expect(result.current.isModified).toBe(false);
|
||||
});
|
||||
|
||||
it('selecting a builtin applies its filter, keeping the rest of the current filter', () => {
|
||||
const { result } = setup(makeApi());
|
||||
act(() => result.current.selectView('locked'));
|
||||
expect(result.current.activeViewId).toBe('locked');
|
||||
expect(result.current.filter).toStrictEqual({
|
||||
query: 'locked',
|
||||
sort: 'updated',
|
||||
});
|
||||
expect(result.current.isModified).toBe(false);
|
||||
});
|
||||
|
||||
it('selecting a custom view applies its stored filter', () => {
|
||||
const { result } = setup(makeApi());
|
||||
act(() => result.current.selectView('v1'));
|
||||
expect(result.current.isCustomActive).toBe(true);
|
||||
expect(result.current.activeView).toBe(CUSTOM);
|
||||
expect(result.current.filter).toStrictEqual(CUSTOM.filter);
|
||||
});
|
||||
|
||||
it('detects divergence via isEqual and resets to the canonical filter', () => {
|
||||
const { result } = setup(makeApi(), '?view=v1', CUSTOM.filter);
|
||||
act(() => result.current.setFilter({ query: 'env = prod ', sort: 'x' }));
|
||||
expect(result.current.isModified).toBe(false);
|
||||
act(() => result.current.setFilter({ query: 'env = dev', sort: 'name' }));
|
||||
expect(result.current.isModified).toBe(true);
|
||||
act(() => result.current.resetView());
|
||||
expect(result.current.filter).toStrictEqual(CUSTOM.filter);
|
||||
expect(result.current.isModified).toBe(false);
|
||||
});
|
||||
|
||||
it('saveView creates the view from the current filter and selects it', async () => {
|
||||
const api = makeApi();
|
||||
const { result } = setup(api, '', { query: 'a', sort: 'name' });
|
||||
act(() => result.current.saveView('Mine'));
|
||||
expect(api.createView).toHaveBeenCalledWith('Mine', {
|
||||
query: 'a',
|
||||
sort: 'name',
|
||||
});
|
||||
await waitFor(() => expect(result.current.activeViewId).toBe('new'));
|
||||
});
|
||||
|
||||
it('saveActiveView updates the active custom view with the current filter', () => {
|
||||
const api = makeApi();
|
||||
const { result } = setup(api, '?view=v1', CUSTOM.filter);
|
||||
act(() => result.current.setFilter({ query: 'env = dev', sort: 'name' }));
|
||||
act(() => result.current.saveActiveView());
|
||||
expect(api.updateView).toHaveBeenCalledWith('v1', 'Prod', {
|
||||
query: 'env = dev',
|
||||
sort: 'name',
|
||||
});
|
||||
});
|
||||
|
||||
it('saveActiveView is a no-op on a builtin view', () => {
|
||||
const api = makeApi();
|
||||
const { result } = setup(api);
|
||||
act(() => result.current.saveActiveView());
|
||||
expect(api.updateView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renameView keeps the stored filter', () => {
|
||||
const api = makeApi();
|
||||
const { result } = setup(api, '', { query: 'other', sort: 'x' });
|
||||
act(() => result.current.renameView('v1', 'Renamed'));
|
||||
expect(api.updateView).toHaveBeenCalledWith('v1', 'Renamed', CUSTOM.filter);
|
||||
});
|
||||
|
||||
it('removing the active view falls back to the default view', async () => {
|
||||
const api = makeApi();
|
||||
const { result } = setup(api, '?view=v1', CUSTOM.filter);
|
||||
await act(() => result.current.removeView('v1'));
|
||||
expect(api.deleteView).toHaveBeenCalledWith('v1');
|
||||
expect(result.current.activeViewId).toBe('all');
|
||||
expect(result.current.filter).toStrictEqual({ query: '', sort: 'name' });
|
||||
});
|
||||
|
||||
it('removing an inactive view leaves the selection alone', async () => {
|
||||
const api = makeApi();
|
||||
const { result } = setup(api, '?view=locked', { query: 'locked', sort: 'x' });
|
||||
await act(() => result.current.removeView('v1'));
|
||||
expect(result.current.activeViewId).toBe('locked');
|
||||
expect(result.current.filter.query).toBe('locked');
|
||||
});
|
||||
|
||||
it('swallows delete failures without changing the selection', async () => {
|
||||
const api = makeApi();
|
||||
api.deleteView.mockRejectedValueOnce(new Error('boom'));
|
||||
const { result } = setup(api, '?view=v1', CUSTOM.filter);
|
||||
await act(() => result.current.removeView('v1'));
|
||||
expect(result.current.activeViewId).toBe('v1');
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import type {
|
||||
BuiltinViewConfig,
|
||||
SavedListView,
|
||||
SavedListViewsApi,
|
||||
} from 'components/ListViews/types';
|
||||
|
||||
export interface UseListViewsArgs<F, Ctx> {
|
||||
/** The currently applied filter. */
|
||||
filter: F;
|
||||
applyFilter: (next: F) => void;
|
||||
isEqual: (a: F, b: F) => boolean;
|
||||
builtinViews: readonly Pick<BuiltinViewConfig<F, Ctx>, 'id' | 'filter'>[];
|
||||
/** Passed to each built-in view's `filter`. */
|
||||
ctx: Ctx;
|
||||
defaultViewId: string;
|
||||
api: SavedListViewsApi<F>;
|
||||
/** URL search param holding the active view id. */
|
||||
urlParam?: string;
|
||||
}
|
||||
|
||||
export interface UseListViewsResult<F> {
|
||||
activeViewId: string;
|
||||
activeView: SavedListView<F> | undefined;
|
||||
customViews: SavedListView<F>[];
|
||||
isCustomActive: boolean;
|
||||
/** The current filter diverges from the active view's canonical filter. */
|
||||
isModified: boolean;
|
||||
selectView: (id: string) => void;
|
||||
saveView: (name: string) => void;
|
||||
saveActiveView: () => void;
|
||||
resetView: () => void;
|
||||
removeView: (id: string) => Promise<void>;
|
||||
renameView: (id: string, name: string) => void;
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
|
||||
import type { UseListViewsArgs, UseListViewsResult } from './types';
|
||||
|
||||
// Orchestrates a list page's active view: which view is selected (URL param),
|
||||
// applying a view's filter on select, dirty detection, and save/reset/delete
|
||||
// via the page's saved-views API.
|
||||
export function useListViews<F, Ctx = undefined>({
|
||||
filter,
|
||||
applyFilter,
|
||||
isEqual,
|
||||
builtinViews,
|
||||
ctx,
|
||||
defaultViewId,
|
||||
api,
|
||||
urlParam = 'view',
|
||||
}: UseListViewsArgs<F, Ctx>): UseListViewsResult<F> {
|
||||
const [activeViewId, setActiveViewId] = useQueryState(
|
||||
urlParam,
|
||||
parseAsString.withDefault(defaultViewId).withOptions({ history: 'push' }),
|
||||
);
|
||||
|
||||
const builtinFilter = useCallback(
|
||||
(id: string, current: F): F | null =>
|
||||
builtinViews.find((v) => v.id === id)?.filter(current, ctx) ?? null,
|
||||
[builtinViews, ctx],
|
||||
);
|
||||
|
||||
const { views: customViews, createView, updateView, deleteView } = api;
|
||||
|
||||
const activeView = useMemo(
|
||||
() => customViews.find((v) => v.id === activeViewId),
|
||||
[customViews, activeViewId],
|
||||
);
|
||||
|
||||
// The filter the active view "is" — used to detect divergence.
|
||||
const canonicalFilter = useMemo<F | null>(
|
||||
() => (activeView ? activeView.filter : builtinFilter(activeViewId, filter)),
|
||||
[activeView, builtinFilter, activeViewId, filter],
|
||||
);
|
||||
|
||||
const isModified =
|
||||
canonicalFilter !== null && !isEqual(filter, canonicalFilter);
|
||||
|
||||
const selectView = useCallback(
|
||||
(id: string): void => {
|
||||
void setActiveViewId(id);
|
||||
const custom = customViews.find((v) => v.id === id);
|
||||
const next = custom
|
||||
? custom.filter
|
||||
: (builtinFilter(id, filter) ?? builtinFilter(defaultViewId, filter));
|
||||
if (next !== null) {
|
||||
applyFilter(next);
|
||||
}
|
||||
},
|
||||
[
|
||||
setActiveViewId,
|
||||
customViews,
|
||||
builtinFilter,
|
||||
filter,
|
||||
defaultViewId,
|
||||
applyFilter,
|
||||
],
|
||||
);
|
||||
|
||||
const saveView = useCallback(
|
||||
(name: string): void => {
|
||||
void (async (): Promise<void> => {
|
||||
const created = await createView(name, filter);
|
||||
if (created) {
|
||||
void setActiveViewId(created.id);
|
||||
}
|
||||
})();
|
||||
},
|
||||
[createView, filter, setActiveViewId],
|
||||
);
|
||||
|
||||
const saveActiveView = useCallback((): void => {
|
||||
if (!activeView) {
|
||||
return;
|
||||
}
|
||||
updateView(activeView.id, activeView.name, filter);
|
||||
}, [activeView, updateView, filter]);
|
||||
|
||||
const resetView = useCallback((): void => {
|
||||
if (canonicalFilter === null) {
|
||||
return;
|
||||
}
|
||||
applyFilter(canonicalFilter);
|
||||
}, [canonicalFilter, applyFilter]);
|
||||
|
||||
const removeView = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
try {
|
||||
await deleteView(id);
|
||||
} catch {
|
||||
// Failure is surfaced by the delete mutation's error toast; still
|
||||
// settle so the confirm modal stops loading and closes.
|
||||
return;
|
||||
}
|
||||
if (activeViewId === id) {
|
||||
void setActiveViewId(defaultViewId);
|
||||
const next = builtinFilter(defaultViewId, filter);
|
||||
if (next !== null) {
|
||||
applyFilter(next);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
deleteView,
|
||||
activeViewId,
|
||||
setActiveViewId,
|
||||
defaultViewId,
|
||||
builtinFilter,
|
||||
filter,
|
||||
applyFilter,
|
||||
],
|
||||
);
|
||||
|
||||
// Rename only touches the view's name; its stored filter is preserved.
|
||||
const renameView = useCallback(
|
||||
(id: string, name: string): void => {
|
||||
const view = customViews.find((v) => v.id === id);
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
updateView(id, name, view.filter);
|
||||
},
|
||||
[customViews, updateView],
|
||||
);
|
||||
|
||||
return {
|
||||
activeViewId,
|
||||
activeView,
|
||||
customViews,
|
||||
isCustomActive: !!activeView,
|
||||
isModified,
|
||||
selectView,
|
||||
saveView,
|
||||
saveActiveView,
|
||||
resetView,
|
||||
removeView,
|
||||
renameView,
|
||||
};
|
||||
}
|
||||
@@ -25,7 +25,6 @@ export const useHandleExplorerTabChange = (): {
|
||||
querySearchParameters?: ICurrentQueryData,
|
||||
redirectToUrl?: (typeof ROUTES)[keyof typeof ROUTES],
|
||||
newTab?: boolean,
|
||||
searchParams?: Record<string, unknown>,
|
||||
) => void;
|
||||
} => {
|
||||
const {
|
||||
@@ -70,7 +69,6 @@ export const useHandleExplorerTabChange = (): {
|
||||
currentQueryData?: ICurrentQueryData,
|
||||
redirectToUrl?: (typeof ROUTES)[keyof typeof ROUTES],
|
||||
newTab?: boolean,
|
||||
searchParams?: Record<string, unknown>,
|
||||
) => {
|
||||
const newPanelType = type as PANEL_TYPES;
|
||||
|
||||
@@ -84,7 +82,6 @@ export const useHandleExplorerTabChange = (): {
|
||||
redirectWithQueryBuilderData(
|
||||
query,
|
||||
{
|
||||
...searchParams,
|
||||
[QueryParams.panelTypes]: newPanelType,
|
||||
...(nextViewKey ? { [QueryParams.viewKey]: nextViewKey } : {}),
|
||||
},
|
||||
|
||||
@@ -2,18 +2,14 @@ import {
|
||||
RuletypesAlertStateDTO,
|
||||
RuletypesAlertTypeDTO,
|
||||
RuletypesEvaluationRollingDTOKind,
|
||||
RuletypesListOrderDTO,
|
||||
RuletypesListSortDTO,
|
||||
RuletypesPanelTypeDTO,
|
||||
RuletypesQueryTypeDTO,
|
||||
RuletypesRuleDTOSchemaVersion,
|
||||
RuletypesRuleTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
ListRulesV3200,
|
||||
RuletypesRuleConditionDTO,
|
||||
RuletypesRuleDTO,
|
||||
RuletypesRuleViewDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const baseCondition: RuletypesRuleConditionDTO = {
|
||||
@@ -95,80 +91,3 @@ export const alertRulesPaginationFixture: RuletypesRuleDTO[] = Array.from(
|
||||
createdAt: `2023-10-${String(i + 1).padStart(2, '0')}T10:00:00Z`,
|
||||
}),
|
||||
);
|
||||
|
||||
const RULES_RESERVED_KEYWORDS = [
|
||||
'name',
|
||||
'severity',
|
||||
'alert_type',
|
||||
'rule_type',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
const uniqueLabelPairs = (
|
||||
rules: RuletypesRuleDTO[],
|
||||
): { key: string; value: string }[] => {
|
||||
const seen = new Map<string, { key: string; value: string }>();
|
||||
rules.forEach((r) =>
|
||||
Object.entries(r.labels ?? {}).forEach(([key, value]) => {
|
||||
seen.set(`${key}=${value}`, { key, value: String(value) });
|
||||
}),
|
||||
);
|
||||
return [...seen.values()];
|
||||
};
|
||||
|
||||
// Mirrors /api/v3/rules: filters by `states` and pages by `limit`/`offset`.
|
||||
// The `query` DSL is not evaluated; tests assert on the request instead.
|
||||
export const listRulesV3Response = (
|
||||
rules: RuletypesRuleDTO[],
|
||||
searchParams: URLSearchParams,
|
||||
): ListRulesV3200 => {
|
||||
const states = searchParams.getAll('states');
|
||||
const limit = Number(searchParams.get('limit') ?? rules.length);
|
||||
const offset = Number(searchParams.get('offset') ?? 0);
|
||||
const matching = states.length
|
||||
? rules.filter((r) => states.includes(r.state))
|
||||
: rules;
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
rules: matching.slice(offset, offset + limit),
|
||||
total: matching.length,
|
||||
labels: uniqueLabelPairs(rules),
|
||||
reservedKeywords: RULES_RESERVED_KEYWORDS,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const ruleViewsFixture: RuletypesRuleViewDTO[] = [
|
||||
{
|
||||
id: 'view-1',
|
||||
name: 'Critical firing',
|
||||
orgId: 'org-1',
|
||||
createdAt: '2023-10-15T10:00:00Z',
|
||||
updatedAt: '2023-10-19T10:00:00Z',
|
||||
data: {
|
||||
version: 'v1',
|
||||
query: "severity = 'critical'",
|
||||
states: [RuletypesAlertStateDTO.firing],
|
||||
sort: RuletypesListSortDTO.name,
|
||||
order: RuletypesListOrderDTO.asc,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'view-2',
|
||||
name: 'Infra team',
|
||||
orgId: 'org-1',
|
||||
createdAt: '2023-10-15T10:00:00Z',
|
||||
updatedAt: '2023-10-18T10:00:00Z',
|
||||
data: {
|
||||
version: 'v1',
|
||||
query: "team = 'infra'",
|
||||
states: [],
|
||||
sort: RuletypesListSortDTO.updated_at,
|
||||
order: RuletypesListOrderDTO.desc,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -6,11 +6,7 @@ import {
|
||||
notificationChannelsV2,
|
||||
notificationChannelV2,
|
||||
} from './__mockdata__/alerts';
|
||||
import {
|
||||
alertRulesFixture,
|
||||
listRulesV3Response,
|
||||
ruleViewsFixture,
|
||||
} from './__mockdata__/alert_rules';
|
||||
import { alertRulesFixture } from './__mockdata__/alert_rules';
|
||||
import { triggeredAlertsFixture } from './__mockdata__/triggered_alerts';
|
||||
import { billingSuccessResponse } from './__mockdata__/billing';
|
||||
import {
|
||||
@@ -232,47 +228,6 @@ export const handlers = [
|
||||
ctx.json({ data: alertRulesFixture, status: 'success' }),
|
||||
),
|
||||
),
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(listRulesV3Response(alertRulesFixture, req.url.searchParams)),
|
||||
),
|
||||
),
|
||||
rest.get('http://localhost/api/v2/rule_views', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: { views: ruleViewsFixture }, status: 'success' }),
|
||||
),
|
||||
),
|
||||
rest.post('http://localhost/api/v2/rule_views', async (req, res, ctx) => {
|
||||
const body = (await req.json()) as Pick<
|
||||
(typeof ruleViewsFixture)[number],
|
||||
'name' | 'data'
|
||||
>;
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json({
|
||||
data: { ...ruleViewsFixture[0], ...body, id: 'new-view-id' },
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
}),
|
||||
rest.put('http://localhost/api/v2/rule_views/:id', async (req, res, ctx) => {
|
||||
const body = (await req.json()) as Pick<
|
||||
(typeof ruleViewsFixture)[number],
|
||||
'name' | 'data'
|
||||
>;
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: { ...ruleViewsFixture[0], ...body, id: req.params.id },
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
}),
|
||||
rest.delete('http://localhost/api/v2/rule_views/:id', (_, res, ctx) =>
|
||||
res(ctx.status(204)),
|
||||
),
|
||||
rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => {
|
||||
const body = (await req.json()) as { alert?: string };
|
||||
return res(
|
||||
|
||||
@@ -6,24 +6,9 @@
|
||||
import { rest, type ResponseComposition, type RestContext } from 'msw';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
countControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { useAlertRuleViewsStore } from 'container/ListAlertRules/useAlertRuleViewsStore';
|
||||
import { DEFAULT_RULE_LIST_FILTER } from 'container/ListAlertRules/utils';
|
||||
import {
|
||||
DEFAULT_RULE_VIEW,
|
||||
RULE_BUILTIN_VIEWS,
|
||||
type RuleViewId,
|
||||
} from 'container/ListAlertRules/list.config';
|
||||
|
||||
import {
|
||||
orgUsersResponse,
|
||||
STORY_USER_EMAIL,
|
||||
} from '../../../DashboardsListPage/stories/__story_mockdata__/orgUsers';
|
||||
import {
|
||||
alertRulesResponse,
|
||||
RULE_MAX,
|
||||
@@ -33,60 +18,13 @@ import {
|
||||
type SeverityChoice,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
import { AlertListTabs } from '../../types';
|
||||
import {
|
||||
alertRulesPage,
|
||||
INVALID_QUERY_CHOICES,
|
||||
invalidRuleQueryError,
|
||||
type InvalidQueryChoice,
|
||||
RULE_VIEW_MAX,
|
||||
ruleView,
|
||||
ruleViewsResponse,
|
||||
} from './__story_mockdata__/ruleViews';
|
||||
|
||||
const LIST = 'Alert rules · list';
|
||||
const ACTIONS = 'Alert rules · row actions';
|
||||
const VIEWS = 'Alert rules · saved views';
|
||||
|
||||
const ACTION_STATES = ['success', 'loading', 'error'] as const;
|
||||
type ActionState = (typeof ACTION_STATES)[number];
|
||||
|
||||
const VIEW_CHOICES = [
|
||||
'all',
|
||||
'mine',
|
||||
'firing',
|
||||
'disabled',
|
||||
'saved',
|
||||
] as const satisfies readonly (RuleViewId | 'saved')[];
|
||||
type ViewChoice = (typeof VIEW_CHOICES)[number];
|
||||
|
||||
/**
|
||||
* The rail applies a view by writing `view` with its `query` and `states`, so a
|
||||
* story that opens on one seeds all three or the rail shows unsaved changes.
|
||||
*/
|
||||
const listRoute = (view: ViewChoice): string => {
|
||||
const params = new URLSearchParams({ tab: AlertListTabs.ALERT_RULES });
|
||||
if (view === DEFAULT_RULE_VIEW) {
|
||||
return `/alerts?${params.toString()}`;
|
||||
}
|
||||
|
||||
const saved = ruleView(0);
|
||||
const filter =
|
||||
view === 'saved'
|
||||
? { query: saved.data.query ?? '', states: saved.data.states ?? [] }
|
||||
: RULE_BUILTIN_VIEWS.find((v) => v.id === view)?.filter(
|
||||
DEFAULT_RULE_LIST_FILTER,
|
||||
{ userEmail: STORY_USER_EMAIL },
|
||||
);
|
||||
|
||||
params.set('view', view === 'saved' ? saved.id : view);
|
||||
if (filter?.query) {
|
||||
params.set('query', filter.query);
|
||||
}
|
||||
filter?.states.forEach((state) => params.append('states', state));
|
||||
|
||||
return `/alerts?${params.toString()}`;
|
||||
};
|
||||
|
||||
const actionDescription = (call: string): string =>
|
||||
`How the ${call} behind the row action answers. \`loading\` never answers and keeps the promise toast pending, \`error\` settles it with the failure.`;
|
||||
|
||||
@@ -132,44 +70,6 @@ export const alertRulesMocks = defineStoryMocks({
|
||||
options: RULE_STATE_CHOICES,
|
||||
value: 'mixed',
|
||||
}),
|
||||
invalidQuery: choiceControl<InvalidQueryChoice>('Invalid query', {
|
||||
group: LIST,
|
||||
description:
|
||||
'The 400 code any request carrying a `query` answers with. Both show the server message under the search box.',
|
||||
options: INVALID_QUERY_CHOICES,
|
||||
value: 'off',
|
||||
}),
|
||||
view: choiceControl<ViewChoice>('Active view', {
|
||||
group: VIEWS,
|
||||
description:
|
||||
'The rail entry the page opens on. `saved` is the first saved view; the built-ins apply a query or a state set.',
|
||||
options: VIEW_CHOICES,
|
||||
value: DEFAULT_RULE_VIEW,
|
||||
}),
|
||||
railCollapsed: toggleControl('Rail collapsed', {
|
||||
group: VIEWS,
|
||||
description:
|
||||
'The rail collapsed from the status bar, as the browser remembers it.',
|
||||
value: false,
|
||||
}),
|
||||
ruleViews: countControl('Saved views', {
|
||||
group: VIEWS,
|
||||
value: 3,
|
||||
max: RULE_VIEW_MAX,
|
||||
}),
|
||||
ruleViewsState: choiceControl<ActionState>('Views list', {
|
||||
group: VIEWS,
|
||||
description:
|
||||
'How the views list behind the rail answers, independent of the rule list.',
|
||||
options: ACTION_STATES,
|
||||
value: 'success',
|
||||
}),
|
||||
saveViewState: choiceControl<ActionState>('Save, rename or delete', {
|
||||
group: VIEWS,
|
||||
description: `${actionDescription('POST, PUT or DELETE')} The list does not change after a write: it answers with the Saved views count.`,
|
||||
options: ACTION_STATES,
|
||||
value: 'success',
|
||||
}),
|
||||
toggleState: choiceControl<ActionState>('Enable or disable', {
|
||||
group: ACTIONS,
|
||||
description: actionDescription('PATCH'),
|
||||
@@ -190,51 +90,16 @@ export const alertRulesMocks = defineStoryMocks({
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) =>
|
||||
values.invalidQuery !== 'off' && req.url.searchParams.get('query')
|
||||
? res(ctx.status(400), ctx.json(invalidRuleQueryError(values.invalidQuery)))
|
||||
: response.json(() =>
|
||||
alertRulesPage(
|
||||
values.rules,
|
||||
{ severity: values.ruleSeverity, state: values.ruleState },
|
||||
req.url.searchParams,
|
||||
),
|
||||
)(req, res, ctx),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/users',
|
||||
response.json(() => orgUsersResponse()),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v2/rule_views', (_req, res, ctx) =>
|
||||
actionResponse(values.ruleViewsState, res, ctx, () =>
|
||||
ruleViewsResponse(values.ruleViews),
|
||||
'http://localhost/api/v2/rules',
|
||||
response.json(() =>
|
||||
alertRulesResponse(values.rules, {
|
||||
severity: values.ruleSeverity,
|
||||
state: values.ruleState,
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v2/rule_views', async (req, res, ctx) => {
|
||||
const body = (await req.json()) as object;
|
||||
return actionResponse(values.saveViewState, res, ctx, () => ({
|
||||
status: 'success',
|
||||
data: { ...ruleView(values.ruleViews), ...body },
|
||||
}));
|
||||
}),
|
||||
|
||||
rest.put('http://localhost/api/v2/rule_views/:id', async (req, res, ctx) => {
|
||||
const body = (await req.json()) as object;
|
||||
return actionResponse(values.saveViewState, res, ctx, () => ({
|
||||
status: 'success',
|
||||
data: { ...ruleView(0), ...body, id: req.params.id },
|
||||
}));
|
||||
}),
|
||||
|
||||
rest.delete('http://localhost/api/v2/rule_views/:id', (_req, res, ctx) =>
|
||||
values.saveViewState === 'success'
|
||||
? res(ctx.status(204))
|
||||
: actionResponse(values.saveViewState, res, ctx, () => null),
|
||||
),
|
||||
|
||||
rest.patch('http://localhost/api/v2/rules/:id', (_req, res, ctx) =>
|
||||
actionResponse(values.toggleState, res, ctx, () => ({
|
||||
status: 'success',
|
||||
@@ -259,10 +124,7 @@ export const alertRulesMocks = defineStoryMocks({
|
||||
})),
|
||||
),
|
||||
],
|
||||
config: (values) => ({ route: listRoute(values.view) }),
|
||||
effect: (values) => {
|
||||
useAlertRuleViewsStore.setState({ railCollapsed: values.railCollapsed });
|
||||
},
|
||||
config: () => ({ route: `/alerts?tab=${AlertListTabs.ALERT_RULES}` }),
|
||||
});
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { alertRulesMocks } from './AlertRules.stories.mocks';
|
||||
import {
|
||||
cloneAlert,
|
||||
copyLabel,
|
||||
deleteAlert,
|
||||
openColumnPicker,
|
||||
openDeleteViewConfirm,
|
||||
openDisabledRuleActions,
|
||||
openQuerySuggestions,
|
||||
openRenameViewPopover,
|
||||
openRowActions,
|
||||
openSaveViewPopover,
|
||||
toggleAlert,
|
||||
} from './__story_mockdata__/alertRules.play';
|
||||
alertRulesMocks,
|
||||
runRowAction,
|
||||
untilLoaded,
|
||||
} from './AlertRules.stories.mocks';
|
||||
|
||||
import AlertList from '../../index';
|
||||
import { RULE_MAX } from '../../stories/__story_mockdata__/alerts';
|
||||
@@ -26,10 +18,8 @@ type AlertRulesArgs = PageStoryArgs<typeof alertRulesMocks>;
|
||||
const pageStory = storyMocks(alertRulesMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The rule list tab: the views rail beside every rule with its severity, state
|
||||
* and channels, filtered server-side by a DSL query, a state set and the table
|
||||
* sort, which org-shared saved views capture and restore. Creating and editing
|
||||
* follow the legacy editor role.
|
||||
* The rule list tab: every rule with its severity, state and channels. Creating
|
||||
* and editing follow the legacy editor role.
|
||||
*
|
||||
* Route: `/alerts?tab=AlertRules`.
|
||||
*/
|
||||
@@ -56,84 +46,13 @@ export const NoRules: Story = {
|
||||
args: { rules: 0 },
|
||||
};
|
||||
|
||||
/** A state filter no rule is in: the filters stay and the no-results branch renders. */
|
||||
export const NoMatchingRules: Story = {
|
||||
/** Search: an unmatched query retains the filters and renders the no-results branch. */
|
||||
export const SearchNoResults: Story = {
|
||||
parameters: {
|
||||
signoz: { route: '/alerts?tab=AlertRules&states=recovering' },
|
||||
signoz: { route: '/alerts?tab=AlertRules&search=no-matching-alert-rule' },
|
||||
},
|
||||
};
|
||||
|
||||
/** The query the list was asked for does not parse: the server's message sits under the search box. */
|
||||
export const InvalidQuery: Story = {
|
||||
args: { invalidQuery: 'rule_list_filter_invalid' },
|
||||
parameters: {
|
||||
allowConsoleErrors: true,
|
||||
signoz: {
|
||||
route: "/alerts?tab=AlertRules&query=name%20CONTAINS%20'prod'%20AND",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The query box's suggestions, opened: the rule fields first, then every
|
||||
* `labels.<key>` the org's rules carry.
|
||||
*/
|
||||
export const QuerySuggestions: Story = {
|
||||
play: openQuerySuggestions,
|
||||
};
|
||||
|
||||
/** A workspace with no saved view yet, where the rail says how to make one. */
|
||||
export const NoSavedViews: Story = {
|
||||
args: { ruleViews: 0 },
|
||||
};
|
||||
|
||||
/** A shared link carrying only the view id: the view's query and states are applied once the views load. */
|
||||
export const ViewApplied: Story = {
|
||||
parameters: {
|
||||
signoz: { route: '/alerts?tab=AlertRules&view=view-1' },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The filters changed after the view was applied: the dirty dot on the rail
|
||||
* entry and the header, and Save, Save as and Reset at the foot of the rail.
|
||||
*/
|
||||
export const ViewModified: Story = {
|
||||
parameters: {
|
||||
signoz: {
|
||||
route:
|
||||
'/alerts?tab=AlertRules&view=view-2&query=team%20%3D%20%27infra%27&states=firing',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Filters set with no saved view selected: the rail offers to save them as a new one. */
|
||||
export const UnsavedFilters: Story = {
|
||||
parameters: {
|
||||
signoz: { route: '/alerts?tab=AlertRules&states=firing' },
|
||||
},
|
||||
};
|
||||
|
||||
/** The rail folded away from the status bar, leaving the list the full width. */
|
||||
export const RailCollapsed: Story = {
|
||||
args: { railCollapsed: true },
|
||||
};
|
||||
|
||||
/** The popover that names the current filters as a new saved view. */
|
||||
export const SaveViewPopover: Story = {
|
||||
play: openSaveViewPopover,
|
||||
};
|
||||
|
||||
/** A saved view's rename popover, opened from its row. */
|
||||
export const RenameViewPopover: Story = {
|
||||
play: openRenameViewPopover,
|
||||
};
|
||||
|
||||
/** Deleting a saved view asks first, because every teammate loses it too. */
|
||||
export const DeleteViewConfirm: Story = {
|
||||
play: openDeleteViewConfirm,
|
||||
};
|
||||
|
||||
/** Data: the list remains mounted while its initial request is pending. */
|
||||
export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
@@ -165,18 +84,45 @@ export const Viewer: Story = {
|
||||
|
||||
/** The per-rule actions: enable or disable, edit, clone and delete. */
|
||||
export const RowActions: Story = {
|
||||
play: openRowActions,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const [actions] = await within(canvasElement).findAllByTestId(
|
||||
'alert-actions',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(actions);
|
||||
await screen.findByText(/clone/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** Interaction: a disabled rule exposes Enable in its real row-action menu. */
|
||||
export const RowActionsDisabledRule: Story = {
|
||||
args: { ruleState: 'disabled' },
|
||||
play: openDisabledRuleActions,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const [actions] = await within(canvasElement).findAllByTestId(
|
||||
'alert-actions',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(actions);
|
||||
await screen.findByText(/^enable$/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** The columns the table can show, including the audit ones it hides by default. */
|
||||
export const ColumnPicker: Story = {
|
||||
play: openColumnPicker,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'alert-columns-button',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText(/toggle columns/i);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -202,60 +148,92 @@ export const BottomStripPaginated: Story = {
|
||||
|
||||
/** Enable or Disable from the row menu: the promise toast, pending then settled. */
|
||||
export const ToggleAlertToast: Story = {
|
||||
play: toggleAlert,
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await runRowAction(canvasElement, /^(enable|disable)$/i);
|
||||
if (args.toggleState === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/alert (enabled|disabled)/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** The PATCH never answers: the "Enabling or Disabling alert..." toast stays pending. */
|
||||
export const ToggleAlertLoadingToast: Story = {
|
||||
args: { toggleState: 'loading' },
|
||||
play: toggleAlert,
|
||||
play: ToggleAlertToast.play,
|
||||
};
|
||||
|
||||
/** The PATCH failed: the promise toast settles with the server message. */
|
||||
export const ToggleAlertErrorToast: Story = {
|
||||
args: { toggleState: 'error' },
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: toggleAlert,
|
||||
play: ToggleAlertToast.play,
|
||||
};
|
||||
|
||||
/** Clone from the row menu. */
|
||||
export const CloneAlertToast: Story = {
|
||||
play: cloneAlert,
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await runRowAction(canvasElement, /^clone$/i);
|
||||
if (args.cloneState === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/alert cloned successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** The POST never answers: "Cloning alert..." stays pending. */
|
||||
export const CloneAlertLoadingToast: Story = {
|
||||
args: { cloneState: 'loading' },
|
||||
play: cloneAlert,
|
||||
play: CloneAlertToast.play,
|
||||
};
|
||||
|
||||
/** The POST failed. */
|
||||
export const CloneAlertErrorToast: Story = {
|
||||
args: { cloneState: 'error' },
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: cloneAlert,
|
||||
play: CloneAlertToast.play,
|
||||
};
|
||||
|
||||
/** Delete from the row menu. */
|
||||
export const DeleteAlertToast: Story = {
|
||||
play: deleteAlert,
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await runRowAction(canvasElement, /^delete$/i);
|
||||
if (args.deleteState === 'success') {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/alert deleted successfully/i)).toBeVisible(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** The DELETE never answers: "Deleting alert..." stays pending. */
|
||||
export const DeleteAlertLoadingToast: Story = {
|
||||
args: { deleteState: 'loading' },
|
||||
play: deleteAlert,
|
||||
play: DeleteAlertToast.play,
|
||||
};
|
||||
|
||||
/** The DELETE failed. */
|
||||
export const DeleteAlertErrorToast: Story = {
|
||||
args: { deleteState: 'error' },
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: deleteAlert,
|
||||
play: DeleteAlertToast.play,
|
||||
};
|
||||
|
||||
/** The copy button in a label badge's tooltip, clicked. */
|
||||
export const LabelCopiedToast: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: copyLabel,
|
||||
play: async (): Promise<void> => {
|
||||
const [copy] = await screen.findAllByRole(
|
||||
'button',
|
||||
{ name: 'Copy to clipboard' },
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(copy);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { expect, screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
alertRulesMocks,
|
||||
runRowAction,
|
||||
untilLoaded,
|
||||
} from '../AlertRules.stories.mocks';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
args: PageStoryArgs<typeof alertRulesMocks>;
|
||||
}
|
||||
|
||||
// A row's rename and delete only take the pointer while the row is hovered,
|
||||
// which a play cannot put the real cursor on.
|
||||
const hoverActions = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
export const openQuerySuggestions = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
// The labels come with the rule list, so the box is opened once rows are in.
|
||||
await canvas.findAllByTestId('alert-actions', undefined, untilLoaded);
|
||||
const search = canvas.getByTestId('alert-rules-list-search');
|
||||
await userEvent.click(search.querySelector('.cm-content') ?? search);
|
||||
await screen.findByText('labels.team');
|
||||
};
|
||||
|
||||
export const openSaveViewPopover = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'alert-rules-view-save-trigger',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText('Save as view');
|
||||
};
|
||||
|
||||
export const openRenameViewPopover = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await hoverActions.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'alert-rules-view-rename-view-1',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText('Rename view');
|
||||
};
|
||||
|
||||
export const openDeleteViewConfirm = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await hoverActions.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'alert-rules-view-delete-view-1',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText(/shared across your organization/i);
|
||||
};
|
||||
|
||||
export const openRowActions = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const [actions] = await within(canvasElement).findAllByTestId(
|
||||
'alert-actions',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(actions);
|
||||
await screen.findByText(/clone/i);
|
||||
};
|
||||
|
||||
export const openDisabledRuleActions = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const [actions] = await within(canvasElement).findAllByTestId(
|
||||
'alert-actions',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(actions);
|
||||
await screen.findByText(/^enable$/i);
|
||||
};
|
||||
|
||||
export const openColumnPicker = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'alert-columns-button',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText(/toggle columns/i);
|
||||
};
|
||||
|
||||
export const toggleAlert = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await runRowAction(canvasElement, /^(enable|disable)$/i);
|
||||
if (args.toggleState === 'success') {
|
||||
await expect(
|
||||
await screen.findByText(/alert (enabled|disabled)/i),
|
||||
).toBeVisible();
|
||||
}
|
||||
};
|
||||
|
||||
export const cloneAlert = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await runRowAction(canvasElement, /^clone$/i);
|
||||
if (args.cloneState === 'success') {
|
||||
await expect(
|
||||
await screen.findByText(/alert cloned successfully/i),
|
||||
).toBeVisible();
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteAlert = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await runRowAction(canvasElement, /^delete$/i);
|
||||
if (args.deleteState === 'success') {
|
||||
await expect(
|
||||
await screen.findByText(/alert deleted successfully/i),
|
||||
).toBeVisible();
|
||||
}
|
||||
};
|
||||
|
||||
export const copyLabel = async (): Promise<void> => {
|
||||
const [copy] = await screen.findAllByRole(
|
||||
'button',
|
||||
{ name: 'Copy to clipboard' },
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(copy);
|
||||
await expect(await screen.findByText(/copied! use in search/i)).toBeVisible();
|
||||
};
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ListRuleViews200,
|
||||
type ListRulesV3200,
|
||||
type RenderErrorResponseDTO,
|
||||
RuletypesAlertStateDTO,
|
||||
RuletypesListOrderDTO,
|
||||
RuletypesListSortDTO,
|
||||
type RuletypesRuleViewDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
alertRulesResponse,
|
||||
type RuleShape,
|
||||
} from '../../../stories/__story_mockdata__/alerts';
|
||||
|
||||
/**
|
||||
* The v3 list as the API answers it: `states` and paging are applied on the
|
||||
* server. The `query` DSL is not evaluated, so a query narrows nothing here.
|
||||
*/
|
||||
export const alertRulesPage = (
|
||||
count: number,
|
||||
shape: RuleShape,
|
||||
searchParams: URLSearchParams,
|
||||
): ListRulesV3200 => {
|
||||
const states = searchParams.getAll('states');
|
||||
const rules = alertRulesResponse(count, shape).data.filter(
|
||||
(rule) => states.length === 0 || states.includes(rule.state),
|
||||
);
|
||||
const labels = new Map<string, { key: string; value: string }>();
|
||||
rules.forEach((rule) =>
|
||||
Object.entries(rule.labels ?? {}).forEach(([key, value]) =>
|
||||
labels.set(`${key}=${value}`, { key, value }),
|
||||
),
|
||||
);
|
||||
const limit = Number(searchParams.get('limit') ?? rules.length);
|
||||
const offset = Number(searchParams.get('offset') ?? 0);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
rules: rules.slice(offset, offset + limit),
|
||||
total: rules.length,
|
||||
labels: [...labels.values()],
|
||||
reservedKeywords: [...RESERVED_RULE_KEYWORDS],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** What `ReservedFilterKeys()` in pkg/types/ruletypes/list_filter.go answers. */
|
||||
const RESERVED_RULE_KEYWORDS = [
|
||||
'alert_type',
|
||||
'created_at',
|
||||
'created_by',
|
||||
'labels.<key>',
|
||||
'name',
|
||||
'rule_type',
|
||||
'severity',
|
||||
'updated_at',
|
||||
'updated_by',
|
||||
] as const;
|
||||
|
||||
/** The two 400 codes the v3 list answers for a filter it cannot run. */
|
||||
export const INVALID_QUERY_CHOICES = [
|
||||
'off',
|
||||
'rule_list_filter_invalid',
|
||||
'rule_list_invalid',
|
||||
] as const;
|
||||
export type InvalidQueryChoice = (typeof INVALID_QUERY_CHOICES)[number];
|
||||
|
||||
const INVALID_QUERY_MESSAGES: Record<
|
||||
Exclude<InvalidQueryChoice, 'off'>,
|
||||
string
|
||||
> = {
|
||||
rule_list_filter_invalid:
|
||||
"invalid filter query: unexpected token 'AND' at position 22",
|
||||
rule_list_invalid: 'invalid list params: unknown sort column',
|
||||
};
|
||||
|
||||
export const invalidRuleQueryError = (
|
||||
code: Exclude<InvalidQueryChoice, 'off'>,
|
||||
): RenderErrorResponseDTO =>
|
||||
({
|
||||
status: 'error',
|
||||
error: { code, message: INVALID_QUERY_MESSAGES[code] },
|
||||
}) as RenderErrorResponseDTO;
|
||||
|
||||
const VIEW_SEEDS: Pick<RuletypesRuleViewDTO, 'name' | 'data'>[] = [
|
||||
{
|
||||
name: 'Critical prod alerts',
|
||||
data: {
|
||||
version: 'v1',
|
||||
query: "env = 'prod' AND severity = 'critical'",
|
||||
states: [RuletypesAlertStateDTO.firing, RuletypesAlertStateDTO.pending],
|
||||
sort: RuletypesListSortDTO.updated_at,
|
||||
order: RuletypesListOrderDTO.desc,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Infra team',
|
||||
data: {
|
||||
version: 'v1',
|
||||
query: "team = 'infra'",
|
||||
states: [],
|
||||
sort: RuletypesListSortDTO.name,
|
||||
order: RuletypesListOrderDTO.asc,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Firing right now',
|
||||
data: {
|
||||
version: 'v1',
|
||||
query: '',
|
||||
states: [RuletypesAlertStateDTO.firing],
|
||||
sort: RuletypesListSortDTO.severity,
|
||||
order: RuletypesListOrderDTO.desc,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Recently created',
|
||||
data: {
|
||||
version: 'v1',
|
||||
query: '',
|
||||
states: [],
|
||||
sort: RuletypesListSortDTO.created_at,
|
||||
order: RuletypesListOrderDTO.desc,
|
||||
},
|
||||
},
|
||||
{
|
||||
name:
|
||||
'Payments on-call, everything that pages the payments rotation overnight',
|
||||
data: {
|
||||
version: 'v1',
|
||||
query: "team = 'payments' AND severity IN ('critical', 'error')",
|
||||
states: [],
|
||||
sort: RuletypesListSortDTO.state,
|
||||
order: RuletypesListOrderDTO.asc,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const RULE_VIEW_MAX = VIEW_SEEDS.length;
|
||||
|
||||
export const ruleView = (index: number): RuletypesRuleViewDTO => ({
|
||||
id: `view-${index + 1}`,
|
||||
orgId: 'org-1',
|
||||
createdAt: '2026-09-01T10:00:00Z',
|
||||
updatedAt: '2026-09-15T08:00:00Z',
|
||||
...VIEW_SEEDS[index % RULE_VIEW_MAX],
|
||||
});
|
||||
|
||||
export const ruleViewsResponse = (count: number): ListRuleViews200 => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
views: Array.from({ length: count }, (_unused, index) => ruleView(index)),
|
||||
},
|
||||
});
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
CHANNEL_ACTION_OUTCOMES,
|
||||
channelActionError,
|
||||
CHANNEL_TYPES,
|
||||
JIRA_DEPLOYMENTS,
|
||||
type JiraDeploymentChoice,
|
||||
notificationChannelResponse,
|
||||
type ChannelActionOutcome,
|
||||
type ChannelType,
|
||||
@@ -49,13 +47,6 @@ export const channelsEditMocks = defineStoryMocks({
|
||||
options: WEBHOOK_AUTHS,
|
||||
value: 'basic',
|
||||
}),
|
||||
jiraDeployment: choiceControl<JiraDeploymentChoice>('Jira deployment', {
|
||||
group: CHANNEL,
|
||||
description:
|
||||
'Whether the saved Jira channel points at Jira Cloud (email + API token) or a self-hosted Jira (personal access token). Only read when the channel type is jira.',
|
||||
options: JIRA_DEPLOYMENTS,
|
||||
value: 'cloud',
|
||||
}),
|
||||
saveOutcome: choiceControl<ChannelActionOutcome>('Saving the channel', {
|
||||
group: ACTIONS,
|
||||
options: CHANNEL_ACTION_OUTCOMES,
|
||||
@@ -75,7 +66,6 @@ export const channelsEditMocks = defineStoryMocks({
|
||||
String(req.params.id),
|
||||
values.channelType,
|
||||
values.webhookAuth,
|
||||
values.jiraDeployment,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -176,26 +176,6 @@ export const Jira: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A Jira channel saved against a self-hosted Jira: the form opens on the
|
||||
* Self-hosted option and shows the personal access token instead of the
|
||||
* Atlassian email and API token.
|
||||
*/
|
||||
export const JiraSelfHosted: Story = {
|
||||
args: { channelType: 'jira', jiraDeployment: 'selfhosted' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
await canvas.findByTestId('jira-pat-textbox', undefined, {
|
||||
timeout: 15_000,
|
||||
}),
|
||||
).toHaveValue('story-jira-pat');
|
||||
await expect(
|
||||
canvas.queryByTestId('jira-email-textbox'),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Jira Service Management Ops channel, whose tags are stored as one
|
||||
* comma-separated string and come back as chips.
|
||||
|
||||
@@ -299,32 +299,6 @@ export const JiraProxySite: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Jira form switched to a self-hosted Jira: the site URL and a personal
|
||||
* access token replace the Atlassian email and API token, and the test send
|
||||
* goes through.
|
||||
*/
|
||||
export const JiraSelfHosted: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await selectChannelType(canvasElement, /^Jira$/);
|
||||
await fillName(canvasElement, 'alerts-jira-dc');
|
||||
await userEvent.click(
|
||||
canvas.getByTestId('jira-deployment-radio-item-selfhosted'),
|
||||
);
|
||||
await userEvent.type(
|
||||
canvas.getByTestId('jira-site-textbox'),
|
||||
'https://jira.acme.internal',
|
||||
);
|
||||
await userEvent.type(canvas.getByTestId('jira-pat-textbox'), 'pat');
|
||||
await userEvent.type(canvas.getByTestId('jira-project-textbox'), 'OPS');
|
||||
await userEvent.type(canvas.getByTestId('jira-issue-type-textbox'), 'Task');
|
||||
|
||||
await sendTest(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/** The Jira Service Management Ops form: the API key, priority and tags. */
|
||||
export const JiraServiceManagementOps: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
|
||||
@@ -127,11 +127,11 @@ const CASES: PrefillCase[] = [
|
||||
{
|
||||
kind: 'jira',
|
||||
spec: {
|
||||
deployment: 'cloud',
|
||||
site: 'https://acme.atlassian.net',
|
||||
project: 'OPS',
|
||||
issueType: 'Task',
|
||||
auth: { email: 'someone@acme.io', apiToken: 'jira-token' },
|
||||
email: 'someone@acme.io',
|
||||
apiToken: 'jira-token',
|
||||
summary: 'jira summary',
|
||||
description: 'jira description',
|
||||
priority: 'High',
|
||||
@@ -155,20 +155,6 @@ const CASES: PrefillCase[] = [
|
||||
['jira-reopen-duration-textbox', '72h'],
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'jira',
|
||||
spec: {
|
||||
deployment: 'selfhosted',
|
||||
site: 'https://jira.acme.com',
|
||||
project: 'OPS',
|
||||
issueType: 'Task',
|
||||
auth: { pat: 'jira-pat' },
|
||||
},
|
||||
fields: [
|
||||
['jira-site-textbox', 'https://jira.acme.com'],
|
||||
['jira-pat-textbox', 'jira-pat'],
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'jsmops',
|
||||
spec: {
|
||||
|
||||
@@ -175,67 +175,6 @@ describe('ChannelForm save', () => {
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'sends only the credentials of the picked jira deployment',
|
||||
async () => {
|
||||
let body: Record<string, unknown> | undefined;
|
||||
server.use(
|
||||
rest.post(
|
||||
'http://localhost/api/v2/notification_channels',
|
||||
async (req, res, ctx) => {
|
||||
body = await req.json();
|
||||
return res(ctx.status(201), ctx.json({ status: 'success', data: {} }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChannelForm onDone={jest.fn()} />);
|
||||
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
await user.click(await screen.findByTestId('channel-type-select', {}, FIND));
|
||||
await user.click(await screen.findByRole('option', { name: /^Jira$/ }));
|
||||
expect(
|
||||
screen.getByTestId('jira-deployment-radio-item-cloud'),
|
||||
).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
await user.type(screen.getByTestId('channel-name-textbox'), 'jira alerts');
|
||||
await user.type(screen.getByTestId('jira-email-textbox'), 'ops@acme.io');
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId('jira-deployment-radio-item-selfhosted'),
|
||||
);
|
||||
expect(screen.queryByTestId('jira-email-textbox')).not.toBeInTheDocument();
|
||||
await user.type(
|
||||
screen.getByTestId('jira-site-textbox'),
|
||||
'https://jira.acme.com',
|
||||
);
|
||||
await user.type(screen.getByTestId('jira-pat-textbox'), 'pat-1');
|
||||
await user.type(screen.getByTestId('jira-project-textbox'), 'OPS');
|
||||
|
||||
const save = screen.getByTestId('save-channel-button');
|
||||
await waitFor(
|
||||
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
|
||||
FIND,
|
||||
);
|
||||
await user.click(save);
|
||||
|
||||
await waitFor(() => expect(body).toBeDefined(), FIND);
|
||||
expect(
|
||||
(body as { config: { spec: Record<string, unknown> } }).config.spec,
|
||||
).toMatchObject({
|
||||
deployment: 'selfhosted',
|
||||
site: 'https://jira.acme.com',
|
||||
auth: { pat: 'pat-1' },
|
||||
project: 'OPS',
|
||||
issueType: 'Task',
|
||||
});
|
||||
expect(
|
||||
(body as { config: { spec: Record<string, unknown> } }).config.spec,
|
||||
).not.toHaveProperty('auth.email');
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'flags a slack field without a value instead of sending it',
|
||||
async () => {
|
||||
|
||||
@@ -22,18 +22,6 @@ const slackChannel = {
|
||||
},
|
||||
} as AlertmanagertypesGettableNotificationChannelDTO;
|
||||
|
||||
const jiraChannel = (
|
||||
spec: Record<string, unknown>,
|
||||
): AlertmanagertypesGettableNotificationChannelDTO =>
|
||||
({
|
||||
id: '2',
|
||||
name: 'jira-alerts',
|
||||
displayName: 'jira alerts',
|
||||
createdAt: '2026-09-01T00:00:00Z',
|
||||
updatedAt: '2026-09-01T00:00:00Z',
|
||||
config: { kind: 'jira', spec },
|
||||
}) as unknown as AlertmanagertypesGettableNotificationChannelDTO;
|
||||
|
||||
describe('channelToJson', () => {
|
||||
it('hides the credential the API returns in the clear', () => {
|
||||
const json = JSON.parse(channelToJson(slackChannel, false));
|
||||
@@ -59,34 +47,6 @@ describe('channelToJson', () => {
|
||||
expect(json.name).toBe('prod-alerts');
|
||||
});
|
||||
|
||||
it('hides a credential nested under auth for both jira deployments', () => {
|
||||
const cloud = JSON.parse(
|
||||
channelToJson(
|
||||
jiraChannel({
|
||||
deployment: 'cloud',
|
||||
site: 'https://acme.atlassian.net',
|
||||
auth: { email: 'ops@acme.io', apiToken: 'CLOUDSECRET' },
|
||||
}),
|
||||
false,
|
||||
),
|
||||
);
|
||||
expect(cloud.config.spec.auth).toStrictEqual({
|
||||
email: 'ops@acme.io',
|
||||
apiToken: SECRET_PLACEHOLDER,
|
||||
});
|
||||
|
||||
const selfhosted = channelToJson(
|
||||
jiraChannel({
|
||||
deployment: 'selfhosted',
|
||||
site: 'https://jira.acme.com',
|
||||
auth: { pat: 'PATSECRET' },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
expect(JSON.parse(selfhosted).config.spec.auth.pat).toBe(SECRET_PLACEHOLDER);
|
||||
expect(selfhosted).not.toContain('PATSECRET');
|
||||
});
|
||||
|
||||
it('knows which kinds carry a credential', () => {
|
||||
expect(hasSecrets('slack')).toBe(true);
|
||||
expect(hasSecrets('email')).toBe(false);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { cloneDeep, get, set } from 'lodash-es';
|
||||
|
||||
export const SECRET_PLACEHOLDER = '••••••••';
|
||||
|
||||
@@ -14,7 +13,7 @@ const SECRET_FIELDS_BY_KIND: Record<string, readonly string[]> = {
|
||||
opsgenie: ['apiKey'],
|
||||
msteams: ['webhookUrl'],
|
||||
googlechat: ['webhookUrl'],
|
||||
jira: ['auth.apiToken', 'auth.pat'],
|
||||
jira: ['apiToken'],
|
||||
jsmops: ['apiKey'],
|
||||
incidentio: ['token'],
|
||||
telegram: ['botToken'],
|
||||
@@ -27,14 +26,14 @@ export function channelToJson(
|
||||
const { kind, spec } = channel.config;
|
||||
const secrets = SECRET_FIELDS_BY_KIND[kind] ?? [];
|
||||
|
||||
const safeSpec = cloneDeep(spec);
|
||||
if (!showSecrets) {
|
||||
secrets.forEach((path) => {
|
||||
if (get(safeSpec, path)) {
|
||||
set(safeSpec, path, SECRET_PLACEHOLDER);
|
||||
}
|
||||
});
|
||||
}
|
||||
const safeSpec = showSecrets
|
||||
? spec
|
||||
: Object.fromEntries(
|
||||
Object.entries(spec).map(([key, value]) => [
|
||||
key,
|
||||
secrets.includes(key) && value ? SECRET_PLACEHOLDER : value,
|
||||
]),
|
||||
);
|
||||
|
||||
return JSON.stringify(
|
||||
{
|
||||
|
||||
@@ -869,35 +869,6 @@ const webhookSpec = (auth: WebhookAuthChoice): Record<string, unknown> => ({
|
||||
sendResolved: true,
|
||||
});
|
||||
|
||||
export const JIRA_DEPLOYMENTS = ['cloud', 'selfhosted'] as const;
|
||||
|
||||
export type JiraDeploymentChoice = (typeof JIRA_DEPLOYMENTS)[number];
|
||||
|
||||
const JIRA_CONNECTIONS: Record<
|
||||
JiraDeploymentChoice,
|
||||
Record<string, unknown>
|
||||
> = {
|
||||
cloud: {
|
||||
site: 'https://story.atlassian.net',
|
||||
auth: { email: 'story@signoz.io', apiToken: 'story-jira-token' },
|
||||
},
|
||||
selfhosted: {
|
||||
site: 'https://jira.story.internal',
|
||||
auth: { pat: 'story-jira-pat' },
|
||||
},
|
||||
};
|
||||
|
||||
const jiraSpec = (
|
||||
deployment: JiraDeploymentChoice,
|
||||
): Record<string, unknown> => ({
|
||||
deployment,
|
||||
...JIRA_CONNECTIONS[deployment],
|
||||
project: 'OPS',
|
||||
issueType: 'Task',
|
||||
summary: '{{ .CommonLabels.alertname }}',
|
||||
sendResolved: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* The v2 spec each kind is fetched with, so the edit form renders populated
|
||||
* fields. Keys mirror the generated channel spec schemas.
|
||||
@@ -956,7 +927,15 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
|
||||
text: 'Story channel',
|
||||
sendResolved: true,
|
||||
},
|
||||
jira: jiraSpec('cloud'),
|
||||
jira: {
|
||||
site: 'https://story.atlassian.net',
|
||||
project: 'OPS',
|
||||
issueType: 'Task',
|
||||
email: 'story@signoz.io',
|
||||
apiToken: 'story-jira-token',
|
||||
summary: '{{ .CommonLabels.alertname }}',
|
||||
sendResolved: true,
|
||||
},
|
||||
jsmops: {
|
||||
apiKey: 'story-jsm-key',
|
||||
message: '{{ .CommonLabels.alertname }}',
|
||||
@@ -978,26 +957,11 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
|
||||
},
|
||||
};
|
||||
|
||||
const channelSpec = (
|
||||
type: ChannelType,
|
||||
webhookAuth: WebhookAuthChoice,
|
||||
jiraDeployment: JiraDeploymentChoice,
|
||||
): Record<string, unknown> => {
|
||||
if (type === 'webhook') {
|
||||
return webhookSpec(webhookAuth);
|
||||
}
|
||||
if (type === 'jira') {
|
||||
return jiraSpec(jiraDeployment);
|
||||
}
|
||||
return CHANNEL_SPECS[type];
|
||||
};
|
||||
|
||||
/** The v2 fetch by id, which is what the redesigned screens read. */
|
||||
export const notificationChannelResponse = (
|
||||
id: string,
|
||||
type: ChannelType,
|
||||
webhookAuth: WebhookAuthChoice = 'basic',
|
||||
jiraDeployment: JiraDeploymentChoice = 'cloud',
|
||||
): {
|
||||
status: string;
|
||||
data: AlertmanagertypesGettableNotificationChannelDTO;
|
||||
@@ -1018,7 +982,7 @@ export const notificationChannelResponse = (
|
||||
updatedAt: ago((index + 1) * DAY),
|
||||
config: {
|
||||
kind: type,
|
||||
spec: channelSpec(type, webhookAuth, jiraDeployment),
|
||||
spec: type === 'webhook' ? webhookSpec(webhookAuth) : CHANNEL_SPECS[type],
|
||||
} as unknown as AlertmanagertypesChannelConfigDTO,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { type ReactElement, useCallback } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import FilterZone from 'components/ListFilter/FilterZone/FilterZone';
|
||||
import type { SuggestionSource } from 'components/ListFilter/dsl/types';
|
||||
import type {
|
||||
CreatorOption,
|
||||
FilterAppliedPayload,
|
||||
FilterAppliedType,
|
||||
} from 'components/ListFilter/types';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPage/constants/events';
|
||||
import { LIST_CHECKS } from 'pages/DashboardsListPage/constants/permissions';
|
||||
|
||||
import { DASHBOARDS_DSL_GRAMMAR } from '../../list.config';
|
||||
|
||||
interface Props {
|
||||
query: string;
|
||||
creatorOptions: CreatorOption[];
|
||||
source: SuggestionSource;
|
||||
onQueryChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function DashboardsFilterZone({
|
||||
query,
|
||||
creatorOptions,
|
||||
source,
|
||||
onQueryChange,
|
||||
disabled = false,
|
||||
}: Props): JSX.Element {
|
||||
const wrapControl = useCallback(
|
||||
(node: ReactElement): ReactElement => (
|
||||
<AuthZTooltip checks={LIST_CHECKS}>{node}</AuthZTooltip>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const onSubmitted = useCallback((hasQuery: boolean): void => {
|
||||
void logEvent(DashboardListEvents.SearchExecuted, { hasQuery });
|
||||
}, []);
|
||||
|
||||
const onFilterApplied = useCallback(
|
||||
(filterType: FilterAppliedType, payload: FilterAppliedPayload): void => {
|
||||
void logEvent(DashboardListEvents.FilterApplied, {
|
||||
filterType,
|
||||
...payload,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onCleared = useCallback((): void => {
|
||||
void logEvent(DashboardListEvents.FiltersCleared, {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FilterZone
|
||||
query={query}
|
||||
grammar={DASHBOARDS_DSL_GRAMMAR}
|
||||
testIdPrefix="dashboards"
|
||||
placeholder="DSL Filter — e.g. name CONTAINS 'api' AND env IN ['prod','staging']"
|
||||
creatorOptions={creatorOptions}
|
||||
source={source}
|
||||
disabled={disabled}
|
||||
wrapControl={wrapControl}
|
||||
onQueryChange={onQueryChange}
|
||||
onSubmitted={onSubmitted}
|
||||
onFilterApplied={onFilterApplied}
|
||||
onCleared={onCleared}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardsFilterZone;
|
||||
@@ -1,40 +1,33 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './ListCommandHeader.module.scss';
|
||||
import NewDashboardButton from './NewDashboardButton';
|
||||
|
||||
import styles from './DashboardsList.module.scss';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
count: number;
|
||||
isModified: boolean;
|
||||
action?: ReactNode;
|
||||
// Ids: `${testIdPrefix}-list-heading`.
|
||||
testIdPrefix?: string;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
function ListCommandHeader({
|
||||
function CommandHeader({
|
||||
label,
|
||||
count,
|
||||
isModified,
|
||||
action,
|
||||
testIdPrefix,
|
||||
onCreate,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className={styles.commandHeader}>
|
||||
<div className={styles.headingBlock}>
|
||||
<Typography.Title
|
||||
className={styles.title}
|
||||
testId={testIdPrefix && `${testIdPrefix}-list-heading`}
|
||||
>
|
||||
{label}
|
||||
</Typography.Title>
|
||||
<Typography.Title className={styles.title}>{label}</Typography.Title>
|
||||
{isModified && <span className={styles.dirtyDot} title="Unsaved changes" />}
|
||||
<span className={styles.countPill}>{count}</span>
|
||||
</div>
|
||||
<div className={styles.grow} />
|
||||
{action}
|
||||
<NewDashboardButton onClick={onCreate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListCommandHeader;
|
||||
export default CommandHeader;
|
||||
@@ -81,6 +81,53 @@
|
||||
}
|
||||
}
|
||||
|
||||
.commandHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.headingBlock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dirtyDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--warning-background);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.countPill {
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-lg);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 28px;
|
||||
letter-spacing: -0.09px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
@@ -18,8 +18,7 @@ import {
|
||||
useAccumulatedTags,
|
||||
} from '../../hooks/useAccumulatedTags';
|
||||
import { useActiveView } from '../../hooks/useActiveView';
|
||||
import { useDashboardViewCtx } from '../../hooks/useDashboardViewCtx';
|
||||
import { useCreatorOptions } from 'components/ListFilter/useCreatorOptions';
|
||||
import { useCreatorOptions } from '../../hooks/useCreatorOptions';
|
||||
import { useDashboardFilters } from '../../hooks/useDashboardFilters';
|
||||
import {
|
||||
usePage,
|
||||
@@ -28,19 +27,16 @@ import {
|
||||
} from '../../hooks/useDashboardsListQueryParams';
|
||||
import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
|
||||
import { useDashboardsListVisibleColumnsStore } from '../../store/useVisibleColumnsStore';
|
||||
import { DEFAULT_DASHBOARD_VIEW, findBuiltinView } from '../../list.config';
|
||||
import type { SuggestionSource } from 'components/ListFilter/dsl/types';
|
||||
import { BuiltinViewId } from '../../types';
|
||||
import type { SuggestionSource } from '../../utils/dslSuggestions';
|
||||
import type { DashboardListItem } from '../../utils/helpers';
|
||||
import DashboardsFilterZone from '../DashboardsFilterZone/DashboardsFilterZone';
|
||||
import { applyClientView } from '../../utils/views';
|
||||
import FilterZone from '../FilterZone/FilterZone';
|
||||
import NewDashboardModal from '../NewDashboardModal/NewDashboardModal';
|
||||
import type { ReactElement } from 'react';
|
||||
import ListCommandHeader from 'components/ListViews/ListCommandHeader/ListCommandHeader';
|
||||
import ListStatusBar from 'components/ListViews/ListStatusBar/ListStatusBar';
|
||||
import type { ViewSaveMode } from 'components/ListViews/types';
|
||||
import ViewsRail from 'components/ListViews/ViewsRail/ViewsRail';
|
||||
import StatusBar from '../StatusBar/StatusBar';
|
||||
import ViewsRail from '../ViewsRail/ViewsRail';
|
||||
import CommandHeader from './CommandHeader';
|
||||
import DashboardsResults from './DashboardsResults';
|
||||
import NewDashboardButton from './NewDashboardButton';
|
||||
import ViewsRailGuard from './ViewsRailGuard';
|
||||
import WorkspaceEmptyState from './WorkspaceEmptyState';
|
||||
|
||||
import styles from './DashboardsList.module.scss';
|
||||
@@ -62,8 +58,6 @@ function DashboardsList(): JSX.Element {
|
||||
const [sortOrder, setSortOrder] = useSortOrder();
|
||||
const [page, setPage] = usePage();
|
||||
|
||||
const viewCtx = useDashboardViewCtx();
|
||||
|
||||
const {
|
||||
activeViewId,
|
||||
builtinViews,
|
||||
@@ -81,7 +75,7 @@ function DashboardsList(): JSX.Element {
|
||||
} = useActiveView({
|
||||
query,
|
||||
setQuery,
|
||||
ctx: viewCtx,
|
||||
userEmail: user.email,
|
||||
sortColumn,
|
||||
sortOrder,
|
||||
setSortColumn,
|
||||
@@ -91,6 +85,7 @@ function DashboardsList(): JSX.Element {
|
||||
|
||||
const railCollapsed = useDashboardViewsStore((s) => s.railCollapsed);
|
||||
const setRailCollapsed = useDashboardViewsStore((s) => s.setRailCollapsed);
|
||||
const recent = useDashboardViewsStore((s) => s.recent);
|
||||
|
||||
// Any filter change resets to the first page so the user isn't stranded on a
|
||||
// now-out-of-range offset.
|
||||
@@ -130,21 +125,6 @@ function DashboardsList(): JSX.Element {
|
||||
void logEvent(DashboardListEvents.RailToggled, { collapsed: !railCollapsed });
|
||||
setRailCollapsed(!railCollapsed);
|
||||
}, [setRailCollapsed, railCollapsed]);
|
||||
const guardViewsRail = useCallback(
|
||||
(content: ReactElement): ReactElement => (
|
||||
<ViewsRailGuard>{content}</ViewsRailGuard>
|
||||
),
|
||||
[],
|
||||
);
|
||||
const logViewSaved = useCallback((mode: ViewSaveMode): void => {
|
||||
void logEvent(DashboardListEvents.ViewSaved, { mode });
|
||||
}, []);
|
||||
const logViewRenamed = useCallback((): void => {
|
||||
void logEvent(DashboardListEvents.ViewRenamed, {});
|
||||
}, []);
|
||||
const logViewDeleted = useCallback((): void => {
|
||||
void logEvent(DashboardListEvents.ViewDeleted, {});
|
||||
}, []);
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
@@ -181,10 +161,13 @@ function DashboardsList(): JSX.Element {
|
||||
|
||||
// Favorites / recently-viewed constrain the fetched rows by a client-side id
|
||||
// set; all other views are already constrained server-side.
|
||||
const dashboards = useMemo<DashboardListItem[]>(() => {
|
||||
const clientFilter = findBuiltinView(activeViewId)?.clientFilter;
|
||||
return clientFilter ? clientFilter(rawDashboards, viewCtx) : rawDashboards;
|
||||
}, [rawDashboards, activeViewId, viewCtx]);
|
||||
const dashboards = useMemo<DashboardListItem[]>(
|
||||
() =>
|
||||
clientView
|
||||
? applyClientView(rawDashboards, activeViewId, recent)
|
||||
: rawDashboards,
|
||||
[clientView, rawDashboards, activeViewId, recent],
|
||||
);
|
||||
const total = clientView ? dashboards.length : (response?.data?.total ?? 0);
|
||||
|
||||
// Step back a page when a delete empties the current one, instead of showing nothing.
|
||||
@@ -224,17 +207,17 @@ function DashboardsList(): JSX.Element {
|
||||
// Autocomplete data source: reserved keys from the response, tag keys/values
|
||||
// accumulated across pages, and creator emails for `created_by` values.
|
||||
const source = useMemo<SuggestionSource>(() => {
|
||||
const valuesByKey: Record<string, string[]> = {};
|
||||
const dynamicKeys = new Set<string>();
|
||||
const tagValuesByKey: Record<string, string[]> = {};
|
||||
const tagKeys = new Set<string>();
|
||||
availableTags.forEach((t) => {
|
||||
dynamicKeys.add(t.key);
|
||||
tagKeys.add(t.key);
|
||||
const lower = t.key.toLowerCase();
|
||||
(valuesByKey[lower] ??= []).push(t.value);
|
||||
(tagValuesByKey[lower] ??= []).push(t.value);
|
||||
});
|
||||
return {
|
||||
reservedKeys: response?.data?.reservedKeywords,
|
||||
dynamicKeys: Array.from(dynamicKeys),
|
||||
valuesByKey,
|
||||
tagKeys: Array.from(tagKeys),
|
||||
tagValuesByKey,
|
||||
creatorEmails: creatorOptions.map((o) => o.email),
|
||||
currentUserEmail: user.email,
|
||||
};
|
||||
@@ -246,7 +229,7 @@ function DashboardsList(): JSX.Element {
|
||||
);
|
||||
|
||||
const openCreate = useCallback((): void => {
|
||||
void logEvent('Dashboard List: New dashboard clicked', {});
|
||||
logEvent('Dashboard List: New dashboard clicked', {});
|
||||
setIsCreateOpen(true);
|
||||
}, []);
|
||||
|
||||
@@ -274,9 +257,7 @@ function DashboardsList(): JSX.Element {
|
||||
const visitLoggedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!visitLoggedRef.current && !isLoading && response !== undefined) {
|
||||
void logEvent('Dashboard List V2: Page visited', {
|
||||
number: dashboards.length,
|
||||
});
|
||||
logEvent('Dashboard List V2: Page visited', { number: dashboards.length });
|
||||
visitLoggedRef.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -293,7 +274,7 @@ function DashboardsList(): JSX.Element {
|
||||
canList &&
|
||||
!error &&
|
||||
dashboards.length === 0 &&
|
||||
activeViewId === DEFAULT_DASHBOARD_VIEW &&
|
||||
activeViewId === BuiltinViewId.All &&
|
||||
filtersEmpty &&
|
||||
page === 1;
|
||||
|
||||
@@ -315,12 +296,6 @@ function DashboardsList(): JSX.Element {
|
||||
onReset={handleResetView}
|
||||
onDelete={handleRemoveView}
|
||||
onRename={renameView}
|
||||
testIdPrefix="dashboards"
|
||||
deleteDescription="This removes the saved view. Your dashboards are not affected."
|
||||
guard={guardViewsRail}
|
||||
onViewSaved={logViewSaved}
|
||||
onViewRenamed={logViewRenamed}
|
||||
onViewDeleted={logViewDeleted}
|
||||
/>
|
||||
<div className={styles.main}>
|
||||
<div className={styles.mainScroll}>
|
||||
@@ -329,13 +304,13 @@ function DashboardsList(): JSX.Element {
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.headerZone}>
|
||||
<ListCommandHeader
|
||||
<CommandHeader
|
||||
label={activeLabel}
|
||||
count={total}
|
||||
isModified={isModified}
|
||||
action={<NewDashboardButton onClick={openCreate} />}
|
||||
onCreate={openCreate}
|
||||
/>
|
||||
<DashboardsFilterZone
|
||||
<FilterZone
|
||||
query={query}
|
||||
creatorOptions={creatorOptions}
|
||||
source={source}
|
||||
@@ -354,7 +329,7 @@ function DashboardsList(): JSX.Element {
|
||||
hasError={!!error}
|
||||
isCloudUser={!!isCloudUser}
|
||||
onRetry={(): void => {
|
||||
void refetch();
|
||||
refetch();
|
||||
}}
|
||||
errorHttpStatus={errorHttpStatus}
|
||||
errorMessage={errorMessage}
|
||||
@@ -379,13 +354,11 @@ function DashboardsList(): JSX.Element {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ListStatusBar
|
||||
<StatusBar
|
||||
collapsed={railCollapsed}
|
||||
onToggleCollapse={toggleRail}
|
||||
count={dashboards.length}
|
||||
total={total}
|
||||
noun="dashboards"
|
||||
testId="dashboards-rail-toggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user