Compare commits

..

5 Commits

Author SHA1 Message Date
Naman Verma
fe45b9dcae Merge branch 'main' into nv/exec-stats 2026-11-06 00:51:46 +05:30
Naman Verma
63ee60a40c fix(querier): stop cache refreshes from inflating exec stats 2026-11-06 00:51:03 +05:30
Naman Verma
29a393e47e Merge branch 'main' into nv/exec-stats 2026-11-05 10:51:11 +05:30
Naman Verma
c58f76f998 Merge branch 'main' into nv/exec-stats 2026-11-03 11:34:10 +05:30
Naman Verma
e39ad9e221 test: check how exec stats fail 2026-11-02 22:24:22 +05:30
168 changed files with 2373 additions and 8558 deletions

View File

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

View File

@@ -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.
@@ -3970,7 +3929,6 @@ components:
- logs-field
- traces-field
- metrics-field
- llm-pricing-rule
type: string
CoretypesObject:
properties:
@@ -4069,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
@@ -4108,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:
@@ -4124,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
@@ -9610,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
@@ -9768,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.'
@@ -10246,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:
@@ -10275,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
@@ -10313,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:
@@ -10329,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
@@ -10395,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
@@ -10434,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:
@@ -10450,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
@@ -10516,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
@@ -10555,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:
@@ -10571,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
@@ -10676,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
@@ -10709,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
@@ -10904,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:
@@ -11003,7 +10833,6 @@ components:
- raw_stream
- trace
- heatmap
- distribution
type: string
Querybuildertypesv5ScalarData:
properties:
@@ -11115,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
@@ -16149,9 +15978,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- llm-pricing-rule:list
- VIEWER
- tokenizer:
- llm-pricing-rule:list
- VIEWER
summary: List pricing rules
tags:
- llmpricingrules
@@ -16196,9 +16025,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- llm-pricing-rule:update
- ADMIN
- tokenizer:
- llm-pricing-rule:update
- ADMIN
summary: Create or update pricing rules
tags:
- llmpricingrules
@@ -16244,9 +16073,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- llm-pricing-rule:delete
- ADMIN
- tokenizer:
- llm-pricing-rule:delete
- ADMIN
summary: Delete a pricing rule
tags:
- llmpricingrules
@@ -16302,9 +16131,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- llm-pricing-rule:read
- VIEWER
- tokenizer:
- llm-pricing-rule:read
- VIEWER
summary: Get a pricing rule
tags:
- llmpricingrules
@@ -16357,11 +16186,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- llm-pricing-rule:list
- traces:read
- VIEWER
- tokenizer:
- llm-pricing-rule:list
- traces:read
- VIEWER
summary: List unmapped models
tags:
- llmpricingrules
@@ -24223,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
@@ -24242,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

View File

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

View File

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

View File

@@ -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;
/**
@@ -3611,7 +3579,6 @@ export enum CoretypesKindDTO {
'logs-field' = 'logs-field',
'traces-field' = 'traces-field',
'metrics-field' = 'metrics-field',
'llm-pricing-rule' = 'llm-pricing-rule',
}
export enum CoretypesTypeDTO {
user = 'user',
@@ -5306,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;
/**
@@ -5339,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;
/**
@@ -5353,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;
/**
@@ -5698,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;
/**
@@ -5731,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;
/**
@@ -5745,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;
/**
@@ -5878,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;
/**
@@ -5911,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;
/**
@@ -5925,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;
/**
@@ -5977,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;
/**
@@ -6010,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;
/**
@@ -6024,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;
/**
@@ -6970,7 +6937,6 @@ export enum Querybuildertypesv5RequestTypeDTO {
raw_stream = 'raw_stream',
trace = 'trace',
heatmap = 'heatmap',
distribution = 'distribution',
}
/**
* The query kind.
@@ -7098,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.
@@ -7185,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;
/**
@@ -11012,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[];
}
@@ -11633,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[];
/**
@@ -11754,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.
*/
@@ -12094,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;
});
@@ -12249,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 {
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 &ldquo;{query}&rdquo;
</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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
.deployment {
--radio-group-display: flex;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,21 @@
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;
onCreate: () => void;
}
function ListCommandHeader({
function CommandHeader({
label,
count,
isModified,
action,
onCreate,
}: Props): JSX.Element {
return (
<div className={styles.commandHeader}>
@@ -24,9 +25,9 @@ function ListCommandHeader({
<span className={styles.countPill}>{count}</span>
</div>
<div className={styles.grow} />
{action}
<NewDashboardButton onClick={onCreate} />
</div>
);
}
export default ListCommandHeader;
export default CommandHeader;

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
.denied {
padding: 16px 12px;
}

View File

@@ -1,28 +0,0 @@
import type { ReactElement } from 'react';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
import { LIST_CHECKS } from 'pages/DashboardsListPage/constants/permissions';
import styles from './ViewsRailGuard.module.scss';
interface Props {
children: ReactElement;
}
function ViewsRailGuard({ children }: Props): JSX.Element {
return (
<AuthZGuardContent
checks={LIST_CHECKS}
onFailRenderContent={false}
fallback={({ deniedPermissions }): ReactElement => (
<div className={styles.denied} data-testid="views-rail-denied">
<PermissionDeniedCallout deniedPermissions={deniedPermissions} />
</div>
)}
>
{children}
</AuthZGuardContent>
);
}
export default ViewsRailGuard;

View File

@@ -0,0 +1,103 @@
import { Checkbox, Select } from 'antd';
import cx from 'classnames';
import type { UpdatedWindow } from '../../types';
import styles from './FilterZone.module.scss';
export interface CreatorOption {
email: string;
label: string;
}
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'];
const UPDATED_OPTIONS = UPDATED_WINDOWS.map((w) => ({
value: w,
label: UPDATED_LABELS[w],
}));
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;
disabled?: boolean;
}
function FilterChips({
createdBy,
updated,
creatorOptions,
onCreatedByChange,
onUpdatedChange,
onApply,
onClearCreatedBy,
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}>
<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="dashboards-filter-created-by"
disabled={disabled}
onClear={onClearCreatedBy}
optionRender={(option): JSX.Element => (
<div className={styles.creatorOption}>
<Checkbox
checked={createdBy.includes(option.value as string)}
className={styles.creatorCheck}
/>
<span className={styles.creatorLabel}>{option.label}</span>
</div>
)}
onChange={(value): void => onCreatedByChange(value as string[])}
onDropdownVisibleChange={runOnClose}
/>
<Select
showSearch
className={cx(styles.select, styles.selectNarrow)}
placeholder="Updated"
value={updated}
options={UPDATED_OPTIONS}
optionFilterProp="label"
data-testid="dashboards-filter-updated"
disabled={disabled}
onChange={(value): void => onUpdatedChange(value as UpdatedWindow)}
/>
</div>
);
}
export default FilterChips;

View File

@@ -94,8 +94,6 @@
}
.creatorCheck {
// Flex, not block: a line box would shift with the check mark's baseline.
display: flex;
flex-shrink: 0;
pointer-events: none;
}
@@ -105,8 +103,3 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.error {
font-size: var(--font-size-xs);
color: var(--danger-foreground);
}

View File

@@ -0,0 +1,175 @@
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import logEvent from 'api/common/logEvent';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { X } from '@signozhq/icons';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { DashboardListEvents } from 'pages/DashboardsListPage/constants/events';
import { LIST_CHECKS } from 'pages/DashboardsListPage/constants/permissions';
import type { SuggestionSource } from '../../utils/dslSuggestions';
import {
createdByClause,
parseReflectedClauses,
spliceClause,
updatedClause,
} from '../../utils/filterQuery';
import type { UpdatedWindow } from '../../types';
import SearchBar from '../SearchBar/SearchBar';
import FilterChips, { type CreatorOption } from './FilterChips';
import styles from './FilterZone.module.scss';
interface Props {
// The last-run query (source of truth for fetching + the dirty baseline).
query: string;
creatorOptions: CreatorOption[];
source: SuggestionSource;
// Commit (run) the draft — the only path that triggers a fetch.
onQueryChange: (value: string) => void;
// Rendered at the end of the search row (e.g. the New Dashboard action).
rightSlot?: ReactNode;
/** Filtering needs `list`: the box and chips edit a query that cannot be run. */
disabled?: boolean;
}
// The filter command zone. 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 (Cmd/Ctrl+Enter or the Run button). A dirty dot
// signals unrun changes. The draft re-syncs when the last-run query changes
// externally (view select, back/forward).
function FilterZone({
query,
creatorOptions,
source,
onQueryChange,
rightSlot,
disabled = false,
}: Props): JSX.Element {
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 run = useCallback((): void => {
const next = draft.trim();
if (next !== query) {
onQueryChange(next);
}
}, [draft, query, onQueryChange]);
// 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 => {
void logEvent(DashboardListEvents.FilterApplied, {
filterType: 'createdBy',
valueCount: emails.length,
});
setDraft((d) => spliceClause(d, 'created_by', createdByClause(emails)));
}, []);
// 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 => {
void logEvent(DashboardListEvents.FilterApplied, {
filterType: 'updatedWindow',
});
const next = spliceClause(draft, 'updated_at', updatedClause(window));
setDraft(next);
const trimmed = next.trim();
if (trimmed !== query) {
onQueryChange(trimmed);
}
},
[draft, query, onQueryChange],
);
// 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);
const trimmed = next.trim();
if (trimmed !== query) {
onQueryChange(trimmed);
}
}, [draft, query, onQueryChange]);
// Clear resets the draft and runs immediately (empty query).
const handleClear = useCallback((): void => {
void logEvent(DashboardListEvents.FiltersCleared, {});
setDraft('');
if (query !== '') {
onQueryChange('');
}
}, [query, onQueryChange]);
return (
<div className={styles.filterZone}>
<div className={styles.searchRow}>
<AuthZTooltip checks={LIST_CHECKS}>
<div className={styles.searchInput} data-testid="dashboards-search-zone">
<SearchBar
value={draft}
placeholder="DSL Filter — e.g. name CONTAINS 'api' AND env IN ['prod','staging']"
source={source}
dirty={dirty}
disabled={disabled}
onChange={setDraft}
onSubmit={run}
/>
</div>
</AuthZTooltip>
{rightSlot}
</div>
<div className={styles.filtersRow}>
<Typography.Text className={styles.filtersLabel}>Filters</Typography.Text>
<AuthZTooltip checks={LIST_CHECKS}>
<div
className={styles.filterControls}
data-testid="dashboards-filter-zone"
>
<FilterChips
createdBy={reflected.createdBy}
updated={reflected.updated}
creatorOptions={creatorOptions}
onCreatedByChange={handleCreatedByChange}
onUpdatedChange={handleUpdatedChange}
onApply={run}
onClearCreatedBy={handleClearCreatedBy}
disabled={disabled}
/>
{!isEmpty && (
<Button
disabledTooltip={undefined}
variant="outlined"
color="secondary"
size="md"
prefix={<X size={12} />}
disabled={disabled}
onClick={handleClear}
testId="dashboards-filter-clear"
>
Clear
</Button>
)}
</div>
</AuthZTooltip>
</div>
</div>
);
}
export default FilterZone;

View File

@@ -165,9 +165,8 @@
.leadIcon {
position: absolute;
top: 50%;
top: 11px;
left: 10px;
transform: translateY(-50%);
z-index: 1;
}
@@ -177,10 +176,8 @@
.submit {
position: absolute;
top: 50%;
display: flex;
top: 5px;
right: 6px;
transform: translateY(-50%);
z-index: 1;
}

View File

@@ -15,20 +15,18 @@ import CodeMirror, {
Prec,
type ReactCodeMirrorRef,
} from '@uiw/react-codemirror';
import logEvent from 'api/common/logEvent';
import { DashboardListEvents } from 'pages/DashboardsListPage/constants/events';
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import type { DslGrammar, SuggestionSource } from '../dsl/types';
import type { SuggestionSource } from '../../utils/dslSuggestions';
import { dslCompletionSource } from './dslCompletions';
import { dslHighlight } from './dslHighlight';
import { BASIC_SETUP, EMPTY_SOURCE, editorTheme } from './editorConfig';
import styles from './SearchBar.module.scss';
interface Props {
value: string;
grammar: DslGrammar;
// Ids: `${testIdPrefix}`, `-submit`, `-dirty`.
testIdPrefix: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
@@ -37,20 +35,53 @@ interface Props {
dirty?: boolean;
/** Filtering needs `list`: the draft edits a query that cannot be run. */
disabled?: boolean;
onSubmitted?: (hasQuery: boolean) => void;
}
const EMPTY_SOURCE: SuggestionSource = {
tagKeys: [],
tagValuesByKey: {},
creatorEmails: [],
};
// Token colours come from `.cm-dsl-*` classes (SearchBar.module.scss); this only
// handles layout — transparent background, monospace, single line.
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' },
'.cm-scroller': { fontFamily: 'inherit', lineHeight: '1.5' },
'.cm-placeholder': { color: 'var(--l3-foreground)' },
});
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;
function SearchBar({
value,
grammar,
testIdPrefix,
onChange,
onSubmit,
placeholder = "Filter with DSL (e.g. name CONTAINS 'foo')",
source = EMPTY_SOURCE,
dirty = false,
disabled = false,
onSubmitted,
}: Props): JSX.Element {
const isMac = getUserOperatingSystem() === UserOperatingSystem.MACOS;
const editorRef = useRef<ReactCodeMirrorRef>(null);
@@ -58,12 +89,12 @@ function SearchBar({
// Refs so the (memoised, stable) extensions always see the latest values.
const sourceRef = useRef(source);
sourceRef.current = source;
const grammarRef = useRef(grammar);
grammarRef.current = grammar;
const handleSubmit = useCallback((): void => {
onSubmitted?.(!!value.trim());
void logEvent(DashboardListEvents.SearchExecuted, {
hasQuery: !!value.trim(),
});
onSubmit();
}, [onSubmit, onSubmitted, value]);
}, [onSubmit, value]);
const onSubmitRef = useRef(handleSubmit);
onSubmitRef.current = handleSubmit;
@@ -79,12 +110,7 @@ function SearchBar({
? []
: [
autocompletion({
override: [
dslCompletionSource(
() => sourceRef.current,
() => grammarRef.current,
),
],
override: [dslCompletionSource(() => sourceRef.current)],
closeOnBlur: true,
activateOnTyping: true,
maxRenderedOptions: 100,
@@ -142,7 +168,7 @@ function SearchBar({
basicSetup={BASIC_SETUP}
indentWithTab={false}
editable={!disabled}
data-testid={testIdPrefix}
data-testid="dashboards-list-search"
onChange={(next): void => onChange(next.replace(/\n/g, ' '))}
/>
<span
@@ -156,13 +182,13 @@ function SearchBar({
size="sm"
aria-label="Run search"
disabled={disabled}
testId={`${testIdPrefix}-submit`}
testId="dashboards-list-search-submit"
onClick={handleSubmit}
prefix={
dirty ? (
<span
className={styles.dirtyDot}
data-testid={`${testIdPrefix}-dirty`}
data-testid="dashboards-list-search-dirty"
/>
) : undefined
}

View File

@@ -7,21 +7,18 @@ import {
} from '@codemirror/autocomplete';
import type { EditorView } from '@codemirror/view';
import { getSuggestions } from '../dsl/dslSuggestions';
import type { DslGrammar, SuggestionSource } from '../dsl/types';
import {
getSuggestions,
type SuggestionSource,
} from '../../utils/dslSuggestions';
// Getters so the freshest keys/users (which load async) are read on every
// keystroke.
// `getSource` is a getter so the freshest tags/users (which load async) are read
// on every keystroke.
export const dslCompletionSource =
(getSource: () => SuggestionSource, getGrammar: () => DslGrammar) =>
(getSource: () => SuggestionSource) =>
(context: CompletionContext): CompletionResult | null => {
const query = context.state.doc.toString();
const { items, ctx } = getSuggestions(
query,
context.pos,
getSource(),
getGrammar(),
);
const { items, ctx } = getSuggestions(query, context.pos, getSource());
if (items.length === 0) {
return null;
}

View File

@@ -1,4 +1,4 @@
// CodeMirror syntax highlighting for the list-filter DSL, driven by the same
// CodeMirror syntax highlighting for the dashboards-list DSL, driven by the same
// tokenizer used for autocomplete (dslTokenizer). Each key / operator / value /
// AND-OR span gets a `cm-dsl-*` class coloured in SearchBar.module.scss.
import { RangeSetBuilder } from '@codemirror/state';
@@ -10,7 +10,7 @@ import {
type ViewUpdate,
} from '@codemirror/view';
import { scanTerm, splitTopLevelTerms } from '../dsl/dslTokenizer';
import { scanTerm, splitTopLevelTerms } from '../../utils/dslTokenizer';
const KEY = Decoration.mark({ class: 'cm-dsl-key' });
const OP = Decoration.mark({ class: 'cm-dsl-op' });

View File

@@ -2,25 +2,20 @@ import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { PanelLeftClose, PanelLeftOpen } from '@signozhq/icons';
import styles from './ListStatusBar.module.scss';
import styles from './StatusBar.module.scss';
interface Props {
collapsed: boolean;
onToggleCollapse: () => void;
count: number;
total: number;
/** Plural noun for the counted items, e.g. "dashboards". */
noun: string;
testId: string;
}
function ListStatusBar({
function StatusBar({
collapsed,
onToggleCollapse,
count,
total,
noun,
testId,
}: Props): JSX.Element {
return (
<div className={styles.statusBar}>
@@ -32,15 +27,15 @@ function ListStatusBar({
collapsed ? <PanelLeftOpen size={14} /> : <PanelLeftClose size={14} />
}
onClick={onToggleCollapse}
testId={testId}
testId="dashboards-rail-toggle"
>
{collapsed ? 'Expand' : 'Collapse'}
</Button>
<Typography.Text className={styles.count}>
{count} of {total} {noun}
{count} of {total} dashboards
</Typography.Text>
</div>
);
}
export default ListStatusBar;
export default StatusBar;

View File

@@ -5,7 +5,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import styles from './ViewNamePopover.module.scss';
import styles from './ViewsRail.module.scss';
export const VIEW_NAME_MAX_LENGTH = 64;

View File

@@ -9,6 +9,10 @@
transition: width 0.18s cubic-bezier(0.2, 0.7, 0.3, 1);
}
.denied {
padding: 16px 12px;
}
.collapsed {
width: 0;
border-right-color: transparent;
@@ -191,3 +195,30 @@
flex-wrap: wrap;
gap: 6px;
}
.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;
}

View File

@@ -0,0 +1,342 @@
import {
type ChangeEvent,
type ReactElement,
useCallback,
useState,
} from 'react';
import logEvent from 'api/common/logEvent';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { Bookmark, PenLine, Plus, Search, Trash2 } from '@signozhq/icons';
import cx from 'classnames';
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
import { DashboardListEvents } from 'pages/DashboardsListPage/constants/events';
import type { SavedView } from '../../types';
import { type BuiltinView } from '../../utils/views';
import ViewNamePopover from './ViewNamePopover';
import styles from './ViewsRail.module.scss';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
import { LIST_CHECKS } from 'pages/DashboardsListPage/constants/permissions';
interface Props {
activeViewId: string;
builtinViews: BuiltinView[];
customViews: SavedView[];
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;
}
interface ViewRow {
id: string;
label: string;
icon: BuiltinView['icon'];
deletable?: boolean;
}
// Purely presentational — active view, dirty state, and handlers come from
// `useActiveView`.
function ViewsRail({
activeViewId,
builtinViews,
customViews,
customViewsLoading,
isCustomActive,
isModified,
collapsed = false,
onSelect,
onSave,
onSaveChanges,
onReset,
onDelete,
onRename,
}: Props): JSX.Element {
const [saveOpen, setSaveOpen] = useState(false);
const [renamingId, setRenamingId] = useState<string | null>(null);
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: 'This removes the saved view. Your dashboards are not affected.',
// 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> => {
void logEvent(DashboardListEvents.ViewDeleted, {});
return onDelete(id);
},
});
},
[confirmDelete, onDelete],
);
const handleSaveAsView = (name: string): void => {
void logEvent(DashboardListEvents.ViewSaved, { mode: 'new' });
onSave(name);
};
const handleSaveViewChanges = (): void => {
void logEvent(DashboardListEvents.ViewSaved, { mode: 'update' });
onSaveChanges();
};
const renderItem = (row: ViewRow): JSX.Element => {
const Icon = row.icon;
const active = row.id === activeViewId;
return (
<div key={row.id} className={cx(styles.row, { [styles.rowActive]: active })}>
<Button
size="md"
variant="ghost"
color="secondary"
width="100%"
onClick={(): void => onSelect(row.id)}
testId={`dashboards-view-${row.id}`}
prefix={<Icon size={16} className={styles.itemIcon} />}
suffix={
active && isModified ? (
<div className={styles.dirtyDot} title="Unsaved changes" />
) : undefined
}
>
{row.label}
</Button>
{row.deletable && (
<div className={styles.itemActions}>
<ViewNamePopover
open={renamingId === row.id}
onOpenChange={(open): void => setRenamingId(open ? row.id : null)}
onSubmit={(name): void => {
void logEvent(DashboardListEvents.ViewRenamed, {});
onRename(row.id, name);
}}
title="Rename view"
confirmLabel="Rename"
initialName={row.label}
testIdPrefix="rename-view"
tooltip="Rename view"
trigger={
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label="Rename view"
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"
onClick={(e): void => {
e.stopPropagation();
onConfirmDelete(row.id, row.label);
}}
>
<Trash2 size={12} />
</Button>
</Tooltip>
</div>
)}
</div>
);
};
return (
<aside className={cx(styles.rail, { [styles.collapsed]: collapsed })}>
<AuthZGuardContent
checks={LIST_CHECKS}
onFailRenderContent={false}
fallback={({ deniedPermissions }): ReactElement => (
<div className={styles.denied} data-testid="views-rail-denied">
<PermissionDeniedCallout deniedPermissions={deniedPermissions} />
</div>
)}
>
<>
<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="save-view"
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="dashboards-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="dashboards-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((v) => renderItem(v))}
</>
)}
{system.length > 0 && (
<>
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
System
</div>
{system.map((v) => renderItem(v))}
</>
)}
{(!q || custom.length > 0) && (
<>
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
My views
<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) =>
renderItem({
id: v.id,
label: v.name,
icon: Bookmark,
deletable: true,
}),
)
)}
</>
)}
{noMatches && (
<div className={styles.searchEmpty}>
No views match &ldquo;{query}&rdquo;
</div>
)}
</div>
{isCustomActive && isModified && (
<div className={styles.dirtyPanel}>
<div className={styles.dirtyTitle}>Unsaved changes</div>
<div className={styles.dirtyActions}>
<Button
variant="solid"
color="primary"
size="sm"
onClick={handleSaveViewChanges}
testId="dashboards-view-save-changes"
>
Save
</Button>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={(): void => setSaveOpen(true)}
>
Save as…
</Button>
<Button variant="ghost" color="secondary" size="sm" onClick={onReset}>
Reset
</Button>
</div>
</div>
)}
{!isCustomActive && isModified && (
<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={(): void => setSaveOpen(true)}
testId="dashboards-view-save-as-new"
>
Save as new view
</Button>
<Button variant="ghost" color="secondary" size="sm" onClick={onReset}>
Reset
</Button>
</div>
</div>
)}
{contextHolder}
</>
</AuthZGuardContent>
</aside>
);
}
export default ViewsRail;

View File

@@ -4,21 +4,21 @@ import { render, screen } from 'tests/test-utils';
import { setupAuthzAllow } from 'lib/authz/utils/authz-test-utils';
import { DashboardListPermission } from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
import ViewsRailGuard from 'pages/DashboardsListPage/components/DashboardsList/ViewsRailGuard';
import type { BuiltinListView } from '../../types';
import type { BuiltinView } from '../../../utils/views';
import ViewsRail from '../ViewsRail';
const BUILTIN_VIEWS: BuiltinListView[] = [
const BUILTIN_VIEWS: BuiltinView[] = [
{
id: 'all',
label: 'All dashboards',
icon: Bookmark,
section: 'system',
},
} as BuiltinView,
];
const CUSTOM_VIEWS = [{ id: 'view-1', name: 'My prod dashboards' }];
const CUSTOM_VIEWS = [
{ id: 'view-1', name: 'My prod dashboards', query: '', userEmail: 'a@b.co' },
];
function renderRail(
props?: Partial<React.ComponentProps<typeof ViewsRail>>,
@@ -27,7 +27,7 @@ function renderRail(
<ViewsRail
activeViewId="view-1"
builtinViews={BUILTIN_VIEWS}
customViews={CUSTOM_VIEWS}
customViews={CUSTOM_VIEWS as never}
customViewsLoading={false}
isCustomActive
isModified
@@ -37,8 +37,6 @@ function renderRail(
onReset={jest.fn()}
onDelete={jest.fn()}
onRename={jest.fn()}
testIdPrefix="dashboards"
guard={(content): JSX.Element => <ViewsRailGuard>{content}</ViewsRailGuard>}
{...props}
/>,
);

View File

@@ -1,27 +1,27 @@
import { useCallback, useMemo } from 'react';
import { type Options, parseAsString, useQueryState } from 'nuqs';
import type {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
BuiltinListView,
SavedListView,
} from 'components/ListViews/types';
import { useListViews } from 'hooks/listViews/useListViews';
import { areQueriesEqual } from '../utils/filterQuery';
import { BuiltinViewId } from '../types';
import type { SavedView } from '../types';
import {
BUILTIN_VIEWS,
DEFAULT_DASHBOARD_VIEW,
findBuiltinView,
} from '../list.config';
import type { DashboardViewCtx, DashboardViewFilter } from '../types';
import { areViewFiltersEqual } from '../utils/views';
type BuiltinView,
builtinViewQuery,
isClientView,
} from '../utils/views';
import { useSavedViews } from './useSavedViews';
const opts: Options = { history: 'push' };
interface UseActiveViewArgs {
query: string;
setQuery: (next: string) => void;
ctx: DashboardViewCtx;
userEmail: string;
sortColumn: DashboardtypesListSortDTO;
sortOrder: DashboardtypesListOrderDTO;
setSortColumn: (column: DashboardtypesListSortDTO) => void;
@@ -32,8 +32,8 @@ interface UseActiveViewArgs {
export interface UseActiveViewResult {
activeViewId: string;
builtinViews: readonly BuiltinListView[];
customViews: SavedListView<DashboardViewFilter>[];
builtinViews: BuiltinView[];
customViews: SavedView[];
customViewsLoading: boolean;
isCustomActive: boolean;
// The current query diverges from the active view's canonical query.
@@ -48,62 +48,159 @@ export interface UseActiveViewResult {
renameView: (id: string, name: string) => void;
}
// Dashboards' binding of `useListViews`: a view is a `{ query, sort, order }`
// filter, built-in views seed only the query, and dirty detection is a trimmed
// query compare.
// Orchestrates the active view: which view is selected (URL `view` param),
// merging built-in + org-shared saved views, applying a view's query on select,
// dirty detection, and save/reset/delete via the Views API. A view now simply
// "is" a query string, so dirty detection is a trimmed string compare.
export function useActiveView({
query,
setQuery,
ctx,
userEmail,
sortColumn,
sortOrder,
setSortColumn,
setSortOrder,
canListDashboards,
}: UseActiveViewArgs): UseActiveViewResult {
const api = useSavedViews({ enabled: canListDashboards });
const filter = useMemo<DashboardViewFilter>(
() => ({ query, sort: sortColumn, order: sortOrder }),
[query, sortColumn, sortOrder],
const [activeViewId, setActiveViewId] = useQueryState(
'view',
parseAsString.withDefault(BuiltinViewId.All).withOptions(opts),
);
const applyFilter = useCallback(
(next: DashboardViewFilter): void => {
setQuery(next.query);
if (next.sort !== sortColumn) {
setSortColumn(next.sort);
const {
views: customViews,
isLoading: customViewsLoading,
createView,
updateView,
deleteView,
} = useSavedViews({ enabled: canListDashboards });
const activeCustom = useMemo(
() => customViews.find((v) => v.id === activeViewId),
[customViews, activeViewId],
);
// The query the active view "is" — used to detect divergence.
const canonicalQuery = useMemo<string | null>(
() =>
activeCustom
? activeCustom.query
: builtinViewQuery(activeViewId, userEmail),
[activeCustom, activeViewId, userEmail],
);
const isModified =
canonicalQuery !== null && !areQueriesEqual(query, canonicalQuery);
const selectView = useCallback(
(id: string): void => {
void setActiveViewId(id);
const custom = customViews.find((v) => v.id === id);
if (custom) {
setQuery(custom.query);
setSortColumn(custom.sort);
setSortOrder(custom.order);
return;
}
if (next.order !== sortOrder) {
setSortOrder(next.order);
setQuery(builtinViewQuery(id, userEmail) ?? '');
},
[
setActiveViewId,
customViews,
setQuery,
userEmail,
setSortColumn,
setSortOrder,
],
);
const saveView = useCallback(
(name: string): void => {
void (async (): Promise<void> => {
const created = await createView({
name,
query,
sort: sortColumn,
order: sortOrder,
});
if (created) {
void setActiveViewId(created.id);
}
})();
},
[query, createView, sortColumn, sortOrder, setActiveViewId],
);
const saveActiveView = useCallback((): void => {
if (!activeCustom) {
return;
}
updateView(activeCustom.id, {
name: activeCustom.name,
query,
sort: sortColumn,
order: sortOrder,
});
}, [activeCustom, query, updateView, sortColumn, sortOrder]);
const resetView = useCallback((): void => {
if (canonicalQuery === null) {
return;
}
setQuery(canonicalQuery);
if (activeCustom) {
setSortColumn(activeCustom.sort);
setSortOrder(activeCustom.order);
}
}, [canonicalQuery, setQuery, activeCustom, setSortColumn, setSortOrder]);
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(BuiltinViewId.All);
setQuery('');
}
},
[setQuery, sortColumn, sortOrder, setSortColumn, setSortOrder],
[deleteView, activeViewId, setActiveViewId, setQuery],
);
const views = useListViews<DashboardViewFilter, DashboardViewCtx>({
filter,
applyFilter,
isEqual: areViewFiltersEqual,
builtinViews: BUILTIN_VIEWS,
ctx,
defaultViewId: DEFAULT_DASHBOARD_VIEW,
api,
});
// Rename only touches the view's name; its stored query/sort/order are preserved.
const renameView = useCallback(
(id: string, name: string): void => {
const view = customViews.find((v) => v.id === id);
if (!view) {
return;
}
updateView(id, {
name,
query: view.query,
sort: view.sort,
order: view.order,
});
},
[customViews, updateView],
);
return {
activeViewId: views.activeViewId,
activeViewId,
builtinViews: BUILTIN_VIEWS,
customViews: views.customViews,
customViewsLoading: api.isLoading,
isCustomActive: views.isCustomActive,
isModified: views.isModified,
clientView: !!findBuiltinView(views.activeViewId)?.clientFilter,
selectView: views.selectView,
saveView: views.saveView,
saveActiveView: views.saveActiveView,
resetView: views.resetView,
removeView: views.removeView,
renameView: views.renameView,
customViews,
customViewsLoading,
isCustomActive: !!activeCustom,
isModified,
clientView: isClientView(activeViewId),
selectView,
saveView,
saveActiveView,
resetView,
removeView,
renameView,
};
}

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { useListUsers } from 'api/generated/services/users';
import type { CreatorOption } from './types';
import type { CreatorOption } from '../components/FilterZone/FilterChips';
interface Args {
currentUserEmail: string;

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { type Options, parseAsString, useQueryState } from 'nuqs';
import { isQueryEmpty } from 'components/ListFilter/dsl/filterQuery';
import { isQueryEmpty } from '../utils/filterQuery';
const opts: Options = { history: 'push' };

View File

@@ -1,14 +0,0 @@
import { useMemo } from 'react';
import { useAppContext } from 'providers/App/App';
import { useDashboardViewsStore } from '../store/useDashboardViewsStore';
import type { DashboardViewCtx } from '../types';
export function useDashboardViewCtx(): DashboardViewCtx {
const { user } = useAppContext();
const recent = useDashboardViewsStore((s) => s.recent);
return useMemo(
() => ({ userEmail: user.email, recent }),
[user.email, recent],
);
}

View File

@@ -16,54 +16,50 @@ import {
type ListDashboardViews200,
} from 'api/generated/services/sigNoz.schemas';
import type {
SavedListView,
SavedListViewsApi,
} from 'components/ListViews/types';
import type { DashboardViewFilter } from '../types';
import type { SavedView, SavedViewInput } from '../types';
// Schema version stamped on the view's data envelope (the backend requires it).
const VIEW_DATA_VERSION = 'v1';
type DashboardSavedView = SavedListView<DashboardViewFilter>;
const toSavedView = (
dto: DashboardtypesDashboardViewDTO,
): DashboardSavedView => ({
const toSavedView = (dto: DashboardtypesDashboardViewDTO): SavedView => ({
id: dto.id,
name: dto.name,
filter: {
query: dto.data.query ?? '',
sort: dto.data.sort ?? DashboardtypesListSortDTO.updated_at,
order: dto.data.order ?? DashboardtypesListOrderDTO.desc,
},
query: dto.data.query ?? '',
sort: dto.data.sort ?? DashboardtypesListSortDTO.updated_at,
order: dto.data.order ?? DashboardtypesListOrderDTO.desc,
});
const toPostable = (
name: string,
filter: DashboardViewFilter,
input: SavedViewInput,
): { name: string; data: DashboardtypesDashboardViewDTO['data'] } => ({
name: name.trim(),
name: input.name.trim(),
data: {
version: VIEW_DATA_VERSION,
query: filter.query,
sort: filter.sort,
order: filter.order,
query: input.query,
sort: input.sort,
order: input.order,
},
});
export interface UseSavedViewsResult {
views: SavedView[];
isLoading: boolean;
createView: (input: SavedViewInput) => Promise<SavedView | null>;
updateView: (id: string, input: SavedViewInput) => void;
deleteView: (id: string) => Promise<void>;
}
/**
* Org-shared saved views, backed by the Views API. `enabled` is the caller's
* `list` grant: the backend gates view CRUD on `dashboard:list`.
*/
export function useSavedViews({
enabled = true,
}: { enabled?: boolean } = {}): SavedListViewsApi<DashboardViewFilter> {
}: { enabled?: boolean } = {}): UseSavedViewsResult {
const queryClient = useQueryClient();
const { data, isLoading } = useListDashboardViews({ query: { enabled } });
const views = useMemo<DashboardSavedView[]>(
const views = useMemo<SavedView[]>(
() => (data?.data?.views ?? []).map(toSavedView),
[data],
);
@@ -116,14 +112,9 @@ export function useSavedViews({
});
const createView = useCallback(
async (
name: string,
filter: DashboardViewFilter,
): Promise<DashboardSavedView | null> => {
async (input: SavedViewInput): Promise<SavedView | null> => {
try {
const res = await createMutation.mutateAsync({
data: toPostable(name, filter),
});
const res = await createMutation.mutateAsync({ data: toPostable(input) });
return res?.data ? toSavedView(res.data) : null;
} catch {
return null;
@@ -133,11 +124,8 @@ export function useSavedViews({
);
const updateView = useCallback(
(id: string, name: string, filter: DashboardViewFilter): void => {
updateMutation.mutate({
pathParams: { id },
data: toPostable(name, filter),
});
(id: string, input: SavedViewInput): void => {
updateMutation.mutate({ pathParams: { id }, data: toPostable(input) });
},
[updateMutation],
);

View File

@@ -1,115 +0,0 @@
import { Clock, Layers, Lock, Pin, 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 { DashboardViewCtx, DashboardViewFilter } from './types';
import type { DashboardListItem } from './utils/helpers';
// Mirrors pkg/types/dashboardtypes/list_filter.go. Any other key is a tag key.
// (`updated_by` and `source` are NOT queryable.)
export const DASHBOARDS_DSL_GRAMMAR = {
fields: [
{ key: 'name', type: 'string' },
{ key: 'description', type: 'string' },
{ key: 'created_by', type: 'string' },
{ key: 'created_at', type: 'timestamp' },
{ key: 'updated_at', type: 'timestamp' },
{ key: 'locked', type: 'bool' },
],
dynamicKey: { detail: 'tag', prefix: '' },
} as const satisfies DslGrammar;
const where: ClauseBuilder<
(typeof DASHBOARDS_DSL_GRAMMAR)['fields'][number]['key']
> = clause;
type DashboardViewConfig = BuiltinViewConfig<
DashboardViewFilter,
DashboardViewCtx,
DashboardListItem
>;
// Built-in views rendered above the user's saved views. Their result set is
// derived (a fixed query fragment or a client-side id set), never persisted.
// Ids double as the URL `view` param, so they must stay stable.
// Built-in views only seed the query; the current sort/order is kept.
const views = [
{
id: 'mine',
label: 'My dashboards',
icon: User,
section: 'personal',
filter: (current, { userEmail }): DashboardViewFilter => ({
...current,
query: userEmail ? where('created_by', '=', userEmail) : '',
}),
emptyState: {
title: "You haven't created any dashboards",
description: 'Dashboards you create will appear here.',
},
},
{
id: 'pinned',
label: 'Pinned',
icon: Pin,
section: 'personal',
filter: (current): DashboardViewFilter => ({ ...current, query: '' }),
clientFilter: (rows): DashboardListItem[] => rows.filter((d) => d.pinned),
emptyState: {
title: 'No pinned dashboards yet',
description: 'Pin a dashboard to keep it handy here.',
},
},
{
id: 'recent',
label: 'Recently viewed',
icon: Clock,
section: 'personal',
filter: (current): DashboardViewFilter => ({ ...current, query: '' }),
// Keeps visit order, whatever the active sort.
clientFilter: (rows, { recent }): DashboardListItem[] => {
const order = new Map(recent.map((id, index) => [id, index]));
return rows
.filter((d) => order.has(d.id))
.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
},
emptyState: {
title: 'No recently viewed dashboards',
description: 'Dashboards you open will appear here.',
},
},
{
id: 'all',
label: 'All dashboards',
icon: Layers,
section: 'system',
filter: (current): DashboardViewFilter => ({ ...current, query: '' }),
},
{
id: 'locked',
label: 'Locked',
icon: Lock,
section: 'system',
filter: (current): DashboardViewFilter => ({
...current,
query: where('locked', '=', true),
}),
emptyState: {
title: 'No locked dashboards',
description: 'Dashboards locked for editing will appear here.',
},
},
] as const satisfies readonly DashboardViewConfig[];
export type DashboardViewId = (typeof views)[number]['id'];
export const BUILTIN_VIEWS: readonly DashboardViewConfig[] = views;
export const DEFAULT_DASHBOARD_VIEW: DashboardViewId = 'all';
export const findBuiltinView = (id: string): DashboardViewConfig | undefined =>
BUILTIN_VIEWS.find((v) => v.id === id);

View File

@@ -10,11 +10,7 @@ import {
type RestRequest,
} from 'msw';
import logEvent from 'api/common/logEvent';
import {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
type GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import {
expect,
@@ -33,22 +29,19 @@ import {
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
orgUsersResponse,
STORY_USER_EMAIL,
} from './__story_mockdata__/orgUsers';
import { dashboardResponse } from '../../DashboardPage/stories/__story_mockdata__/dashboard';
import {
dashboardIdAt,
dashboardViewsResponse,
dashboardsListResponse,
orgUsersResponse,
PIN_WRITES,
recentDashboardIds,
ROW_MARKERS,
savedView,
seedPinnedDashboards,
setDashboardPinned,
STORY_USER_EMAIL,
TEMPLATE_REQUESTS,
TOOLTIP_TAGS,
WRITE_STATES,
@@ -62,24 +55,21 @@ import {
type DashboardDynamicColumns,
useDashboardsListVisibleColumnsStore,
} from '../store/useVisibleColumnsStore';
import {
DEFAULT_DASHBOARD_VIEW,
type DashboardViewId,
findBuiltinView,
} from '../list.config';
import { BuiltinViewId } from '../types';
import { builtinViewQuery } from '../utils/views';
const LIST = 'Dashboards · list';
const VIEWS = 'Dashboards · views';
const WRITES = 'Dashboards · writes';
const VIEWS_OPTIONS = [
'all',
'mine',
'pinned',
'recent',
'locked',
BuiltinViewId.All,
BuiltinViewId.Mine,
BuiltinViewId.Pinned,
BuiltinViewId.Recent,
BuiltinViewId.Locked,
'saved',
] as const satisfies readonly (DashboardViewId | 'saved')[];
] as const;
type ViewOption = (typeof VIEWS_OPTIONS)[number];
@@ -153,18 +143,7 @@ const listRoute = (view: ViewOption): string => {
const { id, query } =
view === 'saved'
? savedView(0)
: {
id: view,
query:
findBuiltinView(view)?.filter(
{
query: '',
sort: DashboardtypesListSortDTO.updated_at,
order: DashboardtypesListOrderDTO.desc,
},
{ userEmail: STORY_USER_EMAIL, recent: [] },
).query ?? '',
};
: { id: view, query: builtinViewQuery(view, STORY_USER_EMAIL) ?? '' };
const params = new URLSearchParams({ view: id });
@@ -175,12 +154,6 @@ const listRoute = (view: ViewOption): string => {
return `${ROUTES.ALL_DASHBOARD}?${params.toString()}`;
};
export const longQueryRoute = `${ROUTES.ALL_DASHBOARD}?${new URLSearchParams({
view: 'all',
query:
"name CONTAINS 'Kubernetes' OR name CONTAINS 'Checkout' OR name CONTAINS 'Postgres' OR name CONTAINS 'Kafka' OR name CONTAINS 'Redis' OR name CONTAINS 'Nginx'",
}).toString()}`;
const visibleColumns = (
columns: readonly DetailColumn[],
): DashboardDynamicColumns => ({
@@ -305,7 +278,7 @@ export const dashboardsListMocks = defineStoryMocks({
description:
'The rail entry the page opens on. Pinned and Recently viewed constrain the fetched rows client-side; the rest apply a query.',
options: VIEWS_OPTIONS,
value: DEFAULT_DASHBOARD_VIEW,
value: BuiltinViewId.All,
}),
savedViews: countControl('Saved views', {
group: VIEWS,
@@ -592,9 +565,9 @@ export const retryMigration = async (
const retry = await screen.findByTestId('legacy-dashboard-retry-migration');
await waitFor(
async () => {
await expect(retry).toBeEnabled();
await expect(retry).not.toHaveAttribute('aria-disabled', 'true');
() => {
expect(retry).toBeEnabled();
expect(retry).not.toHaveAttribute('aria-disabled', 'true');
},
{ timeout: TOAST_TIMEOUT },
);

View File

@@ -1,42 +1,22 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, screen, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
clickRowAction,
dashboardsListMocks,
longQueryRoute,
openLegacyDialog,
openRowActions,
overflowingRows,
pickRowAction,
requestTemplate,
retryMigration,
TOAST_TIMEOUT,
uploadDashboardJson,
} from './DashboardsListPage.stories.mocks';
import {
copyLegacyDashboardId,
createDashboardFails,
deleteDashboard,
deleteViewFails,
duplicateDashboard,
importCreateFails,
importInvalidImage,
importInvalidJson,
lockDashboard,
migrateDashboard,
migrationFails,
openCreatedByFilter,
openEditTagsDialog,
openFirstRowActions,
openRenameDialog,
openSaveViewPopover,
openTemplateTab,
pinFails,
pinPastLimit,
renameDashboard,
renameViewFails,
saveViewFails,
submitTemplateRequest,
templateRequestFails,
templateRequestRejected,
unlockDashboard,
unpinFails,
updateTags,
} from './__story_mockdata__/dashboardsList.play';
import { BuiltinViewId } from '../types';
import DashboardsListPage from '../DashboardsListPage';
@@ -92,22 +72,24 @@ export const Viewer: Story = {
/** The rows the user pinned, which the page filters out of the fetched page. */
export const Pinned: Story = {
args: { view: 'pinned' },
};
/** A query long enough to wrap onto a second line in the query box. */
export const LongQuery: Story = {
parameters: { signoz: { route: longQueryRoute } },
};
/** The Created-by dropdown, open with one creator picked. */
export const CreatedByFilterOpen: Story = {
play: openCreatedByFilter,
args: { view: BuiltinViewId.Pinned },
};
/** The template tab of the New dashboard dialog. */
export const NewDashboardTemplateTab: Story = {
play: openTemplateTab,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId('new-dashboard-cta', {}, { timeout: 10000 }),
);
await expect(
await screen.findByRole('dialog', {}, { timeout: 10000 }),
).toHaveTextContent('New dashboard');
await userEvent.click(await screen.findByText('From a template'));
await screen.findByText('Dashboard templates');
},
};
/**
@@ -116,7 +98,18 @@ export const NewDashboardTemplateTab: Story = {
* `UPSTREAM_UNAVAILABLE` over the panel's own inline feedback. See BUGS.md 53.
*/
export const NewDashboardImportJsonInvalid: Story = {
play: importInvalidJson,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId('new-dashboard-cta', {}, { timeout: 10000 }),
);
await userEvent.click(await screen.findByText('Import JSON'));
await userEvent.click(await screen.findByTestId('import-json-submit'));
await screen.findByText(/error loading json/i);
// The error modal and the toast under it carry the same message.
await screen.findAllByText('Unexpected end of JSON input');
},
};
/**
@@ -131,146 +124,410 @@ export const Tooltips: Story = {
/** The first row's actions menu, open over the list. */
export const RowActionsMenu: Story = {
play: openFirstRowActions,
play: async ({ canvasElement }) => {
await openRowActions(canvasElement, 0);
},
};
/** The rename dialog, opened from the menu of the second row (the first is locked). */
export const RenameDashboardDialog: Story = {
play: openRenameDialog,
play: async ({ canvasElement }) => {
await openRowActions(canvasElement, 1);
await pickRowAction('Rename');
await screen.findByRole('dialog', { name: 'Rename dashboard' });
},
};
/** The tags dialog, opened from the menu of the second row (the first is locked). */
export const EditTagsDialog: Story = {
play: openEditTagsDialog,
play: async ({ canvasElement }) => {
await openRowActions(canvasElement, 1);
await pickRowAction(/^(Edit|Add) Tags$/);
await screen.findByRole('dialog', { name: /^(Edit|Add) tags$/ });
},
};
/** The popover that names the current filters as a new saved view. */
export const SaveViewPopover: Story = {
play: openSaveViewPopover,
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: 'Save current filters as a view' },
{ timeout: 10000 },
),
);
await screen.findByText('Save as view');
},
};
/** The renamed toast, raised once the PATCH answers. */
export const DashboardRenamedToast: Story = {
play: renameDashboard,
play: async ({ canvasElement, args }) => {
await openRowActions(canvasElement, 1);
await pickRowAction('Rename');
const input = await screen.findByTestId('rename-dashboard-input');
await userEvent.clear(input);
await userEvent.type(input, 'API latency overview');
await userEvent.click(await screen.findByTestId('rename-dashboard-submit'));
if (args.rowWrite === 'success') {
await screen.findByText('Dashboard renamed', {}, { timeout: TOAST_TIMEOUT });
}
},
};
/** The tags toast, raised once the PATCH answers. */
export const TagsUpdatedToast: Story = {
play: updateTags,
play: async ({ canvasElement, args }) => {
await openRowActions(canvasElement, 1);
await pickRowAction(/^(Edit|Add) Tags$/);
const input = await screen.findByPlaceholderText('key:value (press Enter)');
await userEvent.type(input, 'owner:sre{Enter}');
await userEvent.click(await screen.findByTestId('edit-tags-submit'));
if (args.rowWrite === 'success') {
await screen.findByText('Tags updated', {}, { timeout: TOAST_TIMEOUT });
}
},
};
/** Duplicate from the row menu: the toast names the source dashboard. */
export const DashboardDuplicatedToast: Story = {
play: duplicateDashboard,
play: async ({ canvasElement, args }) => {
await openRowActions(canvasElement, 1);
await clickRowAction('dashboard-action-duplicate');
if (args.rowWrite === 'success') {
await screen.findByText(
'Duplicated "API latency and errors"',
{},
{ timeout: TOAST_TIMEOUT },
);
}
},
};
/** Lock from the row menu of an unlocked dashboard. */
export const DashboardLockedToast: Story = {
play: lockDashboard,
play: async ({ canvasElement, args }) => {
await openRowActions(canvasElement, 1);
await clickRowAction('dashboard-action-lock');
if (args.rowWrite === 'success') {
await screen.findByText('Dashboard locked', {}, { timeout: TOAST_TIMEOUT });
}
},
};
/** Unlock from the row menu of the first row, which is locked. */
export const DashboardUnlockedToast: Story = {
play: unlockDashboard,
play: async ({ canvasElement, args }) => {
await openRowActions(canvasElement, 0);
await clickRowAction('dashboard-action-lock');
if (args.rowWrite === 'success') {
await screen.findByText(
'Dashboard unlocked',
{},
{ timeout: TOAST_TIMEOUT },
);
}
},
};
/** Delete confirmed in the dialog the row menu opens. */
export const DashboardDeletedToast: Story = {
play: deleteDashboard,
play: async ({ canvasElement, args }) => {
await openRowActions(canvasElement, 1);
await clickRowAction('dashboard-action-delete');
const dialog = await screen.findByRole('dialog');
await userEvent.click(
await within(dialog).findByRole('button', { name: 'Delete' }),
);
if (args.rowWrite === 'success') {
await screen.findByText(
'Dashboard deleted successfully',
{},
{ timeout: TOAST_TIMEOUT },
);
}
},
};
/** Pinning past the limit: the backend refuses with a 409. */
export const PinLimitToast: Story = {
args: { pinWrite: 'limit' },
play: pinPastLimit,
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByTestId(
'dashboard-pin-3',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await screen.findByText(
/You can pin up to 10 dashboards/,
{},
{ timeout: TOAST_TIMEOUT },
);
},
parameters: { allowConsoleErrors: true },
};
/** A pin the backend failed to write. */
export const PinFailedToast: Story = {
args: { pinWrite: 'error' },
play: pinFails,
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByTestId(
'dashboard-pin-3',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await screen.findByText(
'Failed to pin dashboard.',
{},
{ timeout: TOAST_TIMEOUT },
);
},
parameters: { allowConsoleErrors: true },
};
/** An unpin the backend failed to write, from the first (pinned) row. */
export const UnpinFailedToast: Story = {
args: { pinWrite: 'error' },
play: unpinFails,
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByTestId(
'dashboard-pin-0',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await screen.findByText(
'Failed to unpin dashboard.',
{},
{ timeout: TOAST_TIMEOUT },
);
},
parameters: { allowConsoleErrors: true },
};
/** The id copied from the legacy dashboard dialog. */
export const LegacyDashboardIdCopiedToast: Story = {
play: copyLegacyDashboardId,
play: async ({ canvasElement }) => {
await openLegacyDialog(canvasElement);
await userEvent.click(await screen.findByTestId('legacy-dashboard-copy-id'));
await screen.findByText(
'Dashboard ID copied',
{},
{ timeout: TOAST_TIMEOUT },
);
},
};
/** Retry migration of the legacy dashboard succeeding. */
export const DashboardMigratedToast: Story = {
play: migrateDashboard,
play: async ({ canvasElement, args }) => {
await retryMigration(canvasElement);
if (args.migrationWrite === 'success') {
await screen.findByText(
'Dashboard migrated to the new experience',
{},
{ timeout: TOAST_TIMEOUT },
);
}
},
};
/** Retry migration of the legacy dashboard refused by the backend. */
export const DashboardMigrationFailedToast: Story = {
args: { migrationWrite: 'error' },
play: migrationFails,
play: async ({ canvasElement }) => {
await retryMigration(canvasElement);
await screen.findByText(
'Something went wrong',
{},
{ timeout: TOAST_TIMEOUT },
);
},
parameters: { allowConsoleErrors: true },
};
/** The Blank tab's create failing: the toast raised beside the error modal. */
export const CreateDashboardFailedToast: Story = {
args: { createWrite: 'error' },
play: createDashboardFails,
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByTestId(
'new-dashboard-cta',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await userEvent.click(await screen.findByTestId('create-dashboard-submit'));
await screen.findAllByText(
/Something went wrong/,
{},
{ timeout: TOAST_TIMEOUT },
);
},
parameters: { allowConsoleErrors: true },
};
const IMPORTED_SPEC = { layouts: [], panels: {}, variables: [] };
/** A dashboard file whose `image` is neither an asset path nor base64. */
export const ImportJsonInvalidImageToast: Story = {
play: importInvalidImage,
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByTestId(
'new-dashboard-cta',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await uploadDashboardJson({
schemaVersion: 'v6',
image: 'https://example.com/logo.png',
spec: { display: { name: 'Imported' }, ...IMPORTED_SPEC },
});
await screen.findByText(
/Dashboard "image" must be/,
{},
{ timeout: TOAST_TIMEOUT },
);
},
};
/** A valid dashboard file the create endpoint refuses. */
export const ImportJsonCreateFailedToast: Story = {
args: { createWrite: 'error' },
play: importCreateFails,
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByTestId(
'new-dashboard-cta',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await uploadDashboardJson({
schemaVersion: 'v6',
spec: { display: { name: 'Imported' }, ...IMPORTED_SPEC },
});
await screen.findAllByText(
/Something went wrong/,
{},
{ timeout: TOAST_TIMEOUT },
);
},
parameters: { allowConsoleErrors: true },
};
/** A template request the analytics endpoint accepted. */
export const TemplateRequestSubmittedToast: Story = {
play: submitTemplateRequest,
play: async ({ canvasElement }) => {
await requestTemplate(canvasElement);
await screen.findByText(
'Dashboard request submitted',
{},
{ timeout: TOAST_TIMEOUT },
);
},
};
/** A template request the analytics endpoint answered with a failure. */
export const TemplateRequestFailedToast: Story = {
args: { templateRequest: 'error' },
play: templateRequestFails,
play: async ({ canvasElement }) => {
await requestTemplate(canvasElement);
await screen.findByText('Event rejected', {}, { timeout: TOAST_TIMEOUT });
},
};
/** A template request that threw before an answer came back. */
export const TemplateRequestRejectedToast: Story = {
args: { templateRequest: 'rejected' },
play: templateRequestRejected,
play: async ({ canvasElement }) => {
await requestTemplate(canvasElement);
await screen.findByText(
'Something went wrong',
{},
{ timeout: TOAST_TIMEOUT },
);
},
};
/** Saving the current filters as a view, refused by the backend. */
export const SaveViewFailedToast: Story = {
args: { viewWrite: 'error' },
play: saveViewFails,
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: 'Save current filters as a view' },
{ timeout: TOAST_TIMEOUT },
),
);
await userEvent.type(await screen.findByTestId('save-view-name'), 'On-call');
await userEvent.click(await screen.findByTestId('save-view-confirm'));
await screen.findByText(
'Failed to save view.',
{},
{ timeout: TOAST_TIMEOUT },
);
},
parameters: { allowConsoleErrors: true },
};
/** Renaming a saved view, refused by the backend. */
export const RenameViewFailedToast: Story = {
args: { viewWrite: 'error' },
play: renameViewFails,
play: async ({ canvasElement }) => {
const [rename] = await within(canvasElement).findAllByRole(
'button',
{ name: 'Rename view' },
{ timeout: TOAST_TIMEOUT },
);
// The actions only take pointer events while the row is hovered by CSS.
await userEvent.click(rename, { pointerEventsCheck: 0 });
const input = await screen.findByTestId('rename-view-name');
await userEvent.clear(input);
await userEvent.type(input, 'Renamed view');
await userEvent.click(await screen.findByTestId('rename-view-confirm'));
await screen.findByText(
'Failed to update view.',
{},
{ timeout: TOAST_TIMEOUT },
);
},
parameters: { allowConsoleErrors: true },
};
/** Deleting a saved view, refused by the backend. */
export const DeleteViewFailedToast: Story = {
args: { viewWrite: 'error' },
play: deleteViewFails,
play: async ({ canvasElement }) => {
const [remove] = await within(canvasElement).findAllByRole(
'button',
{ name: 'Delete view' },
{ timeout: TOAST_TIMEOUT },
);
await userEvent.click(remove, { pointerEventsCheck: 0 });
const dialog = await screen.findByRole('dialog');
await userEvent.click(
await within(dialog).findByRole('button', { name: 'Delete' }),
);
await screen.findByText(
'Failed to delete view.',
{},
{ timeout: TOAST_TIMEOUT },
);
},
parameters: { allowConsoleErrors: true },
};

View File

@@ -1,433 +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 {
clickRowAction,
dashboardsListMocks,
openLegacyDialog,
openRowActions,
pickRowAction,
requestTemplate,
retryMigration,
TOAST_TIMEOUT,
uploadDashboardJson,
} from '../DashboardsListPage.stories.mocks';
interface PlayContext {
canvasElement: HTMLElement;
args: PageStoryArgs<typeof dashboardsListMocks>;
}
const IMPORTED_SPEC = { layouts: [], panels: {}, variables: [] };
export const openCreatedByFilter = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const filter = await within(canvasElement).findByTestId(
'dashboards-filter-created-by',
{},
{ timeout: TOAST_TIMEOUT },
);
await userEvent.click(filter.querySelector('.ant-select-selector') ?? filter);
await userEvent.click(await screen.findByText('Ada (ada@signoz.io)'));
await screen.findByRole('checkbox', { checked: true });
};
export const openTemplateTab = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId('new-dashboard-cta', {}, { timeout: 10000 }),
);
await expect(
await screen.findByRole('dialog', {}, { timeout: 10000 }),
).toHaveTextContent('New dashboard');
await userEvent.click(await screen.findByText('From a template'));
await screen.findByText('Dashboard templates');
};
export const importInvalidJson = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId('new-dashboard-cta', {}, { timeout: 10000 }),
);
await userEvent.click(await screen.findByText('Import JSON'));
await userEvent.click(await screen.findByTestId('import-json-submit'));
await screen.findByText(/error loading json/i);
// The error modal and the toast under it carry the same message.
await screen.findAllByText('Unexpected end of JSON input');
};
export const openFirstRowActions = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await openRowActions(canvasElement, 0);
};
export const openRenameDialog = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await openRowActions(canvasElement, 1);
await pickRowAction('Rename');
await screen.findByRole('dialog', { name: 'Rename dashboard' });
};
export const openEditTagsDialog = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await openRowActions(canvasElement, 1);
await pickRowAction(/^(Edit|Add) Tags$/);
await screen.findByRole('dialog', { name: /^(Edit|Add) tags$/ });
};
export const openSaveViewPopover = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: 'Save current filters as a view' },
{ timeout: 10000 },
),
);
await screen.findByText('Save as view');
};
export const renameDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await openRowActions(canvasElement, 1);
await pickRowAction('Rename');
const input = await screen.findByTestId('rename-dashboard-input');
await userEvent.clear(input);
await userEvent.type(input, 'API latency overview');
await userEvent.click(await screen.findByTestId('rename-dashboard-submit'));
if (args.rowWrite === 'success') {
await screen.findByText('Dashboard renamed', {}, { timeout: TOAST_TIMEOUT });
}
};
export const updateTags = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await openRowActions(canvasElement, 1);
await pickRowAction(/^(Edit|Add) Tags$/);
const input = await screen.findByPlaceholderText('key:value (press Enter)');
await userEvent.type(input, 'owner:sre{Enter}');
await userEvent.click(await screen.findByTestId('edit-tags-submit'));
if (args.rowWrite === 'success') {
await screen.findByText('Tags updated', {}, { timeout: TOAST_TIMEOUT });
}
};
export const duplicateDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await openRowActions(canvasElement, 1);
await clickRowAction('dashboard-action-duplicate');
if (args.rowWrite === 'success') {
await screen.findByText(
'Duplicated "API latency and errors"',
{},
{ timeout: TOAST_TIMEOUT },
);
}
};
export const lockDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await openRowActions(canvasElement, 1);
await clickRowAction('dashboard-action-lock');
if (args.rowWrite === 'success') {
await screen.findByText('Dashboard locked', {}, { timeout: TOAST_TIMEOUT });
}
};
export const unlockDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await openRowActions(canvasElement, 0);
await clickRowAction('dashboard-action-lock');
if (args.rowWrite === 'success') {
await screen.findByText('Dashboard unlocked', {}, { timeout: TOAST_TIMEOUT });
}
};
export const deleteDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await openRowActions(canvasElement, 1);
await clickRowAction('dashboard-action-delete');
const dialog = await screen.findByRole('dialog');
await userEvent.click(
await within(dialog).findByRole('button', { name: 'Delete' }),
);
if (args.rowWrite === 'success') {
await screen.findByText(
'Dashboard deleted successfully',
{},
{ timeout: TOAST_TIMEOUT },
);
}
};
export const pinPastLimit = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'dashboard-pin-3',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await screen.findByText(
/You can pin up to 10 dashboards/,
{},
{ timeout: TOAST_TIMEOUT },
);
};
export const pinFails = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'dashboard-pin-3',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await screen.findByText(
'Failed to pin dashboard.',
{},
{ timeout: TOAST_TIMEOUT },
);
};
export const unpinFails = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'dashboard-pin-0',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await screen.findByText(
'Failed to unpin dashboard.',
{},
{ timeout: TOAST_TIMEOUT },
);
};
export const copyLegacyDashboardId = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await openLegacyDialog(canvasElement);
await userEvent.click(await screen.findByTestId('legacy-dashboard-copy-id'));
await screen.findByText('Dashboard ID copied', {}, { timeout: TOAST_TIMEOUT });
};
export const migrateDashboard = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await retryMigration(canvasElement);
if (args.migrationWrite === 'success') {
await screen.findByText(
'Dashboard migrated to the new experience',
{},
{ timeout: TOAST_TIMEOUT },
);
}
};
export const migrationFails = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await retryMigration(canvasElement);
await screen.findByText(
'Something went wrong',
{},
{ timeout: TOAST_TIMEOUT },
);
};
export const createDashboardFails = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'new-dashboard-cta',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await userEvent.click(await screen.findByTestId('create-dashboard-submit'));
await screen.findAllByText(
/Something went wrong/,
{},
{ timeout: TOAST_TIMEOUT },
);
};
export const importInvalidImage = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'new-dashboard-cta',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await uploadDashboardJson({
schemaVersion: 'v6',
image: 'https://example.com/logo.png',
spec: { display: { name: 'Imported' }, ...IMPORTED_SPEC },
});
await screen.findByText(
/Dashboard "image" must be/,
{},
{ timeout: TOAST_TIMEOUT },
);
};
export const importCreateFails = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'new-dashboard-cta',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await uploadDashboardJson({
schemaVersion: 'v6',
spec: { display: { name: 'Imported' }, ...IMPORTED_SPEC },
});
await screen.findAllByText(
/Something went wrong/,
{},
{ timeout: TOAST_TIMEOUT },
);
};
export const submitTemplateRequest = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await requestTemplate(canvasElement);
await screen.findByText(
'Dashboard request submitted',
{},
{ timeout: TOAST_TIMEOUT },
);
};
export const templateRequestFails = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await requestTemplate(canvasElement);
await screen.findByText('Event rejected', {}, { timeout: TOAST_TIMEOUT });
};
export const templateRequestRejected = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await requestTemplate(canvasElement);
await screen.findByText(
'Something went wrong',
{},
{ timeout: TOAST_TIMEOUT },
);
};
export const saveViewFails = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: 'Save current filters as a view' },
{ timeout: TOAST_TIMEOUT },
),
);
await userEvent.type(await screen.findByTestId('save-view-name'), 'On-call');
await userEvent.click(await screen.findByTestId('save-view-confirm'));
await screen.findByText(
'Failed to save view.',
{},
{ timeout: TOAST_TIMEOUT },
);
};
export const renameViewFails = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const [rename] = await within(canvasElement).findAllByRole(
'button',
{ name: 'Rename view' },
{ timeout: TOAST_TIMEOUT },
);
// The actions only take pointer events while the row is hovered by CSS.
await userEvent.click(rename, { pointerEventsCheck: 0 });
const input = await screen.findByTestId('rename-view-name');
await userEvent.clear(input);
await userEvent.type(input, 'Renamed view');
await userEvent.click(await screen.findByTestId('rename-view-confirm'));
await screen.findByText(
'Failed to update view.',
{},
{ timeout: TOAST_TIMEOUT },
);
};
export const deleteViewFails = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const [remove] = await within(canvasElement).findAllByRole(
'button',
{ name: 'Delete view' },
{ timeout: TOAST_TIMEOUT },
);
await userEvent.click(remove, { pointerEventsCheck: 0 });
const dialog = await screen.findByRole('dialog');
await userEvent.click(
await within(dialog).findByRole('button', { name: 'Delete' }),
);
await screen.findByText(
'Failed to delete view.',
{},
{ timeout: TOAST_TIMEOUT },
);
};

View File

@@ -10,10 +10,24 @@ import {
type DashboardtypesListedDashboardForUserV2DTO,
type ListDashboardViews200,
type ListDashboardsForUserV2200,
type ListUsers200,
type TagtypesGettableTagDTO,
} from 'api/generated/services/sigNoz.schemas';
import { createAppContextMock } from 'tests/fixtures/appContextMock';
import { USER_ROLES } from 'types/roles';
import { STORY_USER_EMAIL, TEAMMATE_EMAILS } from './orgUsers';
/**
* "My dashboards" matches on the signed-in address, so the rows have to be
* created by the same user the providers mount.
*/
export const STORY_USER_EMAIL = createAppContextMock(USER_ROLES.ADMIN).user
.email;
const TEAMMATE_EMAILS = [
'ada@signoz.io',
'grace@signoz.io',
'linus@signoz.io',
] as const;
const HOUR = 60 * 60 * 1000;
@@ -310,6 +324,21 @@ export const dashboardViewsResponse = (
});
/** The org's users, which is where the Created-by dropdown gets its options. */
export const orgUsersResponse = (): ListUsers200 => ({
status: 'success',
data: [
{
id: 'storybook-user-me',
email: STORY_USER_EMAIL,
displayName: 'John Doe',
},
...TEAMMATE_EMAILS.map((email, index) => ({
id: `storybook-user-${index + 1}`,
email,
displayName: email.split('@')[0].replace(/^./, (c) => c.toUpperCase()),
})),
],
});
/** Ids the Recently-viewed rail entry reads out of local state. */
export const recentDashboardIds = (count: number): string[] =>

View File

@@ -1,37 +0,0 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import type { ListUsers200 } from 'api/generated/services/sigNoz.schemas';
import { createAppContextMock } from 'tests/fixtures/appContextMock';
import { USER_ROLES } from 'types/roles';
/**
* The address the providers sign in as, so a "mine" view matches rows created
* by it.
*/
export const STORY_USER_EMAIL = createAppContextMock(USER_ROLES.ADMIN).user
.email;
export const TEAMMATE_EMAILS = [
'ada@signoz.io',
'grace@signoz.io',
'linus@signoz.io',
] as const;
export const orgUsersResponse = (): ListUsers200 => ({
status: 'success',
data: [
{
id: 'storybook-user-me',
email: STORY_USER_EMAIL,
displayName: 'John Doe',
},
...TEAMMATE_EMAILS.map((email, index) => ({
id: `storybook-user-${index + 1}`,
email,
displayName: email.split('@')[0].replace(/^./, (c) => c.toUpperCase()),
})),
],
});

View File

@@ -3,16 +3,32 @@ import type {
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
// The filter a saved view captures. The backend stores a flat `{ query, sort, order }`
// (no structured chips), so a view captures the fully-combined DSL query plus the
// sort/order to apply.
export interface DashboardViewFilter {
// Relative "updated within" windows offered by the Updated filter dropdown.
export type UpdatedWindow = 'any' | 'today' | '7d' | '30d';
// A saved view: a named filter the org shares, persisted via the backend Views
// API. The backend stores a flat `{ query, sort, order }` (no structured chips),
// so a view captures the fully-combined DSL query plus the sort/order to apply.
export interface SavedView {
id: string;
name: string;
query: string;
sort: DashboardtypesListSortDTO;
order: DashboardtypesListOrderDTO;
}
export interface DashboardViewCtx {
userEmail: string;
recent: string[];
// The payload for creating or updating a saved view (everything but the id).
export type SavedViewInput = Omit<SavedView, 'id'>;
// Built-in views rendered above the user's saved views. Their result set is
// derived (a fixed query fragment or a client-side id set), never persisted.
// String values double as the URL `view` param, so they must stay stable.
export enum BuiltinViewId {
Mine = 'mine',
Pinned = 'pinned',
Recent = 'recent',
All = 'all',
Locked = 'locked',
}
export type ViewSection = 'personal' | 'system' | 'custom';

View File

@@ -1,21 +1,36 @@
// Grammar-independent operator tables for the list-filter DSL. Key sets and
// field types come from a DslGrammar config mirroring the backend allow-list
// (e.g. pkg/types/dashboardtypes/list_filter.go); keep those configs in sync.
// Frontend mirror of the backend dashboards-list filter DSL grammar
// (pkg/types/dashboardtypes/list_filter.go). There is no keys/operators API, so
// the valid keys, per-field operators, and value shapes are encoded here. Keep in
// sync with the backend allow-lists.
import { negateOperator, OPERATORS } from 'constants/antlrQueryConstants';
import type { DslField, DslFieldType, DslGrammar } from './types';
// Reserved keys the backend recognises as dashboard columns. Any other key is a
// tag key. (`updated_by` and `source` are NOT queryable.)
export const RESERVED_KEYS = [
'name',
'description',
'created_by',
'created_at',
'updated_at',
'locked',
] as const;
export const findField = (
grammar: DslGrammar,
key: string,
): DslField | undefined =>
grammar.fields.find((f) => f.key === key.toLowerCase());
export type DslFieldType = 'string' | 'timestamp' | 'bool' | 'tag';
// A reserved key maps to its column type; anything else is dynamic.
export const classifyField = (grammar: DslGrammar, key: string): DslFieldType =>
findField(grammar, key)?.type ?? 'dynamic';
const RESERVED_FIELD_TYPES: Record<string, DslFieldType> = {
name: 'string',
description: 'string',
created_by: 'string',
created_at: 'timestamp',
updated_at: 'timestamp',
locked: 'bool',
};
// String/dynamic comparison operators, reusing the shared operator constants
// A reserved key maps to its column type; anything else is a tag.
export const classifyField = (key: string): DslFieldType =>
RESERVED_FIELD_TYPES[key.toLowerCase()] ?? 'tag';
// String/tag comparison operators, reusing the shared operator constants
// (REGEXP is allow-listed by the backend but unimplemented, so it is omitted).
const STRING_OPS = [
OPERATORS['='],
@@ -30,10 +45,10 @@ const STRING_OPS = [
negateOperator(OPERATORS.IN),
];
// Valid operators per field type. Dynamic keys additionally support existence checks.
// Valid operators per field type. Tags additionally support existence checks.
export const OPERATOR_MATRIX: Record<DslFieldType, string[]> = {
string: STRING_OPS,
dynamic: [...STRING_OPS, OPERATORS.EXISTS, negateOperator(OPERATORS.EXISTS)],
tag: [...STRING_OPS, OPERATORS.EXISTS, negateOperator(OPERATORS.EXISTS)],
timestamp: [
OPERATORS['='],
OPERATORS['!='],
@@ -45,12 +60,6 @@ export const OPERATOR_MATRIX: Record<DslFieldType, string[]> = {
negateOperator(OPERATORS.BETWEEN),
],
bool: [OPERATORS['='], OPERATORS['!=']],
enum: [
OPERATORS['='],
OPERATORS['!='],
OPERATORS.IN,
negateOperator(OPERATORS.IN),
],
};
// Operators that take no value (the clause is complete after the operator).

View File

@@ -1,23 +1,16 @@
import { DASHBOARDS_DSL_GRAMMAR } from 'pages/DashboardsListPage/list.config';
import { getSuggestions } from './dslSuggestions';
import type { SuggestionSource } from './types';
import { getSuggestions, type SuggestionSource } from './dslSuggestions';
const source: SuggestionSource = {
dynamicKeys: ['env', 'team'],
valuesByKey: { env: ['prod', 'dev'] },
tagKeys: ['env', 'team'],
tagValuesByKey: { env: ['prod', 'dev'] },
creatorEmails: ['alice@x.io', 'bob@x.io'],
currentUserEmail: 'me@x.io',
};
const labels = (q: string, caret: number): string[] =>
getSuggestions(q, caret, source, DASHBOARDS_DSL_GRAMMAR).items.map(
(i) => i.label,
);
getSuggestions(q, caret, source).items.map((i) => i.label);
const inserts = (q: string, caret: number): string[] =>
getSuggestions(q, caret, source, DASHBOARDS_DSL_GRAMMAR).items.map(
(i) => i.insertText,
);
getSuggestions(q, caret, source).items.map((i) => i.insertText);
describe('getSuggestions — key stage', () => {
it('suggests reserved + tag keys matching the partial', () => {
@@ -68,12 +61,7 @@ describe('getSuggestions — operator stage', () => {
describe('getSuggestions — value stage', () => {
it('suggests creator emails (current user first, labelled) for created_by', () => {
const out = getSuggestions(
'created_by = ',
13,
source,
DASHBOARDS_DSL_GRAMMAR,
);
const out = getSuggestions('created_by = ', 13, source);
expect(out.items[0].label).toBe('me@x.io (me)');
expect(out.items.map((i) => i.insertText)).toContain("'alice@x.io' ");
});
@@ -98,7 +86,7 @@ describe('getSuggestions — value stage', () => {
describe('getSuggestions — IN list values', () => {
it('wraps a fresh IN value in a bracketed list, caret before the close', () => {
const q = 'created_by IN ';
const out = getSuggestions(q, q.length, source, DASHBOARDS_DSL_GRAMMAR);
const out = getSuggestions(q, q.length, source);
const alice = out.items.find((i) => i.label === 'alice@x.io');
expect(alice?.insertText).toBe("['alice@x.io']");
expect(alice?.caretOffset).toBe(1);
@@ -112,7 +100,7 @@ describe('getSuggestions — IN list values', () => {
it('appends to an open list and excludes already-entered values', () => {
const q = "env IN ['prod', ]";
const caret = q.indexOf(']'); // inside the brackets, after "'prod', "
const out = getSuggestions(q, caret, source, DASHBOARDS_DSL_GRAMMAR);
const out = getSuggestions(q, caret, source);
expect(out.items.map((i) => i.label)).toStrictEqual(['dev']);
const dev = out.items[0];
// No closing bracket (the existing `]` stays); caret lands at the end.
@@ -122,7 +110,7 @@ describe('getSuggestions — IN list values', () => {
it('treats a complete last literal (no trailing comma) as entered', () => {
const q = "env IN ['prod'";
const out = getSuggestions(q, q.length, source, DASHBOARDS_DSL_GRAMMAR);
const out = getSuggestions(q, q.length, source);
// 'prod' is committed; only 'dev' remains and it appends after it.
expect(out.items.map((i) => i.label)).toStrictEqual(['dev']);
expect(out.items[0].insertText).toBe("['prod', 'dev'");

View File

@@ -1,29 +1,48 @@
// Stage-aware autocomplete for the list-filter DSL: as the caret moves, it
// Stage-aware autocomplete for the dashboards-list DSL: as the caret moves, it
// suggests keys, then operators (valid for that key's field type), then values
// (drawn from the dynamic keys/users the API reports). Operator tables live in
// dslGrammar.ts; caret/stage detection in dslTokenizer.ts.
// (drawn from the tags/users the API reports). Grammar lives in dslGrammar.ts;
// caret/stage detection in dslTokenizer.ts.
import {
classifyField,
findField,
LIST_OPERATORS,
literal,
OPERATOR_MATRIX,
RESERVED_KEYS,
VALUELESS_OPERATORS,
} from './dslGrammar';
import { type CaretContext, getCaretContext } from './dslTokenizer';
import type {
DslGrammar,
Suggestion,
SuggestionSource,
SuggestionsResult,
} from './types';
// Show the full key set (the popup scrolls); values are capped a bit higher too.
const KEY_LIMIT = 100;
const VALUE_LIMIT = 50;
// Reserved keys whose values are user emails.
const USER_KEYS = new Set(['created_by', 'updated_by']);
export interface SuggestionSource {
// Reserved column keys (from the list response, falling back to RESERVED_KEYS).
reservedKeys?: string[];
tagKeys: string[];
// Known values per tag key (lower-cased key → values).
tagValuesByKey: Record<string, string[]>;
// Creator emails for `created_by` value suggestions.
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 org tag 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;
}
const includesInsensitive = (haystack: string, needle: string): boolean =>
haystack.toLowerCase().includes(needle.toLowerCase());
@@ -33,20 +52,17 @@ const dedupe = (values: string[]): string[] => Array.from(new Set(values));
const keySuggestions = (
partial: string,
source: SuggestionSource,
grammar: DslGrammar,
): Suggestion[] => {
const hidden = new Set(grammar.hiddenKeys ?? []);
const known = grammar.fields.map((f) => f.key);
const reserved = (source.reservedKeys ?? known).filter((k) => !hidden.has(k));
const reserved = source.reservedKeys ?? [...RESERVED_KEYS];
const reservedSet = new Set(reserved.map((k) => k.toLowerCase()));
// Show reserved keys in the grammar's (purpose) order — name, description, … —
// rather than the API's alphabetical order; any extra API keys follow, then
// dynamic keys.
// Show reserved keys in our defined (purpose) order — name, description, … —
// rather than the API's alphabetical order; any extra API keys follow, then tags.
const known = RESERVED_KEYS as readonly string[];
const orderedReserved = [
...known.filter((k) => reserved.includes(k)),
...reserved.filter((k) => !known.includes(k)),
];
const keys = dedupe([...orderedReserved, ...source.dynamicKeys]);
const keys = dedupe([...orderedReserved, ...source.tagKeys]);
const lower = partial.toLowerCase();
return keys
.filter((k) => includesInsensitive(k, partial) && k.toLowerCase() !== lower)
@@ -55,19 +71,16 @@ const keySuggestions = (
label: k,
insertText: `${k} `,
kind: 'key',
// Distinguish built-in columns from dynamic keys in the popup.
detail: reservedSet.has(k.toLowerCase())
? 'field'
: grammar.dynamicKey.detail,
// Distinguish built-in columns from org tag keys in the popup.
detail: reservedSet.has(k.toLowerCase()) ? 'field' : 'tag',
}));
};
const operatorSuggestions = (
fieldKey: string,
partial: string,
grammar: DslGrammar,
): Suggestion[] => {
const ops = OPERATOR_MATRIX[classifyField(grammar, fieldKey)];
const ops = OPERATOR_MATRIX[classifyField(fieldKey)];
const upper = partial.toUpperCase();
return ops
.filter((op) => op.startsWith(upper))
@@ -142,39 +155,15 @@ const parseListPartial = (partial: string): ListPartial => {
};
};
// Known values for a key: user emails, enum values, or the dynamic key's values
// (a reserved dynamic key falls back to its prefixed alias, e.g. `labels.severity`).
const knownValues = (
key: string,
source: SuggestionSource,
grammar: DslGrammar,
): string[] | null => {
const type = classifyField(grammar, key);
if (USER_KEYS.has(key) && type === 'string') {
return source.currentUserEmail
? dedupe([source.currentUserEmail, ...source.creatorEmails])
: source.creatorEmails;
}
if (type === 'enum') {
return [...(findField(grammar, key)?.values ?? [])];
}
if (type === 'dynamic') {
const alias = `${grammar.dynamicKey.prefix}${key}`.toLowerCase();
return source.valuesByKey[key] ?? source.valuesByKey[alias] ?? [];
}
return null;
};
const valueSuggestions = (
ctx: CaretContext,
source: SuggestionSource,
grammar: DslGrammar,
): Suggestion[] => {
if (VALUELESS_OPERATORS.has(ctx.operator)) {
return [];
}
const key = ctx.fieldKey.toLowerCase();
const type = classifyField(grammar, ctx.fieldKey);
const type = classifyField(ctx.fieldKey);
// List operators (`IN`/`NOT IN`) take a `[...]` list: parse what's already in
// the brackets so we exclude entered values and append rather than replace.
@@ -191,8 +180,15 @@ const valueSuggestions = (
.map((v) => ({ label: v, insertText: `${v} `, kind: 'value' }));
}
const raw = knownValues(key, source, grammar);
if (raw === null) {
let raw: string[] = [];
if (key === 'created_by') {
const emails = source.currentUserEmail
? dedupe([source.currentUserEmail, ...source.creatorEmails])
: source.creatorEmails;
raw = emails;
} else if (type === 'tag') {
raw = source.tagValuesByKey[key] ?? [];
} else {
// name / description / timestamps have no known value set — free text.
return [];
}
@@ -203,7 +199,7 @@ const valueSuggestions = (
.slice(0, VALUE_LIMIT)
.map((v) => {
const label =
v === source.currentUserEmail && USER_KEYS.has(key) ? `${v} (me)` : v;
v === source.currentUserEmail && key === 'created_by' ? `${v} (me)` : v;
if (isList) {
const values = [...(list?.committed ?? []), v].map(literal).join(', ');
return {
@@ -243,16 +239,15 @@ export const getSuggestions = (
query: string,
caret: number,
source: SuggestionSource,
grammar: DslGrammar,
): SuggestionsResult => {
const ctx = getCaretContext(query, caret);
let items: Suggestion[] = [];
if (ctx.stage === 'key') {
items = keySuggestions(ctx.partial, source, grammar);
items = keySuggestions(ctx.partial, source);
} else if (ctx.stage === 'operator') {
items = operatorSuggestions(ctx.fieldKey, ctx.partial, grammar);
items = operatorSuggestions(ctx.fieldKey, ctx.partial);
} else if (ctx.stage === 'value') {
items = valueSuggestions(ctx, source, grammar);
items = valueSuggestions(ctx, source);
} else if (ctx.stage === 'connector') {
items = connectorSuggestions(ctx.partial);
}

View File

@@ -1,4 +1,4 @@
// Quote/paren/bracket-aware scanning of the list-filter DSL, shared by
// Quote/paren/bracket-aware scanning of the dashboards-list filter DSL, shared by
// the autocomplete engine (dslSuggestions) and the clause splicer (filterQuery).
// The DSL is a boolean composition of `key OP value` terms joined by AND/OR.
import {

View File

@@ -1,5 +1,5 @@
// Pure, side-effect-free helpers over the backend list-filter DSL string
// (e.g. `GET /api/v2/dashboards?query=...`). The query string is the single source of
// (`GET /api/v2/dashboards?query=...`). The query string is the single source of
// truth; these build/parse/splice clauses for it. Free of React so the same
// logic backs live filtering, saved views, and built-in views.
//
@@ -7,11 +7,11 @@
// name CONTAINS 'text'
// created_by = 'a@b.com' | created_by IN ['a@b.com', 'c@d.com']
// updated_at >= '2026-06-01T00:00:00.000Z' (RFC3339)
// env = 'prod' (any non-reserved key is a dynamic filter, e.g. a tag)
// env = 'prod' (any non-reserved key is a tag filter)
// locked = true
import dayjs from 'dayjs';
import type { ClauseValue, UpdatedWindow } from './types';
import type { UpdatedWindow } from '../types';
import { literal } from './dslGrammar';
import { scanTerm, splitTopLevelTerms } from './dslTokenizer';
@@ -37,16 +37,6 @@ export const isQueryEmpty = (query: string): boolean => !query.trim();
export const areQueriesEqual = (a: string, b: string): boolean =>
a.trim() === b.trim();
export const clause = (key: string, op: string, value: ClauseValue): string => {
if (typeof value === 'boolean') {
return `${key} ${op} ${value}`;
}
if (typeof value === 'string') {
return `${key} ${op} ${literal(value)}`;
}
return `${key} ${op} [${value.map(literal).join(', ')}]`;
};
// The `created_by` clause for a set of emails (single `=`, multiple `IN [...]`).
export const createdByClause = (emails: string[]): string | null => {
if (emails.length === 0) {

View File

@@ -1,14 +1,82 @@
import { areQueriesEqual } from 'components/ListFilter/dsl/filterQuery';
import type { EmptyStateCopy } from 'components/ListViews/types';
// Built-in view catalogue + the pure logic that maps a view to how it
// constrains the list. Views fall into two mechanisms:
// - snapshot: selecting applies a filter snapshot — some seed the search box
// with DSL (My dashboards → created_by; Locked → locked = true)
// - client: constrains by a client-side id set (Favorites, Recently viewed)
import { Clock, Layers, Lock, Pin, User } from '@signozhq/icons';
import { findBuiltinView } from '../list.config';
import type { DashboardViewFilter } from '../types';
import { createdByClause } from './filterQuery';
import { BuiltinViewId } from '../types';
import type { ViewSection } from '../types';
import type { DashboardListItem } from './helpers';
// A view is dirty only when its query diverges; sort/order changes don't count.
export const areViewFiltersEqual = (
a: DashboardViewFilter,
b: DashboardViewFilter,
): boolean => areQueriesEqual(a.query, b.query);
// All @signozhq icons share this component type.
export type ViewIcon = typeof Pin;
export interface BuiltinView {
id: BuiltinViewId;
label: string;
icon: ViewIcon;
section: Exclude<ViewSection, 'custom'>;
}
export const BUILTIN_VIEWS: BuiltinView[] = [
{
id: BuiltinViewId.Mine,
label: 'My dashboards',
icon: User,
section: 'personal',
},
{ id: BuiltinViewId.Pinned, label: 'Pinned', icon: Pin, section: 'personal' },
{
id: BuiltinViewId.Recent,
label: 'Recently viewed',
icon: Clock,
section: 'personal',
},
{
id: BuiltinViewId.All,
label: 'All dashboards',
icon: Layers,
section: 'system',
},
{ id: BuiltinViewId.Locked, label: 'Locked', icon: Lock, section: 'system' },
];
// Pinned/Recently-viewed constrain client-side — Pinned by the per-row `pinned`
// flag, Recently-viewed by a localStorage id set — so they filter the fetched
// rows rather than adding a server clause.
export const isClientView = (id: string): boolean =>
id === BuiltinViewId.Pinned || id === BuiltinViewId.Recent;
// DSL the Locked view applies — visible and editable in the query box rather
// than applied invisibly behind the scenes.
export const LOCKED_QUERY = 'locked = true';
// The canonical query string a built-in view applies when selected. `null` for
// ids that aren't built-in (custom views carry their own query).
export const builtinViewQuery = (
id: string,
userEmail: string,
): string | null => {
switch (id) {
case BuiltinViewId.Mine:
return userEmail ? (createdByClause([userEmail]) ?? '') : '';
case BuiltinViewId.Locked:
return LOCKED_QUERY;
case BuiltinViewId.All:
case BuiltinViewId.Pinned:
case BuiltinViewId.Recent:
return '';
default:
return null;
}
};
export interface EmptyStateCopy {
title: string;
description: string;
}
// Context-aware copy for the no-results state, so an empty Locked view doesn't
// read like a failed search ("No dashboards found for .").
@@ -24,17 +92,56 @@ export const noResultsCopy = (
description: 'Try a different search term or clear your filters.',
};
}
const viewCopy = findBuiltinView(activeViewId)?.emptyState;
if (viewCopy) {
return viewCopy;
}
return hasActiveFilters
? {
title: 'No dashboards match your filters',
description: 'Try adjusting or clearing your filters.',
}
: {
title: 'No dashboards found',
description: 'Create a dashboard to get started.',
switch (activeViewId) {
case BuiltinViewId.Pinned:
return {
title: 'No pinned dashboards yet',
description: 'Pin a dashboard to keep it handy here.',
};
case BuiltinViewId.Recent:
return {
title: 'No recently viewed dashboards',
description: 'Dashboards you open will appear here.',
};
case BuiltinViewId.Locked:
return {
title: 'No locked dashboards',
description: 'Dashboards locked for editing will appear here.',
};
case BuiltinViewId.Mine:
return {
title: "You haven't created any dashboards",
description: 'Dashboards you create will appear here.',
};
default:
return hasActiveFilters
? {
title: 'No dashboards match your filters',
description: 'Try adjusting or clearing your filters.',
}
: {
title: 'No dashboards found',
description: 'Create a dashboard to get started.',
};
}
};
// Apply a client-side view's constraint to already-fetched rows. Pinned filters
// by the per-row `pinned` flag; Recently-viewed filters by a localStorage id set
// and preserves visit order regardless of the active sort.
export const applyClientView = (
items: DashboardListItem[],
id: string,
recent: string[],
): DashboardListItem[] => {
if (id === BuiltinViewId.Pinned) {
return items.filter((d) => d.pinned);
}
if (id === BuiltinViewId.Recent) {
const order = new Map(recent.map((rid, index) => [rid, index]));
return items
.filter((d) => order.has(d.id))
.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
}
return items;
};

View File

@@ -36,12 +36,6 @@ describe('hasPreferences', () => {
).toBe(true);
expect(
hasPreferences(SavedviewtypesSourceDTO.ai_observability, PANEL_TYPES.LIST),
).toBe(true);
expect(
hasPreferences(
SavedviewtypesSourceDTO.ai_observability,
PANEL_TYPES.TIME_SERIES,
),
).toBe(false);
});

View File

@@ -7,7 +7,6 @@ import {
DEFAULT_PANEL_TYPE,
TRACE_VIEW_DEFAULT_COLUMNS,
} from 'container/LLMObservability/Explorer/constants';
import { defaultSelectedColumns as LIST_VIEW_DEFAULT_COLUMNS } from 'container/LLMObservability/Explorer/ListView/configs';
import { PanelColumns } from '../types';
import { isAiObservabilityExplorerRoute } from '../utils';
@@ -46,11 +45,6 @@ const toPanels = (value: unknown): PanelColumns => {
);
};
const PANEL_DEFAULT_COLUMNS: PanelColumns = {
[PANEL_TYPES.LIST]: LIST_VIEW_DEFAULT_COLUMNS,
[PANEL_TYPES.TRACE]: TRACE_VIEW_DEFAULT_COLUMNS,
};
// Stored as { [panelType]: columns }.
export const readStoredPanels = (): PanelColumns => {
try {
@@ -97,8 +91,9 @@ const getAiObservabilityLoaderConfig = (
): AiObservabilityLoaderConfig => ({
local: () => ({ columns: readStoredPanels()[panelType] ?? [] }),
url: () => ({ columns: readUrlPanels()[panelType] ?? [] }),
// Only the trace panel keeps columns today.
default: () => ({
columns: PANEL_DEFAULT_COLUMNS[panelType] ?? [],
columns: panelType === PANEL_TYPES.TRACE ? TRACE_VIEW_DEFAULT_COLUMNS : [],
}),
priority: ['local', 'url', 'default'] as const,
});

View File

@@ -10,6 +10,5 @@ export const PREFERENCE_SOURCE_PAGES = [
// The AI observability panels that keep their own columns.
export const AI_OBSERVABILITY_COLUMN_PANELS: PANEL_TYPES[] = [
PANEL_TYPES.LIST,
PANEL_TYPES.TRACE,
];

Some files were not shown because too many files have changed in this diff Show More