Compare commits

...

46 Commits

Author SHA1 Message Date
Gaurav Tewari
98a0d906ed fix(navigation): keep an explicit replace: false in safeNavigate 2026-11-13 01:06:21 +05:30
Ashwin Bhatkal
20e6d44289 fix(api): send array query params as repeated keys (#13457)
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

- The generated API client now sends array query params as repeated keys
(`states=firing&states=pending`) instead of axios' default `states[]=`.
The backend binds only repeated keys, so a `states` filter sent through
a generated hook was ignored and every rule came back.
- The alert list page and the home page alerts card drop their own
requests that worked around this.
- `states` on `GET /api/v3/rules` is the only array query param in the
generated and AI assistant clients today, so no other request changes.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/719
2026-11-12 16:50:41 +00:00
Ashwin Bhatkal
9fef8eea69 test(alerts): stop the saved views tests timing out on CI (#13481)
#### Description

- `ListAlertRules.views.test.tsx` now gives each test 15 s instead of
Jest's default 5 s. Other slow page tests use the same timeout.
- "deleting the active view falls back to All" is the slowest test in
the file, about 0.8 s locally. CI runs the file about 4x slower (48 to
52 s against 13 s locally), which pushes it past 5 s. It failed the
merge queue for [#13476](https://github.com/SigNoz/signoz/pull/13476)
and [#13301](https://github.com/SigNoz/signoz/pull/13301), and two
unrelated PRs.
- Each step in it finishes well inside the 1 s `waitFor` limit. Only the
total runs over, so this is load, not a stuck wait.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/726
2026-11-12 15:46:37 +00:00
Swapnil Nakade
d00471c6b9 refactor: using X-Forwarded-For header for client.address (#13543)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- using X-Forwarded-For header for client.address as audit log attribute

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
closes https://github.com/SigNoz/keystone-pod/issues/213
2026-11-12 11:18:59 +00:00
Swapnil Nakade
b91f7e9835 refactor: making querier changes for audit schema (#13438)
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
- Audit query_range reads the new `signoz_audit` schema: `body`,
`scope`, `attributes` and `resource` are JSON columns, and the old typed
maps are gone.
- Audit skips the attribute and resource key tables, which the new
schema no longer has. Keys resolve from the intrinsic fields plus a
fixed list of the resource keys the audit writer emits, so bare resource
keys (e.g. `signoz.resource.kind`) work without the `resource.` prefix
until keys move to `signoz_metadata`.
- Raw audit rows return `resource` with flat dotted keys
(`resource.attributes["service.name"]`) instead of nesting at every dot.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
closes https://github.com/SigNoz/keystone-pod/issues/201
2026-11-12 11:02:10 +00:00
Aditya Singh
0b48326b10 feat(api-monitoring): move the endpoint filters and group-by off v3 autocomplete (#13160)
#### Description

- The All Endpoints and Endpoint Stats pages use `QuerySearch` for the
filter and `GroupByFilter` for the group-by, the query builder's own
components. This removes the last v3 autocomplete calls in API
monitoring.
- Each drawer tab keeps its filter in `QuerySearchV2Provider`, with its
own URL param (`apiMonitoringAllEndpointsExpression`,
`apiMonitoringEndpointStatsExpression`), the same way infra monitoring's
tabs do. The payload builders and the row-click drill-down work on the
expression directly, so the items conversion is gone. The filter applies
on Ctrl+Enter or when focus leaves the box.
- The group-by page no longer fetches keys or caches them to resolve
picks. `GroupByFilter` returns full keys, so about 80 lines of page
logic are gone.
- A Run button sits next to the filter on both pages. It applies the
filter the same way leaving the box does, and shows a spinner while that
tab's queries load.
- The filter shows the drawer's locked scope as a chip in front of the
text, like infra monitoring: `http_host = '<domain>' +1`, plus
`http_url` on Endpoint Stats. The chip is a new opt-in `scopeClauses`
prop on `QuerySearch`.
- Endpoint Stats and Top 10 Errors pick the endpoint from a searchable
select above the filter, not from text in the expression. The endpoint
is added to the request filter when the request is built.
- Removed the dead v4 fallback on the stats page and an unused
zero-state payload builder. Both have had no effect since November 2025.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/143
Closes https://github.com/SigNoz/events-pod/issues/144

#### Screenshots

sbshot:
https://claude.ai/artifact/YLourNcAvCqsmVbtnGhH2L?sk=bVmSGZMwZ7VSmSukZLyNAw



https://github.com/user-attachments/assets/bf6e9930-4d14-40f7-ba66-fe36045eb4ef

url now carries filter exp


https://github.com/user-attachments/assets/3310000a-d03a-4a54-9119-dde748aa6dc7





#### Additional Information

- The filter applies on Ctrl+Enter or blur, not on every completed chip.
Free-form expressions (OR, NOT, functions) now work.
- Group-by keys store the v5 field context (`attribute` / `resource`)
where v3 wrote `tag`. Keys restored from the URL carry the name only; v5
resolves them by name.
- The group-by has no clear-all cross and no `+N` collapse because the
shared component has neither. Every selected key stays visible with its
own ×.
- The filter row on the stats page now grows with a multi-line
expression instead of overflowing.
- Links saved with the old items-shaped filters in `apiMonitoringParams`
still open. The list page moves those filters into the tab params once
and drops the old keys.
- The drawer starts from the list's filter when you open it by clicking
a domain. A drawer opened from a link uses only the filters in the link.
A cleared tab filter stays cleared across tab switches and reloads.
- The user's filter is joined to the page's base filter as `base AND
(filter)`, so an `OR` in the user's filter stays scoped to the domain
and endpoint.
- Clicking a grouped row whose group value is empty now drills down with
`key NOT EXISTS`. It used to send `key = null`, which matched nothing.
- A hand-typed `http_url = …` in the All Endpoints filter now carries
into Endpoint Stats as part of the user's filter. Before, opening a row
dropped it, so "All endpoints" showed every endpoint. Now the clause
still narrows the data and the endpoint list. Other `http_url` operators
behave as before.
- Fixed a URL bug: writes to `apiMonitoringParams` now build on the live
URL. Before, several writes in one render kept only the last one. On
main, picking a time range in the drawer dropped `selectedInterval` from
the URL.
- `QuerySearchV2Provider` now follows URL changes after mount. Before,
it read its URL param once and wrote its stored filter back over later
changes, so Back and Forward did not restore a filter. Using it here
exposed this. Infra monitoring's Logs, Traces, Events and Overview tabs
share the provider and get the same fix.
- The `QuerySearch` changes are opt-in props (`showRunButton`,
`isLoadingQueries`, `scopeClauses`). Its other callers, infra monitoring
included, render as before.
- Storybook: the existing drawer stories (Domain Endpoints, Endpoint
Stats, Service Filtered, Port Domain, Failing Domain, Top Errors)
already show the picker, the Run button and the chip. No story shows the
Run button busy.
- Part of https://github.com/SigNoz/events-pod/issues/156.
2026-11-12 10:53:04 +00:00
ashu8912
95ee58eba5 fix(alerts): clean up the alert rules list styling (#13530)
#### Description

Visual cleanup of the alert rules list, to match the dashboards list:
- Removed the gap between the tab bar and the list.
- Rows use one black background instead of alternating colors.
- Rule labels are neutral grey instead of red, so only firing state and
severity draw attention.
- Column header dividers show only on hover.

#### Additional Information

- The existing `Pages/Alerts/Rules` stories already render these states;
no story change needed.
2026-11-12 07:56:31 +00:00
Jugal Kishore
9100d9aea6 feat(onboarding): add Pixeltable and Claude Cowork datasources (#13512)
#### Description

- Add Pixeltable and Claude Cowork cards to the onboarding data sources.
Pixeltable opens the trace-first guide. Claude Cowork opens the
log-event guide.
- Add a Pixeltable logo (`Logos/pixeltable.svg`). Pixeltable has no
vector logo, so I redrew its dot-matrix "P" on a 24x24 grid.
- Add the `python celery` search keyword to the Celery card. Searching
`python celery` returned no results before.
- Fix the built-in and AWS integration card links. The old
`/integrations?integration=...` links only opened the integrations list.
They now use `/integrations/builtin-<id>` and
`/integrations/aws?service=<id>`.

#### Issues closed by this PR

Closes SigNoz/signoz.io#4487
Closes SigNoz/signoz.io#4482
Closes SigNoz/signoz.io#3279
2026-11-12 07:45:54 +00:00
Naman Verma
3acfb0c8c0 chore: remove source and version from rules api schema (#13541)
<!--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

We don't want external clients like Terraform or AI Agents to be sending
these fields anymore cuz they are useless. That's why we need to remove
these from the schema. But sadly, we probably have users who send these
in their scripts, and our rules APIs don't accept fields not in the
struct, so these fields stay in the struct.

<!--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/731
2026-11-12 08:29:38 +00:00
Nikhil Mantri
75994b0d6f fix(alert-channel-integrations): describe jira auth as a flat object in the openapi spec (#13540)
#### Description

- The Terraform provider is generated from our OpenAPI spec. #13424
described the Jira channel `auth` as a `oneOf` (cloud or self-hosted)
with no discriminator, which the generator cannot map, so the
notification channel resource stopped building.
- `deployment` sits next to `auth`, not inside it, so it cannot act as
the discriminator.
- This PR describes `auth` in the spec as one object with optional
`email`, `apiToken` and `pat`. Each description says which deployment
needs it.
- The Go type now matches the spec: one auth struct replaces the two
per-deployment structs, and the fields are validated against
`deployment`.
- The accepted payload, the responses and stored channels stay the same,
so no migration is needed.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#728

#### Additional Information

- Problem and options doc:
https://app.notion.com/p/415fcc6bcd1980a0869fd5ed2e19ca3c
- The spec no longer marks the auth fields as required per deployment.
The server still rejects a wrong or missing combination with a 400.
- A field of the other deployment (for example `pat` on a cloud channel)
now gets an explicit error message instead of the unknown field one. It
is rejected only when it is not blank.
- The generated spec and API types are in a separate commit.
2026-11-12 08:13:20 +00:00
Gaurav Tewari
3bfb8708e9 test(ai-observability): explorer side panels and query builder in tab tests (#13523)
#### Description

- `QuickFiltersLayout` and `QuerySection` in the AI Explorer tests.
Neither is under test, and rendering them made the tab-switch test take
~1s; it now takes ~0.1s.
- Assert the trace view and its URL columns before switching tabs, so
the cleared-columns check can't pass when the columns were never set.
- After the switch, assert the list panel type is in the URL and the
trace view unmounts.

#### Issues closed by this PR

#### Screenshots / Screen Recordings

#### Additional Information

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-12 05:47:34 +00:00
Aditya Singh
876d84ac53 fix(sentry): expected 401, 403 and network failures page the frontend (#13510)
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

- API calls that nobody catches reach Sentry as unhandled rejections.
Most of these failures are expected: a 403 for a user without a viewer,
editor or admin role, a 401 for an expired session, or a network error
from a dropped connection.
- Since the axios 1.21.0 bump (#13339), these errors carry our call site
in the stack. Sentry opens a new issue for each call site, and each new
issue pages. SIGNOZ-UI-5N4 paged twice this way.
- `beforeSend` now drops axios errors with status 401 or 403, or with
code `ERR_NETWORK`. Sentry still gets 400, 404 and 5xx.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/261

#### Additional Information

- The filter matches raw axios errors only. A call that goes through
`ErrorResponseHandlerV2`, `ErrorResponseHandlerForGeneratedAPIs` or
`convertToApiError` rethrows an `APIError`. Sentry still reports that
error when nobody catches it.
- The filter drops all 403 and `ERR_NETWORK` errors. A missing
permission check in the UI, or a CORS or base-path regression, no longer
shows in Sentry. This is a deliberate choice.
- The filter also drops 401 errors from the token refresh retry path.
- A separate PR adds an error state for the empty key suggestions in the
query search bar.
2026-11-11 18:32:35 +00:00
Nikhil Soni
88e9ad73e0 fix(quick-filters): reject high-cardinality fields as quick filters (#13385)
#### Description

- `PUT /api/v2/quick_filters/{source}` (and the legacy v1 update) now
returns 400 for fields whose values are unique per span/log, so a quick
filter on them has nothing to pick from: trace/span/parent-span IDs
(incl. legacy `traceID`/`spanID`/`parentSpanID`), `timestamp`, log
`body`, `gen_ai.tool.call.id`, free-text payloads
(gen_ai/OpenInference/Langfuse/Traceloop messages,
`exception.message`/`stacktrace`, `db.statement`/`db.query.text`), token
counts (`gen_ai.usage.*`, `llm.token_count.*`, `ai.usage.*`, …) and the
per-span `signoz.gen_ai.*.cost` keys. The list is hard-coded in
`quickfiltertypes`.
- `trace_id` is removed from the default traces quick filters, including
the seeds in migrations `030`/`031` (separate commit), so `147` is a
no-op on fresh installs.
- Migration `147` strips these fields from every stored quick filter;
other entries are kept byte-for-byte.

#### Issues closed by this PR

Closes #13157

#### Additional Information

- Follows the approach agreed in SigNoz/platform-pod#3282, replacing the
frontend-only blocklist from #13182.
- Changelog: `trace_id`, `span_id` and other high-cardinality fields
(token counts, costs, message payloads) are no longer supported as quick
filters and are removed from existing quick filter settings; filter by
them from the search bar instead.
- The quick filter settings panel still lists these fields as
suggestions; adding one now surfaces the API error. Hiding them is out
of scope.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:10:49 +00:00
Ashwin Bhatkal
7f5bd4d1cf fix(alerts): match the rule state filter to the created-by filter (#13460)
#### Description

- The alert rules State filter now uses the same select as the Created
by filter: checkbox options, one pill plus "+N", and the same background
and width. The placeholder reads "State" instead of "Filter by state".
- The Created by select moves into a shared `MultiSelectFilter` in
`components/ListFilter`, and both filters render through it. Created by
looks and behaves as before on the dashboards and alert rules lists.
- The State filter moves from `@signozhq/ui` `ComboboxSimple` to antd
`Select`, to match the antd Created by and Updated filters beside it.
`ComboboxSimple` can't run the query once on close or show checkbox
options yet (https://github.com/SigNoz/components/issues/483).
- New story on the alert rules page: the State dropdown open.

#### Issues closed by this PR

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

#### Screenshots / Screen Recordings

Visual diff in dark and light:
https://claude.ai/artifact/UwA1RZA6VTDMEEbGpyVsvg

- 10 changed, all inside the State filter box on the alert rules list.
- 2 new: the State dropdown open.
- 8 pixel-identical: the dashboards list, where Created by is unchanged.
2026-11-11 10:44:29 +00:00
Nityananda Gohain
86e0e4840b feat(authz): enable FGA for llm pricing rule routes (#13260)
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
<!--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
- Get and delete check the path id. Bulk upsert checks each rule id and
falls back to the wildcard for creates and sync rows.
- Managed roles keep the same access as before: admin writes, editor and
viewer read. A migration backfills the tuples for existing orgs.
- Integration tests cover managed roles plus wildcard and rule-scoped
custom roles.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/nerve-pod/issues/463
2026-11-11 09:57:38 +00:00
Naman Verma
9ee433424d feat: migrate all rules to v2 schema (#13158)
#### Description

https://app.notion.com/p/signoz/Alert-Schema-Crosswalk-v1-v2alpha1-3cefcc6bcd198115acf3daf3fcedb409?source=copy_link

<!--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/225
Closes https://github.com/SigNoz/pulse-pod/issues/588
Closes https://github.com/SigNoz/pulse-pod/issues/362
Closes https://github.com/SigNoz/pulse-pod/issues/233
Closes https://github.com/SigNoz/uforge-pod/issues/5698
Closes https://github.com/SigNoz/pulse-pod/issues/696

---------

Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
2026-11-11 07:37:41 +00:00
Swapnil Nakade
da8d66b5b9 feat(audit): emit audit logs per signoz semantic conventions (#13477)
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
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- using semantic conventions described in `signoz-semanatic-conventions`
repository for audit

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
contributes to https://github.com/SigNoz/keystone-pod/issues/188
2026-11-10 21:26:00 +00:00
Swapnil Nakade
f1d6c988c9 feat(audit): adding kafka audit exporter (#13357)
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
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- add `kafka` auditor provider that produces audit logs to a
kafka/redpanda topic as `otlp_proto`
- uses franz-go v1.21.7.
- no delivery timeout or retry cap: on franz-go < v1.22.2 these can ack
a record that was never written (twmb/franz-go#1484).
- added unit tests

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
closes https://github.com/SigNoz/keystone-pod/issues/2

<!--Please delete paragraphs that you did not use before submitting.-->
2026-11-10 13:38:39 +00:00
Tushar Vats
f02b5521c6 feat(querier): heatmaps for the logs and traces signals (#13318)
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

Extends the metrics heatmap to logs, traces and audit logs, so a heatmap
can be built ad hoc from raw events with any filter instead of from a
pre-generated metric.

- The aggregation is the bare field to bucket, e.g. `duration_nano`.
Each cell counts events. Any other aggregation, such as `count()`, is
rejected, and a query takes one aggregation.
- Rows without the field are excluded by default: `<field> EXISTS` is
ANDed onto the filter, so it also applies to the resource filter and the
group limit. A present but non-numeric value counts as 0.
- `bucketOptions` is optional and defaults to the log axis, as for
metrics. Distribution requests work the same way.
- The heatmap is the time series query with the value bucket joining the
group keys, so a row is one cell and the response shape is unchanged.
Results are cached, and a cached axis is trimmed to the requested
window.
- The bucket expressions move to `pkg/querybuilder/heatmap.go`, shared
with metrics. The value is read once through a `WITH` alias instead of
being repeated in every bound. The metrics SQL is unchanged.
- A disabled query in a heatmap request feeds a formula, so it builds as
a time series, as metrics already did.
- A field nothing carries draws an empty chart with the key-not-found
warning.

#### Issues closed by this PR

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

#### Additional Information

- No API schema change.
- Integration tests (`querierlogs/17_heatmap.py`,
`queriertraces/16_heatmap.py`, `auditquerier/02_heatmap.py`) cover
linear, log and default axes, group by, rows without the field, and the
rejections.
- Backend only. Dashboards first, explorers later.
2026-11-10 09:05:04 +00:00
Srikanth Chekuri
4b1b3092d5 fix(querier): skip the cache for logs and traces queries with a limited group by (#13472)
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 logs or traces time series query with a group by and a limit ranks
its groups over the statement window. On a partial cache hit the gap
statement ranked over the gap, so the response was a union of the
winners of each piece, each with points for part of the window. A
narrower window served from a wider cached entry got the wider window's
winners.
- No piece of a window can stand in for the window, so such a query is
no longer served from the cache. Its fingerprint is empty, the same way
an AI builder query opts out today. Metrics are not affected, their
limit is applied after the full series set is read.
- Integration tests: a top-2 over a window whose first half is cached
must return the window's two winners with points for the whole window,
and a top-1 over the second half of a cached window must return that
half's winner. Both match the request with noCache.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#627

#### Additional Information

- The first commit adds the tests alone. CI ran them against main and
both failed. The second commit is the fix.
- This is the stop gap. Caching these queries exactly needs the ranking
summary of each piece, top-K totals with a threshold, so the merge can
rank with bounds and fall back when it cannot prove the top-N. That is a
design item, not a cache patch.
2026-11-09 20:33:56 +00:00
Srikanth Chekuri
d6017f88fd fix(querier): do not shift the gap of a timeShift query twice (#13470)
#### Description

- A timeShift query's window is moved into the shifted clock when the
query is built. On a partial cache hit the missing range comes from that
window, and the ranged gap query shifted it again, so the gap was read
one shift too far back, and the wrong points were served and written
back to the cache.
- The ranged query now takes the range as given. The spec copy already
carries the shift, so the gap statement reads the right data and the
postprocess shift moves it forward once.
- Integration test: a timeShift(3600) logs query with its first 30
minutes cached must return the same points for the next 20 minutes as
the request with noCache.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#625

#### Additional Information

- The first commit adds the test alone. CI ran it against main and it
failed with only that test failing in the suite. The second commit is
the fix.
2026-11-09 20:33:48 +00:00
Srikanth Chekuri
9d28014664 feat(querier): apply the having of a formula (#13452)
#### Description

The querier computes a formula in Go after its queries return, so a
formula having cannot run as SQL HAVING. It was accepted and ignored.

The having now filters the formula values in Go. It uses the having
grammar, validation, and messages of a builder query, with the formula
name and __result as the only references. A time series keeps the
points that match and drops a series with no points left. A scalar
result keeps the rows that match. The having runs before functions,
order, and limit, as HAVING does in SQL. A bad having fails the
request before any query runs.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#612

#### Additional Information

Assisted-by: Claude Opus 5.5
2026-11-09 20:33:22 +00:00
Srikanth Chekuri
1997901102 fix(querier): validate formula limits, raw order keys, and request types (#13426)
#### Description

- A negative formula `limit` now returns 400, as for builder queries.
Before, validation accepted it, and the series limit treated it as no
limit.
- An empty order key on a `raw`, `raw_stream`, or `trace` request now
returns 400. Before, validation accepted it. Aggregation requests keep
their own order key check, which lists the valid keys.
- A missing `requestType` returned `invalid request type: {}` and
suggested `timeseries`, which the decoder rejects. It now returns
`requestType is required`, with the valid request types as suggestions.
The decoder error for an unknown value lists the same set.
- Validation now accepts exactly what `RequestType.Enum` lists. The
spec, the decoder error, and validation read one list, so they cannot
drift apart.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#609

#### Additional Information

- Existing alerts should not be affected. Stored rules load without
validation, and the querier validates only in the `query_range` handler.
The UI does not send a negative formula limit or an empty order key.
- Part of https://github.com/SigNoz/nerve-pod/issues/428
2026-11-09 20:33:14 +00:00
Srikanth Chekuri
84268d00c8 docs(querier): add query_range examples for every builder function (#13475)
#### Description

- The query_range examples cover signals and request types but none of
the functions a builder query can carry, so a client has no place to
learn the argument shapes.
- Adds one example per function family: timeShift, fillZero, the four
threshold functions, runningDiff, ewma, median, cumulativeSum, log,
anomaly. Each description says what the function does to the series,
which arguments it takes, their units and their defaults.
- A test unmarshals every example into a `QueryRangeRequest` and
validates it, so an example cannot drift from the request type.
- The OpenAPI spec is regenerated in its own commit.
2026-11-09 20:32:41 +00:00
Ashwin Bhatkal
628e989bc0 fix(alerts): refresh the v3 rules list after rule changes (#13442)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (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

- Saving a rule, and cloning or renaming one from its details page, now
refreshes the v3 rules list instead of v2. The list page (#13322) and
`useListAllRules` (#13440) both read v3, so nothing reads the v2 list
anymore.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/718
2026-11-09 13:53:16 +00:00
Ashwin Bhatkal
74d272a945 fix(ai-assistant): read alert rules from the v3 list API (#13441)
#### Description

- The AI assistant's Alerts context picker now loads rules with one
`/api/v3/rules` request (`limit: 200`, the most the API returns per
call) instead of the deprecated `GET /api/v2/rules`. The Dashboards tab
next to it already loads one page (`limit: 1000`) the same way.
- Alert context chips read their names from the same cache entry.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/717
2026-11-09 13:20:39 +00:00
Ashwin Bhatkal
f8421e81e9 fix(planned-downtime): read alert rules from the v3 list API (#13440)
#### Description

- The planned downtime rule picker now reads `/api/v3/rules` through a
new `useListAllRules` hook instead of the deprecated `GET
/api/v2/rules`.
- The picker lists every rule, and v3 returns at most 200 per call, so
the hook fetches the remaining pages in parallel, like
`useChannelOptions` does for channels. Orgs with 200 rules or fewer make
one request.
- The planned downtime story now mocks `/api/v3/rules`.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/713
2026-11-09 12:45:49 +00:00
Ashwin Bhatkal
e938a10ad5 fix(home): read alert rules from the v3 list API (#13439)
#### Description

- `GET /api/v2/rules` is deprecated in favor of the paginated
`/api/v3/rules`, and #13158 removes it. This moves the home page alerts
widget to v3 ahead of that.
- The widget still shows firing rules first, then the most recently
updated others, up to 5. It asks v3 for the firing rules first and
fetches the latest rules only when fewer than 5 are firing, instead of
loading every rule.
- Clicking a rule now opens the overview with only `ruleId`, like the
alert list, triggered alerts and Metrics Explorer. The overview loads
the rule and builds its query from it, so the `compositeQuery` the old
link carried is not needed.
- The home page story now mocks `/api/v3/rules`.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/712
2026-11-09 11:52:40 +00:00
Ashwin Bhatkal
93cc1957d4 chore: make pulse-frontend the code owner of the list filter, views and alert rules folders (#13476)
#### Description

- `@SigNoz/pulse-frontend` now owns `components/ListFilter`,
`components/ListViews` and `hooks/listViews`. Only the dashboards list
and the alert rules list use them.
- It also owns `container/PlannedDowntime`, `hooks/alerts` and the home
page alert rules widget (`container/Home/AlertRules`).
- Until now these fell to the `/frontend/` catch-all, so changes to them
also needed `@SigNoz/frontend-maintainers`.
2026-11-09 11:26:04 +00:00
Swapnil Nakade
313ec2d657 chore: bump go version to 1.25 in github ci (#13480)
<!--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
- bump go version in ci to 1.25

---------

Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
2026-11-09 10:08:41 +00:00
Vinicius Lourenço
87bcd1b702 test(saved-views): cover behaviour the current tests miss (#13301)
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

Stacked on #13287, so that one merges first.

The saved views tests touch about 98% of the lines, yet a lot of
behaviour could break without any of them failing. This adds tests for
that behaviour:

- The row link and Copy link follow the selected time range.
- Saving from the header: a failed update, blank names, pressing Enter
twice, keeping the view color, and saving the query that was run.
- The hover card while it loads, when it fails, and when it closes.
- Restoring the last used view, per source.
- The unsaved changes check and spec conversion against what the server
stores.

It also adds the first saved views e2e specs, run against a real
backend:

- Open, create, update, discard, delete and rename a view on the logs
explorer, and check the header is still clean after a reload.
- Row links and Copy link carry the time range, and a Ctrl/Cmd or middle
click opens the view in a new tab.

While adding the tests I found that renaming a view from the row menu
sent the list's copy of the view back as a full replacement, so a stale
list could undo someone else's edit. Rename now reads the stored view
first, and falls back to the list's copy when the user cannot read the
view.

#### Issues closed by this PR

Closes https://github.com/SigNoz/uforge-pod/issues/6397

#### Additional Information

- Each new test fails when the code it covers is broken on purpose.
- Jest: `pnpm jest src/container/SavedViews
src/utils/__tests__/buildExplorerNavigationUrl.test.ts`
- E2E, with the stack up (`pnpm env:start` in `tests/e2e`): `pnpm
playwright test tests/saved-views --project=chromium`
- Opening a view the user cannot read still sends the GET, from
`usePreferenceSync`, which has no read check. The server answers 403 and
the header shows "No view access".
2026-11-08 17:53:07 +00:00
Srikanth Chekuri
43cde62108 test(alertmanager): fix flaky TestAggrGroup timing and resolve checks (#12131)
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

- TestAggrGroup failed four times this week in CI with "received batch
too early after 299.5ms" and "Expected aggregation group to be empty
after resolving alerts".
- The test timed batches with its own clock. The group resets its timer
before it calls notify, so two ticks can be under one group interval on
the test's clock by a millisecond. The test now reads the tick time the
group puts in the context, which is the clock the group itself uses.
- The empty check ran as soon as the batch arrived, but the group
deletes resolved alerts after notify returns. The test now waits for the
delete.
- Checks inside the notify callback use assert, not require, because the
callback runs on the group's goroutine.

## Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/5121
Closes https://github.com/SigNoz/engineering-pod/issues/5636

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-11-07 19:15:26 +00:00
Srikanth Chekuri
94367f4575 fix(querybuilder): warn on unknown keys outside the filter (#13421)
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

`ResolveColumn` dropped the not-found warning from `Resolve`, so a group
by, select field, order by, or aggregation on an unknown key returned
data with no warning. The le bucket key the histogram rewrite adds does
not warn, and meter queries still report no warnings.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#600

#### Additional Information

Assisted-by: Claude Opus 5.5
2026-11-07 10:51:41 +00:00
Srikanth Chekuri
108b2d8694 fix(querybuilder): parse aggregation predicates and field names like filters (#13418)
#### Description

- Gracefully handle aggregation keys names
  - otherwise `sum(http.response.body-size)` became a subtraction
- `count_distinct(k8s.pod.labels.app.kubernetes.io/name)` a division,
and both returned null or 0 with no warning to agent.
- Parsing failure, a wrong argument count, or an argument that is not a
field name now returns 400, earlier 500.
- Each argument of an aggregation call now comes from the source text
before the SQL parse. Predicates go to the filter grammar. A value
argument must be one filter-grammar key, so keys with `-`, `/` and
`:type` resolve.
- rewriter parsed the whole expression with the chsql parser before the
filter grammar saw the predicate. Filter-only syntax in an `*If`
predicate (`EXISTS`, `NOT EXISTS`, `CONTAINS`, `NOT CONTAINS`) failed in
that parser and returned 500.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#599
2026-11-07 09:41:13 +00:00
Srikanth Chekuri
fa6fda338f fix(querybuilder): read an unknown body path in a column as a string (#13429)
#### Description

- A `body.` key that metadata does not report failed with 404 "field not
found" in a select, group by, order by, or aggregation. The logs
fallback kept it with no data type, and the JSON body read needs one. A
filter on the same key worked, because the operand types it.
- The fallback now types it as `bodyPath` does for a bare key: a string
path when no operand types it. Filter SQL does not change.
- `TestResolveColumn_UnknownKey` resolves an unknown key in a column for
every storage and field context and allows only invalid input as an
error. On main, only the logs JSON body `body` context failed.
- The body path parity integration test now covers the `body.` spelling,
and group by, `count_distinct`, and select.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#610

#### Additional Information

- The not-found warning for group by and select reaches the response
with #13421. #13421 also changes `ResolveColumn` to return warnings; the
second PR to merge updates the call in `resolve_test.go`.
2026-11-07 09:39:16 +00:00
Abhi kumar
d1f4503b40 test(query-builder): add e2e suite for the query builder (#13313)
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

- Adds a host-agnostic `qb` driver
(`tests/e2e/drivers/query-builder.ts`) and ~110 query builder tests
across the logs, traces and metrics explorers, the V2 panel editor and
alerts. Assertions read the outgoing `query_range` body and the URL
query, never exact counts.
- Covers URL handling (malformed, partial, legacy shapes,
back/forward/reload), the filter editor (suggestions, quoting, IN lists,
nesting, validation, keyboard, recents, quick filters), metric-type
defaults, aggregations, add-ons per panel kind, multi-query and
formulas, trace operator, signal and query-type switching, cross-host
round trips (explorer → dashboard, alert save → reopen) and
run/cancel/error states.
- Each regression-driven case names the fix it guards (#13257, #12705,
#12496, #10048, #9947, #9732, #11208, #9637, #11035, #10100, #12755,
#9066, #8250).
- Seeds a gauge and a histogram per worker (golden metrics are all
sums); exports the seeder's retrying POST for that.
- Shared helper fixes found under load: `pickChannelByName` re-clicks
while the select is still closed; `holdQueryRange` tolerates a request
cancelled while held.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#662

#### Additional Information

- Stacked: #13315 (legacy having and limit fixes) → #13323 (added query
keeps its signal) → this PR. Merge in that order.
- Regression tests for both fixes (legacy having `COUNT() > 5`, negative
limit, added query's signal) fail against the unfixed app.
- Stable across 2 consecutive CI-mode runs on chromium (198 passed, 0
flaky). Meter source switching is not covered yet: the stack has no
meter metrics.
2026-11-06 20:25:12 +00:00
ashu8912
cbab3ca8d7 feat(alerts): add DSL search and saved views to alert rules list (#13322)
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
#### 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
643 changed files with 26618 additions and 13332 deletions

13
.github/CODEOWNERS vendored
View File

@@ -194,8 +194,8 @@ go.mod @therealpandey
/frontend/src/pages/EditRules/ @SigNoz/pulse-frontend
/frontend/src/container/AlertHistory/ @SigNoz/pulse-frontend
/frontend/src/container/CreateAlertRule/ @SigNoz/pulse-frontend
/frontend/src/container/CreateAlertV2/ @SigNoz/pulse-frontend
/frontend/src/container/EditAlertV2/ @SigNoz/pulse-frontend
/frontend/src/container/CreateAlert/ @SigNoz/pulse-frontend
/frontend/src/container/EditAlert/ @SigNoz/pulse-frontend
/frontend/src/container/FormAlertRules/ @SigNoz/pulse-frontend
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
@@ -209,6 +209,10 @@ go.mod @therealpandey
/frontend/src/types/api/alerts/ @SigNoz/pulse-frontend
/frontend/src/providers/Alert.tsx @SigNoz/pulse-frontend
/frontend/src/constants/alerts.ts @SigNoz/pulse-frontend
/frontend/src/container/PlannedDowntime/ @SigNoz/pulse-frontend
/frontend/src/hooks/alerts/ @SigNoz/pulse-frontend
/frontend/src/container/Home/AlertRules/ @SigNoz/pulse-frontend
/frontend/src/container/Home/__tests__/AlertRules.test.tsx @SigNoz/pulse-frontend
## Notification Channels
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
@@ -217,6 +221,11 @@ go.mod @therealpandey
/frontend/src/container/FormAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/hooks/notificationChannels/ @SigNoz/pulse-frontend
## List filter + views (dashboards and alerts lists)
/frontend/src/components/ListFilter/ @SigNoz/pulse-frontend
/frontend/src/components/ListViews/ @SigNoz/pulse-frontend
/frontend/src/hooks/listViews/ @SigNoz/pulse-frontend
## OpenAPI Schema - Generated
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv

View File

@@ -63,7 +63,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.24
GO_VERSION: 1.25
GO_NAME: signoz-community
GO_INPUT_ARTIFACT_CACHE_KEY: community-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build

View File

@@ -89,7 +89,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.24
GO_VERSION: 1.25
GO_INPUT_ARTIFACT_CACHE_KEY: enterprise-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build
GO_BUILD_CONTEXT: ./cmd/enterprise

View File

@@ -95,7 +95,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.24
GO_VERSION: 1.25
GO_INPUT_ARTIFACT_CACHE_KEY: staging-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build
GO_BUILD_CONTEXT: ./cmd/enterprise

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

View File

@@ -22,7 +22,7 @@ jobs:
with:
PRIMUS_REF: main
GO_TEST_CONTEXT: ./...
GO_VERSION: 1.24
GO_VERSION: 1.25
fmt:
if: |
github.event_name == 'merge_group' ||
@@ -32,7 +32,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.24
GO_VERSION: 1.25
lint:
if: |
github.event_name == 'merge_group' ||
@@ -42,7 +42,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.24
GO_VERSION: 1.25
deps:
if: |
github.event_name == 'merge_group' ||
@@ -52,7 +52,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.24
GO_VERSION: 1.25
semconv-generated:
if: |
github.event_name == 'merge_group' ||
@@ -65,7 +65,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: check-semconv-generated-files
run: make semconv-check
build:
@@ -80,7 +80,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: qemu-install
uses: docker/setup-qemu-action@v3
- name: aarch64-install
@@ -116,7 +116,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: generate-openapi
run: |
go run cmd/enterprise/*.go generate openapi
@@ -133,7 +133,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: generate-authz
run: |
go run cmd/enterprise/*.go generate authz
@@ -150,7 +150,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: generate-web-settings
run: |
go run cmd/enterprise/*.go generate config web-settings
@@ -167,7 +167,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: generate-transaction-groups
run: |
go run cmd/enterprise/*.go generate config transaction-groups

View File

@@ -66,7 +66,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: cross-compilation-tools
if: matrix.os == 'ubuntu-latest'
run: |
@@ -130,7 +130,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
# copy the caches from build
- name: get-sha

View File

@@ -75,7 +75,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: cross-compilation-tools
if: matrix.os == 'ubuntu-latest'
run: |
@@ -138,7 +138,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
# copy the caches from build
- name: get-sha

View File

@@ -9,6 +9,7 @@ import (
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/ee/auditor/fileauditor"
"github.com/SigNoz/signoz/ee/auditor/kafkaauditor"
"github.com/SigNoz/signoz/ee/auditor/otlphttpauditor"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
@@ -156,6 +157,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
if err := factories.Add(fileauditor.NewFactory(licensing, version.Info)); err != nil {
panic(err)
}
if err := factories.Add(kafkaauditor.NewFactory(licensing, version.Info)); err != nil {
panic(err)
}
return factories
},
func(ctx context.Context, providerSettings factory.ProviderSettings, flagger pkgflagger.Flagger, licensing licensing.Licensing, telemetryStore telemetrystore.TelemetryStore, retentionGetter retention.Getter, orgGetter organization.Getter, zeus zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string) {

View File

@@ -436,6 +436,7 @@ auditor:
# Specifies the auditor provider to use.
# noop: discards all audit events (community default).
# otlphttp: exports audit events via OTLP HTTP (enterprise).
# kafka: exports audit events to a Kafka topic as OTLP protobuf (enterprise).
provider: noop
# The async channel capacity for audit events. Events are dropped when full (fail-open).
buffer_size: 1000
@@ -461,6 +462,18 @@ auditor:
max_interval: 30s
# The total maximum time spent retrying.
max_elapsed_time: 60s
kafka:
# The seed broker addresses (host:port) used to discover the cluster.
brokers:
- localhost:9092
# The topic audit events are written to. It must already exist.
topic: ""
# The client id the producer reports to the brokers.
client_id: signoz
# The compression codec for produced batches: none, gzip, snappy, lz4 or zstd.
compression: lz4
# The maximum time to drain buffered events on shutdown. Undelivered events are dropped after it.
shutdown_timeout: 30s
##################### Cloud Integration #####################
cloudintegration:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
package kafkaauditor
import (
"context"
"log/slog"
"github.com/twmb/franz-go/pkg/kgo"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/audittypes"
)
// Produces the whole batch as a single record so the collector's otlp_proto unmarshaler reads it as one ExportLogsServiceRequest.
func (provider *provider) export(ctx context.Context, events []audittypes.AuditEvent) error {
logs := audittypes.NewPLogsFromAuditEvents(events, "signoz", provider.build.Version(), "signoz.audit")
value, err := provider.marshaler.MarshalLogs(logs)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, auditor.ErrCodeAuditExportFailed, "failed to marshal audit logs")
}
record := &kgo.Record{Topic: provider.config.Kafka.Topic, Value: value}
if err := provider.client.ProduceSync(ctx, record).FirstErr(); err != nil {
provider.settings.Logger().ErrorContext(ctx, "audit export failed", errors.Attr(err), slog.Int("dropped_log_records", len(events)))
return errors.Wrapf(err, errors.TypeInternal, auditor.ErrCodeAuditExportFailed, "failed to produce audit logs to topic %q", provider.config.Kafka.Topic)
}
return nil
}

View File

@@ -0,0 +1,132 @@
package kafkaauditor
import (
"context"
"log/slog"
"github.com/twmb/franz-go/pkg/kgo"
"github.com/twmb/franz-go/plugin/kslog"
"go.opentelemetry.io/collector/pdata/plog"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/auditor/auditorserver"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/version"
)
var _ auditor.Auditor = (*provider)(nil)
type provider struct {
settings factory.ScopedProviderSettings
config auditor.Config
licensing licensing.Licensing
build version.Build
server *auditorserver.Server
marshaler plog.ProtoMarshaler
client *kgo.Client
}
func NewFactory(licensing licensing.Licensing, build version.Build) factory.ProviderFactory[auditor.Auditor, auditor.Config] {
return factory.NewProviderFactory(factory.MustNewName("kafka"), func(ctx context.Context, providerSettings factory.ProviderSettings, config auditor.Config) (auditor.Auditor, error) {
return newProvider(ctx, providerSettings, config, licensing, build)
})
}
func newProvider(_ context.Context, providerSettings factory.ProviderSettings, config auditor.Config, licensing licensing.Licensing, build version.Build) (auditor.Auditor, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/auditor/kafkaauditor")
// Acks from all in-sync replicas, idempotent writes and unbounded record retries are franz-go defaults and are kept as is.
// A delivery timeout or retry limit lets franz-go < v1.22.2 ack a record that was never written (twmb/franz-go#1484).
client, err := kgo.NewClient(
kgo.SeedBrokers(config.Kafka.Brokers...),
kgo.ClientID(config.Kafka.ClientID),
kgo.ProducerBatchCompression(compressionCodec(config.Kafka.Compression)),
kgo.ProducerLinger(0),
kgo.WithLogger(kslog.New(settings.Logger())),
)
if err != nil {
return nil, err
}
provider := &provider{
settings: settings,
config: config,
licensing: licensing,
build: build,
marshaler: plog.ProtoMarshaler{},
client: client,
}
server, err := auditorserver.New(settings,
auditorserver.Config{
BufferSize: config.BufferSize,
BatchSize: config.BatchSize,
FlushInterval: config.FlushInterval,
},
provider.export,
)
if err != nil {
client.Close()
return nil, err
}
provider.server = server
return provider, nil
}
func (provider *provider) Start(ctx context.Context) error {
return provider.server.Start(ctx)
}
func (provider *provider) Audit(ctx context.Context, event audittypes.AuditEvent) {
if event.PrincipalAttributes.PrincipalOrgID.IsZero() {
provider.settings.Logger().WarnContext(ctx, "audit event dropped as org_id is zero")
return
}
if _, err := provider.licensing.GetActive(ctx, event.PrincipalAttributes.PrincipalOrgID); err != nil {
return
}
provider.server.Add(ctx, event)
}
func (provider *provider) Healthy() <-chan struct{} {
return provider.server.Healthy()
}
func (provider *provider) Stop(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, provider.config.Kafka.ShutdownTimeout)
defer cancel()
stoppedC := make(chan error, 1)
go func() { stoppedC <- provider.server.Stop(ctx) }()
select {
case err := <-stoppedC:
provider.client.Close()
return err
case <-ctx.Done():
provider.settings.Logger().WarnContext(ctx, "audit drain timed out, closing kafka client", slog.Duration("shutdown_timeout", provider.config.Kafka.ShutdownTimeout))
// Closing fails buffered and in-flight records, so the blocked export and the rest of the drain return immediately.
provider.client.Close()
return <-stoppedC
}
}
func compressionCodec(name string) kgo.CompressionCodec {
switch name {
case "gzip":
return kgo.GzipCompression()
case "snappy":
return kgo.SnappyCompression()
case "lz4":
return kgo.Lz4Compression()
case "zstd":
return kgo.ZstdCompression()
default:
return kgo.NoCompression()
}
}

View File

@@ -0,0 +1,227 @@
package kafkaauditor
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/twmb/franz-go/pkg/kerr"
"github.com/twmb/franz-go/pkg/kfake"
"github.com/twmb/franz-go/pkg/kgo"
"go.opentelemetry.io/collector/pdata/plog"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/types/audittypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/version"
)
func newTestCluster(t *testing.T, topics ...string) *kfake.Cluster {
t.Helper()
cluster, err := kfake.NewCluster(kfake.NumBrokers(1), kfake.SeedTopics(1, topics...))
require.NoError(t, err)
t.Cleanup(cluster.Close)
return cluster
}
func newTestProvider(t *testing.T, brokers []string, topic string, compression string) *provider {
t.Helper()
config := auditor.Config{
Provider: "kafka",
BufferSize: 10,
BatchSize: 5,
FlushInterval: 50 * time.Millisecond,
Kafka: auditor.KafkaConfig{
Brokers: brokers,
Topic: topic,
ClientID: "signoz-test",
Compression: compression,
ShutdownTimeout: 30 * time.Second,
},
}
auditor, err := newProvider(context.Background(), instrumentationtest.New().ToProviderSettings(), config, nil, version.Info)
require.NoError(t, err)
return auditor.(*provider)
}
func newTestEvent(resource coretypes.Resource, action coretypes.Verb, body string) audittypes.AuditEvent {
return audittypes.AuditEvent{
Timestamp: time.Now(),
Body: body,
EventName: audittypes.NewEventName(action),
AuditAttributes: audittypes.AuditAttributes{
Action: action,
Outcome: audittypes.OutcomeSuccess,
},
ResourceAttributes: audittypes.ResourceAttributes{
Resource: resource,
},
}
}
// Polls the topic until the consumed records hold wantLogRecords audit log records in total.
func consumeRecords(t *testing.T, brokers []string, topic string, wantLogRecords int) []*kgo.Record {
t.Helper()
client, err := kgo.NewClient(
kgo.SeedBrokers(brokers...),
kgo.ConsumeTopics(topic),
kgo.ConsumeResetOffset(kgo.NewOffset().AtStart()),
)
require.NoError(t, err)
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var records []*kgo.Record
gotLogRecords := 0
for gotLogRecords < wantLogRecords {
fetches := client.PollFetches(ctx)
require.NoError(t, ctx.Err(), "timed out waiting for %d log records, got %d", wantLogRecords, gotLogRecords)
require.Empty(t, fetches.Errors())
for _, record := range fetches.Records() {
logs, err := (&plog.ProtoUnmarshaler{}).UnmarshalLogs(record.Value)
require.NoError(t, err)
gotLogRecords += logs.LogRecordCount()
records = append(records, record)
}
}
return records
}
func TestExport(t *testing.T) {
testCases := []struct {
name string
compression string
compressionType uint8
}{
{name: "CompressionNone", compression: "none", compressionType: 0},
{name: "CompressionGzip", compression: "gzip", compressionType: 1},
{name: "CompressionSnappy", compression: "snappy", compressionType: 2},
{name: "CompressionLz4", compression: "lz4", compressionType: 3},
{name: "CompressionZstd", compression: "zstd", compressionType: 4},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
cluster := newTestCluster(t, "tenant_audit")
provider := newTestProvider(t, cluster.ListenAddrs(), "tenant_audit", testCase.compression)
defer provider.client.Close()
events := []audittypes.AuditEvent{
newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate, "user updated"),
newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate, "dashboard created"),
newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbDelete, "dashboard deleted"),
}
require.NoError(t, provider.export(context.Background(), events))
records := consumeRecords(t, cluster.ListenAddrs(), "tenant_audit", len(events))
require.Len(t, records, 1)
assert.Equal(t, "tenant_audit", records[0].Topic)
assert.Equal(t, testCase.compressionType, records[0].Attrs.CompressionType())
logs, err := (&plog.ProtoUnmarshaler{}).UnmarshalLogs(records[0].Value)
require.NoError(t, err)
assert.Equal(t, len(events), logs.LogRecordCount())
serviceName, ok := logs.ResourceLogs().At(0).Resource().Attributes().Get("service.name")
require.True(t, ok)
assert.Equal(t, "signoz", serviceName.Str())
})
}
}
func TestExport_RecordTooLarge(t *testing.T) {
cluster := newTestCluster(t, "large_audit")
provider := newTestProvider(t, cluster.ListenAddrs(), "large_audit", "none")
defer provider.client.Close()
event := newTestEvent(coretypes.ResourceUser, coretypes.VerbCreate, strings.Repeat("a", 2<<20))
err := provider.export(context.Background(), []audittypes.AuditEvent{event})
require.Error(t, err)
assert.True(t, errors.Is(err, kerr.MessageTooLarge))
}
func TestExport_UnknownTopic(t *testing.T) {
cluster := newTestCluster(t)
provider := newTestProvider(t, cluster.ListenAddrs(), "missing_audit", "lz4")
defer provider.client.Close()
err := provider.export(context.Background(), []audittypes.AuditEvent{newTestEvent(coretypes.ResourceUser, coretypes.VerbCreate, "user created")})
require.Error(t, err)
assert.True(t, errors.Is(err, kerr.UnknownTopicOrPartition))
}
func TestExport_BrokerDown_RetriesUntilContextDone(t *testing.T) {
cluster := newTestCluster(t, "down_audit")
brokers := cluster.ListenAddrs()
cluster.Close()
provider := newTestProvider(t, brokers, "down_audit", "lz4")
defer provider.client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
start := time.Now()
err := provider.export(ctx, []audittypes.AuditEvent{newTestEvent(coretypes.ResourceUser, coretypes.VerbCreate, "user created")})
require.Error(t, err)
assert.True(t, errors.Is(err, context.DeadlineExceeded))
assert.GreaterOrEqual(t, time.Since(start), 500*time.Millisecond)
}
func TestStop_DrainsQueuedEvents(t *testing.T) {
cluster := newTestCluster(t, "drain_audit")
provider := newTestProvider(t, cluster.ListenAddrs(), "drain_audit", "lz4")
done := make(chan error, 1)
go func() { done <- provider.Start(context.Background()) }()
<-provider.Healthy()
for range 7 {
provider.server.Add(context.Background(), newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate, "user updated"))
}
require.NoError(t, provider.Stop(context.Background()))
require.NoError(t, <-done)
records := consumeRecords(t, cluster.ListenAddrs(), "drain_audit", 7)
assert.NotEmpty(t, records)
}
func TestStop_BrokerDown_ClosesAfterShutdownTimeout(t *testing.T) {
cluster := newTestCluster(t, "stuck_audit")
brokers := cluster.ListenAddrs()
cluster.Close()
provider := newTestProvider(t, brokers, "stuck_audit", "lz4")
provider.config.Kafka.ShutdownTimeout = 500 * time.Millisecond
done := make(chan error, 1)
go func() { done <- provider.Start(context.Background()) }()
<-provider.Healthy()
for range 7 {
provider.server.Add(context.Background(), newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate, "user updated"))
}
start := time.Now()
require.NoError(t, provider.Stop(context.Background()))
require.NoError(t, <-done)
assert.GreaterOrEqual(t, time.Since(start), 500*time.Millisecond)
assert.Less(t, time.Since(start), 5*time.Second)
}

View File

@@ -34,8 +34,7 @@ type AnomalyRule struct {
provider anomaly.Provider
version string
logger *slog.Logger
logger *slog.Logger
seasonality anomaly.Seasonality
}
@@ -63,7 +62,6 @@ func NewAnomalyRule(
r := AnomalyRule{
BaseRule: baseRule,
querier: querier,
version: p.Version,
logger: logger.With(slog.String("rule.id", id)),
}
@@ -105,15 +103,15 @@ func (r *AnomalyRule) Type() ruletypes.RuleType {
}
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbtypes.QueryRangeRequest {
r.logger.InfoContext(
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
slog.Int64("eval.window_ms", r.EvalWindow().Milliseconds()),
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
)
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
r.logger.InfoContext(
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
slog.Int64("eval.window_ms", end-start),
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
)
req := &qbtypes.QueryRangeRequest{
Start: uint64(start),
End: uint64(end),
@@ -420,12 +418,10 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
func (r *AnomalyRule) String() string {
ar := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName(r.Name()),
RuleCondition: r.Condition(),
EvalWindow: r.EvalWindow(),
Labels: r.Labels().Map(),
Annotations: r.Annotations().Map(),
PreferredChannels: r.PreferredChannels(),
AlertName: valuer.NewDisplayName(r.Name()),
RuleCondition: r.Condition(),
Labels: r.Labels().Map(),
Annotations: r.Annotations().Map(),
}
byt, err := json.Marshal(ar)

View File

@@ -54,9 +54,6 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
@@ -157,10 +154,7 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
AlertOnAbsent: true,
Target: &target,
AlertOnAbsent: true,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{

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

@@ -1,7 +1,9 @@
import type { ErrorEvent, StackFrame } from '@sentry/react';
import { AxiosError, AxiosHeaders } from 'axios';
import {
hasNoAppFrames,
isExpectedRequestFailure,
isExternalIntegrationError,
SENTRY_IGNORE_ERRORS,
} from '../sentryFilters';
@@ -138,3 +140,36 @@ describe('SENTRY_IGNORE_ERRORS', () => {
).toBe(true);
});
});
describe('isExpectedRequestFailure', () => {
function axiosErrorWithStatus(status: number): AxiosError {
const config = { headers: new AxiosHeaders() };
return new AxiosError(
`Request failed with status code ${status}`,
AxiosError.ERR_BAD_REQUEST,
config,
undefined,
{ status, statusText: '', headers: {}, config, data: {} },
);
}
it.each([401, 403])('drops a %i response', (status) => {
expect(isExpectedRequestFailure(axiosErrorWithStatus(status))).toBe(true);
});
it('drops a network error', () => {
expect(
isExpectedRequestFailure(
new AxiosError('Network Error', AxiosError.ERR_NETWORK),
),
).toBe(true);
});
it.each([400, 404, 500, 503])('keeps a %i response', (status) => {
expect(isExpectedRequestFailure(axiosErrorWithStatus(status))).toBe(false);
});
it('keeps an error that is not from axios', () => {
expect(isExpectedRequestFailure(new Error('Network Error'))).toBe(false);
});
});

View File

@@ -40,6 +40,7 @@ import defaultRoutes, {
} from './routes';
import {
hasNoAppFrames,
isExpectedRequestFailure,
isExternalIntegrationError,
SENTRY_IGNORE_ERRORS,
} from './sentryFilters';
@@ -341,6 +342,9 @@ function App(): JSX.Element {
if (error?.name === 'AbortError') {
return null;
}
if (isExpectedRequestFailure(hint?.originalException)) {
return null;
}
// Ignore benign Monaco cancellation errors (name 'Canceled').
if (error?.name === 'Canceled') {
return null;

View File

@@ -1,4 +1,5 @@
import type { ErrorEvent } from '@sentry/react';
import { AxiosError, isAxiosError } from 'axios';
export const SENTRY_IGNORE_ERRORS = [
// A tab that outlived a deploy requests hashed assets the new build no longer
@@ -14,6 +15,18 @@ export const SENTRY_IGNORE_ERRORS = [
/Called on script loaded before session recording is available/,
];
// Not UI bugs: 401/403 come from the session or role, ERR_NETWORK from a
// dropped connection.
export function isExpectedRequestFailure(error: unknown): boolean {
if (!isAxiosError(error)) {
return false;
}
const status = error.response?.status;
return (
error.code === AxiosError.ERR_NETWORK || status === 401 || status === 403
);
}
const APP_ASSETS_PATH = '/assets/';
// Frames without a position are `[native code]`, which says nothing about the

View File

@@ -45,7 +45,6 @@ import type {
GetRuleHistoryTopContributorsParams,
GetRuleHistoryTopContributorsPathParameters,
ListRuleViews200,
ListRules200,
ListRulesV3200,
ListRulesV3Params,
PatchRuleByID200,
@@ -427,85 +426,6 @@ export const useUpdateRuleView = <
> => {
return useMutation(getUpdateRuleViewMutationOptions(options));
};
/**
* This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.
* @deprecated
* @summary List alert rules
*/
export const listRules = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListRules200>({
url: `/api/v2/rules`,
method: 'GET',
signal,
});
};
export const getListRulesQueryKey = () => {
return [`/api/v2/rules`] as const;
};
export const getListRulesQueryOptions = <
TData = Awaited<ReturnType<typeof listRules>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof listRules>>, TError, TData>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListRulesQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRules>>> = ({
signal,
}) => listRules(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listRules>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListRulesQueryResult = NonNullable<
Awaited<ReturnType<typeof listRules>>
>;
export type ListRulesQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary List alert rules
*/
export function useListRules<
TData = Awaited<ReturnType<typeof listRules>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof listRules>>, TError, TData>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListRulesQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @deprecated
* @summary List alert rules
*/
export const invalidateListRules = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListRulesQueryKey() },
options,
);
return queryClient;
};
/**
* This endpoint creates a new alert rule
* @summary Create alert rule

View File

@@ -829,6 +829,30 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
jira = 'jira',
}
export interface AlertmanagertypesChannelJiraAuthDTO {
/**
* @type string
* @format password
* @description The Atlassian API token paired with email. Required when deployment is cloud. 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. Required when deployment is cloud.
*/
email?: string;
/**
* @type string
* @format password
* @description The Jira personal access token, sent as a Bearer header. Required when deployment is selfhosted. A secret; returned by create, update and fetch by ID, never by list.
*/
pat?: string;
}
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 +868,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 +927,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 +1737,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 +1790,7 @@ export interface AlertmanagertypesJiraReceiverConfigDTO {
send_resolved?: boolean;
/**
* @type string
* @description The Jira Cloud base URL.
* @description The Jira base URL.
*/
site?: string;
/**
@@ -3579,6 +3604,7 @@ export enum CoretypesKindDTO {
'logs-field' = 'logs-field',
'traces-field' = 'traces-field',
'metrics-field' = 'metrics-field',
'llm-pricing-rule' = 'llm-pricing-rule',
}
export enum CoretypesTypeDTO {
user = 'user',
@@ -5273,7 +5299,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 +5332,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 +5346,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 +5691,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 +5724,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 +5738,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 +5871,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 +5904,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 +5918,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 +5970,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 +6003,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 +6017,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 +6963,7 @@ export enum Querybuildertypesv5RequestTypeDTO {
raw_stream = 'raw_stream',
trace = 'trace',
heatmap = 'heatmap',
distribution = 'distribution',
}
/**
* The query kind.
@@ -7046,7 +7073,7 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
*/
functions?: Querybuildertypesv5FunctionDTO[] | null;
/**
* @description Accepted but currently ignored; the computed values are returned unfiltered.
* @description A filter on the formula values, referencing the formula by its name or as __result, for example F1 > 0. A time series keeps only the points that match and drops a series with none left; a scalar result keeps only the rows that match.
*/
having?: Querybuildertypesv5HavingDTO;
/**
@@ -7064,6 +7091,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 +7178,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 +11005,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 +11626,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 +11747,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 +12087,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 +12242,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 {
/**
@@ -12836,23 +12960,10 @@ export interface RuletypesRuleConditionDTO {
* @description Whether to fire a nodata alert when the query returns no data. Defaults to false.
*/
alertOnAbsent?: boolean;
/**
* @type string
* @description anomaly_rule only: the anomaly detection algorithm. The supported value is standard.
*/
algorithm?: string;
/**
* @description The queries whose result is compared against the thresholds.
*/
compositeQuery: RuletypesAlertCompositeQueryDTO;
/**
* @description schemaVersion v1 only: which points in the window must satisfy op. Accepted as at_least_once, all_the_times, on_average, in_total or last, their aliases avg and sum, or the legacy codes as strings, '1' to '5'. Required for v1; v2alpha1 sets matchType on each threshold.
*/
matchType?: RuletypesMatchTypeDTO;
/**
* @description schemaVersion v1 only: how the value is compared with target. Accepted as above, below, equal, not_equal, above_or_equal, below_or_equal or outside_bounds, their symbols, or the legacy codes as strings, '1' to '7'. Required for v1; v2alpha1 sets op on each threshold.
*/
op?: RuletypesCompareOperatorDTO;
/**
* @type boolean
* @description Whether a series is skipped until it has at least requiredNumPoints points in the window. Defaults to false.
@@ -12872,17 +12983,6 @@ export interface RuletypesRuleConditionDTO {
* @description The name of the query whose result is evaluated, such as A or F1. Must match a query in compositeQuery.
*/
selectedQueryName: string;
/**
* @type number,null
* @format double
* @description schemaVersion v1 only: the value compared against, in targetUnit. Required for v1; v2alpha1 sets target on each threshold.
*/
target?: number | null;
/**
* @type string
* @description schemaVersion v1 only: the unit of target, such as percent or ms. Converted to compositeQuery.unit before comparison when the two differ.
*/
targetUnit?: string;
/**
* @description The thresholds to evaluate, as a kind and spec.
*/
@@ -12918,20 +13018,10 @@ export interface RuletypesPostableRuleDTO {
* @description Whether the rule is paused. A disabled rule is neither evaluated nor notifies. Defaults to false.
*/
disabled?: boolean;
/**
* @type string
* @description The lookback window as a Go duration such as 5m. Only read when schemaVersion is v1, defaulting to 5m; v2alpha1 rules set it in evaluation.spec instead.
*/
evalWindow?: string;
/**
* @description The evaluation window, as a kind and spec.
*/
evaluation: RuletypesEvaluationEnvelopeDTO;
/**
* @type string
* @description How often the rule is evaluated, as a Go duration such as 1m. Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set it in evaluation.spec instead.
*/
frequency?: string;
/**
* @type object
* @description Labels attached to every alert the rule fires. Keys match [a-zA-Z_.][a-zA-Z0-9_.]*; values may use Go templates such as {{$labels.service_name}}. severity is the conventional routing label.
@@ -12941,11 +13031,6 @@ export interface RuletypesPostableRuleDTO {
* @description Grouping, re-notification and routing behaviour.
*/
notificationSettings: RuletypesNotificationSettingsDTO;
/**
* @type array
* @description The displayName of each notification channel to notify, for schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries its own channels.
*/
preferredChannels?: string[];
/**
* @description The evaluation strategy: threshold_rule compares against thresholds, promql_rule evaluates a PromQL expression, anomaly_rule scores deviation from seasonal history. Defaults to threshold_rule for builder and clickhouse_sql queries and is forced to promql_rule for promql queries.
*/
@@ -12956,16 +13041,6 @@ export interface RuletypesPostableRuleDTO {
* @description The rule payload schema.
*/
schemaVersion: RuletypesPostableRuleDTOSchemaVersion;
/**
* @type string
* @description The URL of the page the rule was created from. Informational; it is not used for evaluation.
*/
source?: string;
/**
* @type string
* @description The query payload version. Nothing reads it.
*/
version?: string;
}
export interface RuletypesPostableRuleViewDTO {
@@ -13030,20 +13105,10 @@ export interface RuletypesRuleDTO {
* @description Whether the rule is paused. A disabled rule is neither evaluated nor notifies. Defaults to false.
*/
disabled?: boolean;
/**
* @type string
* @description The lookback window as a Go duration such as 5m. Only read when schemaVersion is v1, defaulting to 5m; v2alpha1 rules set it in evaluation.spec instead.
*/
evalWindow?: string;
/**
* @description The evaluation window, as a kind and spec.
*/
evaluation: RuletypesEvaluationEnvelopeDTO;
/**
* @type string
* @description How often the rule is evaluated, as a Go duration such as 1m. Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set it in evaluation.spec instead.
*/
frequency?: string;
/**
* @type string
* @description The unique identifier of the rule.
@@ -13058,11 +13123,6 @@ export interface RuletypesRuleDTO {
* @description Grouping, re-notification and routing behaviour.
*/
notificationSettings: RuletypesNotificationSettingsDTO;
/**
* @type array
* @description The displayName of each notification channel to notify, for schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries its own channels.
*/
preferredChannels?: string[];
/**
* @description The evaluation strategy: threshold_rule compares against thresholds, promql_rule evaluates a PromQL expression, anomaly_rule scores deviation from seasonal history. Defaults to threshold_rule for builder and clickhouse_sql queries and is forced to promql_rule for promql queries.
*/
@@ -13073,11 +13133,6 @@ export interface RuletypesRuleDTO {
* @description The rule payload schema.
*/
schemaVersion: RuletypesRuleDTOSchemaVersion;
/**
* @type string
* @description The URL of the page the rule was created from. Informational; it is not used for evaluation.
*/
source?: string;
/**
* @description The current evaluation state: inactive, pending, recovering, firing, nodata or disabled.
*/
@@ -13091,11 +13146,6 @@ export interface RuletypesRuleDTO {
* @type string
*/
updatedBy?: string;
/**
* @type string
* @description The query payload version. Nothing reads it.
*/
version?: string;
}
export enum RuletypesThresholdKindDTO {
@@ -16653,17 +16703,6 @@ export type UpdateRuleView200 = {
status: string;
};
export type ListRules200 = {
/**
* @type array
*/
data: RuletypesRuleDTO[];
/**
* @type string
*/
status: string;
};
export type CreateRule201 = {
data: RuletypesRuleDTO;
/**

View File

@@ -11,6 +11,8 @@ import axios, { AxiosError, AxiosRequestConfig } from 'axios';
// generated API Instance
const generatedAPIAxiosInstance = axios.create({
baseURL: ENVIRONMENT.baseURL,
// The backend binds repeated keys (`states=a&states=b`); axios' default sends `states[]=`.
paramsSerializer: { indexes: null },
});
let generatedAPIQueryKeyHeaderContext: Record<string, unknown> | undefined;

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect x="1.25" y="1.25" width="21.5" height="21.5" rx="3.5" fill="none" stroke="#7A8291" stroke-width="1.5"/><g fill="#7A8291"><circle cx="7" cy="4.5" r="1.05"/><circle cx="9.5" cy="4.5" r="1.05"/><circle cx="12" cy="4.5" r="1.05"/><circle cx="14.5" cy="4.5" r="1.05"/><circle cx="7" cy="7" r="1.05"/><circle cx="17" cy="7" r="1.05"/><circle cx="7" cy="9.5" r="1.05"/><circle cx="17" cy="9.5" r="1.05"/><circle cx="7" cy="12" r="1.05"/><circle cx="9.5" cy="12" r="1.05"/><circle cx="12" cy="12" r="1.05"/><circle cx="14.5" cy="12" r="1.05"/><circle cx="7" cy="14.5" r="1.05"/><circle cx="7" cy="17" r="1.05"/><circle cx="7" cy="19.5" r="1.05"/></g></svg>

After

Width:  |  Height:  |  Size: 726 B

View File

@@ -1,28 +1,13 @@
import { Checkbox, Select } from 'antd';
import { Select } from 'antd';
import cx from 'classnames';
import type { UpdatedWindow } from '../../types';
import type { UpdatedWindow } from '../dsl/types';
import type { CreatorOption } from '../types';
import { UPDATED_OPTIONS } from './constants';
import MultiSelectFilter from './MultiSelectFilter';
import styles from './FilterZone.module.scss';
export interface CreatorOption {
email: string;
label: string;
}
const UPDATED_LABELS: Record<UpdatedWindow, string> = {
any: 'Any time',
today: 'Today',
'7d': 'Last 7 days',
'30d': 'Last 30 days',
};
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
const UPDATED_OPTIONS = UPDATED_WINDOWS.map((w) => ({
value: w,
label: UPDATED_LABELS[w],
}));
interface Props {
createdBy: string[];
updated: UpdatedWindow;
@@ -33,6 +18,8 @@ interface Props {
onApply: () => void;
// Clear all Created-by selections and run immediately.
onClearCreatedBy: () => void;
// Ids: `${testIdPrefix}-filter-created-by`, `-filter-updated`.
testIdPrefix: string;
disabled?: boolean;
}
@@ -44,6 +31,7 @@ function FilterChips({
onUpdatedChange,
onApply,
onClearCreatedBy,
testIdPrefix,
disabled = false,
}: Props): JSX.Element {
const creatorOptionsData = creatorOptions.map((o) => ({
@@ -59,30 +47,14 @@ function FilterChips({
return (
<div className={styles.chips}>
<Select
mode="multiple"
showSearch
allowClear
className={cx(styles.select, styles.selectWide)}
placeholder="Created by"
<MultiSelectFilter
value={createdBy}
options={creatorOptionsData}
optionFilterProp="label"
maxTagCount={1}
menuItemSelectedIcon={null}
data-testid="dashboards-filter-created-by"
placeholder="Created by"
testId={`${testIdPrefix}-filter-created-by`}
disabled={disabled}
onClear={onClearCreatedBy}
optionRender={(option): JSX.Element => (
<div className={styles.creatorOption}>
<Checkbox
checked={createdBy.includes(option.value as string)}
className={styles.creatorCheck}
/>
<span className={styles.creatorLabel}>{option.label}</span>
</div>
)}
onChange={(value): void => onCreatedByChange(value as string[])}
onChange={onCreatedByChange}
onDropdownVisibleChange={runOnClose}
/>
<Select
@@ -92,7 +64,7 @@ function FilterChips({
value={updated}
options={UPDATED_OPTIONS}
optionFilterProp="label"
data-testid="dashboards-filter-updated"
data-testid={`${testIdPrefix}-filter-updated`}
disabled={disabled}
onChange={(value): void => onUpdatedChange(value as UpdatedWindow)}
/>

View File

@@ -86,20 +86,27 @@
}
}
// Created-by option: a presentational checkbox + label (antd handles the toggle).
.creatorOption {
// Multi-select option: a presentational checkbox + label (antd handles the toggle).
.checkOption {
display: flex;
align-items: center;
gap: 8px;
}
.creatorCheck {
.checkOptionBox {
// Flex, not block: a line box would shift with the check mark's baseline.
display: flex;
flex-shrink: 0;
pointer-events: none;
}
.creatorLabel {
.checkOptionLabel {
overflow: hidden;
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,72 @@
import { ConfigProvider, Select } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import cx from 'classnames';
import styles from './FilterZone.module.scss';
// The option checkbox already marks selection, so selected rows stay regular weight.
const CHECKBOX_SELECT_THEME = {
components: { Select: { optionSelectedFontWeight: 400 } },
};
export interface MultiSelectFilterOption {
value: string;
label: string;
}
interface Props {
value: string[];
options: MultiSelectFilterOption[];
placeholder: string;
testId: string;
onChange: (values: string[]) => void;
onClear?: () => void;
onDropdownVisibleChange?: (open: boolean) => void;
disabled?: boolean;
}
function MultiSelectFilter({
value,
options,
placeholder,
testId,
onChange,
onClear,
onDropdownVisibleChange,
disabled = false,
}: Props): JSX.Element {
return (
<ConfigProvider theme={CHECKBOX_SELECT_THEME}>
<Select
mode="multiple"
showSearch
allowClear
className={cx(styles.select, styles.selectWide)}
placeholder={placeholder}
value={value}
options={options}
optionFilterProp="label"
maxTagCount={1}
menuItemSelectedIcon={null}
data-testid={testId}
disabled={disabled}
onClear={onClear}
optionRender={(option): JSX.Element => (
<div className={styles.checkOption}>
<div className={styles.checkOptionBox}>
<Checkbox
color="primary"
value={value.includes(option.value as string)}
/>
</div>
<span className={styles.checkOptionLabel}>{option.label}</span>
</div>
)}
onChange={(next): void => onChange(next as string[])}
onDropdownVisibleChange={onDropdownVisibleChange}
/>
</ConfigProvider>
);
}
export default MultiSelectFilter;

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

@@ -48,22 +48,59 @@ export function QuerySearchV2Provider({
}, [initialExpression, store]);
const isInitialized = useRef(false);
useEffect(() => {
if (!isInitialized.current && urlExpression) {
const cleanedExpression = getUserExpressionFromCombined(
initialExpression,
urlExpression,
);
store.getState().initializeFromUrl(cleanedExpression);
isInitialized.current = true;
}
}, [urlExpression, initialExpression, store]);
const lastCommittedRef = useRef(committedExpression);
useEffect(() => {
if (isInitialized.current || !urlExpression) {
setUrlExpression(committedExpression || null);
return;
}
}, [committedExpression, setUrlExpression, urlExpression]);
isInitialized.current = true;
store
.getState()
.initializeFromUrl(
getUserExpressionFromCombined(initialExpression, urlExpression),
);
}, [urlExpression, initialExpression, store]);
// Back and Forward. Reads the browser's URL: after a write, nuqs can briefly
// report the value it replaced.
useEffect(() => {
const onPopState = (): void => {
isInitialized.current = true;
const urlValue = new URLSearchParams(window.location.search).get(
queryParamKey,
);
const expression = urlValue
? getUserExpressionFromCombined(initialExpression, urlValue)
: '';
// Another param changed; keep any text typed but not run.
if (expression === store.getState().committedExpression) {
return;
}
// Already in the URL, so the write below must not run for it.
lastCommittedRef.current = expression;
if (expression) {
store.getState().initializeFromUrl(expression);
} else {
store.getState().resetExpression();
}
};
window.addEventListener('popstate', onPopState);
return (): void => window.removeEventListener('popstate', onPopState);
}, [queryParamKey, initialExpression, store]);
// Write only when the committed filter changes, never in reply to a URL change.
useEffect(() => {
if (committedExpression === lastCommittedRef.current) {
return;
}
lastCommittedRef.current = committedExpression;
isInitialized.current = true;
const next = committedExpression || null;
if (next === urlExpression) {
return;
}
void setUrlExpression(next);
}, [committedExpression, urlExpression, setUrlExpression]);
useEffect(() => {
return (): void => {

View File

@@ -1,9 +1,10 @@
import { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { act, renderHook } from 'tests/test-utils';
import {
useExpression,
useInitialExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
@@ -34,10 +35,22 @@ function createWrapper(
};
}
// Back or Forward. nuqs has not caught up yet, so its value is still the old one.
function goTo(expression: string | null): void {
act(() => {
const search = expression
? `?${new URLSearchParams({ testExpression: expression })}`
: '';
window.history.pushState(null, '', `/${search}`);
window.dispatchEvent(new PopStateEvent('popstate'));
});
}
function useTestHooks(): {
expression: string;
userExpression: string;
initialExpression: string;
inputExpression: string;
querySearchInitialExpressionProp: string | undefined;
onChange: (expr: string) => void;
onRun: (expr: string) => void;
@@ -46,6 +59,7 @@ function useTestHooks(): {
expression: useExpression(),
userExpression: useUserExpression(),
initialExpression: useInitialExpression(),
inputExpression: useInputExpression(),
querySearchInitialExpressionProp: useQuerySearchInitialExpressionProp(),
onChange: useQuerySearchOnChange(),
onRun: useQuerySearchOnRun(),
@@ -56,6 +70,7 @@ describe('QuerySearchExpressionProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUrlValue = null;
window.history.replaceState(null, '', '/');
});
it('should provide initial context values', () => {
@@ -110,6 +125,96 @@ describe('QuerySearchExpressionProvider', () => {
expect(result.current.expression).toBe('status = 500');
});
it('follows Back and Forward from the browser URL', () => {
mockUrlValue = 'status = 500';
const { result } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
goTo('status = 404');
expect(result.current.userExpression).toBe('status = 404');
expect(mockSetQueryState).not.toHaveBeenCalled();
});
it('clears the filter on Back to a URL without the param', () => {
mockUrlValue = 'status = 500';
const { result } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
goTo(null);
expect(result.current.userExpression).toBe('');
expect(mockSetQueryState).not.toHaveBeenCalled();
});
it('keeps text typed but not run when Back changes another param', () => {
mockUrlValue = 'status = 500';
const { result } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
act(() => {
result.current.onChange('status = 404');
});
goTo('status = 500');
expect(result.current.inputExpression).toBe('status = 404');
});
it('writes a run filter to the URL once and ignores it coming back', () => {
const { result, rerender } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
act(() => {
result.current.onRun('status = 500');
});
expect(mockSetQueryState).toHaveBeenCalledTimes(1);
expect(mockSetQueryState).toHaveBeenLastCalledWith('status = 500');
mockUrlValue = 'status = 500';
rerender();
expect(result.current.userExpression).toBe('status = 500');
expect(mockSetQueryState).toHaveBeenCalledTimes(1);
});
it('keeps a run filter when the URL briefly reports the value it replaced', () => {
mockUrlValue = 'status = 500';
const { result, rerender } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
act(() => {
result.current.onRun('status = 404');
});
mockUrlValue = 'status = 404';
rerender();
mockUrlValue = 'status = 500';
rerender();
expect(result.current.userExpression).toBe('status = 404');
});
it('keeps a newer run when a late URL value arrives after an empty mount', () => {
const { result, rerender } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
act(() => {
result.current.onRun('status = 500');
});
act(() => {
result.current.onRun('status = 404');
});
mockUrlValue = 'status = 500';
rerender();
expect(result.current.userExpression).toBe('status = 404');
});
it('should throw error when used outside provider', () => {
expect(() => {
renderHook(() => useExpression());

View File

@@ -15,11 +15,41 @@ $max-recents-shown: 5;
Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', sans-serif;
.query-where-clause-row {
display: flex;
align-items: flex-start;
gap: 8px;
// single-line editor: 36px .cm-line + 1px .cm-content border on each side
--button-height: 38px;
.query-where-clause-editor-container {
flex: 1;
min-width: 0;
}
}
.query-where-clause-editor-container {
position: relative;
display: flex;
flex-direction: row;
// The field draws one outline around the scope chip and the editor, so
// the editor inside draws none
&.hasScopeChip {
align-items: center;
gap: var(--spacing-3);
padding-left: var(--spacing-3);
border: 1px solid var(--l2-border);
border-radius: 2px;
background: var(--l1-background);
--query-builder-v2-border-color: transparent;
--query-builder-v2-background-color: transparent;
.query-where-clause-editor {
min-width: 0;
}
}
.query-search-initial-scope-label {
position: absolute;
left: 8px;

View File

@@ -1,6 +1,12 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CircleCheck, Info, TriangleAlert, Filter } from '@signozhq/icons';
import {
CircleCheck,
Info,
TriangleAlert,
Filter,
Play,
} from '@signozhq/icons';
import {
autocompletion,
closeCompletion,
@@ -16,6 +22,7 @@ import { githubLight } from '@uiw/codemirror-theme-github';
import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
import { Button, Card, Collapse, Popover, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Button as SignozButton } from '@signozhq/ui/button';
import cx from 'classnames';
import {
negationQueryOperatorSuggestions,
@@ -59,6 +66,7 @@ import {
SUGGESTION_FETCH_DEBOUNCE_MS,
SUGGESTIONS_SECTION,
} from './constants';
import { ScopeChip } from './ScopeChip/ScopeChip';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
@@ -105,9 +113,14 @@ interface QuerySearchProps {
signalSource?: string;
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
onRun?: (query: string) => void;
showRunButton?: boolean;
/** Shows the Run button busy while the page's queries fetch. */
isLoadingQueries?: boolean;
showFilterSuggestionsWithoutMetric?: boolean;
/** When set, the editor shows only the user expression; API/filter uses `initial AND (user)`. */
initialExpression?: string;
/** Shows `initialExpression` as a locked chip in the field instead of the scope icon. */
showScopeChip?: boolean;
/** When set, replaces the generic value-suggestion API with a custom fetcher. */
valueSuggestionsOverride?: (
key: string,
@@ -125,10 +138,13 @@ function QuerySearch({
queryData,
dataSource,
onRun,
showRunButton,
isLoadingQueries,
signalSource,
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
initialExpression,
showScopeChip: showScopeChipProp,
metricNamespace,
valueSuggestionsOverride,
}: QuerySearchProps): JSX.Element {
@@ -148,6 +164,7 @@ function QuerySearch({
const editorRef = useRef<EditorView | null>(null);
const isScopedFilter = initialExpression !== undefined;
const showScopeChip = !!showScopeChipProp && !!initialExpression;
const validateExpressionForEditor = useCallback(
(editorDoc: string): void => {
@@ -173,6 +190,18 @@ function QuerySearch({
[],
);
const handleRun = useCallback((): void => {
if (!onRun) {
return;
}
const user = getCurrentExpression();
onRun(
isScopedFilter
? combineInitialAndUserExpression(initialExpression ?? '', user)
: user,
);
}, [onRun, getCurrentExpression, isScopedFilter, initialExpression]);
const updateEditorValue = useCallback(
(value: string, options: { skipOnChange?: boolean } = {}): void => {
const view = editorRef.current;
@@ -1557,169 +1586,182 @@ function QuerySearch({
</div>
)}
<div className="query-where-clause-editor-container">
{isScopedFilter ? (
<Tooltip title={initialExpression || ''} placement="left">
<div className="query-search-initial-scope-label">
<Filter
<div className="query-where-clause-row">
<div
className={cx('query-where-clause-editor-container', {
hasScopeChip: showScopeChip,
})}
>
{showScopeChip && <ScopeChip expression={initialExpression ?? ''} />}
{isScopedFilter && !showScopeChip ? (
<Tooltip title={initialExpression || ''} placement="left">
<div className="query-search-initial-scope-label">
<Filter
size={14}
style={{
opacity: 0.9,
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
</div>
</Tooltip>
) : null}
<Tooltip
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
placement="left"
>
<a
href="https://signoz.io/docs/userguide/search-syntax/"
target="_blank"
rel="noopener noreferrer"
style={{
position: 'absolute',
top: 8,
right: validation.isValid === false && getCurrentExpression() ? 40 : 8, // Move left when error shown
cursor: 'help',
zIndex: 10,
transition: 'right 0.2s ease',
display: 'inline-flex',
alignItems: 'center',
color: '#8c8c8c',
}}
onClick={(e): void => e.stopPropagation()}
>
<Info
size={14}
style={{
opacity: 0.9,
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
</div>
</a>
</Tooltip>
) : null}
<Tooltip
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
placement="left"
>
<a
href="https://signoz.io/docs/userguide/search-syntax/"
target="_blank"
rel="noopener noreferrer"
style={{
position: 'absolute',
top: 8,
right: validation.isValid === false && getCurrentExpression() ? 40 : 8, // Move left when error shown
cursor: 'help',
zIndex: 10,
transition: 'right 0.2s ease',
display: 'inline-flex',
alignItems: 'center',
color: '#8c8c8c',
}}
onClick={(e): void => e.stopPropagation()}
>
<Info
size={14}
style={{
opacity: 0.9,
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
</a>
</Tooltip>
<CodeMirror
theme={isDarkMode ? copilot : githubLight}
onChange={handleChange}
onUpdate={handleUpdate}
onCreateEditor={handleEditorCreate}
data-testid="query-where-clause-editor"
className={cx('query-where-clause-editor', {
isValid: validation.isValid === true,
hasErrors: validation.errors.length > 0,
hasInitialExpression: isScopedFilter,
})}
extensions={[
autocompletion({
override: [combinedSuggestions],
defaultKeymap: true,
closeOnBlur: true,
activateOnTyping: true,
maxRenderedOptions: 50,
addToOptions: [{ render: renderRecentDeleteButton, position: 100 }],
}),
javascript({ jsx: false, typescript: false }),
EditorView.lineWrapping,
stopEventsExtension,
Prec.highest(
keymap.of([
...completionKeymap,
{
key: 'Escape',
run: closeCompletion,
},
{
key: 'Enter',
preventDefault: true,
// Prevent default behavior of Enter to add new line
// and instead run a custom action
run: (): boolean => true,
},
{
key: 'Mod-Enter',
preventDefault: true,
// Prevent default behavior of Mod-Enter to add new line
// and instead run a custom action
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => {
if (onRun && typeof onRun === 'function') {
const user = getCurrentExpression();
onRun(
isScopedFilter
? combineInitialAndUserExpression(initialExpression ?? '', user)
: user,
);
}
return true;
},
},
{
key: 'Shift-Enter',
preventDefault: true,
// Prevent default behavior of Shift-Enter to add new line
run: (): boolean => true,
},
]),
),
]}
placeholder={placeholder}
basicSetup={{
lineNumbers: false,
}}
onFocus={(): void => {
setIsFocused(true);
}}
onBlur={handleBlur}
/>
{getCurrentExpression() && validation.isValid === false && !isFocused && (
<div
className={cx('query-status-container', {
<CodeMirror
theme={isDarkMode ? copilot : githubLight}
onChange={handleChange}
onUpdate={handleUpdate}
onCreateEditor={handleEditorCreate}
data-testid="query-where-clause-editor"
className={cx('query-where-clause-editor', {
isValid: validation.isValid === true,
hasErrors: validation.errors.length > 0,
hasInitialExpression: isScopedFilter && !showScopeChip,
})}
data-testid="query-validation-status"
>
<Popover
placement="bottomRight"
showArrow={false}
content={
<div className="query-status-content">
<div className="query-status-content-header">
<div className="query-validation">
<div className="query-validation-errors">
{validation.errors.map((error) => (
<div key={error.message} className="query-validation-error">
<div className="query-validation-error">
{error.line}:{error.column} - {error.message}
extensions={[
autocompletion({
override: [combinedSuggestions],
defaultKeymap: true,
closeOnBlur: true,
activateOnTyping: true,
maxRenderedOptions: 50,
addToOptions: [{ render: renderRecentDeleteButton, position: 100 }],
}),
javascript({ jsx: false, typescript: false }),
EditorView.lineWrapping,
stopEventsExtension,
Prec.highest(
keymap.of([
...completionKeymap,
{
key: 'Escape',
run: closeCompletion,
},
{
key: 'Enter',
preventDefault: true,
// Prevent default behavior of Enter to add new line
// and instead run a custom action
run: (): boolean => true,
},
{
key: 'Mod-Enter',
preventDefault: true,
// Prevent default behavior of Mod-Enter to add new line
// and instead run a custom action
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => {
handleRun();
return true;
},
},
{
key: 'Shift-Enter',
preventDefault: true,
// Prevent default behavior of Shift-Enter to add new line
run: (): boolean => true,
},
]),
),
]}
placeholder={placeholder}
basicSetup={{
lineNumbers: false,
}}
onFocus={(): void => {
setIsFocused(true);
}}
onBlur={handleBlur}
/>
{getCurrentExpression() && validation.isValid === false && !isFocused && (
<div
className={cx('query-status-container', {
hasErrors: validation.errors.length > 0,
})}
data-testid="query-validation-status"
>
<Popover
placement="bottomRight"
showArrow={false}
content={
<div className="query-status-content">
<div className="query-status-content-header">
<div className="query-validation">
<div className="query-validation-errors">
{validation.errors.map((error) => (
<div key={error.message} className="query-validation-error">
<div className="query-validation-error">
{error.line}:{error.column} - {error.message}
</div>
</div>
</div>
))}
))}
</div>
</div>
</div>
</div>
</div>
}
overlayClassName="query-status-popover"
>
{validation.isValid ? (
<Button
type="text"
icon={<CircleCheck size="md" />}
className="periscope-btn ghost"
/>
) : (
<Button
type="text"
icon={<TriangleAlert size={14} color={Color.BG_CHERRY_500} />}
className="periscope-btn ghost"
/>
)}
</Popover>
</div>
}
overlayClassName="query-status-popover"
>
{validation.isValid ? (
<Button
type="text"
icon={<CircleCheck size="md" />}
className="periscope-btn ghost"
/>
) : (
<Button
type="text"
icon={<TriangleAlert size={14} color={Color.BG_CHERRY_500} />}
className="periscope-btn ghost"
/>
)}
</Popover>
</div>
)}
</div>
{showRunButton && (
<SignozButton
variant="solid"
color="secondary"
size="md"
prefix={<Play size={14} />}
loading={isLoadingQueries}
onClick={handleRun}
testId="query-search-run-button"
>
Run
</SignozButton>
)}
</div>
@@ -1813,12 +1855,15 @@ function QuerySearch({
QuerySearch.defaultProps = {
onRun: undefined,
showRunButton: false,
isLoadingQueries: false,
signalSource: '',
hardcodedAttributeKeys: undefined,
placeholder:
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
showFilterSuggestionsWithoutMetric: false,
initialExpression: undefined,
showScopeChip: false,
};
export default QuerySearch;

View File

@@ -0,0 +1,57 @@
.scopeChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
flex: 0 1 auto;
min-width: 0;
max-width: 420px;
padding: var(--spacing-1) var(--spacing-3);
border-radius: 2px;
background: var(--l3-background);
color: var(--l2-foreground);
font-size: 12px;
line-height: 20px;
cursor: default;
}
.scopeIcon {
flex: none;
}
.scopeMore {
flex: none;
color: var(--l3-foreground);
}
// The editor beside it is CodeMirror on the copilot theme in dark and
// githubLight in light; the chip borrows those token colours so one filter
// does not read as two
.scopeKey {
color: #939da5;
}
.scopeOperator {
color: #939da5;
}
.scopeValue {
color: #5bec95;
}
:global(.lightMode) {
.scopeKey,
.scopeOperator {
color: #005cc5;
}
.scopeValue {
color: #032f62;
}
}
.scopeExpression {
font-family: var(--font-mono, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,41 @@
import { useMemo } from 'react';
import { Lock } from '@signozhq/icons';
import { Tooltip } from '@signozhq/ui/tooltip';
import { extractQueryPairs } from 'utils/queryContextUtils';
import styles from './ScopeChip.module.scss';
interface ScopeChipProps {
expression: string;
}
/**
* The locked half of the filter. It reads like the editor beside it: the last
* clause carries the relation, the ones scoping it are counted instead.
*/
export function ScopeChip({ expression }: ScopeChipProps): JSX.Element {
const clauses = useMemo(() => extractQueryPairs(expression), [expression]);
const relation = clauses[clauses.length - 1];
return (
<Tooltip title={expression} side="top">
<span className={styles.scopeChip} data-testid="query-search-scope-chip">
<Lock size={12} className={styles.scopeIcon} />
<span className={styles.scopeExpression}>
{relation ? (
<>
<span className={styles.scopeKey}>{relation.key}</span>
<span className={styles.scopeOperator}>{` ${relation.operator} `}</span>
<span className={styles.scopeValue}>{relation.value}</span>
</>
) : (
expression
)}
</span>
{clauses.length > 1 && (
<span className={styles.scopeMore}>{`+${clauses.length - 1}`}</span>
)}
</span>
</Tooltip>
);
}

View File

@@ -224,6 +224,134 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
await waitFor(() => expect(onRun).toHaveBeenCalled(), { timeout: 2000 });
});
it('does not render the run button by default', () => {
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
onRun={jest.fn()}
/>,
);
expect(
screen.queryByTestId('query-search-run-button'),
).not.toBeInTheDocument();
});
it('calls onRun with the editor text when the run button is clicked', async () => {
const onRun = jest.fn() as jest.MockedFunction<(q: string) => void>;
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={{
...initialQueriesMap.logs.builder.queryData[0],
filter: { expression: SAMPLE_STATUS_QUERY },
}}
dataSource={DataSource.LOGS}
onRun={onRun}
showRunButton
/>,
);
await waitFor(() =>
expect(document.querySelector(CM_EDITOR_SELECTOR)?.textContent).toBe(
SAMPLE_STATUS_QUERY,
),
);
await userEvent.click(screen.getByTestId('query-search-run-button'));
expect(onRun).toHaveBeenCalledWith(SAMPLE_STATUS_QUERY);
});
it('shows the locked scope as a chip in place of the scope icon', () => {
const scope = "kind_string = 'Client' AND http_host = 'api.example.com'";
const { container } = render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.traces.builder.queryData[0]}
dataSource={DataSource.TRACES}
initialExpression={scope}
showScopeChip
/>,
);
const chip = screen.getByTestId('query-search-scope-chip');
expect(chip).toHaveTextContent("http_host = 'api.example.com'");
expect(chip).toHaveTextContent('+1');
expect(chip).not.toHaveTextContent('kind_string');
expect(
container.querySelector('.query-search-initial-scope-label'),
).not.toBeInTheDocument();
});
it('keeps the scope icon unless the chip is asked for', () => {
const { container } = render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.traces.builder.queryData[0]}
dataSource={DataSource.TRACES}
initialExpression="kind_string = 'Client'"
/>,
);
expect(
screen.queryByTestId('query-search-scope-chip'),
).not.toBeInTheDocument();
expect(
container.querySelector('.query-search-initial-scope-label'),
).toBeInTheDocument();
});
it('shows the run button busy while the queries load', () => {
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
onRun={jest.fn()}
showRunButton
isLoadingQueries
/>,
);
expect(screen.getByTestId('query-search-run-button')).toHaveAttribute(
'aria-busy',
'true',
);
});
it('runs the locked scope combined with the editor text', async () => {
const onRun = jest.fn() as jest.MockedFunction<(q: string) => void>;
const scope = "kind_string = 'Client'";
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={{
...initialQueriesMap.traces.builder.queryData[0],
filter: { expression: SAMPLE_STATUS_QUERY },
}}
dataSource={DataSource.TRACES}
onRun={onRun}
initialExpression={scope}
showRunButton
/>,
);
await waitFor(() =>
expect(document.querySelector(CM_EDITOR_SELECTOR)?.textContent).toBe(
SAMPLE_STATUS_QUERY,
),
);
await userEvent.click(screen.getByTestId('query-search-run-button'));
expect(onRun).toHaveBeenCalledWith(`${scope} AND (${SAMPLE_STATUS_QUERY})`);
});
it('initializes CodeMirror with expression from queryData.filter.expression on mount', async () => {
const testExpression =
"http.status_code >= 500 AND service.name = 'frontend'";

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

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

@@ -53,7 +53,6 @@ export enum QueryParams {
variables = 'variables',
version = 'version',
source = 'source',
showClassicCreateAlertsPage = 'showClassicCreateAlertsPage',
isTestAlert = 'isTestAlert',
yAxisUnit = 'yAxisUnit',
ruleName = 'ruleName',

View File

@@ -26,7 +26,6 @@ export const REACT_QUERY_KEY = {
REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE',
CLONE_ALERT_RULE: 'CLONE_ALERT_RULE',
GET_HOST_LIST: 'GET_HOST_LIST',
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
@@ -70,8 +69,6 @@ export const REACT_QUERY_KEY = {
GET_NESTED_ENDPOINTS_LIST: 'GET_NESTED_ENDPOINTS_LIST',
GET_ENDPOINT_METRICS_DATA: 'GET_ENDPOINT_METRICS_DATA',
GET_ENDPOINT_STATUS_CODE_DATA: 'GET_ENDPOINT_STATUS_CODE_DATA',
GET_ENDPOINT_RATE_OVER_TIME_DATA: 'GET_ENDPOINT_RATE_OVER_TIME_DATA',
GET_ENDPOINT_LATENCY_OVER_TIME_DATA: 'GET_ENDPOINT_LATENCY_OVER_TIME_DATA',
GET_ENDPOINT_DROPDOWN_DATA: 'GET_ENDPOINT_DROPDOWN_DATA',
GET_ENDPOINT_DEPENDENT_SERVICES_DATA: 'GET_ENDPOINT_DEPENDENT_SERVICES_DATA',
GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA:

View File

@@ -21,14 +21,15 @@ import {
useListDashboardsForUserV2,
} from 'api/generated/services/dashboard';
import {
getListRulesQueryKey,
useListRules,
getListRulesV3QueryKey,
useListRulesV3,
} from 'api/generated/services/rules';
import type {
DashboardtypesListedDashboardForUserV2DTO,
ListDashboardsForUserV2200,
ListDashboardsForUserV2Params,
ListRules200,
ListRulesV3200,
ListRulesV3Params,
} from 'api/generated/services/sigNoz.schemas';
import logEvent from 'api/common/logEvent';
import { useQueryService } from 'hooks/useQueryService';
@@ -179,6 +180,9 @@ const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
*/
const DASHBOARD_LIST_PARAMS: ListDashboardsForUserV2Params = { limit: 1000 };
/** Same as dashboards. 200 is the most the rules list returns per call. */
const ALERT_RULE_LIST_PARAMS: ListRulesV3Params = { limit: 200 };
function dashboardTitle(
dashboard: DashboardtypesListedDashboardForUserV2DTO,
): string {
@@ -755,7 +759,7 @@ export default function ChatInput({
data: alertsResponse,
isLoading: isAlertsLoading,
isError: isAlertsError,
} = useListRules({
} = useListRulesV3(ALERT_RULE_LIST_PARAMS, {
query: {
enabled: isContextPickerOpen && activeContextCategory === 'Alerts',
staleTime: Infinity,
@@ -804,10 +808,10 @@ export default function ChatInput({
}
}
if (ctx.type === 'alert' && ctx.resourceId) {
const cached = queryClient.getQueryData<ListRules200>(
getListRulesQueryKey(),
const cached = queryClient.getQueryData<ListRulesV3200>(
getListRulesV3QueryKey(ALERT_RULE_LIST_PARAMS),
);
const rule = cached?.data?.find((r) => r.id === ctx.resourceId);
const rule = cached?.data.rules.find((r) => r.id === ctx.resourceId);
if (rule?.alert) {
return rule.alert;
}
@@ -835,8 +839,8 @@ export default function ChatInput({
value: dashboardTitle(dashboard),
})) ?? [],
Alerts:
alertsResponse?.data
?.filter((alertRule) => Boolean(alertRule.alert))
alertsResponse?.data.rules
.filter((alertRule) => Boolean(alertRule.alert))
.map((alertRule) => ({
id: alertRule.id,
value: alertRule.alert,
@@ -1059,6 +1063,7 @@ export default function ChatInput({
color="secondary"
size="sm"
id={`ai-context-tab-${category}`}
testId={`ai-context-tab-${category}`}
// Single stable panel id shared by every tab: only the
// active category's tabpanel is rendered, so per-category
// `aria-controls` ids would point at nonexistent nodes

View File

@@ -0,0 +1,70 @@
import {
alertRulesFixture,
listRulesV3Response,
} from 'mocks-server/__mockdata__/alert_rules';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent } from 'tests/test-utils';
jest.mock('api/generated/services/dashboard', () => ({
useListDashboardsForUserV2: (): unknown => ({
data: undefined,
isLoading: false,
isError: false,
}),
getListDashboardsForUserV2QueryKey: (): string[] => ['dashboards'],
}));
jest.mock('hooks/useQueryService', () => ({
useQueryService: (): unknown => ({
data: [],
isLoading: false,
isFetching: false,
isError: false,
}),
}));
jest.mock('../../../hooks/useSpeechRecognition', () => ({
useSpeechRecognition: (): unknown => ({
isListening: false,
isSupported: false,
permission: 'prompt',
start: jest.fn(),
discard: jest.fn(),
}),
}));
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
useAIAssistantAnalyticsContext: (): unknown => ({
threadId: undefined,
page: '/',
mode: 'sidepane',
}),
}));
// eslint-disable-next-line import/first
import ChatInput from '../ChatInput';
describe('ChatInput — alerts context picker', () => {
it('lists the rules from one v3 rules request', async () => {
const [first, second] = alertRulesFixture;
const requests: string[] = [];
server.use(
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
requests.push(req.url.search);
return res(
ctx.status(200),
ctx.json(listRulesV3Response([first, second], req.url.searchParams)),
);
}),
);
render(<ChatInput onSend={jest.fn()} />);
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
await userEvent.click(await screen.findByTestId('ai-context-tab-Alerts'));
await expect(screen.findByText(first.alert)).resolves.toBeInTheDocument();
expect(screen.getByText(second.alert)).toBeInTheDocument();
expect(requests).toStrictEqual(['?limit=200']);
});
});

View File

@@ -12,8 +12,12 @@ jest.mock('api/generated/services/dashboard', () => ({
}));
jest.mock('api/generated/services/rules', () => ({
useListRules: (): unknown => ({ data: [], isLoading: false, isError: false }),
getListRulesQueryKey: (): string[] => ['rules'],
useListRulesV3: (): unknown => ({
data: undefined,
isLoading: false,
isError: false,
}),
getListRulesV3QueryKey: (): string[] => ['rules'],
}));
jest.mock('hooks/useQueryService', () => ({

View File

@@ -1,20 +1,24 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Select } from 'antd';
import { useCallback, useEffect, useMemo } from 'react';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap } from 'constants/queryBuilder';
import {
getAllEndpointsWidgetData,
getGroupByFiltersFromGroupByValues,
getGroupByExpressionFromRowData,
} from 'container/ApiMonitoring/utils';
import GridCard from 'container/WidgetCard/Card';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import { isEqual } from 'lodash-es';
import { parseAsString, useQueryState } from 'nuqs';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { ENDPOINT_STATS_EXPRESSION_KEY } from '../../../filterParamKeys';
import { useApiMonitoringParams } from '../../../queryParams';
import { SPAN_ATTRIBUTES, VIEWS } from './constants';
import { useFilterExpression } from './hooks/useFilterExpression';
import { useWidgetQueries } from './hooks/useWidgetQueries';
function AllEndPoints({
domainName,
@@ -23,8 +27,6 @@ function AllEndPoints({
groupBy,
setGroupBy,
timeRange,
initialFilters,
setInitialFiltersEndPointStats,
}: {
domainName: string;
setSelectedEndPointName: (name: string) => void;
@@ -35,113 +37,50 @@ function AllEndPoints({
startTime: number;
endTime: number;
};
initialFilters: IBuilderQuery['filters'];
setInitialFiltersEndPointStats: (filters: IBuilderQuery['filters']) => void;
}): JSX.Element {
const [params, setParams] = useApiMonitoringParams();
const [groupBySearchValue, setGroupBySearchValue] = useState<string>('');
const [allAvailableGroupByOptions, setAllAvailableGroupByOptions] = useState<{
[key: string]: any;
}>({});
const { data: groupByFiltersData, isLoading: isLoadingGroupByFilters } =
useGetAggregateKeys({
dataSource: DataSource.TRACES,
aggregateAttribute: '',
aggregateOperator: 'noop',
searchText: groupBySearchValue,
tagType: '',
});
const [groupByOptions, setGroupByOptions] = useState<
{ value: string; label: string }[]
>([]);
const [, setEndPointStatsExpression] = useQueryState(
ENDPOINT_STATS_EXPRESSION_KEY,
parseAsString,
);
// --- FILTERS STATE SYNC ---
const [filters, setFilters] = useState<IBuilderQuery['filters']>(() => {
if (params.allEndpointsLocalFilters) {
return params.allEndpointsLocalFilters;
}
// Initialize filters based on the initial endPointName prop
const initialItems = [...(initialFilters?.items || [])];
return { op: 'AND', items: initialItems };
const {
expression: filterExpression,
initialExpression: scopeExpression,
queryData,
onDraftChange,
onRun,
onBlur: handleFilterBlur,
} = useFilterExpression({
groupBy,
});
// Sync params to local filters state on param change
useEffect(() => {
if (
params.allEndpointsLocalFilters &&
!isEqual(params.allEndpointsLocalFilters, filters)
) {
setFilters(params.allEndpointsLocalFilters);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.allEndpointsLocalFilters]);
// Handler for changes from the QueryBuilderSearchV2 component
const handleFilterChange = useCallback(
(newFilters: IBuilderQuery['filters']): void => {
setFilters(newFilters);
setParams({ allEndpointsLocalFilters: newFilters });
},
[setParams],
);
const currentQuery = initialQueriesMap[DataSource.TRACES];
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.TRACES,
filters, // Use the local filters state
},
],
},
}),
[filters, currentQuery],
);
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
const allEndpointsWidgetData = useMemo(
() => getAllEndpointsWidgetData(groupBy, domainName, filters),
[groupBy, domainName, filters],
() => getAllEndpointsWidgetData(groupBy, domainName, filterExpression),
[groupBy, domainName, filterExpression],
);
const tableWidgets = useMemo(
() => [allEndpointsWidgetData],
[allEndpointsWidgetData],
);
const { isFetching: isFetchingTable, refetch: refetchTable } =
useWidgetQueries(tableWidgets);
const handleRun = useCallback(
(value: string): void => onRun(value, refetchTable),
[onRun, refetchTable],
);
// --- GROUP BY STATE SYNC (existing) ---
const handleGroupByChange = useCallback(
(value: IBuilderQuery['groupBy']) => {
const newGroupBy = [];
for (let index = 0; index < value.length; index++) {
const element = value[index] as unknown as string;
// Check if the key exists in our cached options first
if (allAvailableGroupByOptions[element]) {
newGroupBy.push(allAvailableGroupByOptions[element]);
} else {
// If not found in cache, check the current filtered results
const key = groupByFiltersData?.payload?.attributeKeys?.find(
(key) => key.key === element,
);
if (key) {
newGroupBy.push(key);
}
}
}
setGroupBy(newGroupBy);
setGroupBySearchValue('');
setGroupBy(value);
// Update params
setParams({ groupBy: newGroupBy.map((g) => g.key) });
setParams({ groupBy: value.map((g) => g.key) });
},
[groupByFiltersData, setGroupBy, allAvailableGroupByOptions, setParams],
[setGroupBy, setParams],
);
// Sync params to local groupBy state on param change
@@ -154,77 +93,31 @@ function AllEndPoints({
groupBy.map((g) => g.key),
)
) {
// Only update if different
const newGroupBy = params.groupBy
.map((key) => allAvailableGroupByOptions[key])
.filter(Boolean);
if (newGroupBy.length === params.groupBy.length) {
setGroupBy(newGroupBy);
}
}
}, [params.groupBy, allAvailableGroupByOptions, groupBy, setGroupBy]);
useEffect(() => {
if (groupByFiltersData?.payload) {
// Update dropdown options
setGroupByOptions(
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
value: filter.key,
label: filter.key,
})) || [],
setGroupBy(
params.groupBy.map((key) => chooseAutocompleteFromCustomValue([], key)),
);
// Cache all available options to preserve selected values using functional update
setAllAvailableGroupByOptions((prevOptions) => {
const newOptions = { ...prevOptions };
groupByFiltersData?.payload?.attributeKeys?.forEach((filter) => {
newOptions[filter.key] = filter;
});
return newOptions;
});
}
}, [groupByFiltersData]); // Only depends on groupByFiltersData now
// Cache existing selected options on component mount
useEffect(() => {
if (groupBy && groupBy.length > 0) {
setAllAvailableGroupByOptions((prevOptions) => {
const newOptions = { ...prevOptions };
groupBy.forEach((option) => {
newOptions[option.key] = option;
});
return newOptions;
});
}
}, [groupBy]);
}, [params.groupBy, groupBy, setGroupBy]);
const onRowClick = useCallback(
(props: any): void => {
setSelectedEndPointName(props[SPAN_ATTRIBUTES.HTTP_URL] as string);
setSelectedView(VIEWS.ENDPOINT_STATS);
const initialItems = [
...(filters?.items || []),
...(getGroupByFiltersFromGroupByValues(props, groupBy)?.items || []),
];
setInitialFiltersEndPointStats({
items: initialItems,
op: 'AND',
});
const statsExpression = combineInitialAndUserExpression(
getGroupByExpressionFromRowData(props),
filterExpression,
);
void setEndPointStatsExpression(statsExpression || null);
setParams({
selectedEndPointName: props[SPAN_ATTRIBUTES.HTTP_URL] as string,
selectedView: VIEWS.ENDPOINT_STATS,
endPointDetailsLocalFilters: {
items: initialItems,
op: 'AND',
},
});
},
[
filters,
setInitialFiltersEndPointStats,
filterExpression,
setEndPointStatsExpression,
setSelectedEndPointName,
setSelectedView,
groupBy,
setParams,
],
);
@@ -232,28 +125,28 @@ function AllEndPoints({
return (
<div className="all-endpoints-container">
<div className="all-endpoints-header">
<div className="filter-container">
<QueryBuilderSearchV2
query={query}
onChange={handleFilterChange}
<div className="filter-container" onBlur={handleFilterBlur}>
<QuerySearch
queryData={queryData}
onChange={onDraftChange}
onRun={handleRun}
showRunButton
isLoadingQueries={isFetchingTable}
initialExpression={scopeExpression}
showScopeChip
dataSource={DataSource.TRACES}
placeholder="Search for filters..."
/>
</div>
</div>
<div className="group-by-container">
<div className="group-by-label"> Group by </div>
<Select
className="group-by-select"
loading={isLoadingGroupByFilters}
mode="multiple"
value={groupBy}
allowClear
maxTagCount="responsive"
placeholder="Search for attribute"
options={groupByOptions}
<GroupByFilter
query={queryData}
onChange={handleGroupByChange}
onSearch={(value: string): void => setGroupBySearchValue(value)}
/>{' '}
disabled={false}
placeholder="Search for attribute"
/>
</div>
<div className="endpoints-table-container">
<GridCard

View File

@@ -195,16 +195,6 @@
line-height: 18px;
letter-spacing: -0.07px;
}
.group-by-select {
width: 100%;
.ant-select-selector {
font-size: 14px;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
}
// Add border-bottom to table cells when pagination is not present
.ant-spin-container:not(:has(.ant-pagination)) .ant-table-cell {
@@ -365,16 +355,9 @@
.top-errors-dropdown-container {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 10px;
align-items: center;
.endpoint-details-filters-container-dropdown {
width: 100%;
}
.endpoint-details-filters-container-search {
flex: 1;
}
}
.endpoint-details-container {
@@ -416,29 +399,8 @@
.endpoint-details-filters-container {
display: flex;
flex-direction: row;
align-items: center;
border: 1px solid var(--l1-border);
height: 36px;
box-sizing: content-box;
.ant-select-selector {
border: none !important;
}
.endpoint-details-filters-container-dropdown {
width: 120px;
border-right: 1px solid var(--l1-border);
height: 36px;
display: flex;
align-items: center;
.ant-select-single {
height: 32px;
}
}
.endpoint-details-filters-container-search {
flex: 1;
}
flex-direction: column;
gap: 8px;
}
.ant-select-item,

View File

@@ -6,6 +6,7 @@ import { Button, Drawer } from 'antd';
import { ToggleGroup } from '@signozhq/ui/toggle-group';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import { QuerySearchV2Provider } from 'components/QueryBuilderV2';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
@@ -17,7 +18,12 @@ import { AppState } from 'store/reducers';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
import {
ALL_ENDPOINTS_EXPRESSION_KEY,
ENDPOINT_STATS_EXPRESSION_KEY,
} from '../../../filterParamKeys';
import { useApiMonitoringParams } from '../../../queryParams';
import { getDomainScopeExpression } from '../../../utils';
import AllEndPoints from './AllEndPoints';
import DomainMetrics from './components/DomainMetrics';
import { VIEW_TYPES, VIEWS } from './constants';
@@ -34,14 +40,14 @@ function DomainDetails({
selectedDomainIndex,
setSelectedDomainIndex,
domainListLength,
domainListFilters,
domainListFilterExpression,
}: {
domainData: any;
handleClose: () => void;
selectedDomainIndex: number;
setSelectedDomainIndex: (index: number) => void;
domainListLength: number;
domainListFilters: IBuilderQuery['filters'];
domainListFilterExpression: string;
}): JSX.Element {
const [params, setParams] = useApiMonitoringParams();
const [selectedView, setSelectedView] = useState<VIEWS>(
@@ -53,8 +59,16 @@ function DomainDetails({
const [endPointsGroupBy, setEndPointsGroupBy] = useState<
IBuilderQuery['groupBy']
>([]);
const [initialFiltersEndPointStats, setInitialFiltersEndPointStats] =
useState<IBuilderQuery['filters']>(domainListFilters);
const domainName = domainData?.domainName ?? '';
const allEndpointsScope = useMemo(
() => getDomainScopeExpression(domainName),
[domainName],
);
const endPointStatsScope = useMemo(
() => getDomainScopeExpression(domainName, selectedEndPointName),
[domainName, selectedEndPointName],
);
const handleTabChange = (value: string): void => {
setSelectedView(value as VIEWS);
@@ -222,7 +236,7 @@ function DomainDetails({
<>
<DomainMetrics
domainName={domainData.domainName}
domainListFilters={domainListFilters}
domainListFilterExpression={domainListFilterExpression}
timeRange={modalTimeRange}
/>
<div className="views-tabs-container">
@@ -253,34 +267,43 @@ function DomainDetails({
/>
</div>
{selectedView === VIEW_TYPES.ALL_ENDPOINTS && (
<AllEndPoints
domainName={domainData.domainName}
setSelectedEndPointName={handleEndPointChange}
setSelectedView={setSelectedView}
groupBy={endPointsGroupBy}
setGroupBy={setEndPointsGroupBy}
timeRange={modalTimeRange}
initialFilters={domainListFilters}
setInitialFiltersEndPointStats={setInitialFiltersEndPointStats}
/>
<QuerySearchV2Provider
queryParamKey={ALL_ENDPOINTS_EXPRESSION_KEY}
initialExpression={allEndpointsScope}
persistOnUnmount
>
<AllEndPoints
domainName={domainData.domainName}
setSelectedEndPointName={handleEndPointChange}
setSelectedView={setSelectedView}
groupBy={endPointsGroupBy}
setGroupBy={setEndPointsGroupBy}
timeRange={modalTimeRange}
/>
</QuerySearchV2Provider>
)}
{selectedView === VIEW_TYPES.ENDPOINT_STATS && (
<EndPointDetails
domainName={domainData.domainName}
endPointName={selectedEndPointName}
setSelectedEndPointName={handleEndPointChange}
initialFilters={initialFiltersEndPointStats}
timeRange={modalTimeRange}
handleTimeChange={handleTimeChange}
/>
<QuerySearchV2Provider
queryParamKey={ENDPOINT_STATS_EXPRESSION_KEY}
initialExpression={endPointStatsScope}
persistOnUnmount
>
<EndPointDetails
domainName={domainData.domainName}
endPointName={selectedEndPointName}
setSelectedEndPointName={handleEndPointChange}
timeRange={modalTimeRange}
handleTimeChange={handleTimeChange}
/>
</QuerySearchV2Provider>
)}
{selectedView === VIEW_TYPES.TOP_ERRORS && (
<TopErrors
domainName={domainData.domainName}
timeRange={modalTimeRange}
initialFilters={domainListFilters}
initialFilterExpression={domainListFilterExpression}
/>
)}
</>

View File

@@ -1,17 +1,17 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo } from 'react';
import { useQueries } from 'react-query';
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap } from 'constants/queryBuilder';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
import {
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
extractPortAndEndpoint,
getEndPointDetailsQueryPayload,
getHttpUrlExpression,
getLatencyOverTimeWidgetData,
getRateOverTimeWidgetData,
} from 'container/ApiMonitoring/utils';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import {
CustomTimeType,
Time,
@@ -19,36 +19,28 @@ import {
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { extractQueryPairs } from 'utils/queryContextUtils';
import DependentServices from './components/DependentServices';
import EndPointMetrics from './components/EndPointMetrics';
import EndPointsDropDown from './components/EndPointsDropDown';
import EndPointPicker from './components/EndPointPicker';
import MetricOverTimeGraph from './components/MetricOverTimeGraph';
import StatusCodeBarCharts from './components/StatusCodeBarCharts';
import StatusCodeTable from './components/StatusCodeTable';
import { SPAN_ATTRIBUTES } from './constants';
const httpUrlKey = {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.HTTP_URL,
type: 'tag',
};
import { useFilterExpression } from './hooks/useFilterExpression';
import { useWidgetQueries } from './hooks/useWidgetQueries';
function EndPointDetails({
domainName,
endPointName,
setSelectedEndPointName,
initialFilters,
timeRange,
handleTimeChange,
}: {
domainName: string;
endPointName: string;
setSelectedEndPointName: (value: string) => void;
initialFilters: IBuilderQuery['filters'];
timeRange: {
startTime: number;
endTime: number;
@@ -59,159 +51,66 @@ function EndPointDetails({
) => void;
}): JSX.Element {
const { startTime: minTime, endTime: maxTime } = timeRange;
const [params, setParams] = useApiMonitoringParams();
const currentQuery = initialQueriesMap[DataSource.TRACES];
// Search filters; the endpoint filter is combined in at query time
const {
expression: filterExpression,
initialExpression: scopeExpression,
queryData,
onDraftChange,
onRun,
onBlur: handleFilterBlur,
} = useFilterExpression();
// Local state for filters, combining endpoint filter and search filters
const [filters, setFilters] = useState<IBuilderQuery['filters']>(() => {
// Initialize filters based on the initial endPointName prop
const initialItems = params.endPointDetailsLocalFilters
? [...(params.endPointDetailsLocalFilters?.items || [])]
: [...(initialFilters?.items || [])];
if (endPointName) {
initialItems.push({
id: '92b8a1c1',
key: httpUrlKey,
op: '=',
value: endPointName,
});
}
return { op: 'AND', items: initialItems };
});
// Effect to synchronize local filters when the endPointName prop changes (e.g., from dropdown)
useEffect(() => {
setFilters((currentFilters) => {
const existingHttpUrlFilter = currentFilters?.items?.find(
(item) => item.key?.key === httpUrlKey.key,
);
const existingHttpUrlValue = (existingHttpUrlFilter?.value as string) || '';
// Only update filters if the prop value is different from what's already in filters
if (endPointName === existingHttpUrlValue) {
return currentFilters; // No change needed, prevents loop
}
// Rebuild filters: Keep non-http_url filters and add/update http_url filter based on prop
const otherFilters = currentFilters?.items?.filter(
(item) => item.key?.key !== httpUrlKey.key,
);
const newItems = [...(otherFilters || [])];
if (endPointName) {
newItems.push({
id: '92b8a1c1',
key: httpUrlKey,
op: '=',
value: endPointName,
});
}
return { op: 'AND', items: newItems };
});
}, [endPointName]);
// Separate effect to update params when filters change
useEffect(() => {
const filtersWithoutHttpUrl = {
op: 'AND',
items:
filters?.items?.filter((item) => item.key?.key !== httpUrlKey.key) || [],
};
setParams({ endPointDetailsLocalFilters: filtersWithoutHttpUrl });
}, [filters, setParams]);
// Handler for changes from the QueryBuilderSearchV2 component
const handleFilterChange = useCallback(
(newFilters: IBuilderQuery['filters']): void => {
// 1. Update local filters state immediately
setFilters(newFilters);
// Filter out http_url filter before saving to params
const filteredNewFilters = {
op: 'AND',
items:
newFilters?.items?.filter((item) => item.key?.key !== httpUrlKey.key) ||
[],
};
setParams({ endPointDetailsLocalFilters: filteredNewFilters });
// 2. Derive the endpoint name from the *new* filters state
const httpUrlFilter = newFilters?.items?.find(
(item) => item.key?.key === httpUrlKey.key,
);
const derivedEndPointName = (httpUrlFilter?.value as string) || '';
// 3. If the derived endpoint name is different from the current prop,
// it means the search change modified the effective endpoint.
// Notify the parent component.
if (derivedEndPointName !== endPointName) {
setSelectedEndPointName(derivedEndPointName);
}
},
[endPointName, setSelectedEndPointName, setParams], // Dependencies for the callback
const httpUrlExpression = useMemo(
() => getHttpUrlExpression(endPointName),
[endPointName],
);
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.TRACES,
filters, // Use the local filters state
},
],
},
}),
[filters, currentQuery],
const scopedExpression = useMemo(
() => combineInitialAndUserExpression(httpUrlExpression, filterExpression),
[httpUrlExpression, filterExpression],
);
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
const isServicesFilterApplied = useMemo(
() => filters?.items?.some((item) => item.key?.key === 'service.name'),
[filters],
() =>
extractQueryPairs(filterExpression).some(
(pair) => pair.key === 'service.name',
),
[filterExpression],
);
const endPointDetailsQueryPayload = useMemo(
() => getEndPointDetailsQueryPayload(domainName, minTime, maxTime, filters),
[domainName, filters, minTime, maxTime],
() =>
getEndPointDetailsQueryPayload(
domainName,
minTime,
maxTime,
scopedExpression,
),
[domainName, scopedExpression, minTime, maxTime],
);
const V5_QUERIES = [
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_METRICS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA,
] as const;
const endPointDetailsDataQueries = useQueries(
endPointDetailsQueryPayload.map((payload, index) => {
const queryKey = END_POINT_DETAILS_QUERY_KEYS_ARRAY[index];
const version = (V5_QUERIES as readonly string[]).includes(queryKey)
? ENTITY_VERSION_V5
: ENTITY_VERSION_V4;
return {
queryKey: [
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index],
payload,
...(filters?.items?.length ? filters.items : []), // Include filters.items in queryKey for better caching
version,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, version),
enabled: !!payload,
};
}),
endPointDetailsQueryPayload.map((payload, index) => ({
queryKey: [
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index],
payload,
ENTITY_VERSION_V5,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V5),
enabled: !!payload,
// Keeps the table's rows, and so its height, while a new filter loads
keepPreviousData:
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index] ===
REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
})),
);
const [
endPointMetricsDataQuery,
endPointStatusCodeDataQuery,
endPointDropDownDataQuery,
endPointDependentServicesDataQuery,
endPointStatusCodeBarChartsDataQuery,
endPointStatusCodeLatencyBarChartsDataQuery,
@@ -222,7 +121,6 @@ function EndPointDetails({
endPointDetailsDataQueries[2],
endPointDetailsDataQueries[3],
endPointDetailsDataQueries[4],
endPointDetailsDataQueries[5],
],
[endPointDetailsDataQueries],
);
@@ -234,10 +132,32 @@ function EndPointDetails({
const [rateOverTimeWidget, latencyOverTimeWidget] = useMemo(
() => [
getRateOverTimeWidgetData(domainName, endPointName, filters),
getLatencyOverTimeWidgetData(domainName, endPointName, filters),
getRateOverTimeWidgetData(domainName, endPointName, scopedExpression),
getLatencyOverTimeWidgetData(domainName, endPointName, scopedExpression),
],
[domainName, endPointName, filters], // Use combinedFilters
[domainName, endPointName, scopedExpression],
);
const graphWidgets = useMemo(
() => [rateOverTimeWidget, latencyOverTimeWidget],
[rateOverTimeWidget, latencyOverTimeWidget],
);
const { isFetching: isFetchingGraphs, refetch: refetchGraphs } =
useWidgetQueries(graphWidgets);
const isFetchingQueries =
isFetchingGraphs ||
endPointDetailsDataQueries.some((query) => query.isFetching);
const refetchQueries = useCallback((): void => {
refetchGraphs();
endPointDetailsDataQueries.forEach((query) => {
void query.refetch();
});
}, [refetchGraphs, endPointDetailsDataQueries]);
const handleRun = useCallback(
(value: string): void => onRun(value, refetchQueries),
[onRun, refetchQueries],
);
// // [TODO] Fix this later
@@ -257,19 +177,23 @@ function EndPointDetails({
return (
<div className="endpoint-details-container">
<div className="endpoint-details-filters-container">
<div className="endpoint-details-filters-container-dropdown">
<EndPointsDropDown
selectedEndPointName={endPointName}
setSelectedEndPointName={setSelectedEndPointName}
endPointDropDownDataQuery={endPointDropDownDataQuery}
parentContainerDiv=".endpoint-details-filters-container"
dropdownStyle={{ width: 'calc(100% - 36px)' }}
/>
</div>
<div className="endpoint-details-filters-container-search">
<QueryBuilderSearchV2
query={query}
onChange={handleFilterChange}
<EndPointPicker
domainName={domainName}
timeRange={timeRange}
filterExpression={filterExpression}
value={endPointName}
onChange={setSelectedEndPointName}
/>
<div onBlur={handleFilterBlur}>
<QuerySearch
queryData={queryData}
onChange={onDraftChange}
onRun={handleRun}
showRunButton
isLoadingQueries={isFetchingQueries}
initialExpression={scopeExpression}
showScopeChip
dataSource={DataSource.TRACES}
placeholder="Search for filters..."
/>
</div>
@@ -299,7 +223,7 @@ function EndPointDetails({
endPointStatusCodeLatencyBarChartsDataQuery
}
domainName={domainName}
filters={filters}
filterExpression={scopedExpression}
timeRange={timeRange}
onDragSelect={onDragSelect}
/>

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { QueryFunctionContext, useQueries, useQuery } from 'react-query';
import { QueryFunctionContext, useQuery } from 'react-query';
import { Spin, Table, Tooltip } from 'antd';
import { Info, Loader } from '@signozhq/icons';
import { Switch } from '@signozhq/ui/switch';
@@ -7,41 +7,36 @@ import { Typography } from '@signozhq/ui/typography';
import { getQueryRangeV5 } from 'api/v5/queryRange/getQueryRange';
import { MetricRangePayloadV5, ScalarData } from 'api/v5/v5';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { withErrorBoundary } from 'components/ErrorBoundaryHOC';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
formatTopErrorsDataForTable,
getEndPointDetailsQueryPayload,
getHttpUrlExpression,
getTopErrorsColumnsConfig,
getTopErrorsCoRelationQueryFilters,
getTopErrorsQueryPayload,
} from 'container/ApiMonitoring/utils';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { SuccessResponse, SuccessResponseV2 } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { SuccessResponseV2 } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
import EndPointsDropDown from './components/EndPointsDropDown';
import EndPointPicker from './components/EndPointPicker';
import ErrorState from './components/ErrorState';
import { SPAN_ATTRIBUTES } from './constants';
function TopErrors({
domainName,
timeRange,
initialFilters,
initialFilterExpression,
}: {
domainName: string;
timeRange: {
startTime: number;
endTime: number;
};
initialFilters: IBuilderQuery['filters'];
initialFilterExpression: string;
}): JSX.Element {
const { startTime: minTime, endTime: maxTime } = timeRange;
@@ -55,27 +50,10 @@ function TopErrors({
domainName,
minTime,
maxTime,
{
items: endPointName
? [
// Remove any existing http_url filters from initialFilters to avoid duplicates
...(initialFilters?.items?.filter(
(item) => item.key?.key !== SPAN_ATTRIBUTES.HTTP_URL,
) || []),
{
id: '92b8a1c1',
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.HTTP_URL,
type: 'tag',
},
op: '=',
value: endPointName,
},
]
: [...(initialFilters?.items || [])],
op: 'AND',
},
combineInitialAndUserExpression(
getHttpUrlExpression(endPointName),
initialFilterExpression,
),
showStatusCodeErrors,
),
[
@@ -83,7 +61,7 @@ function TopErrors({
endPointName,
minTime,
maxTime,
initialFilters,
initialFilterExpression,
showStatusCodeErrors,
],
);
@@ -120,35 +98,6 @@ function TopErrors({
[topErrorsData],
);
const endPointDropDownQueryPayload = useMemo(
() => [
getEndPointDetailsQueryPayload(domainName, minTime, maxTime, {
items: [],
op: 'AND',
})[2],
],
[domainName, minTime, maxTime],
);
const endPointDropDownDataQueries = useQueries(
endPointDropDownQueryPayload.map((payload) => ({
queryKey: [
END_POINT_DETAILS_QUERY_KEYS_ARRAY[2],
payload,
ENTITY_VERSION_V5,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V5),
enabled: !!payload,
staleTime: 60 * 1000,
})),
);
const [endPointDropDownDataQuery] = useMemo(
() => [endPointDropDownDataQueries[0]],
[endPointDropDownDataQueries],
);
const navigateToExplorer = useNavigateToExplorer();
if (isError) {
@@ -162,14 +111,13 @@ function TopErrors({
return (
<div className="all-endpoints-container">
<div className="top-errors-dropdown-container">
<div className="endpoint-details-filters-container-dropdown">
<EndPointsDropDown
selectedEndPointName={endPointName}
setSelectedEndPointName={setSelectedEndPointName}
endPointDropDownDataQuery={endPointDropDownDataQuery}
parentContainerDiv=".endpoint-details-filters-container"
/>
</div>
<EndPointPicker
domainName={domainName}
timeRange={timeRange}
filterExpression=""
value={endPointName}
onChange={setSelectedEndPointName}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Switch
color="primary"

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { UseQueryResult } from 'react-query';
import { Skeleton, Table, TablePaginationConfig } from 'antd';
import { Table, TablePaginationConfig } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { QueryParams } from 'constants/query';
import {
@@ -54,10 +54,6 @@ function DependentServices({
[isExpanded, dependentServicesData.length],
);
if (isLoading || isRefetching) {
return <Skeleton />;
}
if (isError) {
return <ErrorState refetch={refetch} />;
}

View File

@@ -3,7 +3,6 @@ import { render, screen, waitFor } from '@testing-library/react';
import { TraceAggregation } from 'api/v5/v5';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { SPAN_ATTRIBUTES } from '../constants';
import DomainMetrics from './DomainMetrics';
@@ -34,10 +33,7 @@ describe('DomainMetrics - V5 Query Payload Tests', () => {
startTime: 1758259531000,
endTime: 1758261331000,
},
domainListFilters: {
items: [],
op: 'AND' as const,
} as IBuilderQuery['filters'],
domainListFilterExpression: '',
};
const mockSuccessResponse = {
@@ -174,35 +170,12 @@ describe('DomainMetrics - V5 Query Payload Tests', () => {
it('includes custom filters in filter expressions', async () => {
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'my-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND' as const,
};
const customFilterExpression =
"service.name = 'my-service' AND deployment.environment = 'production'";
renderComponent({
...mockProps,
domainListFilters: customFilters,
domainListFilterExpression: customFilterExpression,
});
await waitFor(() => {

View File

@@ -14,18 +14,17 @@ import {
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import ErrorState from './ErrorState';
function DomainMetrics({
domainName,
timeRange,
domainListFilters,
domainListFilterExpression,
}: {
domainName: string;
timeRange: { startTime: number; endTime: number };
domainListFilters: IBuilderQuery['filters'];
domainListFilterExpression: string;
}): JSX.Element {
const { startTime: minTime, endTime: maxTime } = timeRange;
@@ -35,9 +34,9 @@ function DomainMetrics({
domainName,
minTime,
maxTime,
domainListFilters,
domainListFilterExpression,
),
[domainName, minTime, maxTime, domainListFilters],
[domainName, minTime, maxTime, domainListFilterExpression],
);
// Since only one query here

View File

@@ -114,10 +114,7 @@ describe('EndPointMetrics - V5 Query Payload Tests', () => {
const domainName = 'api.example.com';
const startTime = 1758259531000;
const endTime = 1758261331000;
const filters = {
items: [],
op: 'AND' as const,
};
const filters = '';
// Get the actual payload that would be generated
const payloads = getEndPointDetailsQueryPayload(
@@ -237,31 +234,8 @@ describe('EndPointMetrics - V5 Query Payload Tests', () => {
it('includes custom domainListFilters in all query expressions', async () => {
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
const customFilters = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'payment-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'staging',
},
],
op: 'AND' as const,
};
const customFilters =
"service.name = 'payment-service' AND deployment.environment = 'staging'";
const payloads = getEndPointDetailsQueryPayload(
'api.internal.com',

View File

@@ -0,0 +1,18 @@
.picker {
width: 460px;
max-width: 100%;
// Popover fits its longest option by default; match the trigger so long URLs truncate.
[data-slot='combobox-content'] {
--popover-width: var(--radix-popover-trigger-width);
}
}
.option {
display: block;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,92 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
import { ComboboxSimple, ComboboxSimpleItem } from '@signozhq/ui/combobox';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
getEndPointDropDownQueryPayload,
getFormattedEndPointDropDownData,
} from 'container/ApiMonitoring/utils';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import styles from './EndPointPicker.module.scss';
const VIRTUALIZE_AFTER = 100;
const ALL_ENDPOINTS = '__all_endpoints__';
interface EndPointPickerProps {
domainName: string;
timeRange: {
startTime: number;
endTime: number;
};
filterExpression: string;
value: string;
onChange: (endPointName: string) => void;
}
function EndPointPicker({
domainName,
timeRange,
filterExpression,
value,
onChange,
}: EndPointPickerProps): JSX.Element {
const payload = useMemo(
() =>
getEndPointDropDownQueryPayload(
domainName,
timeRange.startTime,
timeRange.endTime,
filterExpression,
),
[domainName, timeRange.startTime, timeRange.endTime, filterExpression],
);
const { data, isLoading, isFetching } = useQuery({
queryKey: [
REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA,
payload,
ENTITY_VERSION_V5,
],
queryFn: () => GetMetricQueryRange(payload, ENTITY_VERSION_V5),
staleTime: 60 * 1000,
});
const items = useMemo(
(): ComboboxSimpleItem[] => [
{ value: ALL_ENDPOINTS, label: 'All endpoints' },
...getFormattedEndPointDropDownData(
data?.payload?.data?.result?.[0]?.table?.rows ?? [],
).map(({ value: url }) => ({
value: url,
label: (
<span className={styles.option} title={url}>
{url}
</span>
),
})),
],
[data],
);
return (
<div className={styles.picker}>
<ComboboxSimple
items={items}
value={value || ALL_ENDPOINTS}
onChange={(next): void =>
onChange(!next || next === ALL_ENDPOINTS ? '' : (next as string))
}
inputPlaceholder="Search endpoints"
emptyPlaceholder="No endpoints found"
loading={isLoading || isFetching}
virtualized={items.length > VIRTUALIZE_AFTER}
withPortal={false}
testId="endpoint-picker"
/>
</div>
);
}
export default EndPointPicker;

View File

@@ -1,65 +0,0 @@
import { useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { Select } from 'antd';
import { getFormattedEndPointDropDownData } from 'container/ApiMonitoring/utils';
import { SuccessResponse } from 'types/api';
interface EndPointsDropDownProps {
selectedEndPointName?: string;
setSelectedEndPointName: (value: string) => void;
endPointDropDownDataQuery: UseQueryResult<SuccessResponse<any>, unknown>;
parentContainerDiv?: string;
dropdownStyle?: React.CSSProperties;
}
const defaultProps = {
selectedEndPointName: '',
parentContainerDiv: '',
dropdownStyle: {},
};
function EndPointsDropDown({
selectedEndPointName,
setSelectedEndPointName,
endPointDropDownDataQuery,
parentContainerDiv,
dropdownStyle,
}: EndPointsDropDownProps): JSX.Element {
const { data, isLoading, isFetching } = endPointDropDownDataQuery;
const handleChange = (value: string): void => {
setSelectedEndPointName(value);
};
const formattedData = useMemo(
() =>
getFormattedEndPointDropDownData(data?.payload.data.result[0].table.rows),
[data?.payload.data.result],
);
return (
<Select
value={selectedEndPointName || undefined}
placeholder="Select endpoint"
loading={isLoading || isFetching}
style={{ width: '100%' }}
onChange={handleChange}
options={formattedData}
getPopupContainer={
parentContainerDiv
? (): HTMLElement =>
document.querySelector(parentContainerDiv) as HTMLElement
: (triggerNode): HTMLElement => triggerNode.parentNode as HTMLElement
}
dropdownStyle={dropdownStyle}
allowClear
onClear={(): void => {
setSelectedEndPointName('');
}}
/>
);
}
EndPointsDropDown.defaultProps = defaultProps;
export default EndPointsDropDown;

View File

@@ -24,7 +24,6 @@ import { LegendPosition } from 'lib/uPlotV2/components/types';
import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/widgets/widget';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import ErrorState from './ErrorState';
import {
@@ -37,7 +36,7 @@ function StatusCodeBarCharts({
endPointStatusCodeBarChartsDataQuery,
endPointStatusCodeLatencyBarChartsDataQuery,
domainName,
filters,
filterExpression,
timeRange,
onDragSelect,
}: {
@@ -50,7 +49,7 @@ function StatusCodeBarCharts({
unknown
>;
domainName: string;
filters: IBuilderQuery['filters'];
filterExpression: string;
timeRange: {
startTime: number;
endTime: number;
@@ -130,12 +129,8 @@ function StatusCodeBarCharts({
);
const widget = useMemo<Widgets>(
() =>
getStatusCodeBarChartWidgetData(domainName, {
items: [...(filters?.items || [])],
op: filters?.op || 'AND',
}),
[domainName, filters],
() => getStatusCodeBarChartWidgetData(domainName, filterExpression),
[domainName, filterExpression],
);
const activeApiResponse = useMemo(

View File

@@ -0,0 +1,157 @@
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { FilterProviders } from 'container/ApiMonitoring/__tests__/FilterProviders';
import { getCurrentNuqsSearchParams, resetNuqsState } from 'tests/nuqs-helpers';
import {
act,
render,
renderHook,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { useFilterExpression } from '../useFilterExpression';
const PARAM_KEY = 'testExpression';
const linkExpression = "service.name = 'frontend'";
const linkSearch = `${PARAM_KEY}=${encodeURIComponent(linkExpression)}`;
function renderFilterHook(
searchParams = '',
): ReturnType<
typeof renderHook<ReturnType<typeof useFilterExpression>, unknown>
> {
return renderHook(() => useFilterExpression(), {
wrapper: ({ children }) => (
<FilterProviders queryParamKey={PARAM_KEY} searchParams={searchParams}>
{children}
</FilterProviders>
),
});
}
function FilterBox(): JSX.Element {
const { expression, onDraftChange, onBlur } = useFilterExpression();
return (
<div onBlur={onBlur}>
<span data-testid="applied">{expression}</span>
<input
data-testid="filter-input"
onChange={(e): void => onDraftChange(e.target.value)}
/>
<button type="button" data-testid="inside-button">
Run
</button>
</div>
);
}
describe('useFilterExpression', () => {
beforeEach(() => {
resetNuqsState();
});
it('reads the filter from the link', async () => {
const { result } = renderFilterHook(linkSearch);
await waitFor(() => expect(result.current.expression).toBe(linkExpression));
expect(result.current.queryData.dataSource).toBe('traces');
expect(result.current.queryData.filter?.expression).toBe(linkExpression);
});
it('applies a valid expression on run and ignores an invalid one', async () => {
const { result } = renderFilterHook(linkSearch);
await waitFor(() => expect(result.current.expression).toBe(linkExpression));
act(() => {
result.current.onRun('service.name =');
});
expect(result.current.expression).toBe(linkExpression);
act(() => {
result.current.onRun('http.status_code = 500');
});
expect(result.current.expression).toBe('http.status_code = 500');
await waitFor(() =>
expect(getCurrentNuqsSearchParams().get(PARAM_KEY)).toBe(
'http.status_code = 500',
),
);
});
it('runs again instead of applying when the filter is already applied', async () => {
const scope = "http_host = 'api.example.com'";
const { result } = renderHook(() => useFilterExpression(), {
wrapper: ({ children }) => (
<FilterProviders
queryParamKey={PARAM_KEY}
initialExpression={scope}
searchParams={linkSearch}
>
{children}
</FilterProviders>
),
});
await waitFor(() => expect(result.current.expression).toBe(linkExpression));
const onUnchanged = jest.fn() as jest.MockedFunction<() => void>;
act(() => {
result.current.onRun(
combineInitialAndUserExpression(scope, linkExpression),
onUnchanged,
);
});
expect(onUnchanged).toHaveBeenCalledTimes(1);
act(() => {
result.current.onRun(
combineInitialAndUserExpression(scope, 'http.status_code = 500'),
onUnchanged,
);
});
expect(onUnchanged).toHaveBeenCalledTimes(1);
expect(result.current.expression).toBe('http.status_code = 500');
});
it('applies the draft on blur only when focus leaves the container', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<FilterProviders queryParamKey={PARAM_KEY}>
<FilterBox />
</FilterProviders>,
);
await user.type(screen.getByTestId('filter-input'), "service.name = 'cart'");
await user.click(screen.getByTestId('inside-button'));
expect(screen.getByTestId('applied')).toHaveTextContent('');
await user.click(document.body);
expect(screen.getByTestId('applied')).toHaveTextContent(
"service.name = 'cart'",
);
});
it('keeps only the user text when the scope is set', async () => {
const scope = "http_host = 'api.example.com'";
const { result } = renderHook(() => useFilterExpression(), {
wrapper: ({ children }) => (
<FilterProviders queryParamKey={PARAM_KEY} initialExpression={scope}>
{children}
</FilterProviders>
),
});
await waitFor(() => expect(result.current.initialExpression).toBe(scope));
act(() => {
result.current.onRun(`${scope} AND (service.name = 'cart')`);
});
expect(result.current.expression).toBe("service.name = 'cart'");
await waitFor(() =>
expect(getCurrentNuqsSearchParams().get(PARAM_KEY)).toBe(
"service.name = 'cart'",
),
);
});
});

View File

@@ -0,0 +1,82 @@
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider, QueryObserver } from 'react-query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
import { act, renderHook, waitFor } from 'tests/test-utils';
import { Widgets } from 'types/api/widgets/widget';
import { useWidgetQueries } from '../useWidgetQueries';
const widgetWithQueryId = (id: string): Widgets =>
getWidgetQueryBuilder({
query: { ...initialQueriesMap.traces, id },
panelTypes: PANEL_TYPES.TABLE,
});
const gridCardKey = (queryId: string): unknown[] => [
REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE,
0,
0,
{ id: queryId },
];
function startGridCardFetch(client: QueryClient, queryId: string): void {
void client.prefetchQuery(gridCardKey(queryId), () => new Promise(() => {}));
}
function renderWidgetQueries(
client: QueryClient,
widgets: Widgets[],
): ReturnType<typeof renderHook<ReturnType<typeof useWidgetQueries>, unknown>> {
return renderHook(() => useWidgetQueries(widgets), {
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
),
});
}
describe('useWidgetQueries', () => {
it('counts only the given widgets’ grid card queries', async () => {
const client = new QueryClient();
const { result } = renderWidgetQueries(client, [widgetWithQueryId('table')]);
await act(async () => {
startGridCardFetch(client, 'other-tab-chart');
await new Promise((resolve) => setTimeout(resolve, 50));
});
expect(client.isFetching()).toBe(1);
expect(result.current.isFetching).toBe(false);
act(() => startGridCardFetch(client, 'table'));
await waitFor(() => expect(result.current.isFetching).toBe(true));
});
it('refetches only the given widgets’ grid card queries', async () => {
const client = new QueryClient();
const fetchTable = jest.fn(() =>
Promise.resolve('table'),
) as jest.MockedFunction<() => Promise<string>>;
const fetchOther = jest.fn(() =>
Promise.resolve('other'),
) as jest.MockedFunction<() => Promise<string>>;
const unsubscribers = [
new QueryObserver(client, {
queryKey: gridCardKey('table'),
queryFn: fetchTable,
}).subscribe(() => {}),
new QueryObserver(client, {
queryKey: gridCardKey('other-tab-chart'),
queryFn: fetchOther,
}).subscribe(() => {}),
];
await waitFor(() => expect(client.isFetching()).toBe(0));
const { result } = renderWidgetQueries(client, [widgetWithQueryId('table')]);
act(() => result.current.refetch());
await waitFor(() => expect(fetchTable).toHaveBeenCalledTimes(2));
expect(fetchOther).toHaveBeenCalledTimes(1);
unsubscribers.forEach((unsubscribe) => unsubscribe());
});
});

View File

@@ -0,0 +1,93 @@
import { FocusEvent, useCallback, useEffect, useMemo, useRef } from 'react';
import {
useInitialExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnRun,
useUserExpression,
} from 'components/QueryBuilderV2';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { initialQueriesMap } from 'constants/queryBuilder';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { validateQuery } from 'utils/queryValidationUtils';
interface UseFilterExpressionParams {
groupBy?: IBuilderQuery['groupBy'];
}
interface UseFilterExpressionReturn {
expression: string;
/** The provider's locked scope, for `QuerySearch`'s `initialExpression` */
initialExpression: string | undefined;
queryData: IBuilderQuery;
onDraftChange: (value: string) => void;
/** `onUnchanged` runs instead when the filter is already applied. */
onRun: (value: string, onUnchanged?: () => void) => void;
onBlur: (event: FocusEvent<HTMLDivElement>) => void;
}
/** Must be used inside a `QuerySearchV2Provider`. */
export function useFilterExpression({
groupBy,
}: UseFilterExpressionParams = {}): UseFilterExpressionReturn {
const expression = useUserExpression();
const scope = useInitialExpression();
const initialExpression = useQuerySearchInitialExpressionProp();
const querySearchOnRun = useQuerySearchOnRun();
const queryData = useMemo(
(): IBuilderQuery => ({
...initialQueriesMap[DataSource.TRACES].builder.queryData[0],
dataSource: DataSource.TRACES,
filter: { expression },
...(groupBy ? { groupBy } : {}),
}),
[expression, groupBy],
);
const onRun = useCallback(
(value: string, onUnchanged?: () => void): void => {
if (!validateQuery(value).isValid) {
return;
}
if (getUserExpressionFromCombined(scope, value) === expression) {
onUnchanged?.();
return;
}
querySearchOnRun(value);
},
[querySearchOnRun, scope, expression],
);
// A ref, not the provider's draft, so typing does not re-render the page.
const draftRef = useRef(expression);
useEffect(() => {
draftRef.current = expression;
}, [expression]);
const onDraftChange = useCallback((value: string): void => {
draftRef.current = value;
}, []);
const onBlur = useCallback(
(event: FocusEvent<HTMLDivElement>): void => {
if (event.currentTarget.contains(event.relatedTarget as Node | null)) {
return;
}
onRun(combineInitialAndUserExpression(scope, draftRef.current));
},
[onRun, scope],
);
return {
expression,
initialExpression,
queryData,
onDraftChange,
onRun,
onBlur,
};
}

View File

@@ -0,0 +1,45 @@
import { useCallback, useMemo } from 'react';
import {
Query as ReactQuery,
useIsFetching,
useQueryClient,
} from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { Widgets } from 'types/api/widgets/widget';
interface UseWidgetQueriesReturn {
isFetching: boolean;
refetch: () => void;
}
/**
* These widgets' `GridCard` queries. Matches on the widget's query id, so
* another tab's cards are neither counted nor refetched.
*/
export function useWidgetQueries(widgets: Widgets[]): UseWidgetQueriesReturn {
const queryClient = useQueryClient();
const predicate = useMemo(() => {
const queryIds = new Set(
widgets
.map((widget) => widget.query?.id)
.filter((id): id is string => Boolean(id)),
);
return ({ queryKey }: ReactQuery): boolean =>
Array.isArray(queryKey) &&
queryKey[0] === REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE &&
queryKey.some((part) => {
const id = (part as Query | undefined)?.id;
return !!id && queryIds.has(id);
});
}, [widgets]);
const isFetching = useIsFetching({ predicate }) > 0;
const refetch = useCallback((): void => {
void queryClient.refetchQueries({ predicate, active: true });
}, [queryClient, predicate]);
return { isFetching, refetch };
}

View File

@@ -19,6 +19,7 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useListOverview } from 'hooks/thirdPartyApis/useListOverview';
import { get } from 'lodash-es';
import { parseAsString, useQueryStates } from 'nuqs';
import { AppState } from 'store/reducers';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
@@ -27,16 +28,26 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
import { ApiMonitoringHardcodedAttributeKeys } from '../../constants';
import {
ALL_ENDPOINTS_EXPRESSION_KEY,
ENDPOINT_STATS_EXPRESSION_KEY,
} from '../../filterParamKeys';
import { DEFAULT_PARAMS, useApiMonitoringParams } from '../../queryParams';
import { columnsConfig, formatDataForTable } from '../../utils';
import DomainDetails from './DomainDetails/DomainDetails';
import { useConvertLegacyFilterLinks } from './hooks/useConvertLegacyFilterLinks';
import '../Explorer.styles.scss';
function DomainList(): JSX.Element {
const [params, setParams] = useApiMonitoringParams();
const { showIP, selectedDomain } = params;
const [, setDrawerFilters] = useQueryStates({
[ALL_ENDPOINTS_EXPRESSION_KEY]: parseAsString,
[ENDPOINT_STATS_EXPRESSION_KEY]: parseAsString,
});
const [selectedDomainIndex, setSelectedDomainIndex] = useState<number>(-1);
useConvertLegacyFilterLinks();
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
@@ -55,6 +66,8 @@ function DomainList(): JSX.Element {
});
const compositeData = useGetCompositeQueryParam();
const listFilterExpression =
compositeData?.builder?.queryData[0]?.filter?.expression ?? '';
const [isCancelled, setIsCancelled] = useState(false);
@@ -155,6 +168,7 @@ function DomainList(): JSX.Element {
dataSource={DataSource.TRACES}
queryData={query}
onChange={handleSearchChange}
onRun={handleStageAndRunQuery}
placeholder="Enter your filter query (e.g., deployment.environment = 'otel-demo' AND service.name = 'frontend')"
hardcodedAttributeKeys={ApiMonitoringHardcodedAttributeKeys}
/>
@@ -225,6 +239,11 @@ function DomainList(): JSX.Element {
);
setSelectedDomainIndex(dataIndex);
setParams({ selectedDomain: record.domainName });
// The drawer tabs start from the list's filter
void setDrawerFilters({
[ALL_ENDPOINTS_EXPRESSION_KEY]: listFilterExpression || null,
[ENDPOINT_STATS_EXPRESSION_KEY]: listFilterExpression || null,
});
logEvent('API Monitoring: Domain name row clicked', {});
}
},
@@ -245,8 +264,12 @@ function DomainList(): JSX.Element {
handleClose={(): void => {
setSelectedDomainIndex(-1);
setParams(DEFAULT_PARAMS);
void setDrawerFilters({
[ALL_ENDPOINTS_EXPRESSION_KEY]: null,
[ENDPOINT_STATS_EXPRESSION_KEY]: null,
});
}}
domainListFilters={query?.filters}
domainListFilterExpression={listFilterExpression}
/>
)}
</section>

View File

@@ -0,0 +1,122 @@
import { ReactNode, useEffect, useState } from 'react';
import {
ALL_ENDPOINTS_EXPRESSION_KEY,
ENDPOINT_STATS_EXPRESSION_KEY,
} from 'container/ApiMonitoring/filterParamKeys';
import { getApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import {
getCurrentNuqsSearchParams,
onNuqsUrlUpdate,
resetNuqsState,
} from 'tests/nuqs-helpers';
import { mockAppLocation, mockNavigate } from 'tests/router-mocks';
import { renderHook, waitFor } from 'tests/test-utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
import { useConvertLegacyFilterLinks } from '../useConvertLegacyFilterLinks';
const legacyFilter = (key: string, value: string): TagFilter => ({
op: 'AND',
items: [
{
id: key,
key: { key, dataType: DataTypes.String, type: 'tag' },
op: '=',
value,
},
],
});
// 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>
);
}
function renderWithLink(linkParams: object): jest.SpyInstance {
const search = `?apiMonitoringParams=${encodeURIComponent(
encodeURIComponent(JSON.stringify(linkParams)),
)}`;
__setSearchParamsGetterForTest(() => new URLSearchParams(search));
mockAppLocation({ search });
const navigateSpy = mockNavigate();
renderHook(() => useConvertLegacyFilterLinks(), {
wrapper: ({ children }: { children: ReactNode }) => (
<UrlAdapter searchParams={search}>{children}</UrlAdapter>
),
});
return navigateSpy;
}
describe('useConvertLegacyFilterLinks', () => {
beforeEach(() => {
resetNuqsState();
});
afterEach(() => {
__resetSearchParamsGetter();
});
it('moves an older link’s filters into the drawer params once', async () => {
const navigateSpy = renderWithLink({
selectedDomain: 'api.example.com',
allEndpointsLocalFilters: legacyFilter('service.name', 'cart'),
endPointDetailsLocalFilters: legacyFilter('http.status_code', '500'),
});
await waitFor(() => {
const params = getCurrentNuqsSearchParams();
expect(params.get(ALL_ENDPOINTS_EXPRESSION_KEY)).toBe(
"service.name = 'cart'",
);
expect(params.get(ENDPOINT_STATS_EXPRESSION_KEY)).toBe(
"http.status_code = '500'",
);
});
const [to, options] = navigateSpy.mock.calls[0];
const rewritten = getApiMonitoringParams(
typeof to === 'string' ? to : (to.search ?? ''),
);
expect(rewritten.selectedDomain).toBe('api.example.com');
expect(rewritten).not.toHaveProperty('allEndpointsLocalFilters');
expect(options).toStrictEqual({ replace: true });
});
it('leaves a link without legacy filters alone', () => {
const navigateSpy = renderWithLink({ selectedDomain: 'api.example.com' });
expect(navigateSpy).not.toHaveBeenCalledWith(
expect.objectContaining({ search: expect.any(String) }),
expect.anything(),
);
expect(getCurrentNuqsSearchParams().has(ALL_ENDPOINTS_EXPRESSION_KEY)).toBe(
false,
);
});
});

View File

@@ -0,0 +1,42 @@
import { useEffect } from 'react';
import { parseAsString, useQueryStates } from 'nuqs';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import {
ALL_ENDPOINTS_EXPRESSION_KEY,
ENDPOINT_STATS_EXPRESSION_KEY,
} from '../../../filterParamKeys';
import {
getLegacyFilterExpressions,
useApiMonitoringParams,
} from '../../../queryParams';
/**
* Moves the filters an older link keeps inside `apiMonitoringParams` into the
* drawer's own params, then rewrites `apiMonitoringParams` without them, so it
* happens once.
*/
export function useConvertLegacyFilterLinks(): void {
const [, setParams] = useApiMonitoringParams();
const [, setDrawerFilters] = useQueryStates({
[ALL_ENDPOINTS_EXPRESSION_KEY]: parseAsString,
[ENDPOINT_STATS_EXPRESSION_KEY]: parseAsString,
});
useEffect(() => {
const legacy = getLegacyFilterExpressions(
`?${getUnstableCurrentSearchParams().toString()}`,
);
if (
legacy.allEndpoints === undefined &&
legacy.endPointDetails === undefined
) {
return;
}
void setDrawerFilters({
[ALL_ENDPOINTS_EXPRESSION_KEY]: legacy.allEndpoints || null,
[ENDPOINT_STATS_EXPRESSION_KEY]: legacy.endPointDetails || null,
});
setParams({}, true);
}, [setParams, setDrawerFilters]);
}

View File

@@ -1,6 +1,4 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
import {
@@ -11,20 +9,14 @@ import {
getFormattedEndPointDropDownData,
getFormattedEndPointStatusCodeChartData,
getFormattedEndPointStatusCodeData,
getGroupByFiltersFromGroupByValues,
getGroupByExpressionFromRowData,
getHttpUrlExpression,
getStatusCodeBarChartWidgetData,
getTopErrorsColumnsConfig,
getTopErrorsCoRelationQueryFilters,
} from '../utils';
import { APIMonitoringColumnsMock } from './mock';
// Mock or define DataTypes since it seems to be missing from imports
const DataTypes = {
String: 'string',
Float64: 'float64',
bool: 'bool',
};
// Mock the external utils dependencies that are used within our tested functions
jest.mock('../utils', () => {
// Import the actual module to partial mock
@@ -90,140 +82,39 @@ describe('API Monitoring Utils', () => {
expect(result[0].lastUsed).toBe('-');
});
});
describe('getGroupByFiltersFromGroupByValues', () => {
it('should convert row data to filters correctly', () => {
// Arrange
const rowData = {
'http.method': 'GET',
'http.status_code': '200',
'service.name': 'api-service',
// Fields that should be filtered out
data: 'someValue',
key: 'someKey',
};
const groupBy = [
{
id: 'group-by-1',
key: 'http.method',
dataType: DataTypes.String,
type: '',
},
{
id: 'group-by-2',
key: 'http.status_code',
dataType: DataTypes.String,
type: '',
},
{
id: 'group-by-3',
key: 'service.name',
dataType: DataTypes.String,
type: 'tag',
},
];
// Act
const result = getGroupByFiltersFromGroupByValues(
rowData,
groupBy as BaseAutocompleteData[],
);
// Assert
expect(result).toBeDefined();
expect(result?.op).toBe('AND');
// The implementation includes all keys from rowData, not just those in groupBy
expect(result?.items?.length).toBeGreaterThanOrEqual(3);
// Verify each filter matches the corresponding groupBy
describe('getGroupByExpressionFromRowData', () => {
it('builds an AND expression from the row values, skipping the url and metric columns', () => {
expect(
result?.items?.some(
(item) =>
item.key &&
item.key.key === 'http.method' &&
item.value === 'GET' &&
item.op === '=',
),
).toBe(true);
expect(
result?.items?.some(
(item) =>
item.key &&
item.key.key === 'http.status_code' &&
item.value === '200' &&
item.op === '=',
),
).toBe(true);
expect(
result?.items?.some(
(item) =>
item.key &&
item.key.key === 'service.name' &&
item.value === 'api-service' &&
item.op === '=',
),
).toBe(true);
getGroupByExpressionFromRowData({
http_url: '/api/test',
key: '3f2a-row-uuid',
A: 10,
B: 20,
C: 1,
F1: 5,
'service.name': 'api-service',
'http.status_code': 200,
}),
).toBe("service.name = 'api-service' AND http.status_code = 200");
});
it('should handle fields not in groupBy', () => {
// Arrange
const rowData = {
'http.method': 'GET',
'unknown.field': 'someValue',
};
const groupBy = [
{
id: 'group-by-1',
key: 'http.method',
dataType: DataTypes.String,
type: '',
},
];
// Act
const result = getGroupByFiltersFromGroupByValues(
rowData,
groupBy as BaseAutocompleteData[],
it('escapes quotes in values', () => {
expect(getGroupByExpressionFromRowData({ 'user.name': "o'brien" })).toBe(
"user.name = 'o\\'brien'",
);
// Assert
expect(result).toBeDefined();
// The implementation includes all keys from rowData, not just those in groupBy
expect(result?.items?.length).toBeGreaterThanOrEqual(1);
// Should include the known field with the proper dataType from groupBy
const knownField = result?.items?.find(
(item) => item.key && item.key.key === 'http.method',
);
expect(knownField).toBeDefined();
if (knownField && knownField.key) {
expect(knownField.key.dataType).toBe(DataTypes.String);
}
// Should include the unknown field
const unknownField = result?.items?.find(
(item) => item.key && item.key.key === 'unknown.field',
);
expect(unknownField).toBeDefined();
if (unknownField && unknownField.key) {
expect(unknownField.key.dataType).toBe(DataTypes.String); // Default
}
});
it('should handle empty input', () => {
// Arrange
const rowData = {};
const groupBy: any[] = [];
it('matches spans without the attribute when the group has no value', () => {
expect(
getGroupByExpressionFromRowData({
'service.name': 'cart',
'ai.embeddings': null,
}),
).toBe("service.name = 'cart' AND ai.embeddings NOT EXISTS");
});
// Act
const result = getGroupByFiltersFromGroupByValues(rowData, groupBy);
// Assert
expect(result).toBeDefined();
expect(result?.op).toBe('AND');
expect(result?.items).toHaveLength(0);
it('returns an empty expression for a row without group columns', () => {
expect(getGroupByExpressionFromRowData({ http_url: '/x', A: 1 })).toBe('');
});
});
@@ -703,13 +594,9 @@ describe('API Monitoring Utils', () => {
it('should generate widget configuration for status code bar chart', () => {
// Arrange
const domainName = 'test-domain';
const filters = { items: [], op: 'AND' };
// Act
const result = getStatusCodeBarChartWidgetData(
domainName,
filters as IBuilderQuery['filters'],
);
const result = getStatusCodeBarChartWidgetData(domainName, '');
// Assert
expect(result).toBeDefined();
@@ -723,47 +610,29 @@ describe('API Monitoring Utils', () => {
const queryData = result.query.builder.queryData[0];
// Should have domain filter
const domainFilter = queryData.filters?.items?.find(
(item) => item.key && item.key.key === SPAN_ATTRIBUTES.SERVER_NAME,
expect(queryData.filter?.expression).toBe(
`${SPAN_ATTRIBUTES.SERVER_NAME} = '${domainName}'`,
);
expect(domainFilter).toBeDefined();
if (domainFilter) {
expect(domainFilter.value).toBe(domainName);
}
});
it('should include custom filters in the widget configuration', () => {
// Arrange
const domainName = 'test-domain';
const customFilter = {
id: 'custom-filter',
key: {
dataType: 'string',
key: 'custom.key',
type: '',
},
op: '=',
value: 'custom-value',
};
const filters = { items: [customFilter], op: 'AND' };
const customFilterExpression = "custom.key = 'custom-value'";
// Act
const result = getStatusCodeBarChartWidgetData(
domainName,
filters as IBuilderQuery['filters'],
customFilterExpression,
);
// Assert
const queryData = result.query.builder.queryData[0];
// Should include our custom filter
const includedFilter = queryData.filters?.items?.find(
(item) => item.id === 'custom-filter',
// Should include our custom filter after the domain filter
expect(queryData.filter?.expression).toBe(
`${SPAN_ATTRIBUTES.SERVER_NAME} = '${domainName}' AND (${customFilterExpression})`,
);
expect(includedFilter).toBeDefined();
if (includedFilter) {
expect(includedFilter.value).toBe('custom-value');
}
});
});
@@ -927,4 +796,17 @@ describe('API Monitoring Utils', () => {
expect(latencyColumn?.title).toBeDefined();
});
});
describe('getHttpUrlExpression', () => {
it('returns no clause when no endpoint is picked', () => {
expect(getHttpUrlExpression('')).toBe('');
});
it('quotes the endpoint and escapes apostrophes', () => {
expect(getHttpUrlExpression('/x')).toBe("http_url = '/x'");
expect(getHttpUrlExpression("/users/o'brien")).toBe(
"http_url = '/users/o\\'brien'",
);
});
});
});

View File

@@ -1,11 +1,17 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import {
getAllEndpointsWidgetData,
getGroupByFiltersFromGroupByValues,
getGroupByExpressionFromRowData,
} from 'container/ApiMonitoring/utils';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import {
ALL_ENDPOINTS_EXPRESSION_KEY,
ENDPOINT_STATS_EXPRESSION_KEY,
} from 'container/ApiMonitoring/filterParamKeys';
import { getCurrentNuqsSearchParams, resetNuqsState } from 'tests/nuqs-helpers';
import { cleanup, fireEvent, render, screen, waitFor } from 'tests/test-utils';
import AllEndPoints from '../Explorer/Domains/DomainDetails/AllEndPoints';
import { FilterProviders } from './FilterProviders';
import {
SPAN_ATTRIBUTES,
VIEWS,
@@ -14,7 +20,7 @@ import {
// Mock the dependencies
jest.mock('container/ApiMonitoring/utils', () => ({
getAllEndpointsWidgetData: jest.fn(),
getGroupByFiltersFromGroupByValues: jest.fn(),
getGroupByExpressionFromRowData: jest.fn(),
}));
jest.mock('container/WidgetCard/Card', () => ({
@@ -34,53 +40,47 @@ jest.mock('container/WidgetCard/Card', () => ({
)),
}));
jest.mock(
'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2',
() => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onChange }) => (
<div data-testid="query-builder-mock">
<button
type="button"
data-testid="filter-change-button"
onClick={(): void =>
onChange({
items: [{ id: 'test', key: 'test', op: '=', value: 'test' }],
op: 'AND',
})
}
>
Change Filter
</button>
</div>
)),
}),
);
jest.mock('hooks/queryBuilder/useGetAggregateKeys', () => ({
useGetAggregateKeys: jest.fn(),
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onRun }) => (
<div data-testid="query-builder-mock">
<button
type="button"
data-testid="filter-change-button"
onClick={(): void => onRun("test = 'test'")}
>
Change Filter
</button>
</div>
)),
}));
jest.mock('antd', () => {
const originalModule = jest.requireActual('antd');
return {
...originalModule,
Select: jest.fn().mockImplementation(({ onChange }) => (
<div data-testid="select-mock">
<button
data-testid="select-change-button"
type="button"
onClick={(): void => onChange(['http.status_code'])}
>
Change GroupBy
</button>
</div>
)),
};
});
jest.mock('container/QueryBuilder/filters/GroupByFilter/GroupByFilter', () => ({
GroupByFilter: jest.fn().mockImplementation(({ onChange }) => (
<div data-testid="select-mock">
<button
data-testid="select-change-button"
type="button"
onClick={(): void =>
onChange([
{
id: 'http.status_code--string--attribute',
key: 'http.status_code',
dataType: 'string',
type: 'attribute',
},
])
}
>
Change GroupBy
</button>
</div>
)),
}));
// Mock useApiMonitoringParams hook
jest.mock('container/ApiMonitoring/queryParams', () => ({
...jest.requireActual('container/ApiMonitoring/queryParams'),
useApiMonitoringParams: jest.fn().mockReturnValue([
{
showIP: true,
@@ -88,8 +88,6 @@ jest.mock('container/ApiMonitoring/queryParams', () => ({
selectedView: 'all_endpoints',
selectedEndPointName: '',
groupBy: [],
allEndpointsLocalFilters: undefined,
endPointDetailsLocalFilters: undefined,
modalTimeRange: undefined,
selectedInterval: undefined,
},
@@ -108,29 +106,21 @@ describe('AllEndPoints', () => {
startTime: 1609459200000,
endTime: 1609545600000,
},
initialFilters: { op: 'AND', items: [] },
setInitialFiltersEndPointStats: jest.fn(),
};
const renderAllEndPoints = (): void => {
render(
<FilterProviders queryParamKey={ALL_ENDPOINTS_EXPRESSION_KEY}>
<AllEndPoints {...mockProps} />
</FilterProviders>,
);
};
beforeEach(() => {
jest.clearAllMocks();
resetNuqsState();
// Setup mock implementations
(useGetAggregateKeys as jest.Mock).mockReturnValue({
data: {
payload: {
attributeKeys: [
{
key: 'http.status_code',
dataType: 'string',
type: '',
},
],
},
},
isLoading: false,
});
(getAllEndpointsWidgetData as jest.Mock).mockReturnValue({
id: 'test-widget',
title: 'Endpoint Overview',
@@ -139,10 +129,9 @@ describe('AllEndPoints', () => {
queryData: [],
});
(getGroupByFiltersFromGroupByValues as jest.Mock).mockReturnValue({
items: [{ id: 'group-filter', key: 'status', op: '=', value: '200' }],
op: 'AND',
});
(getGroupByExpressionFromRowData as jest.Mock).mockReturnValue(
"status = '200'",
);
});
// Add cleanup after each test
@@ -151,7 +140,7 @@ describe('AllEndPoints', () => {
});
it('renders component correctly', () => {
render(<AllEndPoints {...mockProps} />);
renderAllEndPoints();
// Verify basic component rendering
expect(screen.getByText('Group by')).toBeInTheDocument();
@@ -161,7 +150,7 @@ describe('AllEndPoints', () => {
});
it('handles filter changes', () => {
render(<AllEndPoints {...mockProps} />);
renderAllEndPoints();
// Trigger filter change
fireEvent.click(screen.getByTestId('filter-change-button'));
@@ -170,25 +159,24 @@ describe('AllEndPoints', () => {
expect(getAllEndpointsWidgetData).toHaveBeenCalledWith(
expect.anything(),
'test-domain',
expect.objectContaining({
items: expect.arrayContaining([expect.objectContaining({ id: 'test' })]),
op: 'AND',
}),
"test = 'test'",
);
});
it('handles group by changes', () => {
render(<AllEndPoints {...mockProps} />);
renderAllEndPoints();
// Trigger group by change
fireEvent.click(screen.getByTestId('select-change-button'));
// Check if setGroupBy was called with updated group by value
expect(mockProps.setGroupBy).toHaveBeenCalled();
// The picked keys are stored as given and written to the URL as bare names
expect(mockProps.setGroupBy).toHaveBeenCalledWith([
expect.objectContaining({ key: 'http.status_code', type: 'attribute' }),
]);
});
it('handles row click in grid card', async () => {
render(<AllEndPoints {...mockProps} />);
renderAllEndPoints();
// Trigger row click
fireEvent.click(screen.getByTestId('row-click-button'));
@@ -196,7 +184,11 @@ describe('AllEndPoints', () => {
// Check if proper functions were called
expect(mockProps.setSelectedEndPointName).toHaveBeenCalledWith('/api/test');
expect(mockProps.setSelectedView).toHaveBeenCalledWith(VIEWS.ENDPOINT_STATS);
expect(mockProps.setInitialFiltersEndPointStats).toHaveBeenCalled();
expect(getGroupByFiltersFromGroupByValues).toHaveBeenCalled();
expect(getGroupByExpressionFromRowData).toHaveBeenCalled();
await waitFor(() =>
expect(getCurrentNuqsSearchParams().get(ENDPOINT_STATS_EXPRESSION_KEY)).toBe(
"status = '200'",
),
);
});
});

View File

@@ -14,16 +14,12 @@ import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
describe('AllEndpointsWidget - V5 Migration Validation', () => {
const mockDomainName = 'api.example.com';
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
const emptyFilters = '';
const emptyGroupBy: BaseAutocompleteData[] = [];
describe('1. V5 Format Migration - All Four Queries', () => {

View File

@@ -121,8 +121,7 @@ describe('DependentServices', () => {
);
});
it('renders loading state correctly', () => {
// Arrange
it('keeps the table in place while loading', () => {
const mockQuery: MockQueryResult = {
isLoading: true,
isRefetching: false,
@@ -131,16 +130,15 @@ describe('DependentServices', () => {
refetch: refetchFn,
};
// Act
const { container } = render(
render(
<DependentServices
dependentServicesQuery={mockQuery as any}
timeRange={mockTimeRange}
/>,
);
// Assert
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
expect(screen.getByTestId('dependent-services-table')).toBeInTheDocument();
expect(screen.getByTestId('loading-state')).toHaveTextContent('Loading');
});
it('renders error state correctly', () => {
@@ -225,8 +223,7 @@ describe('DependentServices', () => {
expect(screen.getByTestId('page-size')).toHaveTextContent('5');
});
it('handles refetching state correctly', () => {
// Arrange
it('shows the rows under the spinner while refetching', () => {
const mockQuery: MockQueryResult = {
isLoading: false,
isRefetching: true,
@@ -235,16 +232,16 @@ describe('DependentServices', () => {
refetch: refetchFn,
};
// Act
const { container } = render(
render(
<DependentServices
dependentServicesQuery={mockQuery as any}
timeRange={mockTimeRange}
/>,
);
// Assert
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
expect(screen.getByTestId('loading-state')).toHaveTextContent('Loading');
expect(screen.getByTestId('row-count')).toHaveTextContent('2');
expect(screen.getByTestId('table-row-0')).toHaveTextContent('auth-service');
});
it('handles row click correctly', () => {

View File

@@ -0,0 +1,60 @@
import { useApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
import { render, screen } from 'tests/test-utils';
import { VIEWS } from '../Explorer/Domains/DomainDetails/constants';
import DomainDetails from '../Explorer/Domains/DomainDetails/DomainDetails';
jest.mock('container/ApiMonitoring/queryParams', () => ({
...jest.requireActual('container/ApiMonitoring/queryParams'),
useApiMonitoringParams: jest.fn(),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('../Explorer/Domains/DomainDetails/components/DomainMetrics', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('../Explorer/Domains/DomainDetails/EndPointDetails', () => {
const { useInitialExpression } = jest.requireActual(
'components/QueryBuilderV2',
);
return {
__esModule: true,
default: function MockEndPointDetails(): JSX.Element {
return (
<span data-testid="endpoint-stats-scope">{useInitialExpression()}</span>
);
},
};
});
describe('DomainDetails', () => {
it('scopes Endpoint Stats to the domain and the picked endpoint', () => {
jest
.mocked(useApiMonitoringParams)
.mockReturnValue([
{ selectedView: VIEWS.ENDPOINT_STATS, selectedEndPointName: '/v1/charges' },
jest.fn(),
]);
render(
<DomainDetails
domainData={{ domainName: 'api.example.com' }}
handleClose={jest.fn()}
selectedDomainIndex={0}
setSelectedDomainIndex={jest.fn()}
domainListLength={1}
domainListFilterExpression=""
/>,
);
expect(screen.getByTestId('endpoint-stats-scope')).toHaveTextContent(
"kind_string = 'Client' AND http_host = 'api.example.com' AND http_url = '/v1/charges'",
);
});
});

View File

@@ -0,0 +1,84 @@
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import DomainList from '../Explorer/Domains/DomainList';
const FILTER = "http_host = 'api.example.com'";
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(
({
onChange,
onRun,
}: {
onChange: (query: string) => void;
onRun?: (query: string) => void;
}) => (
<>
<button
type="button"
data-testid="query-search-type"
onClick={(): void => onChange("http_host = 'api.example.com'")}
>
Type
</button>
<button
type="button"
data-testid="query-search-run"
onClick={(): void => onRun?.("http_host = 'api.example.com'")}
>
Run
</button>
</>
),
),
}));
jest.mock('container/Toolbar/Toolbar', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('../Explorer/Domains/DomainDetails/DomainDetails', () => ({
__esModule: true,
default: (): null => null,
}));
// Under the test router it keeps rewriting the URL, which re-renders the page
// without end.
jest.mock('hooks/queryBuilder/useShareBuilderUrl', () => ({
useShareBuilderUrl: jest.fn(),
}));
describe('DomainList', () => {
it('runs the typed filter from the filter box', async () => {
let listRequests = 0;
server.use(
rest.post('*/third-party-apis/overview/list', (_req, res, ctx) => {
listRequests += 1;
return res(ctx.status(200), ctx.json({ data: { data: { results: [] } } }));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<DomainList />, undefined, {
initialRoute: '/api-monitoring/explorer',
});
await waitFor(() => expect(listRequests).toBe(1));
await user.click(screen.getByTestId('query-search-type'));
await user.click(screen.getByTestId('query-search-run'));
await waitFor(() => expect(listRequests).toBe(2));
const [url] = safeNavigateMock.mock.calls[0];
const compositeQuery = new URLSearchParams(String(url).split('?')[1]).get(
'compositeQuery',
);
expect(decodeURIComponent(compositeQuery ?? '')).toContain(
`"expression":"${FILTER}"`,
);
});
});

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