Compare commits

...

18 Commits

Author SHA1 Message Date
ashu8912
cbab3ca8d7 feat(alerts): add DSL search and saved views to alert rules list (#13322)
Some checks are pending
build-staging / staging (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- Alert rules list now uses the server-side `/api/v3/rules` endpoint.
Filtering, sorting and pagination happen on the backend instead of the
client.
- The search bar takes the filter DSL (`name`, `severity`, `alert_type`,
`rule_type`, `created_by`, `labels.<key>`, …) with autocomplete. Invalid
queries show the backend's error inline.
- New saved views rail: built-in views (My alert rules, All, Firing,
Disabled) plus org-shared custom views backed by `/api/v2/rule_views`.
- Search bar, filter zone and views rail come from the shared
`components/ListFilter` and `components/ListViews`.

#### Issues closed by this PR

Closes: https://github.com/SigNoz/pulse-pod/issues/594

#### Screenshots / Screen Recordings

[Visual diff](https://claude.ai/artifact/944j4x95qf1Ybh1pEw8uNt) against
#13372, dark and light: 138 pairs, 86 changed, 22 new, 2 removed, 28
identical. The `alert-rules` tag holds this PR's changes; Dashboards
List pairs are within the measured noise floor.

#### Additional Information

- Depends on #13372; review after it merges.
2026-11-06 14:31:21 +00:00
Nikhil Mantri
ab28e69397 feat(alert-channel-integrations): support self-hosted jira on the jira channel (#13424)
#### Description

- The Jira notification channel so far worked with Jira Cloud only. This
PR adds support for self-hosted Jira (Server / Data Center, version 8.14
or newer).
- The channel form now asks where Jira runs: Cloud keeps email + API
token, self-hosted takes a personal access token.
- Behind the scenes the channel talks to self-hosted Jira the way it
expects: token as a Bearer header, the v2 REST API, and wiki-markup text
for the issue description and comments. The issue still carries the
status panel, alert details and links.
- Existing Jira Cloud channels keep working unchanged; a migration marks
them as Cloud.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#691

#### Screenshots / Screen Recordings

Self-hosted jira channel form: 

<img width="1512" height="828" alt="Screenshot 2026-11-05 at 2 27 26 AM"
src="https://github.com/user-attachments/assets/ad990a40-d469-4c16-aed0-cd897f441f2f"
/>

Jira cloud channel form:

<img width="1512" height="828" alt="Screenshot 2026-11-05 at 2 25 59 AM"
src="https://github.com/user-attachments/assets/42b1c7fa-f692-438f-8892-87a92d6ef1a5"
/>

#### Additional Information

- Design doc: https://app.notion.com/p/40efcc6bcd1981f98d60cdaaec074d94
(discussion SigNoz/pulse-pod#690, docs follow-up SigNoz/pulse-pod#692).
- Docs PR on signoz.io comes separately.
- Breaking change on the v2 Jira spec: `deployment` is now required and
the credentials sit under `auth` (`{email, apiToken}` for cloud, `{pat}`
for self-hosted). Migration 144 restructures stored channels.

---------

Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
2026-11-06 11:17:30 +00:00
Naman Verma
d98fca08dd feat: add support for offset in all query range request types (#13436)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- `offset` was only applied to raw and trace requests; scalar,
time_series, trace operator and formula results now honor it too.
- Reworded the `limit`, `offset` and `cursor` descriptions to say which
request types use them.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/677
2026-11-06 10:50:45 +00:00
Ashwin Bhatkal
648c8d0740 ci(e2e): pin Node 22 (#13396)
#### Description

- e2eci now runs on Node 22, like jsci, goci, the gor workflows,
`frontend/.nvmrc` and the with-web image. It was on `lts/*`, which moved
to Node 26 in late October.
- Under Node 26, Playwright 1.57 never finishes writing a trace, so
every first retry hangs until the 60 s test timeout (found in
[#13210](https://github.com/SigNoz/signoz/pull/13210)).

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/688
2026-11-06 10:46:23 +00:00
Ashwin Bhatkal
e6c19dd15a chore(ci): skip e2e when only generated api clients change (#13456)
#### Description

- e2e no longer runs when the only frontend changes in a PR are under
`frontend/src/api/generated/`.
- `pnpm generate:api` rewrites these files whenever the backend API spec
changes, so most backend PRs touch them with no frontend change.
Backend-only PRs already skip e2e.
- The jsci type check still catches a regenerated client that breaks a
call site. Add the `safe-to-e2e` label to force a run.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/701
2026-11-06 10:45:43 +00:00
Naman Verma
ccfe8114f1 feat: add support for distribution query in query range (#13379)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Distribution queries are basically each row of a heatmap summed up to
counts, and rotated to have the heatmap Y axis as the distribution
graph's X axis.

Query range API supports distribution queries in exactly this way by
going through the usual query range flow for heatmaps, and then summing
up the counts for each bucket.

Because this is no longer time series data, a new response type is
added.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/675

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

All existing integration tests for heatmaps have been extended to test
for distributions

<!--Please delete paragraphs that you did not use before submitting.-->
2026-11-06 10:27:37 +00:00
ashu8912
b87e2b6375 refactor(dashboards): extract list filter and saved views into shared components (#13372)
#### Description

- Move the search bar, filter zone, views rail, command header and
status bar out of the dashboards list into shared
`components/ListFilter` and `components/ListViews`, with `useListViews`
for view state, so the alert rules list can reuse them.
- Dashboards behaviour is unchanged, except: the search icon and Run
query stay centred when a query wraps, selected Created-by options are
no longer bold, and the search box is 1px shorter (whole-pixel line
height).
- Dashboards list stories gain `LongQuery` and `CreatedByFilterOpen`;
their plays move to `dashboardsList.play.ts`.
- Add unit tests for the filter draft and the views rail.

#### Screenshots / Screen Recordings

[Visual diff](https://claude.ai/artifact/944j4x95qf1Ybh1pEw8uNt) against
`main`, dark and light: 116 pairs, 71 changed, 4 new, 41 identical. The
`fix` tag holds the two new states; `shift` is the 1px search box change
plus timestamp noise.

#### Additional Information

- Alert rules adopt these in #13322.
2026-11-06 10:19:59 +00:00
Gaurav Tewari
b45c4828ac feat(ai-observability): add column options to the ai list view (#13303)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- AI explorer list view gets the column picker (`useOptionsMenu`, keys
from `/api/v1/fields/keys`), like the trace view. Picked columns are
sent with the list query, so adding or removing one refetches;
reordering does not.
- The list panel keeps its own columns in the AI preference slice, so
saved views and `options.selectColumns` in the URL cover it too.
- The list panel defaults to the current static columns. The column
store keeps only widths.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

https://github.com/SigNoz/nerve-pod/issues/551

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings



https://github.com/user-attachments/assets/f2d455f5-ba97-4325-8832-79ac6b637cf7



<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

- Stacked on #13297.
- Review commit by commit: preferences → list view. Each commit builds.
- Column order picked on the list view before this PR was kept in the
table's column store; it is ignored now, so those users see the default
order once. Column widths are kept.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-06 09:52:46 +00:00
Abhi kumar
40d3f9c81d test(e2e): wait for the funnel step popover to close before adding a step (#13455)
#### Description

- `traces-funnels/steps.spec.ts` TC-04 failed with a strict-mode
violation on `funnel-step-action-delete`: step 1's popover was still
fading out when step 3's opened, so two delete buttons were briefly
visible.
- The spec now waits for the first popover to close. Verified locally:
0/5 before, 5/5 after.
2026-11-06 09:39:55 +00:00
Aditya Singh
c14d256e6f feat(traces-explorer): track how users open the trace details page (#13420)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- A row click in the traces List view now opens the span drawer. We need
to know how users still reach the trace details page.
- The explorer now logs `Traces Explorer: Open trace clicked` on each
click that opens the trace details page.
- The event has two attributes:
- `panelType`: `list` or `trace`. These are the same values as `Traces
Explorer: Data present`.
  - `source`: `row`, `row_icon`, `trace_id` or `span_drawer`.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/195

#### Additional Information

- Stacked on #13411.
- The event does not fire when a user opens the trace_id link from the
right-click menu. The browser does not tell the page which menu item the
user picks.
- Entry points outside the traces explorer do not log this event.
2026-11-06 05:15:33 +00:00
Gaurav Tewari
9a541c851f feat:ai o11y saved views (#13297)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Adds saved views to the AI observability explorer.
- New `ai_observability` preference slice. Columns are stored per panel
in localStorage (`AI_OBSERVABILITY_OPTIONS`); the slice holds only the
open panel's `{ columns, panelType }`. Only the Traces tab keeps columns
today.
- The Traces tab reads columns through `useOptionsMenu`, like the logs
and traces lists. Defaults are a fixed list
(`TRACE_VIEW_DEFAULT_COLUMNS`) matching the backend's trace aggregates,
so the table no longer waits for the keys API.
- The URL carries the open panel's columns in `options.selectColumns`;
logs and traces ignore it on the AI explorer route.
- Fixes saved views opening on the wrong tab: the explorer now reads the
tab from the URL For AI View

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings



https://github.com/user-attachments/assets/daab1db3-b8df-435a-8252-d6bb35a86e99


<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

- Review commit by commit: preferences → saved views → trace view →
explorer wiring → tab fix → discard and loading fixes. Each commit
builds.
- Columns picked on the Traces tab before this PR were kept in the
table's column store; they are ignored now, so those users see the
defaults once. Column widths are kept.
- The AI Explorer story is not updated in this PR.
- SB Shot -
https://claude.ai/artifact/R77CFZKS71pSxNLV4ZhpxY?sk=dM6jybUP456zVTcW9xrz8Q
- Check to have a summary of comment raised and closed with greptile
https://github.com/SigNoz/signoz/pull/13297#issuecomment-6508458226

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-05 16:53:57 +00:00
Nikhil Mantri
e0802b8274 feat(authz): rule state history FGA (#13252)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Move `/api/v2/rules/{id}/history/*` routes from role gates to
`CheckResources`.
- `stats` and `overall_status` check `rule:read` on the id.
- `timeline` and `top_contributors` check `rule:read` on the id plus
read on the telemetry resources the rule's query touches, so a broad
telemetry grant alone cannot read another rule's firing history.
- `filter_keys` and `filter_values` check `rule:read` on the id plus
list and read on the field resources of the signals the rule queries.
The field kinds come from the same query walker `query_range` uses,
mapped telemetry kind to field kind.
- Integration tests cover each gate with and without the grant. No
migration, rule tuples come from #13251.

#### Issues closed by this PR

Part of SigNoz/pulse-pod#286
Part of SigNoz/pulse-pod#355

#### Additional Information

Fourth of five stacked PRs splitting #12927. Stacked on #13251.
2026-11-05 15:42:36 +00:00
Srikanth Chekuri
add47f6516 fix(telemetrystore): return 400 for five more ClickHouse SQL mistakes (#13444)
- Five ClickHouse errors that user SQL causes returned 500. They now
return 400 with a suggestion, as #13328 does for 170, 288, and 407: 386
(no common type), 215 (column neither in GROUP BY nor in an aggregate),
184 (aggregate where none is allowed, mostly an alias that shadows a
column in WHERE), 190 (arrays of different sizes), 427 (a regular
expression RE2 cannot compile).
- Code 48 stays a 500. A correlated subquery over a distributed table
raises it, but the code covers every unimplemented feature, so a mapping
could hide a fault in generated SQL.
- 215 and 386 can also come from a builder bug; that is the trade-off
#13328 made.
- `test_clickhouse_user_mistake_is_bad_request` raises each code with a
real query, plus a builder `REGEXP` filter.
2026-11-05 15:17:03 +00:00
Gaurav Tewari
2d3c4fd7d2 fix(ai-assistant): disable open actions for the page already open (#13370)
#### Description
- Clicking a Noz "Open …" action (e.g. "Open alert") while already on
that resource re-navigated to the same page; the page re-initialised and
the Noz chat jumped up.
- Open-resource chips are now disabled, with a "You're already viewing
this page" tooltip, when their target is the current page. Elsewhere
they navigate as before.
- The alert Overview tab counts as the alert's page, since it renders
the same editor as `/alerts/edit`; the History tab keeps the button
enabled.
- Saved-view chips are intentionally never disabled: reopening one
reapplies the saved query, resetting unsaved explorer edits.

#### Issues closed by this PR


https://github.com/orgs/SigNoz/projects/59/views/18?pane=issue&itemId=201428604&issue=SigNoz%7Cnerve-pod%7C69

#### Screenshots / Screen Recordings

Before - 


https://github.com/user-attachments/assets/56983c3a-cc75-4290-88b6-cdfc893f5984


Now - 


https://github.com/user-attachments/assets/63507ea7-f2e5-44ae-b722-771188cb17e3



#### Additional Information
<img width="648" height="329" alt="image"
src="https://github.com/user-attachments/assets/656855d9-64c5-47ea-ab49-2ea917c247d4"
/>

in Main 3 tests fails , i have added 2 extra test cases for the base
folow. lmk if we should remove them.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-05 13:17:39 +00:00
Abhi kumar
8f86b59822 feat(dashboard): group small pie slices into an automatic Other slice (#13428)
#### Description

Groups small pie slices into one automatic **Other** slice, so a pie
with many groups stays readable.

- A slice under 1% of the total joins Other, and the pie draws at most
15 slices; Other needs at least 2 members and is always the last arc.
- Automatic, with no panel setting.
- Legend lists every series; hiding or showing one regroups Other from
the series left showing.
- Hovering Other lists its members in the timeseries-style tooltip;
clicking pins it, and clicking a member drills into that series through
the existing slice drilldown.
- Storybook: `Pages/Dashboards/Detail/Panels/PieOther`.



https://github.com/user-attachments/assets/143626a2-7e8a-48ac-a9cd-742f0fcd18ec



#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/363

#### Additional Information

- V2 dashboards only; V1 pie is unchanged.
2026-11-05 11:34:01 +00:00
ashu8912
e53d16157b test(e2e): scroll drivers with the mouse wheel (#13433)
#### Description

- Drivers now scroll with the mouse wheel through a new `wheelTo` helper
(`drivers/scroll.ts`) instead of `scrollIntoView` /
`scrollIntoViewIfNeeded`. Scripted scrolling moves even an `overflow:
hidden` box a user can't scroll, so the channel form clipping passed e2e
while users couldn't reach the fields.
- `wheelTo` wheels over the target's visible scroll container until the
target is centred and not covered by a sticky header, then asserts
`toBeInViewport()`. If a turn doesn't move the target, it fails with "a
container clips it instead of scrolling".
- oxlint now rejects `scrollIntoView` and `scrollIntoViewIfNeeded` in
`tests/e2e`, and `AGENTS.md` adds the rule.
- The panel handle's `scrollIntoView()` is renamed to `wheelIntoView()`.
- The channel form (`fillField`) and the alert rule form (threshold
channels, labels, advanced options, evaluation settings) wheel to lower
controls before acting, so a clipped form fails the existing specs, not
only the dedicated regression spec.

#### Additional Information

- Other `click()` / `fill()` calls still use Playwright's scripted
scroll; the wheel is applied where content can sit below the fold.
- Verified on Chromium only.


Closes: https://github.com/SigNoz/platform-pod/issues/3338
2026-11-05 11:10:34 +00:00
Ashwin Bhatkal
83ebe26877 ci(e2e): run the suite on 10 shards (#13395)
#### Description

- The e2e suite now runs on 10 shards instead of 4. Since
[#13189](https://github.com/SigNoz/signoz/pull/13189) the suite is about
twice as long, so each of the 4 shards spends 16 to 20 min in tests and
a PR waits 23 to 27 min for e2e.
- Each shard also pays about 7 min of fixed setup, so 10 shards should
bring the slowest one to about 16 to 17 min. Past 10, every 2 extra
shards save only about 1.5 min.
2026-11-05 11:04:21 +00:00
Aditya Singh
33d2ff917f feat(explorer): make the traces and logs explorer toolbars and tables match (#13411)
#### Description

- The traces and logs explorers now look the same above and around the
table. Both toolbars use the same row height, spacing and controls.
- The traces Options control is now an icon button, the same as the logs
one.
- The logs Options menu now uses the design system buttons and number
input. Its rows no longer fill on hover. The logs Options button no
longer turns blue on hover.
- Max lines per row now clamps a typed value to 1–10. Before, it ignored
1 and accepted 2.5.
- The logs list now reaches the bottom of the page.
- The logs list and table now use Inter, the same as traces. Only the
raw view keeps the mono font.
- The traces and logs table rows are now 30px tall, the row height in
the design.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/195

#### Screenshots

https://claude.ai/artifact/6zsvLZFYYzfFrPy9r6Xq9F?sk=ymS3KLyoZjWMf2axjY1-ng

#### Additional Information

- Stacked on #13392.
- The traces styles moved to CSS modules. The LLM Observability explorer
renders the same global classes, so global rules from one page could
restyle the other.
2026-11-05 10:22:09 +00:00
300 changed files with 14458 additions and 4063 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/") | not)')
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)')
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: lts/*
node-version: "22"
- name: install-pnpm
uses: pnpm/action-setup@v6
with:
@@ -67,7 +67,7 @@ jobs:
matrix:
project:
- chromium
shard: [1, 2, 3, 4]
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
needs: changes
if: needs.changes.outputs.e2e == 'true'
runs-on: ubuntu-latest
@@ -105,7 +105,7 @@ jobs:
- name: node
uses: actions/setup-node@v4
with:
node-version: lts/*
node-version: "22"
- name: install-pnpm
uses: pnpm/action-setup@v6
with:
@@ -162,7 +162,7 @@ jobs:
- name: playwright-test
run: |
cd tests/e2e && \
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/4
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/10
- name: teardown-stack
if: always()
run: |

View File

@@ -552,26 +552,47 @@ components:
required:
- apiKey
type: object
AlertmanagertypesChannelJiraConfig:
AlertmanagertypesChannelJiraAuth:
oneOf:
- $ref: '#/components/schemas/AlertmanagertypesChannelJiraCloudAuth'
- $ref: '#/components/schemas/AlertmanagertypesChannelJiraSelfHostedAuth'
type: object
AlertmanagertypesChannelJiraCloudAuth:
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
@@ -604,8 +625,8 @@ components:
nullable: true
type: boolean
site:
description: The base URL of the Jira Cloud site, for example https://example.atlassian.net.
Only Jira Cloud is supported.
description: The base URL of the Jira site, for example https://example.atlassian.net
or https://jira.example.com.
type: string
summary:
description: Alertmanager template for the issue summary; defaults to the
@@ -615,11 +636,21 @@ components:
description: The resolution name that stops an issue from being reopened.
type: string
required:
- deployment
- site
- project
- issueType
- email
- apiToken
- 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
type: object
AlertmanagertypesChannelKind:
enum:
@@ -1155,6 +1186,11 @@ components:
description: Comma-separated tags attached to the alert.
type: string
type: object
AlertmanagertypesJiraDeployment:
enum:
- cloud
- selfhosted
type: string
AlertmanagertypesJiraReceiverConfig:
properties:
custom_fields:
@@ -1162,6 +1198,10 @@ 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
@@ -1169,7 +1209,8 @@ components:
allOf:
- $ref: '#/components/schemas/ConfigHTTPClientConfig'
description: HTTP client settings; basic auth carries the account email
and API token.
and API token on cloud, authorization carries the Bearer personal access
token on self-hosted.
issue_type:
description: The issue type name.
type: string
@@ -1199,7 +1240,7 @@ components:
send_resolved:
type: boolean
site:
description: The Jira Cloud base URL.
description: The Jira base URL.
type: string
summary:
description: Alertmanager template for the issue summary.
@@ -4027,7 +4068,9 @@ components:
the query they draw.
cursor:
description: The opaque nextCursor value from a previous raw result, used
to fetch the next page.
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.
type: string
disabled:
description: When true, the result is excluded from the response; the query
@@ -4064,10 +4107,11 @@ components:
are replaced by group-by label values.
type: string
limit:
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.
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.
type: integer
limitBy:
allOf:
@@ -4079,8 +4123,11 @@ components:
operators and selectedQueryName refer to it.
type: string
offset:
description: The number of rows to skip before returning results. Prefer
cursor for raw requests.
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.
type: integer
order:
description: The sort order of the result. Order by a group-by field, an
@@ -9562,9 +9609,10 @@ components:
Querybuildertypesv5AggregationMeta:
properties:
buckets:
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.
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.
items:
format: double
type: number
@@ -9719,6 +9767,101 @@ 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.'
@@ -10102,6 +10245,10 @@ 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:
@@ -10127,7 +10274,9 @@ components:
the query they draw.
cursor:
description: The opaque nextCursor value from a previous raw result, used
to fetch the next page.
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.
type: string
disabled:
description: When true, the result is excluded from the response; the query
@@ -10163,10 +10312,11 @@ components:
are replaced by group-by label values.
type: string
limit:
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.
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.
type: integer
limitBy:
allOf:
@@ -10178,8 +10328,11 @@ components:
operators and selectedQueryName refer to it.
type: string
offset:
description: The number of rows to skip before returning results. Prefer
cursor for raw requests.
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.
type: integer
order:
description: The sort order of the result. Order by a group-by field, an
@@ -10241,7 +10394,9 @@ components:
the query they draw.
cursor:
description: The opaque nextCursor value from a previous raw result, used
to fetch the next page.
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.
type: string
disabled:
description: When true, the result is excluded from the response; the query
@@ -10278,10 +10433,11 @@ components:
are replaced by group-by label values.
type: string
limit:
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.
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.
type: integer
limitBy:
allOf:
@@ -10293,8 +10449,11 @@ components:
operators and selectedQueryName refer to it.
type: string
offset:
description: The number of rows to skip before returning results. Prefer
cursor for raw requests.
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.
type: integer
order:
description: The sort order of the result. Order by a group-by field, an
@@ -10356,7 +10515,9 @@ components:
the query they draw.
cursor:
description: The opaque nextCursor value from a previous raw result, used
to fetch the next page.
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.
type: string
disabled:
description: When true, the result is excluded from the response; the query
@@ -10393,10 +10554,11 @@ components:
are replaced by group-by label values.
type: string
limit:
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.
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.
type: integer
limitBy:
allOf:
@@ -10408,8 +10570,11 @@ components:
operators and selectedQueryName refer to it.
type: string
offset:
description: The number of rows to skip before returning results. Prefer
cursor for raw requests.
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.
type: integer
order:
description: The sort order of the result. Order by a group-by field, an
@@ -10510,8 +10675,9 @@ components:
description: The unique name of the trace operator query.
type: string
offset:
description: The number of raw results to skip before returning. Aggregation
requests ignore it.
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.
type: integer
order:
description: The sort order of the matched traces; only span_count and trace_duration
@@ -10542,10 +10708,12 @@ 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, ScalarData for scalar and RawData for raw requests.'
for time_series and heatmap, ScalarData for scalar, RawData for raw and
DistributionData for distribution requests.'
items: {}
nullable: true
type: array
@@ -10735,8 +10903,9 @@ 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,
ScalarData for scalar, or RawData for raw requests.'
contains typed results depending on the requestType: TimeSeriesData for time_series
and heatmap, ScalarData for scalar, RawData for raw, or DistributionData for
distribution requests.'
properties:
data:
allOf:
@@ -10833,6 +11002,7 @@ components:
- raw_stream
- trace
- heatmap
- distribution
type: string
Querybuildertypesv5ScalarData:
properties:
@@ -10944,8 +11114,8 @@ components:
format: double
type: number
values:
description: The per-band counts for heatmap results, in the order of meta.buckets
plus a trailing overflow count.
description: The per-bucket counts for heatmap results, in the order of
meta.buckets plus a trailing overflow count.
items:
format: double
type: number
@@ -24050,14 +24220,16 @@ 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.
with an Atlassian account email and API token under `auth`.
summary: Jira Cloud issues
value:
config:
kind: jira
spec:
apiToken: XXXXXXXXXXXXXXXXXXXXXXXX
email: oncall@example.com
auth:
apiToken: XXXXXXXXXXXXXXXXXXXXXXXX
email: oncall@example.com
deployment: cloud
issueType: Bug
labels:
- signoz
@@ -24067,6 +24239,23 @@ 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
@@ -28287,6 +28476,12 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -28295,9 +28490,15 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- logs-field:list
- traces-field:list
- metrics-field:list
- tokenizer:
- VIEWER
- rule:read
- logs-field:list
- traces-field:list
- metrics-field:list
summary: Get rule history filter keys
tags:
- rules
@@ -28465,6 +28666,12 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -28473,9 +28680,15 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- logs-field:read
- traces-field:read
- metrics-field:read
- tokenizer:
- VIEWER
- rule:read
- logs-field:read
- traces-field:read
- metrics-field:read
summary: Get rule history filter values
tags:
- rules
@@ -28548,9 +28761,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- tokenizer:
- VIEWER
- rule:read
summary: Get rule overall status timeline
tags:
- rules
@@ -28620,9 +28833,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- tokenizer:
- VIEWER
- rule:read
summary: Get rule history stats
tags:
- rules
@@ -28704,6 +28917,12 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -28712,9 +28931,19 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- VIEWER
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Get rule history timeline
tags:
- rules
@@ -28779,6 +29008,12 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -28787,9 +29022,19 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- VIEWER
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Get top contributors to rule firing
tags:
- rules

View File

@@ -18,15 +18,25 @@
"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.",
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
"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_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.",
@@ -34,6 +44,7 @@
"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,15 +26,25 @@
"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.",
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
"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_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.",
@@ -42,6 +52,7 @@
"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,6 +829,37 @@ 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.
*/
@@ -844,26 +875,23 @@ export type AlertmanagertypesChannelJiraConfigDTOCustomFields = Alertmanagertype
export interface AlertmanagertypesChannelJiraConfigDTO {
/**
* @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.
* @description The credentials: email and apiToken when deployment is cloud, pat when deployment is selfhosted.
*/
apiToken: string;
auth: AlertmanagertypesChannelJiraAuthDTO;
/**
* @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.
@@ -906,7 +934,7 @@ export interface AlertmanagertypesChannelJiraConfigDTO {
sendResolved?: boolean | null;
/**
* @type string
* @description The base URL of the Jira Cloud site, for example https://example.atlassian.net. Only Jira Cloud is supported.
* @description The base URL of the Jira site, for example https://example.atlassian.net or https://jira.example.com.
*/
site: string;
/**
@@ -1716,13 +1744,17 @@ 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.
* @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.
*/
http_config?: ConfigHTTPClientConfigDTO;
/**
@@ -1765,7 +1797,7 @@ export interface AlertmanagertypesJiraReceiverConfigDTO {
send_resolved?: boolean;
/**
* @type string
* @description The Jira Cloud base URL.
* @description The Jira base URL.
*/
site?: string;
/**
@@ -5273,7 +5305,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
* @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.
*/
cursor?: string;
/**
@@ -5306,7 +5338,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
legend?: string;
/**
* @type integer
* @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.
* @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.
*/
limit?: number;
/**
@@ -5320,7 +5352,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
name?: string;
/**
* @type integer
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
* @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.
*/
offset?: number;
/**
@@ -5665,7 +5697,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
* @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.
*/
cursor?: string;
/**
@@ -5698,7 +5730,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
legend?: string;
/**
* @type integer
* @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.
* @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.
*/
limit?: number;
/**
@@ -5712,7 +5744,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
name?: string;
/**
* @type integer
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
* @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.
*/
offset?: number;
/**
@@ -5845,7 +5877,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
* @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.
*/
cursor?: string;
/**
@@ -5878,7 +5910,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
legend?: string;
/**
* @type integer
* @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.
* @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.
*/
limit?: number;
/**
@@ -5892,7 +5924,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
name?: string;
/**
* @type integer
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
* @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.
*/
offset?: number;
/**
@@ -5944,7 +5976,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
* @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.
*/
cursor?: string;
/**
@@ -5977,7 +6009,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
legend?: string;
/**
* @type integer
* @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.
* @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.
*/
limit?: number;
/**
@@ -5991,7 +6023,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
name?: string;
/**
* @type integer
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
* @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.
*/
offset?: number;
/**
@@ -6937,6 +6969,7 @@ export enum Querybuildertypesv5RequestTypeDTO {
raw_stream = 'raw_stream',
trace = 'trace',
heatmap = 'heatmap',
distribution = 'distribution',
}
/**
* The query kind.
@@ -7064,6 +7097,11 @@ 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.
@@ -7146,7 +7184,7 @@ export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
name?: string;
/**
* @type integer
* @description The number of raw results to skip before returning. Aggregation requests ignore it.
* @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.
*/
offset?: number;
/**
@@ -10973,7 +11011,7 @@ export interface Querybuildertypesv5TimeSeriesValueDTO {
value?: number;
/**
* @type array
* @description The per-band counts for heatmap results, in the order of meta.buckets plus a trailing overflow count.
* @description The per-bucket counts for heatmap results, in the order of meta.buckets plus a trailing overflow count.
*/
values?: number[];
}
@@ -11594,7 +11632,7 @@ export interface PromotetypesPostableIndexMaterializedPathsDTO {
export interface Querybuildertypesv5AggregationMetaDTO {
/**
* @type array
* @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.
* @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.
*/
buckets?: number[];
/**
@@ -11715,6 +11753,91 @@ 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.
*/
@@ -11970,21 +12093,28 @@ export type Querybuildertypesv5QueryDataDTO =
| (Querybuildertypesv5TimeSeriesDataDTO & {
/**
* @type array,null
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
* @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;
})
| (Querybuildertypesv5ScalarDataDTO & {
/**
* @type array,null
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
* @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;
})
| (Querybuildertypesv5RawDataDTO & {
/**
* @type array,null
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
* @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.
*/
results?: unknown[] | null;
});
@@ -12118,7 +12248,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, ScalarData for scalar, or RawData for raw requests.
* 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.
*/
export interface Querybuildertypesv5QueryRangeResponseDTO {
/**

View File

@@ -0,0 +1,100 @@
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

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

View File

@@ -0,0 +1,166 @@
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

@@ -0,0 +1,135 @@
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

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,121 @@
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

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

View File

@@ -15,18 +15,20 @@ 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 { SuggestionSource } from '../../utils/dslSuggestions';
import type { DslGrammar, SuggestionSource } from '../dsl/types';
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;
@@ -35,53 +37,20 @@ 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);
@@ -89,12 +58,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 => {
void logEvent(DashboardListEvents.SearchExecuted, {
hasQuery: !!value.trim(),
});
onSubmitted?.(!!value.trim());
onSubmit();
}, [onSubmit, value]);
}, [onSubmit, onSubmitted, value]);
const onSubmitRef = useRef(handleSubmit);
onSubmitRef.current = handleSubmit;
@@ -110,7 +79,12 @@ function SearchBar({
? []
: [
autocompletion({
override: [dslCompletionSource(() => sourceRef.current)],
override: [
dslCompletionSource(
() => sourceRef.current,
() => grammarRef.current,
),
],
closeOnBlur: true,
activateOnTyping: true,
maxRenderedOptions: 100,
@@ -168,7 +142,7 @@ function SearchBar({
basicSetup={BASIC_SETUP}
indentWithTab={false}
editable={!disabled}
data-testid="dashboards-list-search"
data-testid={testIdPrefix}
onChange={(next): void => onChange(next.replace(/\n/g, ' '))}
/>
<span
@@ -182,13 +156,13 @@ function SearchBar({
size="sm"
aria-label="Run search"
disabled={disabled}
testId="dashboards-list-search-submit"
testId={`${testIdPrefix}-submit`}
onClick={handleSubmit}
prefix={
dirty ? (
<span
className={styles.dirtyDot}
data-testid="dashboards-list-search-dirty"
data-testid={`${testIdPrefix}-dirty`}
/>
) : undefined
}

View File

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

View File

@@ -1,4 +1,4 @@
// CodeMirror syntax highlighting for the dashboards-list DSL, driven by the same
// CodeMirror syntax highlighting for the list-filter 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 '../../utils/dslTokenizer';
import { scanTerm, splitTopLevelTerms } from '../dsl/dslTokenizer';
const KEY = Decoration.mark({ class: 'cm-dsl-key' });
const OP = Decoration.mark({ class: 'cm-dsl-op' });

View File

@@ -0,0 +1,41 @@
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,36 +1,21 @@
// 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.
// 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.
import { negateOperator, OPERATORS } from 'constants/antlrQueryConstants';
// 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;
import type { DslField, DslFieldType, DslGrammar } from './types';
export type DslFieldType = 'string' | 'timestamp' | 'bool' | 'tag';
export const findField = (
grammar: DslGrammar,
key: string,
): DslField | undefined =>
grammar.fields.find((f) => f.key === key.toLowerCase());
const RESERVED_FIELD_TYPES: Record<string, DslFieldType> = {
name: 'string',
description: 'string',
created_by: 'string',
created_at: 'timestamp',
updated_at: 'timestamp',
locked: 'bool',
};
// 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';
// 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
// String/dynamic comparison operators, reusing the shared operator constants
// (REGEXP is allow-listed by the backend but unimplemented, so it is omitted).
const STRING_OPS = [
OPERATORS['='],
@@ -45,10 +30,10 @@ const STRING_OPS = [
negateOperator(OPERATORS.IN),
];
// Valid operators per field type. Tags additionally support existence checks.
// Valid operators per field type. Dynamic keys additionally support existence checks.
export const OPERATOR_MATRIX: Record<DslFieldType, string[]> = {
string: STRING_OPS,
tag: [...STRING_OPS, OPERATORS.EXISTS, negateOperator(OPERATORS.EXISTS)],
dynamic: [...STRING_OPS, OPERATORS.EXISTS, negateOperator(OPERATORS.EXISTS)],
timestamp: [
OPERATORS['='],
OPERATORS['!='],
@@ -60,6 +45,12 @@ 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,16 +1,23 @@
import { getSuggestions, type SuggestionSource } from './dslSuggestions';
import { DASHBOARDS_DSL_GRAMMAR } from 'pages/DashboardsListPage/list.config';
import { getSuggestions } from './dslSuggestions';
import type { SuggestionSource } from './types';
const source: SuggestionSource = {
tagKeys: ['env', 'team'],
tagValuesByKey: { env: ['prod', 'dev'] },
dynamicKeys: ['env', 'team'],
valuesByKey: { 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).items.map((i) => i.label);
getSuggestions(q, caret, source, DASHBOARDS_DSL_GRAMMAR).items.map(
(i) => i.label,
);
const inserts = (q: string, caret: number): string[] =>
getSuggestions(q, caret, source).items.map((i) => i.insertText);
getSuggestions(q, caret, source, DASHBOARDS_DSL_GRAMMAR).items.map(
(i) => i.insertText,
);
describe('getSuggestions — key stage', () => {
it('suggests reserved + tag keys matching the partial', () => {
@@ -61,7 +68,12 @@ 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);
const out = getSuggestions(
'created_by = ',
13,
source,
DASHBOARDS_DSL_GRAMMAR,
);
expect(out.items[0].label).toBe('me@x.io (me)');
expect(out.items.map((i) => i.insertText)).toContain("'alice@x.io' ");
});
@@ -86,7 +98,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);
const out = getSuggestions(q, q.length, source, DASHBOARDS_DSL_GRAMMAR);
const alice = out.items.find((i) => i.label === 'alice@x.io');
expect(alice?.insertText).toBe("['alice@x.io']");
expect(alice?.caretOffset).toBe(1);
@@ -100,7 +112,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);
const out = getSuggestions(q, caret, source, DASHBOARDS_DSL_GRAMMAR);
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.
@@ -110,7 +122,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);
const out = getSuggestions(q, q.length, source, DASHBOARDS_DSL_GRAMMAR);
// '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,48 +1,29 @@
// Stage-aware autocomplete for the dashboards-list DSL: as the caret moves, it
// Stage-aware autocomplete for the list-filter DSL: as the caret moves, it
// suggests keys, then operators (valid for that key's field type), then values
// (drawn from the tags/users the API reports). Grammar lives in dslGrammar.ts;
// caret/stage detection in dslTokenizer.ts.
// (drawn from the dynamic keys/users the API reports). Operator tables live 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;
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;
}
// Reserved keys whose values are user emails.
const USER_KEYS = new Set(['created_by', 'updated_by']);
const includesInsensitive = (haystack: string, needle: string): boolean =>
haystack.toLowerCase().includes(needle.toLowerCase());
@@ -52,17 +33,20 @@ const dedupe = (values: string[]): string[] => Array.from(new Set(values));
const keySuggestions = (
partial: string,
source: SuggestionSource,
grammar: DslGrammar,
): Suggestion[] => {
const reserved = source.reservedKeys ?? [...RESERVED_KEYS];
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 reservedSet = new Set(reserved.map((k) => k.toLowerCase()));
// 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[];
// 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.
const orderedReserved = [
...known.filter((k) => reserved.includes(k)),
...reserved.filter((k) => !known.includes(k)),
];
const keys = dedupe([...orderedReserved, ...source.tagKeys]);
const keys = dedupe([...orderedReserved, ...source.dynamicKeys]);
const lower = partial.toLowerCase();
return keys
.filter((k) => includesInsensitive(k, partial) && k.toLowerCase() !== lower)
@@ -71,16 +55,19 @@ const keySuggestions = (
label: k,
insertText: `${k} `,
kind: 'key',
// Distinguish built-in columns from org tag keys in the popup.
detail: reservedSet.has(k.toLowerCase()) ? 'field' : 'tag',
// Distinguish built-in columns from dynamic keys in the popup.
detail: reservedSet.has(k.toLowerCase())
? 'field'
: grammar.dynamicKey.detail,
}));
};
const operatorSuggestions = (
fieldKey: string,
partial: string,
grammar: DslGrammar,
): Suggestion[] => {
const ops = OPERATOR_MATRIX[classifyField(fieldKey)];
const ops = OPERATOR_MATRIX[classifyField(grammar, fieldKey)];
const upper = partial.toUpperCase();
return ops
.filter((op) => op.startsWith(upper))
@@ -155,15 +142,39 @@ 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(ctx.fieldKey);
const type = classifyField(grammar, 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.
@@ -180,15 +191,8 @@ const valueSuggestions = (
.map((v) => ({ label: v, insertText: `${v} `, kind: 'value' }));
}
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 {
const raw = knownValues(key, source, grammar);
if (raw === null) {
// name / description / timestamps have no known value set — free text.
return [];
}
@@ -199,7 +203,7 @@ const valueSuggestions = (
.slice(0, VALUE_LIMIT)
.map((v) => {
const label =
v === source.currentUserEmail && key === 'created_by' ? `${v} (me)` : v;
v === source.currentUserEmail && USER_KEYS.has(key) ? `${v} (me)` : v;
if (isList) {
const values = [...(list?.committed ?? []), v].map(literal).join(', ');
return {
@@ -239,15 +243,16 @@ 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);
items = keySuggestions(ctx.partial, source, grammar);
} else if (ctx.stage === 'operator') {
items = operatorSuggestions(ctx.fieldKey, ctx.partial);
items = operatorSuggestions(ctx.fieldKey, ctx.partial, grammar);
} else if (ctx.stage === 'value') {
items = valueSuggestions(ctx, source);
items = valueSuggestions(ctx, source, grammar);
} else if (ctx.stage === 'connector') {
items = connectorSuggestions(ctx.partial);
}

View File

@@ -1,4 +1,4 @@
// Quote/paren/bracket-aware scanning of the dashboards-list filter DSL, shared by
// Quote/paren/bracket-aware scanning of the 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
// (`GET /api/v2/dashboards?query=...`). The query string is the single source of
// (e.g. `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 tag filter)
// env = 'prod' (any non-reserved key is a dynamic filter, e.g. a tag)
// locked = true
import dayjs from 'dayjs';
import type { UpdatedWindow } from '../types';
import type { ClauseValue, UpdatedWindow } from './types';
import { literal } from './dslGrammar';
import { scanTerm, splitTopLevelTerms } from './dslTokenizer';
@@ -37,6 +37,16 @@ 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

@@ -0,0 +1,69 @@
import { RULES_DSL_GRAMMAR } from 'container/ListAlertRules/list.config';
import { getSuggestions } from './dslSuggestions';
import type { SuggestionSource } from './types';
const source: SuggestionSource = {
reservedKeys: [
'alert_type',
'created_at',
'created_by',
'labels.<key>',
'name',
'rule_type',
'severity',
'updated_at',
'updated_by',
],
dynamicKeys: ['labels.team', 'labels.env'],
valuesByKey: { 'labels.team': ['infra'], 'labels.severity': ['critical'] },
creatorEmails: ['alice@x.io'],
currentUserEmail: 'me@x.io',
};
const suggest = (q: string): ReturnType<typeof getSuggestions>['items'] =>
getSuggestions(q, q.length, source, RULES_DSL_GRAMMAR).items;
const labels = (q: string): string[] => suggest(q).map((i) => i.label);
describe('getSuggestions — rules grammar', () => {
it('orders reserved keys, hides labels.<key> and appends label keys', () => {
expect(labels('')).toStrictEqual([
'name',
'severity',
'alert_type',
'rule_type',
'created_by',
'updated_by',
'created_at',
'updated_at',
'labels.team',
'labels.env',
]);
expect(suggest('labels.t').map((i) => i.detail)).toStrictEqual(['label']);
});
it('restricts enum keys to = != IN NOT IN and suggests their values', () => {
expect(labels('rule_type ')).toStrictEqual([
'rule_type =',
'rule_type !=',
'rule_type IN',
'rule_type NOT IN',
]);
expect(labels('rule_type = ')).toStrictEqual([
'threshold_rule',
'promql_rule',
'anomaly_rule',
]);
expect(labels("alert_type = 'LOGS")).toStrictEqual(['LOGS_BASED_ALERT']);
});
it('gives severity and labels.* the label operators and values', () => {
expect(labels('severity ')).toContain('severity EXISTS');
expect(labels('severity = ')).toStrictEqual(['critical']);
expect(labels('labels.team = ')).toStrictEqual(['infra']);
});
it('suggests user emails for updated_by', () => {
expect(labels('updated_by = ')).toStrictEqual(['me@x.io (me)', 'alice@x.io']);
});
});

View File

@@ -0,0 +1,61 @@
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

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

View File

@@ -0,0 +1,46 @@
.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,33 +1,40 @@
import type { ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import NewDashboardButton from './NewDashboardButton';
import styles from './DashboardsList.module.scss';
import styles from './ListCommandHeader.module.scss';
interface Props {
label: string;
count: number;
isModified: boolean;
onCreate: () => void;
action?: ReactNode;
// Ids: `${testIdPrefix}-list-heading`.
testIdPrefix?: string;
}
function CommandHeader({
function ListCommandHeader({
label,
count,
isModified,
onCreate,
action,
testIdPrefix,
}: Props): JSX.Element {
return (
<div className={styles.commandHeader}>
<div className={styles.headingBlock}>
<Typography.Title className={styles.title}>{label}</Typography.Title>
<Typography.Title
className={styles.title}
testId={testIdPrefix && `${testIdPrefix}-list-heading`}
>
{label}
</Typography.Title>
{isModified && <span className={styles.dirtyDot} title="Unsaved changes" />}
<span className={styles.countPill}>{count}</span>
</div>
<div className={styles.grow} />
<NewDashboardButton onClick={onCreate} />
{action}
</div>
);
}
export default CommandHeader;
export default ListCommandHeader;

View File

@@ -2,20 +2,25 @@ import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { PanelLeftClose, PanelLeftOpen } from '@signozhq/icons';
import styles from './StatusBar.module.scss';
import styles from './ListStatusBar.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 StatusBar({
function ListStatusBar({
collapsed,
onToggleCollapse,
count,
total,
noun,
testId,
}: Props): JSX.Element {
return (
<div className={styles.statusBar}>
@@ -27,15 +32,15 @@ function StatusBar({
collapsed ? <PanelLeftOpen size={14} /> : <PanelLeftClose size={14} />
}
onClick={onToggleCollapse}
testId="dashboards-rail-toggle"
testId={testId}
>
{collapsed ? 'Expand' : 'Collapse'}
</Button>
<Typography.Text className={styles.count}>
{count} of {total} dashboards
{count} of {total} {noun}
</Typography.Text>
</div>
);
}
export default StatusBar;
export default ListStatusBar;

View File

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

@@ -0,0 +1,87 @@
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

@@ -0,0 +1,105 @@
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

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

View File

@@ -0,0 +1,151 @@
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

@@ -0,0 +1,43 @@
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

@@ -18,71 +18,51 @@
right: -2px;
width: 240px;
[data-format-menu='back'],
[data-format-menu='font-option'],
[data-format-menu='font-size'],
[data-format-menu='edit-columns'] {
--button-justify-content: flex-start;
}
[data-format-menu='font-option'],
[data-format-menu='font-size'],
[data-format-menu='edit-columns'] {
--button-label-min-width: 100%;
--button-font-size: 13px;
--button-variant-link-font-weight: 400;
--button-size-line-height: 20px;
}
[data-format-menu='back'] {
text-transform: uppercase;
}
[data-format-menu='font-option'],
[data-format-menu='font-size'] {
text-transform: capitalize;
}
.menu-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.font-size-dropdown {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
.back-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 12px;
border: none !important;
box-shadow: none !important;
.icon {
flex-shrink: 0;
}
.text {
color: var(--l1-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: 0.14px;
text-transform: uppercase;
}
}
.back-btn:hover {
background-color: unset !important;
.horizontal-line {
margin-inline: -12px;
}
.content {
display: flex;
flex-direction: column;
.option-btn {
display: flex;
align-items: center;
padding: 12px;
border: none !important;
box-shadow: none !important;
justify-content: space-between;
.icon {
flex-shrink: 0;
}
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal; /* 142.857% */
letter-spacing: 0.14px;
text-transform: capitalize;
}
.text:hover {
color: var(--l2-foreground);
}
}
.option-btn:hover {
background-color: unset !important;
}
}
}
@@ -102,31 +82,6 @@
letter-spacing: 0.88px;
text-transform: uppercase;
}
.value {
display: flex;
height: 20px;
padding: 4px 0px;
justify-content: space-between;
align-items: center;
border: none !important;
.font-value {
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
text-transform: capitalize;
}
.icon {
}
}
.value:hover {
background-color: unset !important;
}
}
.menu-container {
@@ -198,90 +153,10 @@
cursor: pointer;
}
}
.max-lines-per-row-input {
display: flex;
.ant-input-number-handler-wrap {
display: none;
}
.ant-input-number {
min-width: 36px;
width: auto;
border-right: none;
border-left: none;
border-top: 1px solid var(--l1-border);
border-bottom: 1px solid var(--l1-border);
text-align: center;
height: 26px;
border-radius: 0;
&:active,
&:focus {
border: none;
box-shadow: none;
}
}
.ant-input-number-focused {
box-shadow: none !important;
}
.ant-input-number-input-wrap {
input {
text-align: center;
font-size: 13px;
&:active,
&:focus {
border: none;
}
}
&:active,
&:focus {
border: none;
}
}
.periscope-btn {
box-shadow: none;
padding: 6px 12px;
height: 26px;
border-radius: 0px 1px 1px 0px;
background: var(--l3-background);
}
}
}
.edit-columns-container {
padding: 12px;
.edit-columns-btn {
display: flex;
width: 100%;
height: 20px;
padding: 4px 0px;
justify-content: space-between;
align-items: center;
border: none !important;
.edit-columns-text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
}
}
.edit-columns-btn:hover {
background-color: unset !important;
}
}
.add-new-column-container {

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { Button, InputNumber, Popover, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Popover, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { InputNumber } from '@signozhq/ui/input-number';
import cx from 'classnames';
import {
LogViewMode,
@@ -11,13 +12,15 @@ import {
Check,
ChevronLeft,
ChevronRight,
Minus,
Plus,
SlidersVertical,
} from '@signozhq/icons';
import './LogsFormatOptionsMenu.styles.scss';
const MIN_LINES_PER_ROW = 1;
const MAX_LINES_PER_ROW = 10;
const FONT_SIZES = [FontSize.SMALL, FontSize.MEDIUM, FontSize.LARGE];
interface LogsFormatOptionsMenuProps {
items: any;
selectedOptionFormat: any;
@@ -62,25 +65,11 @@ function OptionsMenu({
onChange(key);
};
const incrementMaxLinesPerRow = (): void => {
if (maxLinesPerRow < 10) {
setMaxLinesPerRow(maxLinesPerRow + 1);
}
};
const decrementMaxLinesPerRow = (): void => {
if (maxLinesPerRow > 1) {
setMaxLinesPerRow(maxLinesPerRow - 1);
}
};
const handleLinesPerRowChange = (maxLinesPerRow: number | null): void => {
if (
maxLinesPerRow &&
Number.isInteger(maxLinesNumber) &&
maxLinesPerRow > 1
) {
setMaxLinesPerRow(maxLinesPerRow);
const handleLinesPerRowChange = (value: number | null): void => {
if (value !== null) {
setMaxLinesPerRow(
Math.min(Math.max(value, MIN_LINES_PER_ROW), MAX_LINES_PER_ROW),
);
}
};
@@ -111,51 +100,36 @@ function OptionsMenu({
{isFontSizeOptionsOpen ? (
<div className="font-size-dropdown">
<Button
variant="link"
color="secondary"
size="sm"
width="100%"
data-format-menu="back"
testId="font-size-back"
prefix={<ChevronLeft size={14} />}
onClick={(): void => setIsFontSizeOptionsOpen(false)}
className="back-btn"
type="text"
>
<ChevronLeft size={14} className="icon" />
<Typography.Text className="text">Select font size</Typography.Text>
Select font size
</Button>
<div className="horizontal-line" />
<div className="content">
<Button
onClick={(): void => {
setFontSizeValue(FontSize.SMALL);
}}
className="option-btn"
type="text"
>
<Typography.Text className="text">{FontSize.SMALL}</Typography.Text>
{fontSizeValue === FontSize.SMALL && (
<Check size={14} className="icon" />
)}
</Button>
<Button
onClick={(): void => {
setFontSizeValue(FontSize.MEDIUM);
}}
className="option-btn"
type="text"
>
<Typography.Text className="text">{FontSize.MEDIUM}</Typography.Text>
{fontSizeValue === FontSize.MEDIUM && (
<Check size={14} className="icon" />
)}
</Button>
<Button
onClick={(): void => {
setFontSizeValue(FontSize.LARGE);
}}
className="option-btn"
type="text"
>
<Typography.Text className="text">{FontSize.LARGE}</Typography.Text>
{fontSizeValue === FontSize.LARGE && (
<Check size={14} className="icon" />
)}
</Button>
{FONT_SIZES.map((size) => (
<Button
key={size}
variant="link"
color="secondary"
size="sm"
width="100%"
data-format-menu="font-option"
testId={`font-size-option-${size}`}
onClick={(): void => setFontSizeValue(size)}
>
<span className="menu-row">
{size}
{fontSizeValue === size && <Check size={14} />}
</span>
</Button>
))}
</div>
</div>
) : (
@@ -163,14 +137,20 @@ function OptionsMenu({
<div className="font-size-container">
<div className="title">Font Size</div>
<Button
className="value"
type="text"
variant="link"
color="secondary"
size="sm"
width="100%"
data-format-menu="font-size"
testId="font-size-select"
onClick={(): void => {
setIsFontSizeOptionsOpen(true);
}}
>
<Typography.Text className="font-value">{fontSizeValue}</Typography.Text>
<ChevronRight size={14} className="icon" />
<span className="menu-row">
{fontSizeValue}
<ChevronRight size={14} />
</span>
</Button>
</div>
<div className="horizontal-line" />
@@ -200,30 +180,20 @@ function OptionsMenu({
<div className="horizontal-line" />
<div className="max-lines-per-row">
<div className="title"> max lines per row </div>
<div className="raw-format max-lines-per-row-input">
<button
type="button"
className="periscope-btn"
onClick={decrementMaxLinesPerRow}
>
{' '}
<Minus size={12} />{' '}
</button>
<InputNumber
min={1}
max={10}
value={maxLinesPerRow}
onChange={handleLinesPerRowChange}
/>
<button
type="button"
className="periscope-btn"
onClick={incrementMaxLinesPerRow}
>
{' '}
<Plus size={12} />{' '}
</button>
</div>
<InputNumber
controls
mode="spinner"
size="small"
min={MIN_LINES_PER_ROW}
max={MAX_LINES_PER_ROW}
precision={0}
value={maxLinesPerRow}
onChange={handleLinesPerRowChange}
aria-label="Max lines per row"
decrementAriaLabel="Fewer lines per row"
incrementAriaLabel="More lines per row"
testId="max-lines-per-row"
/>
</div>
</>
)}
@@ -233,15 +203,18 @@ function OptionsMenu({
<div className="horizontal-line" />
<div className="edit-columns-container">
<Button
className="edit-columns-btn"
type="text"
variant="link"
color="secondary"
size="sm"
width="100%"
data-format-menu="edit-columns"
onClick={handleEditColumns}
data-testid="periscope-btn-edit-columns"
testId="periscope-btn-edit-columns"
>
<Typography.Text className="edit-columns-text">
<span className="menu-row">
Edit columns
</Typography.Text>
<ChevronRight size={14} className="icon" />
<ChevronRight size={14} />
</span>
</Button>
</div>
</>
@@ -280,10 +253,15 @@ function LogsFormatOptionsMenu({
>
<Tooltip title="Options">
<Button
className="periscope-btn ghost"
icon={<SlidersVertical size="md" />}
data-testid="periscope-btn-format-options"
/>
variant="ghost"
color="secondary"
size="sm"
icon
aria-label="Options"
testId="periscope-btn-format-options"
>
<SlidersVertical size={14} />
</Button>
</Tooltip>
</Popover>
);

View File

@@ -1,5 +1,11 @@
import { FontSize } from 'container/OptionsMenu/types';
import { fireEvent, render, waitFor } from 'tests/test-utils';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import LogsFormatOptionsMenu from '../LogsFormatOptionsMenu';
@@ -107,18 +113,12 @@ describe('LogsFormatOptionsMenu (unit)', () => {
});
it('increments max-lines and calls onChange', async () => {
const { maxLinesOnChange } = setup();
const { maxLinesOnChange, getByTestId } = setup();
// Increment max lines
const input = document.querySelector(
'.max-lines-per-row-input input',
) as HTMLInputElement;
const input = getByTestId('max-lines-per-row') as HTMLInputElement;
const initial = Number(input.value);
const buttons = document.querySelectorAll(
'.max-lines-per-row-input .periscope-btn',
);
const incrementBtn = buttons[1] as HTMLElement;
fireEvent.click(incrementBtn);
fireEvent.click(screen.getByRole('button', { name: 'More lines per row' }));
await waitFor(() => {
expect(Number(input.value)).toBe(initial + 1);
@@ -128,28 +128,47 @@ describe('LogsFormatOptionsMenu (unit)', () => {
});
});
it('clamps a typed max-lines value to 10', async () => {
const { maxLinesOnChange, getByTestId } = setup();
const input = getByTestId('max-lines-per-row') as HTMLInputElement;
const user = userEvent.setup();
await user.clear(input);
await user.type(input, '15');
await user.tab();
await waitFor(() => expect(input.value).toBe('10'));
expect(maxLinesOnChange).toHaveBeenLastCalledWith(10);
});
it('stops max-lines at 10', async () => {
const { getByTestId } = setup();
const input = getByTestId('max-lines-per-row') as HTMLInputElement;
const increment = screen.getByRole('button', { name: 'More lines per row' });
const user = userEvent.setup();
for (let i = 0; i < 12; i++) {
await user.click(increment);
}
await waitFor(() => expect(input.value).toBe('10'));
});
it('changes font size to MEDIUM and calls onChange', async () => {
const { fontSizeOnChange } = setup();
const { fontSizeOnChange, getByTestId } = setup();
// Open font dropdown
const fontButton = document.querySelector(
'.font-size-container .value',
) as HTMLElement;
fireEvent.click(fontButton);
fireEvent.click(getByTestId('font-size-select'));
// Choose MEDIUM
const optionButtons = Array.from(
document.querySelectorAll('.font-size-dropdown .option-btn'),
);
const mediumBtn = optionButtons[1] as HTMLElement;
fireEvent.click(mediumBtn);
fireEvent.click(getByTestId(`font-size-option-${FontSize.MEDIUM}`));
await waitFor(() => {
expect(
document.querySelectorAll('.font-size-dropdown .option-btn .icon'),
).toHaveLength(1);
expect(
(optionButtons[1] as Element).querySelector('.icon'),
getByTestId(`font-size-option-${FontSize.MEDIUM}`).querySelector('svg'),
).toBeInTheDocument();
expect(
getByTestId(`font-size-option-${FontSize.SMALL}`).querySelector('svg'),
).not.toBeInTheDocument();
});
await waitFor(() => {
expect(fontSizeOnChange).toHaveBeenCalledWith(FontSize.MEDIUM);

View File

@@ -0,0 +1,26 @@
.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

@@ -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 './ViewsRail.module.scss';
import styles from './ViewNamePopover.module.scss';
export const VIEW_NAME_MAX_LENGTH = 64;

View File

@@ -13,6 +13,7 @@ export enum LOCALSTORAGE {
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
AI_OBSERVABILITY_TRACE_VIEW_COLUMNS = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMNS',
AI_OBSERVABILITY_PANEL_COLUMNS = 'AI_OBSERVABILITY_PANEL_COLUMNS',
AI_OBSERVABILITY_LIST_COLUMNS = 'AI_OBSERVABILITY_LIST_COLUMNS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
@@ -44,6 +45,7 @@ export enum LOCALSTORAGE {
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
ALERT_RULES_LIST_VIEWS = 'ALERT_RULES_LIST_VIEWS',
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
}

View File

@@ -48,18 +48,19 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { getOpenResourceTarget } from './utils/getOpenResourceTarget';
import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from './utils/getPanelTypeForRequestType';
import { isOnResourcePage } from './utils/isOnResourcePage';
import { openSavedViewByKey } from './utils/openSavedView';
import {
isSavedViewOpenAction,
resolveOpenResourceType,
resolveResourceId,
resolveSavedViewSourceHint,
} from './utils/resolveOpenResource';
import { ResourceType, resourceRoute } from './utils/resourceRoute';
import { ResourceType } from './utils/resourceRoute';
import styles from './ActionsSection.module.scss';
@@ -397,7 +398,7 @@ export default function ActionsSection({
actions,
messageId,
}: ActionsSectionProps): JSX.Element | null {
const { pathname } = useAppLocation();
const { pathname, search } = useAppLocation();
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
@@ -493,23 +494,17 @@ export default function ActionsSection({
return;
}
const resourceType = resolveOpenResourceType(action);
const resourceId = resolveResourceId(action);
if (!resourceType || !resourceId) {
return;
}
const path = resourceRoute(resourceType, resourceId);
if (!path) {
const target = getOpenResourceTarget(action);
if (!target) {
return;
}
void logEvent(AIAssistantEvents.ResourceOpened, {
threadId,
messageId,
targetModule: targetModuleForResource(resourceType),
resourceId,
targetModule: targetModuleForResource(target.resourceType),
resourceId: target.resourceId,
});
navigate(path);
navigate(target.path);
};
const handleClick = (key: string, action: MessageActionDTO): void => {
@@ -607,6 +602,10 @@ export default function ActionsSection({
// is still surfaced visually via the suffix pill below.
const tooltip = isError ? result.error : (action.tooltip ?? undefined);
const openTarget = getOpenResourceTarget(action);
const isViewingResource =
openTarget !== null &&
isOnResourcePage(openTarget.path, { pathname, search });
let icon: JSX.Element;
if (isSuccess) {
@@ -619,12 +618,16 @@ export default function ActionsSection({
const chip = (
<Button
disabledTooltip="This action already ran"
disabledTooltip={
isViewingResource
? "You're already viewing this page"
: 'This action already ran'
}
variant="outlined"
color="secondary"
size="sm"
onClick={(): void => handleClick(key, action)}
disabled={isSuccess}
disabled={isSuccess || isViewingResource}
loading={isLoading}
aria-label={action.label}
testId={`ai-action-chip-${action.kind}`}

View File

@@ -0,0 +1,99 @@
import {
MessageActionDTO,
MessageActionKindDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import logEvent from 'api/common/logEvent';
import { getCurrentLocation, navigate } from 'lib/router/navigation';
import { act, render, screen, userEvent, waitFor } from 'tests/test-utils';
import { AIAssistantEvents } from '../../../events';
import ActionsSection from '../ActionsSection';
jest.mock('api/common/logEvent');
const mockLogEvent = jest.mocked(logEvent);
const OPEN_ALERT: MessageActionDTO = {
kind: MessageActionKindDTO.open_resource,
label: 'Open alert',
resourceType: 'alert',
resourceId: 'rule-1',
actionMetadataId: 'meta-1',
};
const OPEN_ALERT_CHIP = 'ai-action-chip-open_resource';
describe('ActionsSection open_resource', () => {
beforeEach(() => {
mockLogEvent.mockClear();
});
it.each([
'/alerts/edit?ruleId=rule-1&tab=1',
'/alerts/overview?ruleId=rule-1&relativeTime=6h',
])('disables Open alert while that alert is open at %s', async (route) => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection actions={[OPEN_ALERT]} messageId="message-1" />,
undefined,
{ initialRoute: route },
);
const chip = screen.getByTestId(OPEN_ALERT_CHIP);
// With a disabled tooltip the button is aria-disabled so the tooltip stays reachable.
expect(chip).toHaveAttribute('aria-disabled', 'true');
await user.click(chip);
expect(`${getCurrentLocation().pathname}${getCurrentLocation().search}`).toBe(
route,
);
expect(mockLogEvent).not.toHaveBeenCalled();
});
it.each(['/alerts/edit?ruleId=rule-2', '/alerts/history?ruleId=rule-1'])(
'opens the alert from %s',
async (route) => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection actions={[OPEN_ALERT]} messageId="message-2" />,
undefined,
{ initialRoute: route },
);
const chip = screen.getByTestId(OPEN_ALERT_CHIP);
expect(chip).not.toHaveAttribute('aria-disabled', 'true');
await user.click(chip);
await waitFor(() =>
expect(
`${getCurrentLocation().pathname}${getCurrentLocation().search}`,
).toBe('/alerts/edit?ruleId=rule-1'),
);
expect(mockLogEvent).toHaveBeenCalledWith(
AIAssistantEvents.ResourceOpened,
expect.objectContaining({ resourceId: 'rule-1' }),
);
},
);
it('enables Open alert again after navigating away from that alert', () => {
render(
<ActionsSection actions={[OPEN_ALERT]} messageId="message-3" />,
undefined,
{ initialRoute: '/alerts/edit?ruleId=rule-1' },
);
expect(screen.getByTestId(OPEN_ALERT_CHIP)).toHaveAttribute(
'aria-disabled',
'true',
);
act(() => navigate('/alerts/edit?ruleId=rule-2'));
expect(screen.getByTestId(OPEN_ALERT_CHIP)).not.toHaveAttribute(
'aria-disabled',
'true',
);
});
});

View File

@@ -0,0 +1,39 @@
import {
type MessageActionDTO,
MessageActionKindDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
isSavedViewOpenAction,
resolveOpenResourceType,
resolveResourceId,
} from './resolveOpenResource';
import { resourceRoute } from './resourceRoute';
export interface OpenResourceTarget {
path: string;
resourceType: string;
resourceId: string;
}
// Saved views return null so they're never disabled: reopening one resets the explorer to the saved query.
export function getOpenResourceTarget(
action: MessageActionDTO,
): OpenResourceTarget | null {
if (
action.kind !== MessageActionKindDTO.open_resource ||
isSavedViewOpenAction(action)
) {
return null;
}
const resourceType = resolveOpenResourceType(action);
const resourceId = resolveResourceId(action);
if (!resourceType || !resourceId) {
return null;
}
const path = resourceRoute(resourceType, resourceId);
if (!path) {
return null;
}
return { path, resourceType, resourceId };
}

View File

@@ -0,0 +1,23 @@
import ROUTES from 'constants/routes';
import type { AppLocation } from 'lib/router/types';
// The alert details Overview tab renders the same editor as the edit route.
const EQUIVALENT_PATHNAMES: Record<string, string[]> = {
[ROUTES.EDIT_ALERTS]: [ROUTES.ALERT_OVERVIEW],
};
// Extra params on the current page (time range, view options) don't make it a different resource.
export function isOnResourcePage(
path: string,
location: Pick<AppLocation, 'pathname' | 'search'>,
): boolean {
const [pathname, query = ''] = path.split('?');
const pathnames = [pathname, ...(EQUIVALENT_PATHNAMES[pathname] ?? [])];
if (!pathnames.includes(location.pathname)) {
return false;
}
const current = new URLSearchParams(location.search);
return [...new URLSearchParams(query)].every(
([key, value]) => current.get(key) === value,
);
}

View File

@@ -2,7 +2,12 @@ import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/s
import { toChannelConfig, toPostableChannel } from './channelConfig';
import { toChannelFormState } from './channelFormValues';
import { ChannelFormValues, ChannelKind, WebhookAuth } from './types';
import {
ChannelFormValues,
ChannelKind,
JiraDeployment,
WebhookAuth,
} from './types';
const slackValues: ChannelFormValues = {
name: 'prod alerts',
@@ -145,11 +150,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',
email: 'a@b.c',
apiToken: 'tok',
auth: { email: 'a@b.c', apiToken: 'tok' },
reopenDuration: typed,
}).spec,
).toMatchObject({ reopenDuration: sent });
@@ -161,3 +166,53 @@ 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,6 +22,8 @@ import {
ChannelFormValues,
ChannelKind,
ChannelSpecFormValues,
JiraDeployment,
KeysOfUnion,
WebhookAuth,
} from './types';
import {
@@ -95,11 +97,11 @@ const SPEC_FIELDS = {
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelGoogleChatConfigDTO)[],
[ChannelKind.jira]: [
'deployment',
'site',
'project',
'issueType',
'email',
'apiToken',
'auth',
'summary',
'description',
'priority',
@@ -145,7 +147,7 @@ const SPEC_FIELDS = {
* instead, and the error names the missing field.
*/
type MissingFrom<D, T extends readonly PropertyKey[]> = Exclude<
keyof D,
KeysOfUnion<D>,
T[number]
>;
@@ -230,6 +232,15 @@ 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',
email: 'a@b.c',
apiToken: 'tok',
auth: { 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 } from './types';
import { ChannelKind, ChannelSpecFormValues, JiraDeployment } from './types';
// shared by slack and ms teams, both render the same title / description boxes
export const SlackInitialConfig: Partial<AlertmanagertypesChannelSlackConfigDTO> =
@@ -58,6 +58,7 @@ 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,20 +1,40 @@
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({
site: required,
email: required,
apiToken: required,
project: required,
issueType: required,
reopenDuration: z
.string()
.optional()
.refine((v) => !v || isValidJiraReopenDuration(v), {
message: 'jira_reopen_duration_invalid',
}),
});
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'] },
);

View File

@@ -2,6 +2,7 @@ import {
AlertmanagertypesChannelEmailConfigDTO,
AlertmanagertypesChannelGoogleChatConfigDTO,
AlertmanagertypesChannelIncidentIOConfigDTO,
AlertmanagertypesChannelJiraAuthDTO,
AlertmanagertypesChannelJiraConfigDTO,
AlertmanagertypesChannelJSMOpsConfigDTO,
AlertmanagertypesChannelKindDTO,
@@ -11,11 +12,33 @@ 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 &
@@ -25,7 +48,7 @@ export type ChannelSpecFormValues = Partial<
AlertmanagertypesChannelOpsgenieConfigDTO &
AlertmanagertypesChannelMSTeamsConfigDTO &
AlertmanagertypesChannelGoogleChatConfigDTO &
AlertmanagertypesChannelJiraConfigDTO &
JiraFormSpec &
AlertmanagertypesChannelJSMOpsConfigDTO &
AlertmanagertypesChannelIncidentIOConfigDTO &
AlertmanagertypesChannelTelegramConfigDTO

View File

@@ -1,6 +1,11 @@
import { TFunction } from 'i18next';
import { ChannelFormValues, ChannelKind, WebhookAuth } from './types';
import {
ChannelFormValues,
ChannelKind,
JiraDeployment,
WebhookAuth,
} from './types';
import { validateChannel } from './validation';
const t = ((key: string) => key) as TFunction;
@@ -17,14 +22,50 @@ describe('validateChannel', () => {
'a jira site that is not on atlassian.net',
ChannelKind.jira,
{
deployment: JiraDeployment.cloud,
site: 'https://jira-proxy.acme.com/',
email: 'a@acme.com',
apiToken: 't',
auth: { 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

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

View File

@@ -1,13 +1,19 @@
import { useTranslation } from 'react-i18next';
import { Controller, useFormContext } from 'react-hook-form';
import { Controller, useFormContext, useWatch } 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 } from '../../CreateAlertChannels/types';
import {
ChannelSpecFormValues,
JiraDeployment,
} from '../../CreateAlertChannels/types';
import FormCard from '../FormCard';
import FormField from '../FormField';
import styles from './Jira.module.scss';
const { TextArea } = AntdInput;
function JiraSettings(): JSX.Element {
@@ -17,6 +23,8 @@ function JiraSettings(): JSX.Element {
control,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
const deployment = useWatch({ control, name: 'deployment' });
const selfHosted = deployment === JiraDeployment.selfhosted;
const advanced = (
<>
@@ -99,62 +107,132 @@ function JiraSettings(): JSX.Element {
return (
<>
<FormCard title={t('section_connection')}>
<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"
<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 }}
>
{t('jira_service_account_tip_link')}
</Typography.Link>
</Typography.Text>
{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>
)}
<FormField
label={t('field_jira_site')}
tooltip={t('tooltip_jira_site')}
tooltip={t(
selfHosted ? 'tooltip_jira_site_selfhosted' : 'tooltip_jira_site',
)}
error={errors.site?.message}
required
>
<Input
{...register('site')}
aria-required
placeholder="https://your-domain.atlassian.net"
placeholder={
selfHosted
? 'https://jira.your-company.com'
: 'https://your-domain.atlassian.net'
}
data-testid="jira-site-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>
{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_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_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_project')}
@@ -203,7 +281,9 @@ function JiraSettings(): JSX.Element {
<FormField
label={t('field_jira_description')}
help={t('help_jira_description')}
help={t(
selfHosted ? 'help_jira_description_selfhosted' : 'help_jira_description',
)}
error={errors.description?.message}
>
<Controller

View File

@@ -3,6 +3,7 @@ import { useQueryClient } from 'react-query';
import { useAppSearchParams } from 'lib/router/useAppSearchParams';
import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
@@ -10,6 +11,10 @@ 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';
@@ -85,8 +90,9 @@ function Explorer(): JSX.Element {
setIsLoadingQueries(false);
}, [queryClient]);
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
const selectedView = useMemo(
() => getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
[searchParams, panelTypesFromUrl],
);
const [warning, setWarning] = useState<Warning | undefined>();
@@ -113,13 +119,16 @@ function Explorer(): JSX.Element {
const handleChangeSelectedView = useCallback(
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
setSelectedView(view);
const panelType = explorerViewToPanelType[view];
handleSetConfig(panelType, DataSource.TRACES);
// Drop the left panel's url columns; each panel loads its own.
handleExplorerTabChange(
explorerViewToPanelType[view],
panelType,
querySearchParameters,
undefined,
undefined,
{ [URL_OPTIONS]: defaultOptionsQuery },
);
},
[handleExplorerTabChange, handleSetConfig],
@@ -190,6 +199,7 @@ function Explorer(): JSX.Element {
testId="llm-observability-explorer"
showFilters={isOpen}
onToggleFilters={(): void => setOpen(!isOpen)}
savedViewProps={{ source: SavedviewtypesSourceDTO.ai_observability }}
quickFilterProps={{
className: 'qf-traces-explorer',
quickFilterSource: QuickFiltersSource.AI_OBSERVABILITY,

View File

@@ -12,12 +12,15 @@ 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';
@@ -37,11 +40,7 @@ import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import TraceExplorerControls from '../Controls';
import { getListViewQuery } from '../explorerUtils';
import {
defaultSelectedColumns,
PER_PAGE_OPTIONS,
TIMESTAMP_FIELD,
} from './configs';
import { PER_PAGE_OPTIONS, TIMESTAMP_FIELD } from './configs';
import './ListView.styles.scss';
import styles from './ListView.module.scss';
@@ -77,6 +76,11 @@ 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,
);
@@ -88,6 +92,18 @@ 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,
@@ -97,6 +113,7 @@ function ListView({
stagedQuery,
panelType,
paginationConfig,
selectColumnsSignature,
],
[
stagedQuery,
@@ -105,6 +122,7 @@ function ListView({
paginationConfig,
maxTime,
minTime,
selectColumnsSignature,
],
);
@@ -123,7 +141,7 @@ function ListView({
},
tableParams: {
pagination: paginationConfig,
selectColumns: defaultSelectedColumns,
selectColumns,
},
},
ENTITY_VERSION_V5,
@@ -156,13 +174,20 @@ function ListView({
[queryTableDataResult],
);
// TODO(ai-explorer): static columns until the preferences framework lands.
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(
() =>
[TIMESTAMP_FIELD, ...defaultSelectedColumns].map((field) =>
getFieldColumn(field),
),
[],
[
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],
);
const rows = useMemo(
@@ -204,6 +229,7 @@ function ListView({
isLoading={isFetching}
totalCount={rows.length}
perPageOptions={PER_PAGE_OPTIONS}
config={config}
/>
</div>
@@ -211,7 +237,8 @@ function ListView({
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_LIST_COLUMNS}
respectColumnOrder
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={config?.addColumn?.onRemove}
panelType="LIST"
getRowHref={getTraceLink}
isLoading={isLoading}

View File

@@ -0,0 +1,193 @@
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

@@ -11,13 +11,16 @@ const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const ROWS = [{ id: 't1', trace_id: 'abc', 'service.name': 'checkout' }];
// An aggregate outside the default order starts hidden, so the persisted
// defaults are observable.
// An aggregate that starts hidden, so the persisted defaults are observable.
const COLUMNS = buildTraceViewColumns([
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'unlisted_aggregate' },
]);
]).map((column) =>
column.id === 'unlisted_aggregate'
? { ...column, defaultVisibility: false }
: column,
);
function RaceHarness(): JSX.Element {
const [columnsReady, setColumnsReady] = useState(false);

View File

@@ -12,11 +12,14 @@ 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 { getTraceLink } from '../ListView/utils';
import { TracesTableRow } from '../TracesTable/getFieldColumn';
import TracesTable from '../TracesTable/TracesTable';
@@ -40,8 +43,11 @@ import {
TRACE_VIEW_ORDER_BY_EXTRA_FIELDS,
} from '../constants';
import { getListViewQuery } from '../explorerUtils';
import { PER_PAGE_OPTIONS } from './configs';
import { useTraceViewColumns } from './useTraceViewColumns';
import {
buildTraceViewColumns,
PER_PAGE_OPTIONS,
REQUIRED_FIELDS,
} from './configs';
import styles from './TracesView.module.scss';
interface TracesViewProps {
@@ -65,14 +71,21 @@ function TracesView({
[stagedQuery],
);
const {
columns,
selectedFields,
onFieldsChange,
requiredFields,
isLoading: isColumnsLoading,
canPersistColumns,
} = useTraceViewColumns();
const { options, config } = useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.ai_observability,
});
const tableColumns = useMemo(
() => buildTraceViewColumns(options.selectColumns),
[options.selectColumns],
);
const handleColumnOrderChange = useCallback(
(cols: TableColumnDef<TracesTableRow>[]): void => {
config?.addColumn?.onReorder(cols.map((column) => column.id));
},
[config],
);
const {
selectedTime: globalSelectedTime,
@@ -174,24 +187,6 @@ function TracesView({
[currentQuery, redirectWithQueryBuilderData],
);
// Without the full column set there is no pool to pick from, so the control is dropped.
const fieldsSelectorConfig = useMemo(
() =>
canPersistColumns
? { fieldsSelector: { value: selectedFields, onFieldsChange } }
: null,
[canPersistColumns, selectedFields, onFieldsChange],
);
// Rendering the pool unfiltered would surface columns the defaults keep hidden.
const tableColumns = useMemo(
() =>
canPersistColumns
? columns
: columns.filter((column) => column.defaultVisibility !== false),
[canPersistColumns, columns],
);
return (
<div className={styles.container}>
<div className={styles.actionsContainer}>
@@ -215,11 +210,11 @@ function TracesView({
isLoading={isLoading}
totalCount={rows.length}
perPageOptions={PER_PAGE_OPTIONS}
config={fieldsSelectorConfig}
config={config}
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
extraFields={TRACE_VIEW_COLUMN_EXTRA_FIELDS}
requiredFields={requiredFields}
requiredFields={REQUIRED_FIELDS}
/>
</div>
</div>
@@ -227,15 +222,12 @@ function TracesView({
<TracesTable
data={rows}
columns={tableColumns}
columnStorageKey={
canPersistColumns
? LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS
: undefined
}
respectColumnOrder
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS}
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={config?.addColumn?.onRemove}
panelType="TRACE"
getRowHref={getTraceLink}
isLoading={isLoading || isColumnsLoading}
isLoading={isLoading}
isFetching={isFetching}
isError={isError}
error={error}

View File

@@ -0,0 +1,172 @@
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent } from 'tests/test-utils';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { TRACE_VIEW_DEFAULT_COLUMNS } from '../../constants';
import type { TracesTableProps } from '../../TracesTable/TracesTable';
import TracesView from '../TracesView';
jest.mock('../../TracesTable/TracesTable', () => ({
__esModule: true,
default: ({
columns,
columnStorageKey,
onColumnOrderChange,
onColumnRemove,
}: 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"
onClick={(): void => onColumnOrderChange?.([...columns].reverse())}
>
reverse
</button>
<button
data-testid="remove-total-tokens"
type="button"
onClick={(): void => onColumnRemove?.('trace:total_tokens:float64')}
>
remove
</button>
</div>
),
}));
const PANEL_COLUMNS_KEY = LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS;
const SIZING_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
const TOTAL_TOKENS = {
name: 'total_tokens',
fieldContext: 'trace',
fieldDataType: 'float64',
};
const storedColumnNames = (): string[] | undefined => {
const raw = localStorage.getItem(PANEL_COLUMNS_KEY);
return raw
? (JSON.parse(raw)[PANEL_TYPES.TRACE] as { name: string }[]).map(
(field) => field.name,
)
: undefined;
};
const shownColumns = (): string[] =>
(screen.getByTestId('table-columns').textContent ?? '').split(',');
const renderTracesView = (): ReturnType<typeof render> =>
render(
<TracesView
isFilterApplied={false}
setWarning={jest.fn()}
setIsLoadingQueries={jest.fn()}
/>,
{},
{
initialRoute: '/llm-observability/traces',
queryBuilderOverrides: {
panelType: PANEL_TYPES.TRACE,
stagedQuery: initialQueryAIWithType,
currentQuery: initialQueryAIWithType,
} as never,
},
);
describe('TracesView columns', () => {
beforeEach(() => {
useColumnStore.setState({ tables: {} });
localStorage.clear();
server.use(
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: { type: 'trace', data: {} } })),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('shows the default columns while nothing is picked, storing nothing', () => {
renderTracesView();
expect(shownColumns()).toStrictEqual(
TRACE_VIEW_DEFAULT_COLUMNS.map((field) => field.name),
);
expect(localStorage.getItem(PANEL_COLUMNS_KEY)).toBeNull();
});
it('shows trace_id first when a stored list lacks it', () => {
localStorage.setItem(
PANEL_COLUMNS_KEY,
JSON.stringify({ [PANEL_TYPES.TRACE]: [TOTAL_TOKENS] }),
);
renderTracesView();
expect(shownColumns()).toStrictEqual(['trace_id', 'total_tokens']);
});
it('stores the order a column drag leaves behind', async () => {
const user = userEvent.setup();
localStorage.setItem(
PANEL_COLUMNS_KEY,
JSON.stringify({
[PANEL_TYPES.TRACE]: [{ name: 'trace_id' }, TOTAL_TOKENS],
}),
);
renderTracesView();
await user.click(screen.getByTestId('reverse-columns'));
expect(storedColumnNames()).toStrictEqual(['total_tokens', 'trace_id']);
expect(shownColumns()).toStrictEqual(['total_tokens', 'trace_id']);
});
it('keeps a dragged trace_id in place when the stored list lacks it', async () => {
const user = userEvent.setup();
localStorage.setItem(
PANEL_COLUMNS_KEY,
JSON.stringify({ [PANEL_TYPES.TRACE]: [TOTAL_TOKENS] }),
);
renderTracesView();
await user.click(screen.getByTestId('reverse-columns'));
expect(storedColumnNames()).toStrictEqual(['total_tokens', 'trace_id']);
expect(shownColumns()).toStrictEqual(['total_tokens', 'trace_id']);
});
it('drops a column removed from the table', async () => {
const user = userEvent.setup();
localStorage.setItem(
PANEL_COLUMNS_KEY,
JSON.stringify({
[PANEL_TYPES.TRACE]: [{ name: 'trace_id' }, TOTAL_TOKENS],
}),
);
renderTracesView();
await user.click(screen.getByTestId('remove-total-tokens'));
expect(storedColumnNames()).toStrictEqual(['trace_id']);
expect(shownColumns()).toStrictEqual(['trace_id']);
});
it('keeps column widths under the trace view key', () => {
renderTracesView();
expect(screen.getByTestId('column-storage-key')).toHaveTextContent(
SIZING_KEY,
);
});
});

View File

@@ -12,8 +12,7 @@ import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import TracesView from '../TracesView';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const PANEL_COLUMNS_KEY = LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS;
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
const FIELD_KEYS_URL = `${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`;
@@ -84,9 +83,9 @@ const mockFieldKeysFailure = (): void => {
);
};
const persistedState = (): { hiddenColumnIds: string[] } | null => {
const raw = localStorage.getItem(PERSISTED_KEY);
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
const storedOptions = (): Record<string, { name: string }[]> | null => {
const raw = localStorage.getItem(PANEL_COLUMNS_KEY);
return raw ? (JSON.parse(raw) as Record<string, { name: string }[]>) : null;
};
const renderTracesView = (): ReturnType<typeof render> =>
@@ -121,16 +120,16 @@ describe('TracesView column persistence', () => {
// Rows are virtualised, so a mounted table stands in for "rows arrived".
const findTable = (): Promise<HTMLElement> => screen.findByRole('table');
it('seeds the persisted defaults once the field keys arrive', async () => {
it('shows the default columns once the field keys arrive, storing nothing', async () => {
mockFieldKeys(['llm_call_count', 'tool_call_count']);
renderTracesView();
await findTable();
await waitFor(() => {
expect(persistedState()?.hiddenColumnIds).toStrictEqual([]);
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
});
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
expect(storedOptions()).toBeNull();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
});
@@ -140,20 +139,19 @@ describe('TracesView column persistence', () => {
await findTable();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(persistedState()).toBeNull();
expect(storedOptions()).toBeNull();
});
it('drops the column picker when the field keys fail', async () => {
it('keeps the column picker when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(screen.queryByText(OPTIONS_TRIGGER)).not.toBeInTheDocument();
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
});
it('renders the display-only columns when the field keys fail', async () => {
it('renders the default columns when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
@@ -162,22 +160,27 @@ describe('TracesView column persistence', () => {
expect(screen.getByText('root_span_name')).toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.getByText('input')).toBeInTheDocument();
expect(screen.queryByText('llm_call_count')).not.toBeInTheDocument();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
});
it('leaves an existing selection untouched while the field keys fail', async () => {
it('shows a stored selection, in its order, while the field keys fail', async () => {
const existing = {
hiddenColumnIds: ['trace:tool_call_count:float64', 'input', 'output'],
columnOrder: ['trace_id', 'resource:service.name'],
columnSizing: {},
[PANEL_TYPES.TRACE]: [
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'trace_id' },
],
};
localStorage.setItem(PERSISTED_KEY, JSON.stringify(existing));
localStorage.setItem(PANEL_COLUMNS_KEY, JSON.stringify(existing));
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(persistedState()).toStrictEqual(existing);
const headers = screen
.getAllByRole('columnheader')
.map((header) => header.textContent);
expect(headers).toStrictEqual(['service.name', 'trace_id']);
expect(storedOptions()).toStrictEqual(existing);
});
});

View File

@@ -1,302 +0,0 @@
/* eslint-disable no-restricted-syntax */
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { useTraceViewColumns } from '../useTraceViewColumns';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const AGGREGATE_KEYS = [
'llm_call_count',
'tool_call_count',
'distinct_tool_count',
'input_tokens',
'output_tokens',
'total_tokens',
'estimated_total_cost',
'max_llm_duration_nano',
];
const fieldNames = (fields: TelemetryFieldKey[]): string[] =>
fields.map((field) => field.name);
const columnNames = (columns: { header?: unknown }[]): string[] =>
columns.map((column) => column.header as string);
function wrapper({ children }: { children: ReactNode }): JSX.Element {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
const seenAI: URLSearchParams[] = [];
const mockAggregateKeys = (names: string[]): void => {
server.use(
rest.get(
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
(req, res, ctx) => {
seenAI.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(
names.map((name) => [
name,
[
{
name,
fieldContext: TelemetrytypesFieldContextDTO.trace,
fieldDataType: TelemetrytypesFieldDataTypeDTO.float64,
},
],
]),
),
},
}),
);
},
),
);
};
const renderColumns = async (): Promise<
ReturnType<typeof renderHook<ReturnType<typeof useTraceViewColumns>, unknown>>
> => {
const rendered = renderHook(() => useTraceViewColumns(), { wrapper });
await waitFor(() => {
expect(rendered.result.current.isLoading).toBe(false);
});
return rendered;
};
describe('useTraceViewColumns', () => {
beforeEach(() => {
seenAI.length = 0;
useColumnStore.getState().tables = {};
localStorage.clear();
mockAggregateKeys(AGGREGATE_KEYS);
});
it('reads the aggregates from the trace context of the keys endpoint', async () => {
await renderColumns();
expect(seenAI).toHaveLength(1);
expect(seenAI[0]?.get('searchText')).toBe('');
expect(seenAI[0]?.get('fieldContext')).toBe(
TelemetrytypesFieldContextDTO.trace,
);
});
it('pools the hardcoded display-only columns with the endpoint aggregates', async () => {
const { result } = await renderColumns();
expect(columnNames(result.current.columns)).toStrictEqual([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
]);
});
it('selects only the default-visible columns on first render', async () => {
const { result } = await renderColumns();
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
]);
});
it('keeps a newly reported aggregate hidden until it is picked', async () => {
mockAggregateKeys(['brand_new_aggregate']);
const { result } = await renderColumns();
expect(columnNames(result.current.columns)).toContain('brand_new_aggregate');
expect(fieldNames(result.current.selectedFields)).not.toContain(
'brand_new_aggregate',
);
});
describe('when the keys fetch fails', () => {
beforeEach(() => {
server.use(
rest.get(
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
(_req, res, ctx) => res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
});
it('does not persist defaults', async () => {
await renderColumns();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(
localStorage.getItem(`@signoz/table-columns/${STORAGE_KEY}`),
).toBeNull();
});
it('reports the column state as not persistable', async () => {
const { result } = await renderColumns();
expect(result.current.canPersistColumns).toBe(false);
});
it('ignores a selection change instead of persisting a partial set', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([{ name: 'trace_id' }]);
});
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
});
it('seeds the defaults once a later fetch succeeds', async () => {
const { unmount } = await renderColumns();
unmount();
mockAggregateKeys(AGGREGATE_KEYS);
const { result } = await renderColumns();
expect(result.current.canPersistColumns).toBe(true);
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
]);
});
});
it('hides the columns dropped from the selection', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([
{ name: 'trace_id' },
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
]);
});
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'total_tokens',
]);
});
it('shows a column added back from the pool', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([{ name: 'trace_id' }]);
});
act(() => {
result.current.onFieldsChange([{ name: 'trace_id' }, { name: 'input' }]);
});
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'input',
]);
});
it('keeps the trace id column even when the selection drops it', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([{ name: 'span_count' }]);
});
expect(fieldNames(result.current.selectedFields)).toContain('trace_id');
expect(result.current.requiredFields).toStrictEqual(['trace_id']);
});
it('persists the selection order', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
]);
});
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'total_tokens',
'trace_id',
'service.name',
]);
expect(
useColumnStore.getState().tables[STORAGE_KEY].columnOrder,
).toStrictEqual([
'trace:total_tokens:float64',
'trace_id',
'resource:service.name',
]);
});
});

View File

@@ -3,52 +3,17 @@ import type { TableColumnDef } from 'components/TanStackTableView/types';
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
import { TRACE_ID_COLUMN_ID } from '../constants';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
/** Always present: it is the row's link to the trace, but it can be reordered. */
export const TRACE_ID_COLUMN_ID = 'trace_id';
/** Fallback order, until the user drags a column; unlisted fields keep the order the keys endpoint returns them in. */
const DEFAULT_COLUMN_ORDER = [
TRACE_ID_COLUMN_ID,
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
];
const orderRank = (field: TelemetryFieldKey): number => {
const index = DEFAULT_COLUMN_ORDER.indexOf(field.name);
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
};
export const sortByDefaultOrder = (
fields: TelemetryFieldKey[],
): TelemetryFieldKey[] =>
[...fields].sort((a, b) => orderRank(a) - orderRank(b));
/** Anything the keys endpoint adds beyond the ordered set starts hidden; only applied at first init, since the store persists hidden ids. */
const DEFAULT_VISIBLE_FIELDS = new Set(DEFAULT_COLUMN_ORDER);
export const REQUIRED_FIELDS: readonly string[] = [TRACE_ID_COLUMN_ID];
export const buildTraceViewColumns = (
fields: TelemetryFieldKey[],
): TableColumnDef<TracesTableRow>[] =>
fields.map((field) => ({
...getFieldColumn(field),
defaultVisibility: DEFAULT_VISIBLE_FIELDS.has(field.name),
// The shared column builder pins anything in TIMESTAMP_FIELD_NAMES; these stay movable.
enableMove: true,
enableRemove: field.name !== TRACE_ID_COLUMN_ID,

View File

@@ -1,127 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import { mergeExtraFields } from 'utils/extraFields';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import {
hideColumn,
initializeFromDefaults,
setColumnOrder,
showColumn,
useColumnOrder,
useHiddenColumnIds,
} from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { TracesTableRow } from '../TracesTable/getFieldColumn';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
import {
TRACE_VIEW_BUILDER_QUERY_TYPE,
TRACE_VIEW_COLUMN_EXTRA_FIELDS,
TRACE_VIEW_FIELD_KEYS,
} from '../constants';
import {
buildTraceViewColumns,
sortByDefaultOrder,
TRACE_ID_COLUMN_ID,
} from './configs';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
/** Matches the id getFieldColumn derives, so fields and columns address alike. */
const columnIdOf = (field: TelemetryFieldKey): string =>
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
interface UseTraceViewColumns {
columns: TableColumnDef<TracesTableRow>[];
selectedFields: TelemetryFieldKey[];
onFieldsChange: (next: TelemetryFieldKey[]) => void;
requiredFields: readonly string[];
isLoading: boolean;
/** False until the keys fetch lands; a partial set must not reach the persisted store. */
canPersistColumns: boolean;
}
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
export function useTraceViewColumns(): UseTraceViewColumns {
const {
data: fetchedFields = [],
isFetched,
isSuccess,
} = useFieldKeysSuggestion(
{
...TRACE_VIEW_FIELD_KEYS,
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
searchText: '',
},
TRACE_VIEW_BUILDER_QUERY_TYPE,
);
const availableFields = useMemo(
() =>
sortByDefaultOrder(
mergeExtraFields(TRACE_VIEW_COLUMN_EXTRA_FIELDS, fetchedFields),
),
[fetchedFields],
);
const columns = useMemo(
() => buildTraceViewColumns(availableFields),
[availableFields],
);
// Defaults from a partial column set would persist as the user's own choice.
useEffect(() => {
if (isSuccess) {
initializeFromDefaults(STORAGE_KEY, columns);
}
}, [isSuccess, columns]);
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
const columnOrder = useColumnOrder(STORAGE_KEY);
const selectedFields = useMemo(() => {
const hidden = new Set(hiddenColumnIds);
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
return availableFields
.filter((field) => !hidden.has(columnIdOf(field)))
.sort(
(a, b) =>
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
(orderIndex.get(columnIdOf(b)) ?? Infinity),
);
}, [availableFields, hiddenColumnIds, columnOrder]);
const onFieldsChange = useCallback(
(next: TelemetryFieldKey[]): void => {
if (!isSuccess) {
return;
}
const keptIds = new Set(next.map(columnIdOf));
columns.forEach((column) => {
if (keptIds.has(column.id) || column.id === TRACE_ID_COLUMN_ID) {
showColumn(STORAGE_KEY, column.id);
} else {
hideColumn(STORAGE_KEY, column.id);
}
});
// Columns missing from the order sort last, so the visible ones suffice.
setColumnOrder(STORAGE_KEY, next.map(columnIdOf));
},
[columns, isSuccess],
);
return {
columns,
selectedFields,
onFieldsChange,
requiredFields: [TRACE_ID_COLUMN_ID],
isLoading: !isFetched,
canPersistColumns: isSuccess,
};
}

View File

@@ -0,0 +1,85 @@
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 Explorer from '../Explorer';
jest.mock('../ListView/ListView', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="ai-list-view" />,
}));
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(
JSON.stringify(panelType),
)}`;
describe('AI observability Explorer', () => {
afterEach(() => {
localStorage.clear();
});
it('shows the view of the panel type in the url', () => {
renderWithExplorerProviders(
<Explorer />,
explorerUrlWithPanel(PANEL_TYPES.LIST),
);
expect(screen.getByTestId('ai-list-view')).toBeInTheDocument();
});
it('follows the url when the panel type changes, as opening a saved view does', () => {
const { history } = renderWithExplorerProviders(
<Explorer />,
explorerUrlWithPanel(PANEL_TYPES.LIST),
);
act(() => {
history.push(explorerUrlWithPanel(PANEL_TYPES.TABLE));
});
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

@@ -4,6 +4,9 @@ import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export const DEFAULT_PANEL_TYPE = PANEL_TYPES.TRACE;
/** Always present: it is the row's link to the trace, but it can be reordered. */
export const TRACE_ID_COLUMN_ID = 'trace_id';
export const TOOLBAR_VIEWS = {
trace: {
name: 'trace',
@@ -57,6 +60,81 @@ export const TRACE_VIEW_COLUMN_EXTRA_FIELDS: TelemetryFieldKey[] = [
{ name: 'output' },
] as TelemetryFieldKey[];
/**
* Shown, in this order, until the user picks columns. The aggregates are the
* keys the keys endpoint returns for builder_ai_query; the rest match
* TRACE_VIEW_COLUMN_EXTRA_FIELDS, so both build the same column ids.
*/
export const TRACE_VIEW_DEFAULT_COLUMNS: TelemetryFieldKey[] = [
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'root_span_name' },
{
name: 'estimated_total_cost',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{ name: 'trace_duration_nano' },
{ name: 'span_count' },
{
name: 'total_tokens',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{
name: 'input_tokens',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{
name: 'output_tokens',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{
name: 'distinct_tool_count',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{
name: 'llm_call_count',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{
name: 'tool_call_count',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{ name: 'start_time' },
{ name: 'end_time' },
{ name: 'error_count' },
{ name: 'input' },
{ name: 'output' },
{
name: 'max_llm_duration_nano',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
];
export const ensureAiRequiredColumns = (
columns: TelemetryFieldKey[],
panelType: PANEL_TYPES,
): TelemetryFieldKey[] =>
panelType !== PANEL_TYPES.TRACE ||
columns.some((column) => column.name === TRACE_ID_COLUMN_ID)
? columns
: [{ name: TRACE_ID_COLUMN_ID }, ...columns];
export const TRACE_VIEW_FIELD_KEYS = {
fieldContext: TelemetrytypesFieldContextDTO.trace,
} as const;

View File

@@ -172,6 +172,9 @@
}
.infoLinkContainer {
display: flex;
align-items: center;
svg {
color: var(--primary);
}

View File

@@ -1,5 +1,4 @@
import { ArrowRight, CirclePlay } from '@signozhq/icons';
import { Flex } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { openInNewTab } from 'utils/navigation';
@@ -21,7 +20,8 @@ function InfoLinkText({
onClick,
}: InfoLinkTextProps): JSX.Element {
return (
<Flex
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
onClick={(): void => {
onClick();
openInNewTab(link);
@@ -33,7 +33,7 @@ function InfoLinkText({
{rightIconVisible && (
<ArrowRight size={16} style={{ transform: 'rotate(315deg)' }} />
)}
</Flex>
</div>
);
}

View File

@@ -1,46 +1,30 @@
.container {
.layout {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: stretch;
flex: 1;
min-height: 400px;
width: 100%;
}
.header {
position: relative;
z-index: 10;
display: flex;
justify-content: flex-end;
gap: 1rem;
flex-shrink: 0;
padding: 0 var(--spacing-8);
}
.refreshRow {
display: flex;
align-items: center;
gap: 8px;
}
.filtersRow {
position: relative;
z-index: 10;
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
padding: 0 var(--spacing-8);
--combobox-trigger-height: 2rem;
}
.searchInput {
min-width: 250px;
}
.filterSelect {
min-width: 300px;
.main {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--l1-background);
}
.headerZone {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 14px;
padding: 20px 24px;
background: var(--l2-background);
border-bottom: 1px solid var(--l2-border);
}
.tableContainer {
@@ -77,10 +61,6 @@
--badge-cursor: pointer;
}
.searchIcon {
color: var(--l2-foreground);
}
.actionsColumn {
display: flex;
justify-content: flex-end;

View File

@@ -1,10 +1,14 @@
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import userEvent from '@testing-library/user-event';
import ROUTES from 'constants/routes';
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
import {
alertRulesFixture,
listRulesV3Response,
} from 'mocks-server/__mockdata__/alert_rules';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen } from 'tests/test-utils';
import { getCurrentNuqsSearchParams } from 'tests/nuqs-helpers';
import { screen, waitFor } from 'tests/test-utils';
import { renderListAlertRules } from './_helpers';
@@ -17,8 +21,11 @@ describe('ListAlertRules — empty states', () => {
const user = userEvent.setup({ delay: null });
server.use(
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [], status: 'success' })),
rest.get('http://localhost/api/v3/rules', (req, res, ctx) =>
res(
ctx.status(200),
ctx.json(listRulesV3Response([], req.url.searchParams)),
),
),
);
@@ -42,14 +49,14 @@ describe('ListAlertRules — empty states', () => {
let callCount = 0;
server.use(
rest.get('http://localhost/api/v2/rules', (_, res, ctx) => {
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
callCount += 1;
if (callCount === 1) {
return res(ctx.status(500), ctx.json({ status: 'error' }));
}
return res(
ctx.status(200),
ctx.json({ data: alertRulesFixture, status: 'success' }),
ctx.json(listRulesV3Response(alertRulesFixture, req.url.searchParams)),
);
}),
);
@@ -64,18 +71,29 @@ describe('ListAlertRules — empty states', () => {
expect(rule).toBeInTheDocument();
});
it('renders NoResultsEmptyState when search yields no match; Clear Search resets', async () => {
it('renders NoResultsEmptyState when the query matches nothing; Clear Search resets it', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
server.use(
rest.get('http://localhost/api/v3/rules', (req, res, ctx) =>
res(
ctx.status(200),
ctx.json(
listRulesV3Response(
req.url.searchParams.has('query') ? [] : alertRulesFixture,
req.url.searchParams,
),
),
),
),
);
await screen.findByText('High CPU Alert');
const searchInput = screen.getByTestId('list-alerts-search-input');
await user.clear(searchInput);
await user.type(searchInput, 'totally-not-found');
renderListAlertRules({
initialRoute: `/?query=${encodeURIComponent("name = 'missing'")}`,
});
await screen.findByTestId('no-results-empty-state');
expect(screen.queryByText('No Alert rules yet.')).not.toBeInTheDocument();
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
'No matching alert rules',
);
@@ -85,7 +103,28 @@ describe('ListAlertRules — empty states', () => {
await user.click(screen.getByTestId('no-results-clear-button'));
const rule = await screen.findByText('High CPU Alert');
expect(rule).toBeInTheDocument();
await expect(
screen.findByText('High CPU Alert'),
).resolves.toBeInTheDocument();
await waitFor(() => {
expect(getCurrentNuqsSearchParams().has('query')).toBe(false);
});
});
it('renders NoResultsEmptyState when the states filter matches nothing; Clear Search resets it', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules({ initialRoute: '/?states=nodata' });
await screen.findByTestId('no-results-empty-state');
await user.click(screen.getByTestId('no-results-clear-button'));
await expect(
screen.findByText('High CPU Alert'),
).resolves.toBeInTheDocument();
await waitFor(() => {
expect(getCurrentNuqsSearchParams().has('states')).toBe(false);
});
});
});

View File

@@ -101,17 +101,18 @@ describe('ListAlertRules — list rendering', () => {
);
});
it('renders header controls (search, columns, new alert)', async () => {
it('renders header controls (search, states, columns, new alert)', async () => {
renderListAlertRules();
await waitFor(() =>
expect(screen.getByTestId('alert-row-rule-1-name')).toBeInTheDocument(),
);
expect(screen.getByTestId('list-alerts-search-input')).toBeInTheDocument();
expect(screen.getByTestId('alert-rules-list-search')).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Search by Alert Name, Severity and Labels'),
screen.getByTestId('alert-rules-list-search-submit'),
).toBeInTheDocument();
expect(screen.getByTestId('alert-rules-filter-states')).toBeInTheDocument();
expect(screen.getByTestId('alert-columns-button')).toBeInTheDocument();
expect(
screen.getByTestId('list-alerts-new-alert-button'),

View File

@@ -1,5 +1,8 @@
import userEvent from '@testing-library/user-event';
import { alertRulesPaginationFixture } from 'mocks-server/__mockdata__/alert_rules';
import {
alertRulesPaginationFixture,
listRulesV3Response,
} from 'mocks-server/__mockdata__/alert_rules';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen, waitFor } from 'tests/test-utils';
@@ -8,31 +11,38 @@ import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
import { renderListAlertRules } from './_helpers';
describe('ListAlertRules — pagination', () => {
let requests: URLSearchParams[];
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
requests = [];
server.use(
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
res(
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
requests.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({ data: alertRulesPaginationFixture, status: 'success' }),
),
),
ctx.json(
listRulesV3Response(alertRulesPaginationFixture, req.url.searchParams),
),
);
}),
);
});
it('shows first 10 rows on page 1 (default limit)', async () => {
it('requests the first page with the default limit and renders it', async () => {
renderListAlertRules();
await screen.findByText('Pag Rule 0');
expect(requests[0].get('limit')).toBe('10');
expect(requests[0].get('offset')).toBe('0');
for (let i = 0; i < 10; i += 1) {
expect(screen.getByText(`Pag Rule ${i}`)).toBeInTheDocument();
}
expect(screen.queryByText('Pag Rule 10')).not.toBeInTheDocument();
expect(screen.queryByText('Pag Rule 14')).not.toBeInTheDocument();
});
it('shows total count when showTotalCount is enabled', async () => {
it('shows the total count from the API response', async () => {
renderListAlertRules();
await screen.findByText('Pag Rule 0');
@@ -42,14 +52,13 @@ describe('ListAlertRules — pagination', () => {
expect(totalCount.textContent).toContain('of 15');
});
it('navigates to page 2 and shows remaining rows', async () => {
it('navigates to page 2 by requesting the next offset', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('Pag Rule 0');
const nextBtn = screen.getByLabelText('Go to next page');
await user.click(nextBtn);
await user.click(screen.getByLabelText('Go to next page'));
await waitFor(() => {
expect(screen.getByText('Pag Rule 10')).toBeInTheDocument();
@@ -57,8 +66,20 @@ describe('ListAlertRules — pagination', () => {
expect(screen.queryByText('Pag Rule 0')).not.toBeInTheDocument();
});
const lastRequest = requests[requests.length - 1];
expect(lastRequest.get('limit')).toBe('10');
expect(lastRequest.get('offset')).toBe('10');
await waitFor(() => {
expect(getCurrentNuqsQueryString()).toContain('page=2');
});
});
it('requests the page from the URL on load', async () => {
renderListAlertRules({ initialRoute: '/?page=2' });
await screen.findByText('Pag Rule 10');
expect(requests[0].get('offset')).toBe('10');
expect(screen.queryByText('Pag Rule 0')).not.toBeInTheDocument();
});
});

View File

@@ -9,7 +9,7 @@ describe('ListAlertRules — row click navigation', () => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('clicking a row calls safeNavigate to alerts/overview with composite query + ruleId', async () => {
it('clicking a row calls safeNavigate to alerts/overview with ruleId', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
@@ -26,8 +26,7 @@ describe('ListAlertRules — row click navigation', () => {
const [url] = safeNavigateMock.mock.calls[0];
expect(url).toContain('/alerts/overview?');
expect(url).toContain('ruleId=rule-1');
expect(url).toContain('panelTypes=graph');
expect(url).toContain('compositeQuery=');
expect(url).not.toContain('compositeQuery=');
});
it('ctrl+click on a row navigates with newTab option', async () => {

View File

@@ -1,99 +1,217 @@
import userEvent from '@testing-library/user-event';
import { screen, waitFor } from 'tests/test-utils';
import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
import {
alertRulesFixture,
listRulesV3Response,
} from 'mocks-server/__mockdata__/alert_rules';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
import {
getCurrentNuqsQueryString,
getCurrentNuqsSearchParams,
} from 'tests/nuqs-helpers';
import { renderListAlertRules } from './_helpers';
import {
getSearchDraft,
renderListAlertRules,
runSearchQuery,
setSearchDraft,
} from './_helpers';
function getSearchInput(): HTMLInputElement {
return screen.getByTestId('list-alerts-search-input') as HTMLInputElement;
const QUERY = "name CONTAINS 'CPU'";
function lastRequest(requests: URLSearchParams[]): URLSearchParams {
return requests[requests.length - 1];
}
describe('ListAlertRules — search', () => {
let requests: URLSearchParams[];
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
requests = [];
server.use(
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
requests.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json(listRulesV3Response(alertRulesFixture, req.url.searchParams)),
);
}),
);
});
it('filters rows by alert name with debounce', async () => {
it('sends the draft query only when Run is clicked', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.clear(getSearchInput());
await user.type(getSearchInput(), 'CPU');
setSearchDraft(QUERY);
await expect(
screen.findByTestId('alert-rules-list-search-dirty'),
).resolves.toBeInTheDocument();
expect(requests.some((r) => r.has('query'))).toBe(false);
expect(getCurrentNuqsSearchParams().has('query')).toBe(false);
await user.click(screen.getByTestId('alert-rules-list-search-submit'));
await waitFor(() => {
expect(screen.getByText('High CPU Alert')).toBeInTheDocument();
expect(screen.queryByText('Memory Pending Alert')).not.toBeInTheDocument();
expect(lastRequest(requests).get('query')).toBe(QUERY);
});
await waitFor(() => {
expect(getCurrentNuqsSearchParams().get('query')).toBe(QUERY);
});
expect(
screen.queryByTestId('alert-rules-list-search-dirty'),
).not.toBeInTheDocument();
});
it('filters rows by label values (severity)', async () => {
it('runs the draft on Mod-Enter', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.clear(getSearchInput());
await user.type(getSearchInput(), 'warning');
setSearchDraft(QUERY);
const content = screen
.getByTestId('alert-rules-list-search')
.querySelector<HTMLElement>('.cm-content');
if (!content) {
throw new Error('Editor content not found');
}
content.focus();
await user.keyboard('{Control>}{Enter}{/Control}');
await waitFor(() => {
expect(screen.getByText('Memory Pending Alert')).toBeInTheDocument();
expect(screen.queryByText('High CPU Alert')).not.toBeInTheDocument();
expect(getCurrentNuqsSearchParams().get('query')).toBe(QUERY);
});
});
it('restores all rows when search is cleared', async () => {
it('trims the submitted query', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.clear(getSearchInput());
await user.type(getSearchInput(), 'CPU');
await runSearchQuery(user, ` ${QUERY} `);
await waitFor(() => {
expect(screen.queryByText('Memory Pending Alert')).not.toBeInTheDocument();
});
await user.clear(getSearchInput());
await waitFor(() => {
expect(screen.getByText('High CPU Alert')).toBeInTheDocument();
expect(screen.getByText('Memory Pending Alert')).toBeInTheDocument();
expect(screen.getByText('Healthy Alert')).toBeInTheDocument();
expect(getCurrentNuqsSearchParams().get('query')).toBe(QUERY);
});
expect(lastRequest(requests).get('query')).toBe(QUERY);
});
it('shows no-results state when no match', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
it('loads the query from the URL into the editor and the request', async () => {
renderListAlertRules({
initialRoute: `/?query=${encodeURIComponent(QUERY)}`,
});
await screen.findByText('High CPU Alert');
await user.clear(getSearchInput());
await user.type(getSearchInput(), 'zzzzzz-no-match');
await waitFor(() => {
expect(screen.getByTestId('no-results-empty-state')).toBeInTheDocument();
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
'No matching alert rules',
);
});
expect(getSearchDraft()).toBe(QUERY);
expect(requests[0].get('query')).toBe(QUERY);
});
it('resets page to 1 when search debounce fires', async () => {
it('resets page to 1 when a query is run', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules({ initialRoute: '/?page=2' });
// Page 2 of the 4-rule fixture has no rows; we only need the search input
// to be mounted, which happens before data is fetched.
const input = await screen.findByTestId('list-alerts-search-input');
await user.clear(input);
await user.type(input, 'CPU');
await screen.findByTestId('alert-rules-list-search');
await runSearchQuery(user, QUERY);
await waitFor(() => {
expect(getCurrentNuqsQueryString()).not.toContain('page=2');
expect(getCurrentNuqsSearchParams().get('query')).toBe(QUERY);
});
expect(getCurrentNuqsQueryString()).not.toContain('page=2');
await waitFor(() => {
expect(lastRequest(requests).get('offset')).toBe('0');
});
});
it('sends each selected state as a repeated states= param', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.click(screen.getByTestId('alert-rules-filter-states'));
await user.click(await screen.findByRole('option', { name: 'Firing' }));
await user.click(await screen.findByRole('option', { name: 'Disabled' }));
await waitFor(() => {
expect(lastRequest(requests).getAll('states')).toStrictEqual([
'firing',
'disabled',
]);
});
expect(lastRequest(requests).has('states[]')).toBe(false);
await waitFor(() => {
expect(getCurrentNuqsSearchParams().get('states')).toBe('firing,disabled');
});
});
it('Clear empties the query and the states', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules({
initialRoute: `/?query=${encodeURIComponent(QUERY)}&states=firing`,
});
await screen.findByText('High CPU Alert');
expect(lastRequest(requests).get('query')).toBe(QUERY);
await user.click(screen.getByTestId('alert-rules-filter-clear'));
await waitFor(() => {
const params = getCurrentNuqsSearchParams();
expect(params.has('query')).toBe(false);
expect(params.has('states')).toBe(false);
});
await waitFor(() => {
const last = lastRequest(requests);
expect(last.has('query')).toBe(false);
expect(last.has('states')).toBe(false);
});
expect(getSearchDraft()).toBe('');
expect(
screen.queryByTestId('alert-rules-filter-clear'),
).not.toBeInTheDocument();
});
it.each(['rule_list_filter_invalid', 'rule_list_invalid'])(
'shows the backend message and no-results state for %s',
async (code) => {
const user = userEvent.setup({ delay: null });
server.use(
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
if (req.url.searchParams.has('query')) {
return res(
ctx.status(400),
ctx.json({ error: { code, message: 'unknown field "nme"' } }),
);
}
return res(
ctx.status(200),
ctx.json(listRulesV3Response(alertRulesFixture, req.url.searchParams)),
);
}),
);
renderListAlertRules();
await screen.findByText('High CPU Alert');
await runSearchQuery(user, "nme = 'x'");
await expect(
screen.findByTestId('alert-rules-query-error'),
).resolves.toHaveTextContent('unknown field "nme"');
const empty = screen.getByTestId('no-results-empty-state');
expect(within(empty).getByTestId('no-results-subtitle')).toHaveTextContent(
'The filter query is invalid. Fix it or clear the search.',
);
expect(screen.queryByTestId('error-empty-state')).not.toBeInTheDocument();
expect(screen.queryByText('High CPU Alert')).not.toBeInTheDocument();
},
);
});

View File

@@ -1,14 +1,19 @@
import { logEventMock } from '__tests__/logEventMock';
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
import {
RuletypesListOrderDTO,
RuletypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { SortState } from 'components/TanStackTableView/types';
import type { AlertRule } from '../types';
import type { AlertRule, RuleListFilter } from '../types';
import {
ALERT_ACTIONS,
alertActionLogEvent,
filterRulesByFilters,
getAlertSortValue,
sortRules,
DEFAULT_RULE_LIST_FILTER,
isDefaultRuleListFilter,
isSameRuleListFilter,
toListSort,
toOrderBy,
} from '../utils';
const baseRule = {
@@ -27,159 +32,116 @@ const makeRule = (overrides: Partial<AlertRule>): AlertRule => ({
...overrides,
});
describe('getAlertSortValue', () => {
it('returns state for "state"', () => {
expect(
getAlertSortValue(
makeRule({ state: RuletypesAlertStateDTO.firing }),
'state',
),
).toBe('firing');
describe('toListSort', () => {
it('returns the default sort when orderBy is null', () => {
expect(toListSort(null)).toStrictEqual({
sort: RuletypesListSortDTO.updated_at,
order: RuletypesListOrderDTO.desc,
});
});
it('returns alert name for "name"', () => {
expect(getAlertSortValue(makeRule({ alert: 'My Rule' }), 'name')).toBe(
'My Rule',
);
it('returns the default sort for an unknown column', () => {
expect(toListSort({ columnName: 'labels', order: 'asc' })).toStrictEqual({
sort: RuletypesListSortDTO.updated_at,
order: RuletypesListOrderDTO.desc,
});
});
it('returns severity label for "severity"', () => {
expect(
getAlertSortValue(
makeRule({ labels: { severity: 'critical' } }),
'severity',
),
).toBe('critical');
it.each([
['state', RuletypesListSortDTO.state],
['name', RuletypesListSortDTO.name],
['severity', RuletypesListSortDTO.severity],
['createdAt', RuletypesListSortDTO.created_at],
['updatedAt', RuletypesListSortDTO.updated_at],
])('maps column "%s" to sort "%s"', (columnName, sort) => {
expect(toListSort({ columnName, order: 'asc' })).toStrictEqual({
sort,
order: RuletypesListOrderDTO.asc,
});
});
it('returns createdAt as ms', () => {
const rule = makeRule({ createdAt: '2023-10-15T10:00:00Z' });
const result = getAlertSortValue(rule, 'createdAt');
expect(result).toBe(new Date('2023-10-15T10:00:00Z').getTime());
});
it('returns updatedAt as ms', () => {
const rule = makeRule({ updatedAt: '2023-10-19T10:00:00Z' });
const result = getAlertSortValue(rule, 'updatedAt');
expect(result).toBe(new Date('2023-10-19T10:00:00Z').getTime());
});
it('returns 0 when createdAt missing', () => {
expect(
getAlertSortValue(makeRule({ createdAt: undefined }), 'createdAt'),
).toBe(0);
});
it('returns empty for unknown column', () => {
expect(getAlertSortValue(baseRule, 'xxx')).toBe('');
});
it('returns empty for missing fields', () => {
expect(
getAlertSortValue(
makeRule({ state: undefined, labels: undefined }),
'state',
),
).toBe('');
expect(
getAlertSortValue(
makeRule({ state: undefined, labels: undefined }),
'severity',
),
).toBe('');
it('maps desc order', () => {
expect(toListSort({ columnName: 'name', order: 'desc' })).toStrictEqual({
sort: RuletypesListSortDTO.name,
order: RuletypesListOrderDTO.desc,
});
});
});
describe('sortRules', () => {
const r1 = makeRule({ id: '1', alert: 'A' });
const r2 = makeRule({ id: '2', alert: 'B' });
const r3 = makeRule({ id: '3', alert: 'C' });
it('sorts ascending by name', () => {
const order: SortState = { columnName: 'name', order: 'asc' };
const result = sortRules([r3, r1, r2], order);
expect(result.map((r) => r.alert)).toStrictEqual(['A', 'B', 'C']);
describe('toOrderBy', () => {
it('returns null for the default sort', () => {
expect(
toOrderBy(RuletypesListSortDTO.updated_at, RuletypesListOrderDTO.desc),
).toBeNull();
});
it('sorts descending by name', () => {
const order: SortState = { columnName: 'name', order: 'desc' };
const result = sortRules([r1, r2, r3], order);
expect(result.map((r) => r.alert)).toStrictEqual(['C', 'B', 'A']);
it('returns the column for a non-default sort', () => {
expect(
toOrderBy(RuletypesListSortDTO.created_at, RuletypesListOrderDTO.asc),
).toStrictEqual<SortState>({ columnName: 'createdAt', order: 'asc' });
expect(
toOrderBy(RuletypesListSortDTO.updated_at, RuletypesListOrderDTO.asc),
).toStrictEqual<SortState>({ columnName: 'updatedAt', order: 'asc' });
});
it('returns unsorted when orderBy is null', () => {
const result = sortRules([r3, r1, r2], null);
expect(result.map((r) => r.alert)).toStrictEqual(['C', 'A', 'B']);
it('round-trips with toListSort', () => {
const orderBy: SortState = { columnName: 'severity', order: 'desc' };
const { sort, order } = toListSort(orderBy);
expect(toOrderBy(sort, order)).toStrictEqual(orderBy);
});
});
describe('filterRulesByFilters', () => {
const r1 = makeRule({
id: '1',
alert: 'R1',
state: RuletypesAlertStateDTO.firing,
labels: { severity: 'critical' },
});
const r2 = makeRule({
id: '2',
alert: 'R2',
state: RuletypesAlertStateDTO.inactive,
labels: { severity: 'warning' },
});
const r3 = makeRule({
id: '3',
alert: 'R3',
state: RuletypesAlertStateDTO.firing,
labels: { severity: 'warning' },
});
const rules = [r1, r2, r3];
describe('isSameRuleListFilter', () => {
const filter: RuleListFilter = {
query: "severity = 'critical'",
states: ['firing', 'pending'],
sort: RuletypesListSortDTO.name,
order: RuletypesListOrderDTO.asc,
};
it('returns input when filters empty', () => {
expect(filterRulesByFilters(rules, [])).toStrictEqual(rules);
it('ignores surrounding whitespace in the query', () => {
expect(
isSameRuleListFilter(filter, { ...filter, query: ` ${filter.query} ` }),
).toBe(true);
});
it('filters by state', () => {
const result = filterRulesByFilters(rules, ['state:firing']);
expect(result.map((r) => r.id)).toStrictEqual(['1', '3']);
it('ignores states order', () => {
expect(
isSameRuleListFilter(filter, { ...filter, states: ['pending', 'firing'] }),
).toBe(true);
});
it('filters by severity', () => {
const result = filterRulesByFilters(rules, ['severity:warning']);
expect(result.map((r) => r.id)).toStrictEqual(['2', '3']);
it.each<[string, Partial<RuleListFilter>]>([
['query', { query: "severity = 'warning'" }],
['states', { states: ['firing'] }],
['sort', { sort: RuletypesListSortDTO.state }],
['order', { order: RuletypesListOrderDTO.desc }],
])('detects a different %s', (_, change) => {
expect(isSameRuleListFilter(filter, { ...filter, ...change })).toBe(false);
});
});
describe('isDefaultRuleListFilter', () => {
it('is true for DEFAULT_RULE_LIST_FILTER', () => {
expect(isDefaultRuleListFilter(DEFAULT_RULE_LIST_FILTER)).toBe(true);
});
it('combines state AND severity', () => {
const result = filterRulesByFilters(rules, [
'state:firing',
'severity:warning',
]);
expect(result.map((r) => r.id)).toStrictEqual(['3']);
it('is true for a whitespace-only query', () => {
expect(
isDefaultRuleListFilter({ ...DEFAULT_RULE_LIST_FILTER, query: ' ' }),
).toBe(true);
});
it('OR within same key (state)', () => {
const result = filterRulesByFilters(rules, [
'state:firing',
'state:inactive',
]);
expect(result.map((r) => r.id)).toStrictEqual(['1', '2', '3']);
});
it('matches values case-insensitively', () => {
const result = filterRulesByFilters(rules, ['state:FIRING']);
expect(result.map((r) => r.id)).toStrictEqual(['1', '3']);
});
it('ignores prefixes with wrong case (state: is required lowercase)', () => {
const result = filterRulesByFilters(rules, ['STATE:FIRING']);
expect(result).toStrictEqual(rules);
});
it('returns empty when no rule matches', () => {
expect(filterRulesByFilters(rules, ['state:nonexistent'])).toStrictEqual([]);
});
it('ignores unknown prefix', () => {
expect(filterRulesByFilters(rules, ['foo:bar'])).toStrictEqual(rules);
it('is false once any field differs', () => {
expect(
isDefaultRuleListFilter({ ...DEFAULT_RULE_LIST_FILTER, states: ['firing'] }),
).toBe(false);
expect(
isDefaultRuleListFilter({
...DEFAULT_RULE_LIST_FILTER,
sort: RuletypesListSortDTO.name,
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,567 @@
import { LOCALSTORAGE } from 'constants/localStorage';
import { useAlertRuleViewsStore } from 'container/ListAlertRules/useAlertRuleViewsStore';
import {
alertRulesFixture,
listRulesV3Response,
ruleViewsFixture,
} from 'mocks-server/__mockdata__/alert_rules';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { getCurrentNuqsSearchParams } from 'tests/nuqs-helpers';
import {
getAppContextMock,
screen,
userEvent,
waitFor,
within,
} from 'tests/test-utils';
import { renderListAlertRules, runSearchQuery } from './_helpers';
// jsdom has no layout, and Floating UI's getComputedStyle walk over every
// popover ancestor costs ~1 s per open here, enough to time out under load.
jest.mock('@floating-ui/dom', () => ({
...jest.requireActual('@floating-ui/dom'),
autoUpdate: (): (() => void) => (): void => {},
computePosition: (
_reference: unknown,
_floating: unknown,
{ placement = 'bottom', strategy = 'absolute' } = {},
): Promise<unknown> =>
Promise.resolve({ x: 0, y: 0, placement, strategy, middlewareData: {} }),
}));
const VIEW_1_ORDER_BY = JSON.stringify({ columnName: 'name', order: 'asc' });
const VIEW_1_QUERY = "severity = 'critical'";
type Body = Record<string, unknown>;
type User = ReturnType<typeof userEvent.setup>;
function captureRulesRequests(): URLSearchParams[] {
const requests: URLSearchParams[] = [];
server.use(
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
requests.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json(listRulesV3Response(alertRulesFixture, req.url.searchParams)),
);
}),
);
return requests;
}
function captureViewWrites(): {
posts: Body[];
puts: { id: string; body: Body }[];
deletes: string[];
} {
const captured = {
posts: [] as Body[],
puts: [] as { id: string; body: Body }[],
deletes: [] as string[],
};
server.use(
rest.post('http://localhost/api/v2/rule_views', async (req, res, ctx) => {
const body = (await req.json()) as Body;
captured.posts.push(body);
return res(
ctx.status(201),
ctx.json({
data: { ...body, id: 'new-view-id', orgId: 'org-1' },
status: 'success',
}),
);
}),
rest.put('http://localhost/api/v2/rule_views/:id', async (req, res, ctx) => {
const body = (await req.json()) as Body;
const id = req.params.id as string;
captured.puts.push({ id, body });
return res(
ctx.status(200),
ctx.json({ data: { ...body, id, orgId: 'org-1' }, status: 'success' }),
);
}),
rest.delete('http://localhost/api/v2/rule_views/:id', (req, res, ctx) => {
captured.deletes.push(req.params.id as string);
return res(ctx.status(204));
}),
);
return captured;
}
function lastRequest(requests: URLSearchParams[]): URLSearchParams {
return requests[requests.length - 1];
}
function urlParam(key: string): string | null {
return getCurrentNuqsSearchParams().get(key);
}
// The linked-view restore writes the URL a tick after the views render.
function urlSettled(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
function viewItem(id: string): HTMLElement {
return screen.getByTestId(`alert-rules-view-${id}`);
}
function activeHeading(): HTMLElement {
return screen.getByTestId('alert-rules-list-heading');
}
async function selectView(user: User, id: string): Promise<void> {
await user.click(await screen.findByTestId(`alert-rules-view-${id}`));
await waitFor(() => expect(urlParam('view')).toBe(id));
}
// A role query walks the whole table; the antd confirm's button is found by text.
function findConfirmDelete(): Promise<HTMLElement> {
return screen.findByText('Delete', {
selector: '.ant-modal-confirm button span',
});
}
describe('ListAlertRules — saved views rail', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
localStorage.removeItem(LOCALSTORAGE.ALERT_RULES_LIST_VIEWS);
useAlertRuleViewsStore.setState({ railCollapsed: false });
});
describe('builtin views', () => {
it('renders the builtin views with All active by default', async () => {
renderListAlertRules();
await screen.findByText('High CPU Alert');
expect(viewItem('mine')).toHaveTextContent('My alert rules');
expect(viewItem('all')).toHaveTextContent('All alert rules');
expect(viewItem('firing')).toHaveTextContent('Firing');
expect(viewItem('disabled')).toHaveTextContent('Disabled');
expect(activeHeading()).toHaveTextContent('All alert rules');
expect(
screen.queryByTestId('alert-rules-view-save-as-new'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('alert-rules-view-reset'),
).not.toBeInTheDocument();
});
it.each([
['firing', 'Firing'],
['disabled', 'Disabled'],
])('%s sends states=%s', async (id, label) => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const requests = captureRulesRequests();
renderListAlertRules();
await screen.findByText('High CPU Alert');
await selectView(user, id);
await waitFor(() => {
expect(lastRequest(requests).getAll('states')).toStrictEqual([id]);
});
expect(lastRequest(requests).has('query')).toBe(false);
expect(urlParam('states')).toBe(id);
expect(activeHeading()).toHaveTextContent(label);
expect(
screen.queryByTestId('alert-rules-view-save-as-new'),
).not.toBeInTheDocument();
});
it('My alert rules filters by the current user as creator', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const requests = captureRulesRequests();
const { email } = getAppContextMock('ADMIN').user;
renderListAlertRules();
await screen.findByText('High CPU Alert');
await selectView(user, 'mine');
const expected = `created_by = '${email}'`;
await waitFor(() => {
expect(lastRequest(requests).get('query')).toBe(expected);
});
expect(lastRequest(requests).has('states')).toBe(false);
expect(urlParam('query')).toBe(expected);
expect(activeHeading()).toHaveTextContent('My alert rules');
});
it('editing a builtin view filter offers save as new view, which POSTs', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const captured = captureViewWrites();
renderListAlertRules();
await screen.findByText('High CPU Alert');
await runSearchQuery(user, 'foo');
await user.click(await screen.findByTestId('alert-rules-view-save-as-new'));
expect(
screen.queryByTestId('alert-rules-view-save-changes'),
).not.toBeInTheDocument();
await user.type(
await screen.findByTestId('save-alert-rule-view-name'),
'Foo alerts',
);
await user.click(screen.getByTestId('save-alert-rule-view-confirm'));
await waitFor(() => expect(captured.posts).toHaveLength(1));
expect(captured.posts[0]).toStrictEqual({
name: 'Foo alerts',
data: {
version: 'v1',
query: 'foo',
states: [],
sort: 'updated_at',
order: 'desc',
},
});
await waitFor(() => expect(urlParam('view')).toBe('new-view-id'));
});
});
describe('saved views', () => {
it('lists saved views and applies the selected one', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const requests = captureRulesRequests();
renderListAlertRules({ initialRoute: '/?page=2' });
await expect(
screen.findByTestId('alert-rules-view-view-1'),
).resolves.toHaveTextContent('Critical firing');
expect(viewItem('view-2')).toHaveTextContent('Infra team');
await selectView(user, 'view-1');
await waitFor(() => {
expect(urlParam('query')).toBe(VIEW_1_QUERY);
expect(urlParam('states')).toBe('firing');
expect(urlParam('orderBy')).toBe(VIEW_1_ORDER_BY);
});
expect(urlParam('page')).not.toBe('2');
await waitFor(() => {
const last = lastRequest(requests);
expect(last.get('query')).toBe(VIEW_1_QUERY);
expect(last.getAll('states')).toStrictEqual(['firing']);
expect(last.get('sort')).toBe('name');
expect(last.get('order')).toBe('asc');
expect(last.get('offset')).toBe('0');
});
expect(activeHeading()).toHaveTextContent('Critical firing');
expect(
screen.queryByTestId('alert-rules-view-save-changes'),
).not.toBeInTheDocument();
});
it('shows the unsaved-changes panel after editing an applied view filter', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
renderListAlertRules();
await selectView(user, 'view-1');
await runSearchQuery(user, 'foo');
await screen.findByTestId('alert-rules-view-save-changes');
expect(screen.getByTestId('alert-rules-view-save-as')).toBeInTheDocument();
expect(screen.getByTestId('alert-rules-view-reset')).toBeInTheDocument();
expect(
within(viewItem('view-1')).getByTitle('Unsaved changes'),
).toBeInTheDocument();
});
it('Save PUTs the full current filter and the unchanged name', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const captured = captureViewWrites();
renderListAlertRules();
await selectView(user, 'view-1');
await runSearchQuery(user, 'foo');
await user.click(await screen.findByTestId('alert-rules-view-save-changes'));
await waitFor(() => expect(captured.puts).toHaveLength(1));
expect(captured.puts[0]).toStrictEqual({
id: 'view-1',
body: {
name: 'Critical firing',
data: {
version: 'v1',
query: 'foo',
states: ['firing'],
sort: 'name',
order: 'asc',
},
},
});
await waitFor(() =>
expect(
screen.queryByTestId('alert-rules-view-save-changes'),
).not.toBeInTheDocument(),
);
});
it('Save as POSTs a new view and activates it', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const captured = captureViewWrites();
renderListAlertRules();
await selectView(user, 'view-1');
await runSearchQuery(user, 'foo');
await user.click(await screen.findByTestId('alert-rules-view-save-as'));
await user.type(
await screen.findByTestId('save-alert-rule-view-name'),
'My view',
);
await user.click(screen.getByTestId('save-alert-rule-view-confirm'));
await waitFor(() => expect(captured.posts).toHaveLength(1));
expect(captured.posts[0]).toStrictEqual({
name: 'My view',
data: {
version: 'v1',
query: 'foo',
states: ['firing'],
sort: 'name',
order: 'asc',
},
});
await waitFor(() => expect(urlParam('view')).toBe('new-view-id'));
expect(captured.puts).toHaveLength(0);
});
it('caps a view name at 64 characters', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const captured = captureViewWrites();
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.click(screen.getByLabelText('Save current filters as a view'));
const input = await screen.findByTestId('save-alert-rule-view-name');
await user.type(input, 'x'.repeat(70));
expect(input).toHaveValue('x'.repeat(64));
await user.click(screen.getByTestId('save-alert-rule-view-confirm'));
await waitFor(() => expect(captured.posts).toHaveLength(1));
expect(captured.posts[0]).toMatchObject({ name: 'x'.repeat(64) });
});
it('Reset restores the active view filter', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
renderListAlertRules();
await selectView(user, 'view-1');
await runSearchQuery(user, 'foo');
await waitFor(() => expect(urlParam('query')).toBe('foo'));
await user.click(await screen.findByTestId('alert-rules-view-reset'));
await waitFor(() => {
expect(urlParam('query')).toBe(VIEW_1_QUERY);
});
expect(urlParam('states')).toBe('firing');
expect(urlParam('orderBy')).toBe(VIEW_1_ORDER_BY);
expect(urlParam('view')).toBe('view-1');
expect(
screen.queryByTestId('alert-rules-view-reset'),
).not.toBeInTheDocument();
});
it('rename PUTs the new name and keeps the stored filter', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const captured = captureViewWrites();
renderListAlertRules();
await selectView(user, 'view-2');
await runSearchQuery(user, 'foo');
await screen.findByTestId('alert-rules-view-save-changes');
const row = viewItem('view-1').parentElement as HTMLElement;
await user.click(within(row).getByLabelText('Rename view'));
const input = await screen.findByTestId('rename-alert-rule-view-name');
expect(input).toHaveValue('Critical firing');
await user.clear(input);
await user.type(input, 'Renamed');
await user.click(screen.getByTestId('rename-alert-rule-view-confirm'));
await waitFor(() => expect(captured.puts).toHaveLength(1));
expect(captured.puts[0]).toStrictEqual({
id: 'view-1',
body: {
name: 'Renamed',
data: {
version: 'v1',
query: VIEW_1_QUERY,
states: ['firing'],
sort: 'name',
order: 'asc',
},
},
});
await waitFor(() => expect(viewItem('view-1')).toHaveTextContent('Renamed'));
});
it('delete asks for confirmation and sends DELETE', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const captured = captureViewWrites();
renderListAlertRules();
await selectView(user, 'view-1');
await user.click(screen.getByTestId('alert-rules-view-delete-view-2'));
await expect(
screen.findByText(/Views are shared across your organization/),
).resolves.toBeInTheDocument();
expect(captured.deletes).toHaveLength(0);
await user.click(await findConfirmDelete());
await waitFor(() => expect(captured.deletes).toStrictEqual(['view-2']));
expect(urlParam('view')).toBe('view-1');
});
it('deleting the active view falls back to All', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const captured = captureViewWrites();
renderListAlertRules();
await selectView(user, 'view-1');
await waitFor(() => expect(urlParam('query')).toBe(VIEW_1_QUERY));
await user.click(screen.getByTestId('alert-rules-view-delete-view-1'));
await user.click(await findConfirmDelete());
await waitFor(() => expect(captured.deletes).toStrictEqual(['view-1']));
await waitFor(() => {
expect(urlParam('view')).toBeNull();
expect(urlParam('query')).toBeNull();
expect(urlParam('states')).toBeNull();
});
expect(activeHeading()).toHaveTextContent('All alert rules');
});
it('All alert rules clears the view and its filters', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
renderListAlertRules();
await selectView(user, 'view-1');
await waitFor(() => expect(urlParam('query')).not.toBeNull());
await user.click(viewItem('all'));
await waitFor(() => {
expect(urlParam('view')).toBeNull();
expect(urlParam('query')).toBeNull();
expect(urlParam('states')).toBeNull();
});
expect(activeHeading()).toHaveTextContent('All alert rules');
});
});
describe('shared links', () => {
it('applies the linked view filter when the URL carries only view=', async () => {
renderListAlertRules({ initialRoute: '/?view=view-1' });
await waitFor(() => {
expect(urlParam('query')).toBe(VIEW_1_QUERY);
expect(urlParam('states')).toBe('firing');
expect(urlParam('orderBy')).toBe(VIEW_1_ORDER_BY);
});
expect(urlParam('view')).toBe('view-1');
expect(activeHeading()).toHaveTextContent('Critical firing');
expect(
screen.queryByTestId('alert-rules-view-save-changes'),
).not.toBeInTheDocument();
});
it('keeps explicit filter params from the link and marks the view modified', async () => {
renderListAlertRules({ initialRoute: '/?view=view-1&query=foo' });
await screen.findByTestId('alert-rules-view-save-changes');
expect(urlParam('query')).toBe('foo');
expect(urlParam('states')).toBeNull();
expect(activeHeading()).toHaveTextContent('Critical firing');
});
it('marks a cleared view filter in the URL', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
renderListAlertRules();
await selectView(user, 'view-2');
await user.click(screen.getByTestId('alert-rules-filter-clear'));
await waitFor(() => expect(urlParam('query')).toBe(''));
expect(urlParam('view')).toBe('view-2');
});
it('keeps a cleared view filter cleared on reload', async () => {
const requests = captureRulesRequests();
renderListAlertRules({ initialRoute: '/?view=view-1&query=' });
await screen.findByTestId('alert-rules-view-save-changes');
await urlSettled();
expect(urlParam('query')).toBe('');
expect(urlParam('states')).toBeNull();
expect(lastRequest(requests).get('query') ?? '').toBe('');
});
it('keeps a query run while the linked views are still loading', async () => {
server.use(
rest.get('http://localhost/api/v2/rule_views', (_, res, ctx) =>
res(
ctx.delay(300),
ctx.status(200),
ctx.json({ data: { views: ruleViewsFixture }, status: 'success' }),
),
),
);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
renderListAlertRules({ initialRoute: '/?view=view-1' });
await runSearchQuery(user, 'foo');
await screen.findByTestId(
'alert-rules-view-save-changes',
{},
{ timeout: 5000 },
);
await urlSettled();
expect(urlParam('query')).toBe('foo');
expect(urlParam('states')).toBeNull();
});
});
describe('rail chrome', () => {
it('filters views by name', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
renderListAlertRules();
await screen.findByTestId('alert-rules-view-view-2');
const search = screen.getByTestId('alert-rules-view-search');
await user.type(search, 'infra');
expect(viewItem('view-2')).toBeInTheDocument();
expect(
screen.queryByTestId('alert-rules-view-view-1'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('alert-rules-view-all')).not.toBeInTheDocument();
await user.clear(search);
await user.type(search, 'zzz');
expect(screen.getByText(/No views match/)).toBeInTheDocument();
});
it('collapses the rail from the status bar and persists it', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
renderListAlertRules();
await screen.findByText('High CPU Alert');
const toggle = screen.getByTestId('alert-rules-rail-toggle');
expect(toggle).toHaveTextContent('Collapse');
await user.click(toggle);
expect(toggle).toHaveTextContent('Expand');
const stored = JSON.parse(
localStorage.getItem(LOCALSTORAGE.ALERT_RULES_LIST_VIEWS) ?? '{}',
);
expect(stored.state.railCollapsed).toBe(true);
});
});
});

View File

@@ -1,6 +1,9 @@
import { type ReactNode, useEffect, useState } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, RenderResult, screen } from '@testing-library/react';
import { act, render, RenderResult, screen } from '@testing-library/react';
import type userEvent from '@testing-library/user-event';
import { EditorView } from '@uiw/react-codemirror';
import ListAlertRules from 'container/ListAlertRules';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { AppContext } from 'providers/App/App';
@@ -9,6 +12,32 @@ import { onNuqsUrlUpdate, resetNuqsState } from 'tests/nuqs-helpers';
import { TestRouter } from 'tests/router';
import { getAppContextMock } from 'tests/test-utils';
// NuqsTestingAdapter resets its update queue on every render, not only on mount,
// dropping writes queued just before it re-renders.
function UrlAdapter({
searchParams,
children,
}: {
searchParams: string;
children: ReactNode;
}): JSX.Element {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<NuqsTestingAdapter
searchParams={searchParams}
onUrlUpdate={onNuqsUrlUpdate}
rateLimitFactor={0}
resetUrlUpdateQueueOnMount={!mounted}
hasMemory
>
{children}
</NuqsTestingAdapter>
);
}
interface RenderOptions {
role?: string;
initialRoute?: string;
@@ -33,12 +62,7 @@ export function renderListAlertRules(
return render(
<TestRouter initialRoute={initialRoute}>
<NuqsTestingAdapter
searchParams={initialSearch}
onUrlUpdate={onNuqsUrlUpdate}
rateLimitFactor={0}
hasMemory
>
<UrlAdapter searchParams={initialSearch}>
<QueryClientProvider client={queryClient}>
<AppContext.Provider value={getAppContextMock(role)}>
<TimezoneProvider>
@@ -50,7 +74,7 @@ export function renderListAlertRules(
</TimezoneProvider>
</AppContext.Provider>
</QueryClientProvider>
</NuqsTestingAdapter>
</UrlAdapter>
</TestRouter>,
);
}
@@ -63,3 +87,31 @@ export async function findAlertRow(alertName: string): Promise<HTMLElement> {
}
return row as HTMLElement;
}
type User = ReturnType<typeof userEvent.setup>;
export function setSearchDraft(value: string): void {
const view = EditorView.findFromDOM(
screen.getByTestId('alert-rules-list-search'),
);
if (!view) {
throw new Error('Search editor not found');
}
act(() => {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
});
});
}
export function getSearchDraft(): string {
const view = EditorView.findFromDOM(
screen.getByTestId('alert-rules-list-search'),
);
return view?.state.doc.toString() ?? '';
}
export async function runSearchQuery(user: User, value: string): Promise<void> {
setSearchDraft(value);
await user.click(screen.getByTestId('alert-rules-list-search-submit'));
}

View File

@@ -7,7 +7,7 @@ import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
deleteRuleByID,
invalidateListRules,
invalidateListRulesV3,
patchRuleByID,
cloneRuleByID,
} from 'api/generated/services/rules';
@@ -22,7 +22,7 @@ import { ALERT_ACTIONS, alertActionLogEvent } from '../utils';
interface ActionsMenuProps {
rule: AlertRule;
onEdit: (rule: AlertRule, options?: { newTab?: boolean }) => void;
onEdit: (rule: Pick<AlertRule, 'id'>, options?: { newTab?: boolean }) => void;
}
function ActionsMenu({ rule, onEdit }: ActionsMenuProps): JSX.Element {
@@ -34,7 +34,9 @@ function ActionsMenu({ rule, onEdit }: ActionsMenuProps): JSX.Element {
toast.promise(
patchRuleByID({ id: rule.id ?? '' }, {
disabled: newDisabled,
} as RuletypesPostableRuleDTO).then(() => invalidateListRules(queryClient)),
} as RuletypesPostableRuleDTO).then(() =>
invalidateListRulesV3(queryClient),
),
{
loading: newDisabled ? 'Disabling alert...' : 'Enabling alert...',
success: newDisabled ? 'Alert disabled' : 'Alert enabled',
@@ -65,10 +67,10 @@ function ActionsMenu({ rule, onEdit }: ActionsMenuProps): JSX.Element {
cloneRuleByID({
id: rule.id,
}).then(async (response) => {
await invalidateListRules(queryClient);
await invalidateListRulesV3(queryClient);
const newRule = response.data;
if (newRule) {
onEdit(newRule as AlertRule);
onEdit(newRule);
}
return newRule;
}),
@@ -90,7 +92,7 @@ function ActionsMenu({ rule, onEdit }: ActionsMenuProps): JSX.Element {
alertActionLogEvent(ALERT_ACTIONS.DELETE, rule);
toast.promise(
deleteRuleByID({ id: rule.id ?? '' }).then(() =>
invalidateListRules(queryClient),
invalidateListRulesV3(queryClient),
),
{
loading: 'Deleting alert...',

View File

@@ -0,0 +1,44 @@
import FilterZone from 'components/ListFilter/FilterZone/FilterZone';
import type { SuggestionSource } from 'components/ListFilter/dsl/types';
import type { CreatorOption } from 'components/ListFilter/types';
import { RULES_DSL_GRAMMAR } from '../list.config';
import RuleStatesFilter from './RuleStatesFilter';
interface Props {
query: string;
states: string[];
creatorOptions: CreatorOption[];
source: SuggestionSource;
error: string | null;
onQueryChange: (query: string) => void;
onStatesChange: (states: string[]) => void;
}
function AlertRulesFilterZone({
query,
states,
creatorOptions,
source,
error,
onQueryChange,
onStatesChange,
}: Props): JSX.Element {
return (
<FilterZone
query={query}
grammar={RULES_DSL_GRAMMAR}
testIdPrefix="alert-rules"
placeholder="DSL Filter — e.g. name CONTAINS 'cpu' AND labels.team = 'infra'"
creatorOptions={creatorOptions}
source={source}
onQueryChange={onQueryChange}
extraFilters={<RuleStatesFilter states={states} onChange={onStatesChange} />}
hasExtraFilters={states.length > 0}
onClearAll={(): void => onStatesChange([])}
error={error ?? undefined}
/>
);
}
export default AlertRulesFilterZone;

View File

@@ -0,0 +1,5 @@
.actions {
display: flex;
align-items: center;
gap: 8px;
}

View File

@@ -0,0 +1,51 @@
import type { MouseEvent } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { ColumnSelector } from 'components/TanStackTableView';
import type { TableColumnDef } from 'components/TanStackTableView';
import TextToolTip from 'components/TextToolTip';
import type { AlertRule } from '../types';
import styles from './AlertRulesHeaderActions.module.scss';
interface Props {
columns: TableColumnDef<AlertRule>[];
canAddAlert: boolean;
onNewAlert: (e: MouseEvent) => void;
}
function AlertRulesHeaderActions({
columns,
canAddAlert,
onNewAlert,
}: Props): JSX.Element {
return (
<div className={styles.actions}>
<ColumnSelector
columns={columns}
storageKey="alert-rules-columns"
testId="alert-columns-button"
/>
{canAddAlert && (
<Button
variant="solid"
size="sm"
prefix={<Plus size={14} />}
onClick={onNewAlert}
color="primary"
testId="list-alerts-new-alert-button"
>
New Alert
</Button>
)}
<TextToolTip
text="More details on how to create alerts"
url="https://signoz.io/docs/alerts/?utm_source=product&utm_medium=list-alerts"
urlText="Learn More"
/>
</div>
);
}
export default AlertRulesHeaderActions;

View File

@@ -0,0 +1,4 @@
.statesSelect {
width: 240px;
flex: none;
}

View File

@@ -0,0 +1,49 @@
import { useCallback } from 'react';
import { ComboboxSimple, type ComboboxSimpleItem } from '@signozhq/ui/combobox';
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './RuleStatesFilter.module.scss';
const STATE_ITEMS: ComboboxSimpleItem[] = [
{ value: RuletypesAlertStateDTO.firing, label: 'Firing' },
{ value: RuletypesAlertStateDTO.pending, label: 'Pending' },
{ value: RuletypesAlertStateDTO.recovering, label: 'Recovering' },
{ value: RuletypesAlertStateDTO.inactive, label: 'OK' },
{ value: RuletypesAlertStateDTO.nodata, label: 'No data' },
{ value: RuletypesAlertStateDTO.disabled, label: 'Disabled' },
];
interface RuleStatesFilterProps {
states: string[];
onChange: (states: string[]) => void;
}
function RuleStatesFilter({
states,
onChange,
}: RuleStatesFilterProps): JSX.Element {
const handleChange = useCallback(
(values: unknown): void => {
if (Array.isArray(values)) {
onChange(values as string[]);
}
},
[onChange],
);
return (
<ComboboxSimple
className={styles.statesSelect}
multiple
value={states}
onChange={handleChange}
placeholder="Filter by state"
inputPlaceholder="Select one or more"
items={STATE_ITEMS}
maxDisplayedPills={2}
testId="alert-rules-filter-states"
/>
);
}
export default RuleStatesFilter;

View File

@@ -1,24 +1,34 @@
import { Options, useQueryState, UseQueryStateReturn } from 'nuqs';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import {
Options,
parseAsArrayOf,
parseAsString,
useQueryState,
UseQueryStateReturn,
} from 'nuqs';
const defaultNuqsOptions: Options = {
history: 'push',
};
export const ALERT_RULES_PARAMS = {
SEARCH: 'search',
QUERY: 'query',
STATES: 'states',
VIEW: 'view',
PAGE: 'page',
RULE_TYPE: 'ruleType',
FILTERS: 'alertRulesFilters',
} as const;
export const useAlertRulesFilters = (): UseQueryStateReturn<
export const useAlertRulesQuery = (): UseQueryStateReturn<string, string> =>
useQueryState(
ALERT_RULES_PARAMS.QUERY,
parseAsString.withDefault('').withOptions(defaultNuqsOptions),
);
export const useAlertRulesStates = (): UseQueryStateReturn<
string[],
string[]
> =>
useQueryState(
ALERT_RULES_PARAMS.FILTERS,
parseAsJsonNoValidate<string[]>()
.withDefault([])
.withOptions(defaultNuqsOptions),
ALERT_RULES_PARAMS.STATES,
parseAsArrayOf(parseAsString).withDefault([]).withOptions(defaultNuqsOptions),
);

View File

@@ -1,28 +1,31 @@
import { useCallback, useMemo } from 'react';
import { Plus, Search } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
import TanStackTable, { ColumnSelector } from 'components/TanStackTableView';
import ListCommandHeader from 'components/ListViews/ListCommandHeader/ListCommandHeader';
import ListStatusBar from 'components/ListViews/ListStatusBar/ListStatusBar';
import ViewsRail from 'components/ListViews/ViewsRail/ViewsRail';
import TanStackTable from 'components/TanStackTableView';
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
import { useTableParams } from 'components/TanStackTableView/useTableParams';
import useComponentPermission from 'hooks/useComponentPermission';
import { useUrlSearchState } from 'hooks/useUrlSearchState';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
import TextToolTip from 'components/TextToolTip';
import { AlertsEmptyState } from './AlertsEmptyState/AlertsEmptyState';
import { ActionsMenu } from './components';
import { ALERT_RULES_PARAMS, useAlertRulesFilters } from './hooks';
import AlertRulesFilterZone from './components/AlertRulesFilterZone';
import AlertRulesHeaderActions from './components/AlertRulesHeaderActions';
import styles from './ListAlertRules.module.scss';
import { getAlertRuleColumns } from './table.config';
import type { AlertRule } from './types';
import { useAlertRulesStripInfo } from './useAlertRulesStripInfo';
import { useAlertRulesData } from './useAlertRulesData';
import { useAlertRulesFilterSource } from './useAlertRulesFilterSource';
import { useAlertRulesHandlers } from './useAlertRulesHandlers';
import { useAlertRulesListFilter } from './useAlertRulesListFilter';
import { useAlertRuleViews } from './useAlertRuleViews';
import { useAlertRuleViewsStore } from './useAlertRuleViewsStore';
import { DEFAULT_RULE_VIEW, RULE_BUILTIN_VIEWS } from './list.config';
const QUERY_PARAMS_CONFIG = {
orderBy: 'orderBy',
@@ -40,14 +43,13 @@ function ListAlertRules(): JSX.Element {
user.role,
);
const [filterValues, setFilterValues] = useAlertRulesFilters();
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
rowHeight: 46,
});
const { orderBy, page, limit, setLimit, setPage } = useTableParams(
const { orderBy, page, limit, setLimit, setPage, setOrderBy } = useTableParams(
QUERY_PARAMS_CONFIG,
{
page: DEFAULT_PAGE,
@@ -58,39 +60,63 @@ function ListAlertRules(): JSX.Element {
},
);
const resetPageOnSearch = useCallback((): void => {
setPage(1);
}, [setPage]);
const { filter, applyFilter, submitQuery, changeStates } =
useAlertRulesListFilter({ orderBy, setOrderBy, setPage });
const { searchText, debouncedSearch, handleSearchChange, clearSearch } =
useUrlSearchState(ALERT_RULES_PARAMS.SEARCH, {
onDebouncedChange: resetPageOnSearch,
});
const ruleViews = useAlertRuleViews({ filter, applyFilter });
const { filteredRules, isFetching, isError, allRules, refetch } =
useAlertRulesData(orderBy, debouncedSearch, filterValues ?? []);
const railCollapsed = useAlertRuleViewsStore((s) => s.railCollapsed);
const setRailCollapsed = useAlertRuleViewsStore((s) => s.setRailCollapsed);
const toggleRail = useCallback((): void => {
setRailCollapsed(!railCollapsed);
}, [setRailCollapsed, railCollapsed]);
const listParams = useMemo(
() => ({
query: filter.query || undefined,
states: filter.states.length ? filter.states : undefined,
sort: filter.sort,
order: filter.order,
limit,
offset: (page - 1) * limit,
}),
[filter, limit, page],
);
const {
rules,
total,
labels,
reservedKeywords,
isFetching,
isError,
queryError,
refetch,
} = useAlertRulesData(listParams);
const { creatorOptions, source } = useAlertRulesFilterSource({
rules,
labels,
reservedKeywords,
userEmail: user.email,
});
const { handleEdit, handleNewAlert, handleRowClick, handleRowClickNewTab } =
useAlertRulesHandlers(allRules.length);
useAlertRulesHandlers(total);
const { selectView } = ruleViews;
const handleClearFilters = useCallback((): void => {
void setFilterValues(null);
clearSearch();
}, [setFilterValues, clearSearch]);
selectView(DEFAULT_RULE_VIEW);
}, [selectView]);
const columns = useMemo(
() => getAlertRuleColumns(formatTimezoneAdjustedTimestamp),
[formatTimezoneAdjustedTimestamp],
);
const paginatedRules = useMemo(() => {
const start = (page - 1) * limit;
return filteredRules.slice(start, start + limit);
}, [filteredRules, page, limit]);
useAlertRulesStripInfo({
shownCount: paginatedRules.length,
totalCount: filteredRules.length,
shownCount: rules.length,
totalCount: total,
});
const columnsWithActions = useMemo(() => {
@@ -120,93 +146,113 @@ function ListAlertRules(): JSX.Element {
];
}, [action, columns, handleEdit]);
const hasActiveFilters =
searchText.length > 0 || (filterValues ?? []).length > 0;
const hasActiveFilters = filter.query.length > 0 || filter.states.length > 0;
const isEmptyDueToFilters =
!isFetching &&
filteredRules.length === 0 &&
hasActiveFilters &&
allRules.length > 0;
const isEmptyNoRules = !isFetching && !isError && allRules.length === 0;
!isFetching && total === 0 && (hasActiveFilters || !!queryError);
const isEmptyNoRules =
!isFetching && !isError && !queryError && !hasActiveFilters && total === 0;
const activeLabel =
ruleViews.activeView?.name ??
RULE_BUILTIN_VIEWS.find((v) => v.id === ruleViews.activeViewId)?.label ??
'Alert rules';
return (
<div className={styles.container}>
{!isEmptyNoRules && (
<div className={styles.header}>
<div className={styles.refreshRow}>
<ColumnSelector
columns={columns}
storageKey="alert-rules-columns"
testId="alert-columns-button"
<div className={styles.layout}>
<ViewsRail
activeViewId={ruleViews.activeViewId}
builtinViews={RULE_BUILTIN_VIEWS}
customViews={ruleViews.customViews}
customViewsLoading={ruleViews.customViewsLoading}
isCustomActive={ruleViews.isCustomActive}
isModified={ruleViews.isModified}
collapsed={railCollapsed}
onSelect={ruleViews.selectView}
onSave={ruleViews.saveView}
onSaveChanges={ruleViews.saveActiveView}
onReset={ruleViews.resetView}
onDelete={ruleViews.removeView}
onRename={ruleViews.renameView}
testIdPrefix="alert-rules"
saveTestIdPrefix="save-alert-rule-view"
renameTestIdPrefix="rename-alert-rule-view"
deleteDescription="Views are shared across your organization, so this removes it for everyone. Alert rules are not affected."
/>
<div className={styles.main}>
{!isEmptyNoRules && (
<div className={styles.headerZone}>
<ListCommandHeader
label={activeLabel}
count={total}
isModified={ruleViews.isModified}
testIdPrefix="alert-rules"
action={
<AlertRulesHeaderActions
columns={columns}
canAddAlert={addNewAlert}
onNewAlert={handleNewAlert}
/>
}
/>
{addNewAlert && (
<Button
variant="solid"
size="sm"
prefix={<Plus size={14} />}
onClick={handleNewAlert}
color="primary"
testId="list-alerts-new-alert-button"
>
New Alert
</Button>
)}
<TextToolTip
text="More details on how to create alerts"
url="https://signoz.io/docs/alerts/?utm_source=product&utm_medium=list-alerts"
urlText="Learn More"
<AlertRulesFilterZone
query={filter.query}
states={filter.states}
creatorOptions={creatorOptions}
source={source}
error={queryError}
onQueryChange={submitQuery}
onStatesChange={changeStates}
/>
</div>
</div>
)}
{!isEmptyNoRules && (
<div className={styles.filtersRow}>
<Input
className={styles.searchInput}
placeholder="Search by Alert Name, Severity and Labels"
value={searchText}
onChange={handleSearchChange}
suffix={<Search size={14} className={styles.searchIcon} />}
testId="list-alerts-search-input"
/>
</div>
)}
<div ref={containerRef} className={styles.tableContainer}>
{isError ? (
<ErrorEmptyState title="Failed to load alert rules" onRefresh={refetch} />
) : isEmptyDueToFilters ? (
<NoResultsEmptyState
title="No matching alert rules"
subtitle="No alert rules match your search. Try adjusting your search criteria."
onClear={handleClearFilters}
clearButtonText="Clear Search"
/>
) : isEmptyNoRules ? (
<AlertsEmptyState onRefresh={refetch} />
) : (
<TanStackTable<AlertRule>
data={paginatedRules}
columns={columnsWithActions}
isLoading={isFetching}
getRowKey={(row): string => row.id ?? ''}
getItemKey={(row): string => row.id ?? ''}
columnStorageKey="alert-rules-columns"
enableQueryParams={QUERY_PARAMS_CONFIG}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
pagination={{
total: filteredRules.length,
calculatedPageSize,
onLimitChange: setLimit,
showTotalCount: true,
}}
paginationClassname={styles.paginationContainer}
enableAlternatingRowColors
plainTextCellLineClamp={2}
/>
)}
<div ref={containerRef} className={styles.tableContainer}>
{isError ? (
<ErrorEmptyState title="Failed to load alert rules" onRefresh={refetch} />
) : isEmptyDueToFilters ? (
<NoResultsEmptyState
title="No matching alert rules"
subtitle={
queryError
? 'The filter query is invalid. Fix it or clear the search.'
: 'No alert rules match your search. Try adjusting your search criteria.'
}
onClear={handleClearFilters}
clearButtonText="Clear Search"
/>
) : isEmptyNoRules ? (
<AlertsEmptyState onRefresh={refetch} />
) : (
<TanStackTable<AlertRule>
data={rules}
columns={columnsWithActions}
isLoading={isFetching}
getRowKey={(row): string => row.id ?? ''}
getItemKey={(row): string => row.id ?? ''}
columnStorageKey="alert-rules-columns"
enableQueryParams={QUERY_PARAMS_CONFIG}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
pagination={{
total,
calculatedPageSize,
onLimitChange: setLimit,
showTotalCount: true,
}}
paginationClassname={styles.paginationContainer}
enableAlternatingRowColors
plainTextCellLineClamp={2}
/>
)}
</div>
<ListStatusBar
collapsed={railCollapsed}
onToggleCollapse={toggleRail}
count={rules.length}
total={total}
noun="alert rules"
testId="alert-rules-rail-toggle"
/>
</div>
</div>
);

View File

@@ -0,0 +1,97 @@
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
import { BellOff, Flame, Layers, User } from '@signozhq/icons';
import { clause } from 'components/ListFilter/dsl/filterQuery';
import type {
ClauseBuilder,
DslGrammar,
} from 'components/ListFilter/dsl/types';
import type { BuiltinViewConfig } from 'components/ListViews/types';
import type { RuleListFilter, RuleViewCtx } from './types';
// Mirrors pkg/types/ruletypes/list_filter.go. Label keys go under `labels.`.
export const RULES_DSL_GRAMMAR = {
fields: [
{ key: 'name', type: 'string' },
// Aliases labels.severity, so it takes the label operators.
{ key: 'severity', type: 'dynamic' },
{
key: 'alert_type',
type: 'enum',
values: [
'METRIC_BASED_ALERT',
'LOGS_BASED_ALERT',
'TRACES_BASED_ALERT',
'EXCEPTIONS_BASED_ALERT',
'AI_TRACES_BASED_ALERT',
],
},
{
key: 'rule_type',
type: 'enum',
values: ['threshold_rule', 'promql_rule', 'anomaly_rule'],
},
{ key: 'created_by', type: 'string' },
{ key: 'updated_by', type: 'string' },
{ key: 'created_at', type: 'timestamp' },
{ key: 'updated_at', type: 'timestamp' },
],
dynamicKey: { detail: 'label', prefix: 'labels.' },
hiddenKeys: ['labels.<key>'],
} as const satisfies DslGrammar;
const where: ClauseBuilder<
(typeof RULES_DSL_GRAMMAR)['fields'][number]['key']
> = clause;
type RuleViewConfig = BuiltinViewConfig<RuleListFilter, RuleViewCtx>;
// Built-in views replace query and states; the current sort/order is kept.
const views = [
{
id: 'mine',
label: 'My alert rules',
icon: User,
section: 'personal',
filter: (current, { userEmail }): RuleListFilter => ({
...current,
query: userEmail ? where('created_by', '=', userEmail) : '',
states: [],
}),
},
{
id: 'all',
label: 'All alert rules',
icon: Layers,
section: 'system',
filter: (current): RuleListFilter => ({ ...current, query: '', states: [] }),
},
{
id: 'firing',
label: 'Firing',
icon: Flame,
section: 'system',
filter: (current): RuleListFilter => ({
...current,
query: '',
states: [RuletypesAlertStateDTO.firing],
}),
},
{
id: 'disabled',
label: 'Disabled',
icon: BellOff,
section: 'system',
filter: (current): RuleListFilter => ({
...current,
query: '',
states: [RuletypesAlertStateDTO.disabled],
}),
},
] as const satisfies readonly RuleViewConfig[];
export type RuleViewId = (typeof views)[number]['id'];
export const RULE_BUILTIN_VIEWS: readonly RuleViewConfig[] = views;
export const DEFAULT_RULE_VIEW: RuleViewId = 'all';

View File

@@ -1,3 +1,22 @@
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import type {
RuletypesListableRuleDTO,
RuletypesListOrderDTO,
RuletypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { SavedListView } from 'components/ListViews/types';
export type AlertRule = RuletypesRuleDTO;
export type AlertRule = RuletypesListableRuleDTO;
// The v3 list filter a saved view snapshots; pagination is deliberately excluded.
export interface RuleListFilter {
query: string;
states: string[];
sort: RuletypesListSortDTO;
order: RuletypesListOrderDTO;
}
export type RuleView = SavedListView<RuleListFilter>;
export interface RuleViewCtx {
userEmail: string;
}

View File

@@ -0,0 +1,75 @@
import { useEffect, useRef } from 'react';
import { useQueryState } from 'nuqs';
import type { UseListViewsResult } from 'hooks/listViews/types';
import { useListViews } from 'hooks/listViews/useListViews';
import { ALERT_RULES_PARAMS } from './hooks';
import { DEFAULT_RULE_VIEW, RULE_BUILTIN_VIEWS } from './list.config';
import type { RuleListFilter, RuleViewCtx } from './types';
import { useRuleViewCtx } from './useRuleViewCtx';
import { useRuleViews } from './useRuleViews';
import { isDefaultRuleListFilter, isSameRuleListFilter } from './utils';
interface UseAlertRuleViewsArgs {
filter: RuleListFilter;
applyFilter: (next: RuleListFilter) => void;
}
export interface UseAlertRuleViewsResult extends UseListViewsResult<RuleListFilter> {
customViewsLoading: boolean;
}
// A link carrying only `view=<custom id>` gets that view's filter applied once
// the views load; a link carrying its own filter params keeps them. Clearing a
// custom view's filter writes an empty `query=` so a reload keeps it cleared.
export function useAlertRuleViews({
filter,
applyFilter,
}: UseAlertRuleViewsArgs): UseAlertRuleViewsResult {
const api = useRuleViews();
const ctx = useRuleViewCtx();
const views = useListViews<RuleListFilter, RuleViewCtx>({
filter,
applyFilter,
isEqual: isSameRuleListFilter,
builtinViews: RULE_BUILTIN_VIEWS,
ctx,
defaultViewId: DEFAULT_RULE_VIEW,
api,
urlParam: ALERT_RULES_PARAMS.VIEW,
});
const { activeView } = views;
const [rawQuery, setRawQuery] = useQueryState(ALERT_RULES_PARAMS.QUERY);
const startedFromViewLink = useRef(
isDefaultRuleListFilter(filter) && rawQuery === null,
);
const hasResolvedLinkedView = useRef(false);
// Declared before the restore so it skips the render that applies a linked view.
const clearedCustomView =
!!activeView && rawQuery === null && isDefaultRuleListFilter(filter);
useEffect(() => {
if (hasResolvedLinkedView.current && clearedCustomView) {
void setRawQuery('', { clearOnDefault: false, history: 'replace' });
}
}, [clearedCustomView, setRawQuery]);
useEffect(() => {
if (hasResolvedLinkedView.current || api.isLoading) {
return;
}
hasResolvedLinkedView.current = true;
if (
activeView &&
startedFromViewLink.current &&
isDefaultRuleListFilter(filter)
) {
applyFilter(activeView.filter);
}
}, [api.isLoading, activeView, applyFilter, filter]);
return { ...views, customViewsLoading: api.isLoading };
}

View File

@@ -0,0 +1,20 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { LOCALSTORAGE } from 'constants/localStorage';
interface AlertRuleViewsState {
railCollapsed: boolean;
setRailCollapsed: (collapsed: boolean) => void;
}
export const useAlertRuleViewsStore = create<AlertRuleViewsState>()(
persist(
(set) => ({
railCollapsed: false,
setRailCollapsed: (collapsed): void => {
set({ railCollapsed: collapsed });
},
}),
{ name: LOCALSTORAGE.ALERT_RULES_LIST_VIEWS },
),
);

View File

@@ -1,55 +1,92 @@
import { useEffect, useMemo, useRef } from 'react';
import logEvent from 'api/common/logEvent';
import { useListRules } from 'api/generated/services/rules';
import { searchByLabels } from 'components/Alerts/utils';
import type { SortState } from 'components/TanStackTableView/types';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useListRulesV3 } from 'api/generated/services/rules';
import type {
ListRulesV3200,
ListRulesV3Params,
RenderErrorResponseDTO,
RuletypesLabelPairDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
import { isUndefined } from 'lodash-es';
import type { AlertRule } from './types';
import { filterRulesByFilters, sortRules } from './utils';
import { RULE_LIST_INVALID_ERROR_CODES } from './utils';
interface UseAlertRulesDataReturn {
allRules: AlertRule[];
filteredRules: AlertRule[];
rules: AlertRule[];
total: number;
/** Label pairs across the org's rules, for DSL autocomplete. */
labels: RuletypesLabelPairDTO[];
reservedKeywords: string[] | undefined;
isFetching: boolean;
/** A failure other than an invalid filter query. */
isError: boolean;
/** The backend's message when the filter query is invalid. */
queryError: string | null;
refetch: () => void;
}
export function useAlertRulesData(
orderBy: SortState | null,
searchText = '',
filters: string[] = [],
params: ListRulesV3Params,
): UseAlertRulesDataReturn {
const hasLoggedEvent = useRef(false);
const rulesResponse = useListRules();
const rulesResponse = useListRulesV3(params, {
query: {
keepPreviousData: true,
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
queryFn: ({ signal }) =>
GeneratedAPIInstance<ListRulesV3200>({
url: '/api/v3/rules',
method: 'GET',
params,
paramsSerializer: { indexes: null },
signal,
}),
},
});
const allRules = useMemo(
() => rulesResponse.data?.data ?? [],
const rules = useMemo(
() => rulesResponse.data?.data.rules ?? [],
[rulesResponse.data],
);
const total = rulesResponse.data?.data.total ?? 0;
const labels = useMemo(
() => rulesResponse.data?.data.labels ?? [],
[rulesResponse.data],
);
useEffect(() => {
if (!hasLoggedEvent.current && !isUndefined(rulesResponse.data?.data)) {
void logEvent('Alert: List page visited', {
number: allRules.length,
number: total,
});
hasLoggedEvent.current = true;
}
}, [rulesResponse.data, allRules.length]);
}, [rulesResponse.data, total]);
const filteredRules = useMemo(() => {
const filtered = filterRulesByFilters(allRules, filters);
const searched = searchByLabels(filtered, searchText, (r) => r.alert ?? '');
return sortRules(searched, orderBy);
}, [allRules, filters, searchText, orderBy]);
const apiError = useMemo(
() =>
convertToApiError(
rulesResponse.error as AxiosError<RenderErrorResponseDTO> | null,
),
[rulesResponse.error],
);
const errorCode = apiError?.getErrorCode();
const isQueryError =
!!errorCode && RULE_LIST_INVALID_ERROR_CODES.includes(errorCode);
return {
allRules,
filteredRules,
rules: isQueryError ? [] : rules,
total: isQueryError ? 0 : total,
labels,
reservedKeywords: rulesResponse.data?.data.reservedKeywords,
isFetching: rulesResponse.isFetching,
isError: rulesResponse.isError,
isError: rulesResponse.isError && !isQueryError,
queryError: isQueryError ? (apiError?.getErrorMessage() ?? null) : null,
refetch: rulesResponse.refetch,
};
}

View File

@@ -0,0 +1,57 @@
import { useMemo } from 'react';
import type { RuletypesLabelPairDTO } from 'api/generated/services/sigNoz.schemas';
import type { SuggestionSource } from 'components/ListFilter/dsl/types';
import type { CreatorOption } from 'components/ListFilter/types';
import { useCreatorOptions } from 'components/ListFilter/useCreatorOptions';
import type { AlertRule } from './types';
const LABEL_PREFIX = 'labels.';
interface UseAlertRulesFilterSourceArgs {
rules: AlertRule[];
labels: RuletypesLabelPairDTO[];
reservedKeywords: string[] | undefined;
userEmail: string;
}
interface UseAlertRulesFilterSourceResult {
creatorOptions: CreatorOption[];
source: SuggestionSource;
}
export function useAlertRulesFilterSource({
rules,
labels,
reservedKeywords,
userEmail,
}: UseAlertRulesFilterSourceArgs): UseAlertRulesFilterSourceResult {
const pageAuthorEmails = useMemo<string[]>(
() =>
rules.map((r) => r.createdBy).filter((email): email is string => !!email),
[rules],
);
const creatorOptions = useCreatorOptions({
currentUserEmail: userEmail,
fallbackEmails: pageAuthorEmails,
});
const source = useMemo<SuggestionSource>(() => {
const dynamicKeys = new Set<string>();
const valuesByKey: Record<string, string[]> = {};
labels.forEach(({ key, value }) => {
const fullKey = `${LABEL_PREFIX}${key}`;
dynamicKeys.add(fullKey);
(valuesByKey[fullKey.toLowerCase()] ??= []).push(value);
});
return {
reservedKeys: reservedKeywords,
dynamicKeys: Array.from(dynamicKeys),
valuesByKey,
creatorEmails: creatorOptions.map((o) => o.email),
currentUserEmail: userEmail,
};
}, [labels, reservedKeywords, creatorOptions, userEmail]);
return { creatorOptions, source };
}

View File

@@ -2,18 +2,18 @@ import { useCallback } from 'react';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { sanitizeDefaultAlertQuery } from 'container/EditAlertV2/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useTableRowClick } from 'hooks/useTableRowClick';
import useUrlQuery from 'hooks/useUrlQuery';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
import { isModifierKeyPressed } from 'utils/app';
import type { AlertRule } from './types';
interface UseAlertRulesHandlersReturn {
handleEdit: (rule: AlertRule, options?: { newTab?: boolean }) => void;
handleEdit: (
rule: Pick<AlertRule, 'id'>,
options?: { newTab?: boolean },
) => void;
handleNewAlert: (e: React.MouseEvent) => void;
handleRowClick: (rule: AlertRule) => void;
handleRowClickNewTab: (rule: AlertRule) => void;
@@ -26,30 +26,16 @@ export function useAlertRulesHandlers(
const params = useUrlQuery();
const getEditUrl = useCallback(
(rule: AlertRule): string => {
const compositeQuery = sanitizeDefaultAlertQuery(
mapQueryDataFromApi(toCompositeMetricQuery(rule.condition.compositeQuery)),
rule.alertType,
);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const panelType = rule.condition.compositeQuery.panelType;
if (panelType) {
params.set(QueryParams.panelTypes, panelType);
}
params.set(QueryParams.ruleId, rule.id);
return `${ROUTES.ALERT_OVERVIEW}?${params.toString()}`;
(rule: Pick<AlertRule, 'id'>): string => {
const editParams = new URLSearchParams(params);
editParams.set(QueryParams.ruleId, rule.id);
return `${ROUTES.ALERT_OVERVIEW}?${editParams.toString()}`;
},
[params],
);
const handleEdit = useCallback(
(rule: AlertRule, options?: { newTab?: boolean }): void => {
(rule: Pick<AlertRule, 'id'>, options?: { newTab?: boolean }): void => {
safeNavigate(getEditUrl(rule), options);
},
[getEditUrl, safeNavigate],

View File

@@ -0,0 +1,62 @@
import { useCallback, useMemo } from 'react';
import type { SortState } from 'components/TanStackTableView/types';
import { useAlertRulesQuery, useAlertRulesStates } from './hooks';
import type { RuleListFilter } from './types';
import { toListSort, toOrderBy } from './utils';
interface UseAlertRulesListFilterArgs {
orderBy: SortState | null;
setOrderBy: (orderBy: SortState | null) => void;
setPage: (page: number) => void;
}
interface UseAlertRulesListFilterResult {
filter: RuleListFilter;
applyFilter: (filter: RuleListFilter) => void;
submitQuery: (query: string) => void;
changeStates: (states: string[]) => void;
}
// The v3 list filter, read from and written to the URL. Every change goes back to page 1.
export function useAlertRulesListFilter({
orderBy,
setOrderBy,
setPage,
}: UseAlertRulesListFilterArgs): UseAlertRulesListFilterResult {
const [query, setQuery] = useAlertRulesQuery();
const [states, setStates] = useAlertRulesStates();
const filter = useMemo<RuleListFilter>(
() => ({ query, states, ...toListSort(orderBy) }),
[query, states, orderBy],
);
const applyFilter = useCallback(
(next: RuleListFilter): void => {
void setQuery(next.query || null);
void setStates(next.states.length ? next.states : null);
setOrderBy(toOrderBy(next.sort, next.order));
setPage(1);
},
[setQuery, setStates, setOrderBy, setPage],
);
const submitQuery = useCallback(
(next: string): void => {
void setQuery(next || null);
setPage(1);
},
[setQuery, setPage],
);
const changeStates = useCallback(
(next: string[]): void => {
void setStates(next.length ? next : null);
setPage(1);
},
[setStates, setPage],
);
return { filter, applyFilter, submitQuery, changeStates };
}

View File

@@ -0,0 +1,9 @@
import { useMemo } from 'react';
import { useAppContext } from 'providers/App/App';
import type { RuleViewCtx } from './types';
export function useRuleViewCtx(): RuleViewCtx {
const { user } = useAppContext();
return useMemo(() => ({ userEmail: user.email }), [user.email]);
}

View File

@@ -0,0 +1,143 @@
import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getListRuleViewsQueryKey,
invalidateListRuleViews,
useCreateRuleView,
useDeleteRuleView,
useListRuleViews,
useUpdateRuleView,
} from 'api/generated/services/rules';
import type {
ListRuleViews200,
RenderErrorResponseDTO,
RuletypesPostableRuleViewDTO,
RuletypesRuleViewDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import type { SavedListViewsApi } from 'components/ListViews/types';
import type { RuleListFilter, RuleView } from './types';
import { DEFAULT_RULE_LIST_ORDER, DEFAULT_RULE_LIST_SORT } from './utils';
// Schema version stamped on the view's data envelope (the backend requires it).
const VIEW_DATA_VERSION = 'v1';
const toRuleView = (dto: RuletypesRuleViewDTO): RuleView => ({
id: dto.id,
name: dto.name,
filter: {
query: dto.data.query ?? '',
states: dto.data.states ?? [],
sort: dto.data.sort ?? DEFAULT_RULE_LIST_SORT,
order: dto.data.order ?? DEFAULT_RULE_LIST_ORDER,
},
});
const toPostable = (
name: string,
filter: RuleListFilter,
): RuletypesPostableRuleViewDTO => ({
name: name.trim(),
data: {
version: VIEW_DATA_VERSION,
query: filter.query.trim(),
states: filter.states,
sort: filter.sort,
order: filter.order,
},
});
const toastApiError =
(fallback: string) =>
(error: unknown): void => {
const apiError = convertToApiError(
error as AxiosError<RenderErrorResponseDTO>,
);
toast.error(apiError?.getErrorMessage() || fallback);
};
/** Org-shared saved views for the alert rules list, backed by the rule views API. */
export function useRuleViews(): SavedListViewsApi<RuleListFilter> {
const queryClient = useQueryClient();
const { data, isLoading } = useListRuleViews();
const views = useMemo<RuleView[]>(
() => (data?.data?.views ?? []).map(toRuleView),
[data],
);
const invalidate = useCallback((): void => {
void invalidateListRuleViews(queryClient);
}, [queryClient]);
const createMutation = useCreateRuleView({
mutation: {
onSuccess: invalidate,
onError: toastApiError('Failed to save view.'),
},
});
// Update returns the full view, so patch it into the cached list instead of refetching.
const updateMutation = useUpdateRuleView({
mutation: {
onSuccess: (response): void => {
const updated = response?.data;
const key = getListRuleViewsQueryKey();
const prev = queryClient.getQueryData<ListRuleViews200>(key);
if (!updated || !prev) {
return;
}
queryClient.setQueryData<ListRuleViews200>(key, {
...prev,
data: {
...prev.data,
views: (prev.data.views ?? []).map((v) =>
v.id === updated.id ? updated : v,
),
},
});
},
onError: toastApiError('Failed to update view.'),
},
});
const deleteMutation = useDeleteRuleView({
mutation: {
onSuccess: invalidate,
onError: toastApiError('Failed to delete view.'),
},
});
const createView = useCallback(
async (name: string, filter: RuleListFilter): Promise<RuleView | null> => {
try {
const res = await createMutation.mutateAsync({
data: toPostable(name, filter),
});
return res?.data ? toRuleView(res.data) : null;
} catch {
return null;
}
},
[createMutation],
);
const updateView = useCallback(
(id: string, name: string, filter: RuleListFilter): void => {
updateMutation.mutate({
pathParams: { id },
data: toPostable(name, filter),
});
},
[updateMutation],
);
const deleteView = useCallback(
(id: string): Promise<void> =>
deleteMutation.mutateAsync({ pathParams: { id } }).then(() => undefined),
[deleteMutation],
);
return { views, isLoading, createView, updateView, deleteView };
}

View File

@@ -1,10 +1,12 @@
import logEvent from 'api/common/logEvent';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { sortByColumn } from 'components/Alerts/utils';
import {
RuletypesListOrderDTO,
RuletypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { SortState } from 'components/TanStackTableView/types';
import { dataSourceForAlertType } from 'constants/alerts';
import type { AlertRule } from './types';
import type { AlertRule, RuleListFilter } from './types';
export const ALERT_RULES_REFRESH_INTERVAL = 30_000;
@@ -24,7 +26,7 @@ const ACTION_LABELS: Record<string, string> = {
export const alertActionLogEvent = (
action: string,
record: RuletypesRuleDTO,
record: AlertRule,
): void => {
const actionValue = ACTION_LABELS[action] ?? action;
void logEvent('Alert: Action', {
@@ -35,58 +37,75 @@ export const alertActionLogEvent = (
});
};
export function getAlertSortValue(
rule: AlertRule,
columnName: string,
): string | number {
switch (columnName) {
case 'state':
return rule.state ?? '';
case 'name':
return rule.alert ?? '';
case 'severity':
return rule.labels?.severity ?? '';
case 'createdAt':
return rule.createdAt ? new Date(rule.createdAt).getTime() : 0;
case 'updatedAt':
return rule.updatedAt ? new Date(rule.updatedAt).getTime() : 0;
default:
return '';
}
}
const COLUMN_TO_SORT: Record<string, RuletypesListSortDTO> = {
state: RuletypesListSortDTO.state,
name: RuletypesListSortDTO.name,
severity: RuletypesListSortDTO.severity,
createdAt: RuletypesListSortDTO.created_at,
updatedAt: RuletypesListSortDTO.updated_at,
};
export function sortRules(
rules: AlertRule[],
export const DEFAULT_RULE_LIST_SORT = RuletypesListSortDTO.updated_at;
export const DEFAULT_RULE_LIST_ORDER = RuletypesListOrderDTO.desc;
// `rule_list_filter_invalid`: DSL syntax / key / operator; `rule_list_invalid`: other list params.
export const RULE_LIST_INVALID_ERROR_CODES: readonly string[] = [
'rule_list_filter_invalid',
'rule_list_invalid',
];
export function toListSort(
orderBy: SortState | null,
): AlertRule[] {
return sortByColumn(rules, orderBy, getAlertSortValue);
}
export function filterRulesByFilters(
rules: AlertRule[],
filters: string[],
): AlertRule[] {
if (filters.length === 0) {
return rules;
): Pick<RuleListFilter, 'sort' | 'order'> {
const sort = orderBy ? COLUMN_TO_SORT[orderBy.columnName] : undefined;
if (!orderBy || !sort) {
return { sort: DEFAULT_RULE_LIST_SORT, order: DEFAULT_RULE_LIST_ORDER };
}
const stateFilters = filters
.filter((f) => f.startsWith('state:'))
.map((f) => f.replace('state:', '').toLowerCase());
const severityFilters = filters
.filter((f) => f.startsWith('severity:'))
.map((f) => f.replace('severity:', '').toLowerCase());
return rules.filter((rule) => {
const state = rule.state?.toLowerCase() ?? '';
const severity = rule.labels?.severity?.toLowerCase() ?? '';
const matchesState =
stateFilters.length === 0 || stateFilters.includes(state);
const matchesSeverity =
severityFilters.length === 0 || severityFilters.includes(severity);
return matchesState && matchesSeverity;
});
return {
sort,
order:
orderBy.order === 'asc'
? RuletypesListOrderDTO.asc
: RuletypesListOrderDTO.desc,
};
}
// The backend default sort maps to `null` so the table shows no sort indicator.
export function toOrderBy(
sort: RuletypesListSortDTO,
order: RuletypesListOrderDTO,
): SortState | null {
if (sort === DEFAULT_RULE_LIST_SORT && order === DEFAULT_RULE_LIST_ORDER) {
return null;
}
const columnName = Object.keys(COLUMN_TO_SORT).find(
(key) => COLUMN_TO_SORT[key] === sort,
);
return columnName
? { columnName, order: order === RuletypesListOrderDTO.asc ? 'asc' : 'desc' }
: null;
}
export function isSameRuleListFilter(
a: RuleListFilter,
b: RuleListFilter,
): boolean {
return (
a.query.trim() === b.query.trim() &&
a.sort === b.sort &&
a.order === b.order &&
a.states.length === b.states.length &&
[...a.states].sort().join(',') === [...b.states].sort().join(',')
);
}
export const DEFAULT_RULE_LIST_FILTER: RuleListFilter = {
query: '',
states: [],
sort: DEFAULT_RULE_LIST_SORT,
order: DEFAULT_RULE_LIST_ORDER,
};
export function isDefaultRuleListFilter(filter: RuleListFilter): boolean {
return isSameRuleListFilter(filter, DEFAULT_RULE_LIST_FILTER);
}

View File

@@ -0,0 +1,3 @@
.logsTable {
--tanstack-table-row-height: 30px;
}

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